@saasicat/ui-vue 0.24.2 → 0.26.0
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/README.md +32 -8
- package/dist/{catalog-DJcfm5N2.d.cts → catalog-Dch1Ryw0.d.cts} +353 -69
- package/dist/{catalog-DJcfm5N2.d.ts → catalog-Dch1Ryw0.d.ts} +353 -69
- package/dist/{chunk-F7NRM6KI.js → chunk-BPF2BMCQ.js} +748 -24
- package/dist/{chunk-D74FFCCP.js → chunk-NEXTZZRQ.js} +95 -177
- package/dist/{chunk-TQQMKWN6.js → chunk-O3J3ITF2.js} +119 -7
- package/dist/client/index.cjs +758 -83
- package/dist/client/index.d.cts +414 -9
- package/dist/client/index.d.ts +414 -9
- package/dist/client/index.js +84 -12
- package/dist/index.cjs +1325 -294
- package/dist/index.d.cts +205 -15
- package/dist/index.d.ts +205 -15
- package/dist/index.js +566 -240
- package/dist/quasar/index.cjs +574 -62
- package/dist/quasar/index.d.cts +61 -6
- package/dist/quasar/index.d.ts +61 -6
- package/dist/quasar/index.js +83 -19
- package/dist/{use-sa-theme-OUBlaqgl.d.ts → resource-registry-D7Xjs-5f.d.cts} +157 -7
- package/dist/{use-sa-theme-B5t7oXph.d.cts → resource-registry-TaR7rtq0.d.ts} +157 -7
- package/package.json +4 -3
- package/src/client/admin-error.ts +552 -0
- package/src/client/admin-resource-client.ts +9 -12
- package/src/client/batch-column-fetcher.ts +2 -2
- package/src/client/boot-loader.ts +12 -1
- package/src/client/http/axios-http-client.ts +383 -0
- package/src/client/http/fetch-http-client.ts +89 -0
- package/src/client/http/index.ts +6 -0
- package/src/client/http-json.ts +82 -16
- package/src/client/i18n/messages/discovery.ts +0 -6
- package/src/client/i18n/messages/errors.ts +44 -0
- package/src/client/i18n/messages/marketing.ts +0 -6
- package/src/client/i18n/messages/promos.ts +0 -2
- package/src/client/i18n/messages/shell.ts +12 -0
- package/src/client/i18n/messages.ts +3 -0
- package/src/client/index.ts +6 -0
- package/src/client/manifest-loader.ts +65 -15
- package/src/client/resources/audit.resource.ts +25 -0
- package/src/client/resources/define-resource.ts +166 -0
- package/src/client/resources/index.ts +42 -0
- package/src/client/resources/list-resource.ts +176 -0
- package/src/client/resources/plans.resource.ts +169 -0
- package/src/client/resources/resource-request.ts +117 -0
- package/src/client/resources/tenants.resource.ts +27 -0
- package/src/client/types.ts +10 -3
- package/src/components/BundleVersionPublishDialog.vue +1 -1
- package/src/components/MarketingPromotionsTab.vue +263 -241
- package/src/components/ThemeSwitcher.vue +85 -0
- package/src/components/admin-page/AdminAccordion.vue +125 -0
- package/src/components/bundle-editor/BundleCreatePanel.vue +1 -1
- package/src/components/dialogs/PromoCodeDialogFields.vue +20 -20
- package/src/components/plan/PlanCycleToggle.vue +1 -1
- package/src/components/plan-detail/PlanVersionsPanel.vue +5 -2
- package/src/index.ts +5 -0
- package/src/pages-standard/AdminLayout.vue +57 -0
- package/src/pages-standard/AdminManifestErrorPage.vue +4 -3
- package/src/pages-standard/BundlesPage.vue +5 -35
- package/src/pages-standard/DiscoveryPage.vue +8 -1
- package/src/pages-standard/MarketingCatalogPage.vue +14 -3
- package/src/pages-standard/SuperAdminLoginPage.vue +23 -2
- package/src/pages-standard/SuperAdminSetupWizard.vue +37 -3
- package/src/pages-standard/bundles-page/BundleAccordionList.vue +30 -32
- package/src/pages-standard/discovery-page/DiscoveryFeatureCard.vue +80 -88
- package/src/pages-standard/discovery-page/DiscoveryQuotaCard.vue +73 -79
- package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +44 -6
- package/src/pages-standard/marketing-catalog/MarketingCatalogPreview.vue +8 -2
- package/src/pages-tenant/PackageSnapshotPanel.vue +31 -2
- package/src/quasar/confirm.ts +83 -0
- package/src/quasar/create-super-admin-app.ts +84 -10
- package/src/quasar/dark-bridge.ts +47 -10
- package/src/quasar/index.ts +1 -0
- package/src/ui/theme/components/accordion.css +118 -0
- package/src/ui/theme/index.css +1 -0
- package/src/vue/create-admin-routes.ts +9 -1
- package/src/vue/platform-loaders.ts +12 -2
- package/src/vue/resource-registry.ts +211 -0
- package/src/vue/super-admin-context.ts +20 -5
- package/src/vue/ui-confirm.ts +76 -0
- package/src/vue/use-api-list.ts +50 -45
- package/src/vue/use-async-action.ts +170 -0
- package/src/vue/use-async-data.ts +83 -0
- package/src/vue/use-bundles.ts +34 -7
- package/src/vue/use-catalog-entries.ts +37 -12
- package/src/vue/use-discovery.ts +27 -6
- package/src/vue/use-marketing-projections.ts +29 -7
- package/src/vue/use-plans.ts +36 -8
- package/src/vue/use-promotions.ts +27 -7
- package/src/vue/use-resource-list.ts +272 -0
- package/src/vue/use-sa-theme.ts +34 -5
- package/src/vue/use-tenant-subscription-bundles.ts +22 -3
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// The registry: which resources a page can reach, and what an app may change
|
|
2
|
+
// about them.
|
|
3
|
+
//
|
|
4
|
+
// A page asks for a resource by name and gets operations that already know the
|
|
5
|
+
// endpoint, the project and the locale — so a standard page needs no data
|
|
6
|
+
// props at all. An app that has to divert one call overrides that one
|
|
7
|
+
// operation and keeps the rest, which is the property a prop-based page cannot
|
|
8
|
+
// offer: its props are all-or-nothing.
|
|
9
|
+
|
|
10
|
+
import { inject, type InjectionKey } from 'vue';
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
bindResource,
|
|
14
|
+
type Bound,
|
|
15
|
+
type PlatformResources,
|
|
16
|
+
type ResourceContext,
|
|
17
|
+
type ResourceDef,
|
|
18
|
+
type ResourceOps,
|
|
19
|
+
} from '../client/resources/index.js';
|
|
20
|
+
import type { HttpClient } from '../client/types.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* An override receives the platform's own implementation as `next`, so it
|
|
24
|
+
* wraps rather than replaces. Recording an approval before a publish is three
|
|
25
|
+
* lines; rewriting the publish is not.
|
|
26
|
+
*/
|
|
27
|
+
export type OpOverride<TOp> = TOp extends (
|
|
28
|
+
http: HttpClient,
|
|
29
|
+
ctx: ResourceContext,
|
|
30
|
+
...args: infer A
|
|
31
|
+
) => Promise<infer R>
|
|
32
|
+
? (next: (...args: A) => Promise<R>, ...args: A) => Promise<R>
|
|
33
|
+
: never;
|
|
34
|
+
|
|
35
|
+
export interface ResourceOverride<TOps extends ResourceOps> {
|
|
36
|
+
/** Only the endpoint, project or locale this resource is read under. */
|
|
37
|
+
context?: Partial<ResourceContext>;
|
|
38
|
+
/** Only the transport, e.g. a client with a different auth. */
|
|
39
|
+
http?: HttpClient;
|
|
40
|
+
/** Individual operations, each wrapping the platform's. */
|
|
41
|
+
ops?: { [K in keyof TOps]?: OpOverride<TOps[K]> };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Resources by key, in the shape a registry holds them. */
|
|
45
|
+
export type ResourceMap = Record<string, ResourceDef<ResourceOps>>;
|
|
46
|
+
|
|
47
|
+
export type ResourceOverrides<TMap extends ResourceMap> = {
|
|
48
|
+
[K in keyof TMap]?: ResourceOverride<TMap[K]['ops']>;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export interface ResourceRegistry<TMap extends ResourceMap = ResourceMap> {
|
|
52
|
+
/** The bound operations of one resource. Throws for an unknown key. */
|
|
53
|
+
get<K extends keyof TMap>(key: K): Bound<TMap[K]['ops']>;
|
|
54
|
+
/** Every key the registry can answer for. */
|
|
55
|
+
keys(): string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface CreateResourceRegistryOptions<TMap extends ResourceMap> {
|
|
59
|
+
/**
|
|
60
|
+
* Required — there is deliberately no fallback.
|
|
61
|
+
*
|
|
62
|
+
* A registry that quietly reached for `fetch` when nobody passed a client
|
|
63
|
+
* would send every request without the app's `Authorization` header, and
|
|
64
|
+
* the failure is silent: the call 401s, one card renders an em dash, and
|
|
65
|
+
* nothing is logged. Three such call sites existed in this package and had
|
|
66
|
+
* to be found by reading. Asking for the client makes them unwritable.
|
|
67
|
+
*/
|
|
68
|
+
http: HttpClient;
|
|
69
|
+
/** Read per call, so a switched locale or project applies to what follows. */
|
|
70
|
+
context: ResourceContext | (() => ResourceContext);
|
|
71
|
+
/** The resources on offer. */
|
|
72
|
+
resources: TMap;
|
|
73
|
+
/** Per-resource adjustments from the app. */
|
|
74
|
+
overrides?: ResourceOverrides<TMap>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function boundWithOverride<TOps extends ResourceOps>(
|
|
78
|
+
def: ResourceDef<TOps>,
|
|
79
|
+
http: HttpClient,
|
|
80
|
+
readContext: () => ResourceContext,
|
|
81
|
+
override: ResourceOverride<TOps> | undefined,
|
|
82
|
+
): Bound<TOps> {
|
|
83
|
+
const context = override?.context
|
|
84
|
+
? () => ({ ...readContext(), ...override.context })
|
|
85
|
+
: readContext;
|
|
86
|
+
const platform = bindResource(def, override?.http ?? http, context);
|
|
87
|
+
if (!override?.ops) return platform;
|
|
88
|
+
|
|
89
|
+
const result = { ...platform } as Record<string, unknown>;
|
|
90
|
+
for (const [name, wrap] of Object.entries(override.ops)) {
|
|
91
|
+
if (typeof wrap !== 'function') continue;
|
|
92
|
+
// `Object.hasOwn`, not a truthy read: indexing walks the prototype
|
|
93
|
+
// chain, so an override named `toString` or `constructor` found
|
|
94
|
+
// `Object.prototype`'s and wrapped that instead of reporting a name the
|
|
95
|
+
// resource does not offer — the one case this check exists for. A typo
|
|
96
|
+
// like `lst` failed loudly while `constructor` passed silently.
|
|
97
|
+
if (!Object.hasOwn(platform as object, name)) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`createResourceRegistry: resource "${def.key}" has no operation "${name}" to ` +
|
|
100
|
+
`override. It offers: ${Object.keys(platform).join(', ')}.`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
const next = (platform as Record<string, (...args: never[]) => Promise<unknown>>)[name];
|
|
104
|
+
result[name] = (...args: never[]) =>
|
|
105
|
+
(wrap as (n: unknown, ...a: never[]) => Promise<unknown>)(next, ...args);
|
|
106
|
+
}
|
|
107
|
+
return result as Bound<TOps>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function createResourceRegistry<TMap extends ResourceMap>(
|
|
111
|
+
options: CreateResourceRegistryOptions<TMap>,
|
|
112
|
+
): ResourceRegistry<TMap> {
|
|
113
|
+
if (!options?.http) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
'createResourceRegistry: `http` is required. Pass the HttpClient your app already ' +
|
|
116
|
+
'uses — createAxiosHttpClient(instance) or createFetchHttpClient({ headers }) — ' +
|
|
117
|
+
'so every platform request carries its auth.',
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
const readContext =
|
|
121
|
+
typeof options.context === 'function'
|
|
122
|
+
? options.context
|
|
123
|
+
: () => options.context as ResourceContext;
|
|
124
|
+
|
|
125
|
+
// An override for a resource that is not registered would otherwise be
|
|
126
|
+
// read by nothing: the loop below walks the resources, so a typo like
|
|
127
|
+
// `planVersion` silently drops the approval or auth wrapper it was meant to
|
|
128
|
+
// add and leaves the platform operation running. A misspelled OPERATION
|
|
129
|
+
// already fails at boot, so the two spellings of the same mistake had
|
|
130
|
+
// opposite outcomes. TypeScript catches this for a typed consumer; a
|
|
131
|
+
// JavaScript one, or a config assembled at runtime, gets nothing.
|
|
132
|
+
// `hasOwn`, not `in`: `in` walks the prototype chain, so an override named
|
|
133
|
+
// `constructor` or `toString` passed the check and was then skipped by the
|
|
134
|
+
// loop below — silently ignored, which is the outcome this validation
|
|
135
|
+
// exists to prevent.
|
|
136
|
+
const unknown = Object.keys(options.overrides ?? {}).filter(
|
|
137
|
+
(key) => !Object.hasOwn(options.resources, key),
|
|
138
|
+
);
|
|
139
|
+
if (unknown.length > 0) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`createResourceRegistry: no resource named ${unknown.map((k) => `"${k}"`).join(', ')} ` +
|
|
142
|
+
`to override. The registry holds: ${Object.keys(options.resources).join(', ')}.`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Bound once per resource: the context is read per call, so nothing here
|
|
147
|
+
// goes stale, and a page that asks twice gets the same operations.
|
|
148
|
+
//
|
|
149
|
+
// This loop is also where the third boot-time refusal comes from, though it
|
|
150
|
+
// is not written here: `bindResource` rejects a descriptor that declares
|
|
151
|
+
// itself project-scoped when the context names no project. It is checked
|
|
152
|
+
// per resource rather than once for the registry because being project-
|
|
153
|
+
// scoped is a property of the descriptor — one of the four platform
|
|
154
|
+
// resources reads `projectKey` and three do not, so a registry-wide demand
|
|
155
|
+
// would refuse a tenant list over a project it never asks about.
|
|
156
|
+
const bound = new Map<string, unknown>();
|
|
157
|
+
for (const [key, def] of Object.entries(options.resources)) {
|
|
158
|
+
bound.set(key, boundWithOverride(def, options.http, readContext, options.overrides?.[key]));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
get(key) {
|
|
163
|
+
const ops = bound.get(key as string);
|
|
164
|
+
if (!ops) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`useResource("${String(key)}"): no such resource. The registry offers: ` +
|
|
167
|
+
`${[...bound.keys()].join(', ')}.`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return ops as Bound<TMap[typeof key]['ops']>;
|
|
171
|
+
},
|
|
172
|
+
keys: () => [...bound.keys()],
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Vue inject key for the registry (see `Symbol.for` note in super-admin-context.ts). */
|
|
177
|
+
export const SUPER_ADMIN_RESOURCES_KEY: InjectionKey<ResourceRegistry> = Symbol.for(
|
|
178
|
+
'@saasicat/ui-vue/SUPER_ADMIN_RESOURCES',
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The operations of one resource, ready to call.
|
|
183
|
+
*
|
|
184
|
+
* Typed against the platform roster, so `useResource('plans').update(id, data)`
|
|
185
|
+
* carries the argument types the descriptor declares and an unknown key is a
|
|
186
|
+
* compile error. Without that mapping the key parameter erased the operations
|
|
187
|
+
* to `ResourceOps`, whose `never[]` constraint made *every* call with an
|
|
188
|
+
* argument a type error while accepting any misspelled resource name — the
|
|
189
|
+
* exact opposite of the guarantee the registry exists for.
|
|
190
|
+
*
|
|
191
|
+
* An app with its own resources passes its map as the second type argument.
|
|
192
|
+
*
|
|
193
|
+
* Throws when the shell has no registry rather than returning something
|
|
194
|
+
* inert — a page whose data silently never arrives is harder to diagnose than
|
|
195
|
+
* one that says what is missing.
|
|
196
|
+
*/
|
|
197
|
+
export function useResource<
|
|
198
|
+
K extends keyof TMap & string,
|
|
199
|
+
TMap extends ResourceMap = PlatformResources,
|
|
200
|
+
>(key: K): Bound<TMap[K]['ops']> {
|
|
201
|
+
const registry = inject(SUPER_ADMIN_RESOURCES_KEY, null);
|
|
202
|
+
if (!registry) {
|
|
203
|
+
throw new Error(
|
|
204
|
+
`useResource("${key}"): no resource registry in scope. Either the component was ` +
|
|
205
|
+
'not mounted inside createSuperAdminApp(), or the app did not pass an `http` ' +
|
|
206
|
+
'client — the registry is not installed without one, because a bare fetch would ' +
|
|
207
|
+
'send every request without your Authorization header and fail silently.',
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
return registry.get(key) as Bound<TMap[K]['ops']>;
|
|
211
|
+
}
|
|
@@ -37,6 +37,21 @@ export interface SuperAdminEndpoints {
|
|
|
37
37
|
publicBootEndpoint?: string;
|
|
38
38
|
/** Post-login full-manifest endpoint, default `${apiBase}/manifest`. */
|
|
39
39
|
manifestEndpoint?: string;
|
|
40
|
+
/**
|
|
41
|
+
* The project whose catalogue this admin administers.
|
|
42
|
+
*
|
|
43
|
+
* App-wide and constant, which is why it belongs here. Today each page
|
|
44
|
+
* takes it as a prop and each consumer passes it again per route — the
|
|
45
|
+
* same value, written out once per page, with nothing keeping the copies
|
|
46
|
+
* in step.
|
|
47
|
+
*
|
|
48
|
+
* Optional in the type, but required in practice for an app that also
|
|
49
|
+
* passes `http`: that app gets the resource registry, the registry holds
|
|
50
|
+
* the plan catalogue, and binding a project-scoped resource without a
|
|
51
|
+
* project is refused at boot rather than sent as `?projectKey=`. An app
|
|
52
|
+
* that names no client gets no registry and needs no project.
|
|
53
|
+
*/
|
|
54
|
+
projectKey?: string;
|
|
40
55
|
}
|
|
41
56
|
|
|
42
57
|
export type ExtensionLoader = () => Promise<Component | { default: Component }>;
|
|
@@ -134,11 +149,11 @@ export interface SuperAdminManifestGuardOptions {
|
|
|
134
149
|
* Discards the cached manifest, so the next `ensureLoaded()` fetches a
|
|
135
150
|
* fresh body instead of revalidating.
|
|
136
151
|
*
|
|
137
|
-
* Needed because the loader keeps an ETag in storage
|
|
138
|
-
*
|
|
139
|
-
* a
|
|
140
|
-
*
|
|
141
|
-
*
|
|
152
|
+
* Needed because the loader keeps an ETag in storage, which a full page
|
|
153
|
+
* reload does not touch. A plain retry therefore sends `If-None-Match`,
|
|
154
|
+
* and a 304 hands back the very manifest the app already had — so an
|
|
155
|
+
* operator retrying after a deployment would keep looking at the old one.
|
|
156
|
+
* Clearing first is what makes the retry ask an open question.
|
|
142
157
|
*
|
|
143
158
|
* `ManifestLoader.clearCache()` and the manifest store's reset both fit;
|
|
144
159
|
* pass whichever the app uses.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// UI confirm port — the seam a page asks a question through before doing
|
|
2
|
+
// something it cannot undo. The counterpart to the notify port next door:
|
|
3
|
+
// pages state what they need answered, the host app decides what that looks
|
|
4
|
+
// like.
|
|
5
|
+
//
|
|
6
|
+
// `createSuperAdminApp()` (`@saasicat/ui-vue/quasar`) provides a Quasar-backed
|
|
7
|
+
// default; apps override it via `createSuperAdminApp({ confirm })` or by
|
|
8
|
+
// providing the key themselves.
|
|
9
|
+
//
|
|
10
|
+
// The shape is taken from the six confirmations the standard pages actually
|
|
11
|
+
// raise, not from what a confirm dialog could be in general. Three of them ask
|
|
12
|
+
// a yes/no question; three also need a value back — an audit reason the backend
|
|
13
|
+
// requires before it will reset a password or deactivate a user, and a date a
|
|
14
|
+
// pilot is extended to. A port returning a bare boolean would leave those three
|
|
15
|
+
// on a direct Quasar call, which is the thing this port exists to remove.
|
|
16
|
+
//
|
|
17
|
+
// One dialog in the pages is deliberately NOT modelled here: `UsersPage` shows
|
|
18
|
+
// a generated one-time password with a single acknowledge button. It asks
|
|
19
|
+
// nothing, and giving it a cancel button would let an operator dismiss a
|
|
20
|
+
// password that cannot be retrieved again.
|
|
21
|
+
|
|
22
|
+
import type { InjectionKey } from 'vue';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* How the confirming button is coloured. `negative` marks an action that
|
|
26
|
+
* destroys or revokes something; `primary` marks one that does not.
|
|
27
|
+
*/
|
|
28
|
+
export type UiConfirmTone = 'primary' | 'negative';
|
|
29
|
+
|
|
30
|
+
/** Asks for a value along with the confirmation. */
|
|
31
|
+
export interface UiConfirmPrompt {
|
|
32
|
+
/** Pre-filled value, e.g. the date a pilot currently ends on. */
|
|
33
|
+
initial?: string;
|
|
34
|
+
/** Input kind. Only the two the standard pages need. */
|
|
35
|
+
type?: 'text' | 'date';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface UiConfirmRequest {
|
|
39
|
+
/** What is about to happen, naming the subject. */
|
|
40
|
+
title: string;
|
|
41
|
+
/**
|
|
42
|
+
* The consequence, in a sentence. For an action that cannot be reversed,
|
|
43
|
+
* say so here rather than asking "are you sure".
|
|
44
|
+
*/
|
|
45
|
+
message: string;
|
|
46
|
+
/** Label of the confirming button — a verb, not "OK". */
|
|
47
|
+
confirmLabel: string;
|
|
48
|
+
/** Label of the dismissing button. */
|
|
49
|
+
cancelLabel: string;
|
|
50
|
+
/** Default `primary`. Destructive actions pass `negative`. */
|
|
51
|
+
tone?: UiConfirmTone;
|
|
52
|
+
/** When set, the operator is asked for a value as well. */
|
|
53
|
+
prompt?: UiConfirmPrompt;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface UiConfirmResult {
|
|
57
|
+
/** Whether the operator confirmed. Dismissing counts as `false`. */
|
|
58
|
+
ok: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* What the operator entered, when the request carried a `prompt`.
|
|
61
|
+
* `undefined` otherwise, and whenever `ok` is `false`.
|
|
62
|
+
*/
|
|
63
|
+
value?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Confirm port. Resolves once the operator has answered — an implementation
|
|
68
|
+
* that resolves `{ ok: true }` without asking would turn every guarded action
|
|
69
|
+
* into an unguarded one, so there is no such default.
|
|
70
|
+
*/
|
|
71
|
+
export type UiConfirm = (request: UiConfirmRequest) => Promise<UiConfirmResult>;
|
|
72
|
+
|
|
73
|
+
/** Vue inject key for the confirm port (see `Symbol.for` note in super-admin-context.ts). */
|
|
74
|
+
export const SUPER_ADMIN_CONFIRM_KEY: InjectionKey<UiConfirm> = Symbol.for(
|
|
75
|
+
'@saasicat/ui-vue/SUPER_ADMIN_CONFIRM',
|
|
76
|
+
);
|
package/src/vue/use-api-list.ts
CHANGED
|
@@ -1,18 +1,35 @@
|
|
|
1
1
|
// useApiList — generic reactive list composable with filter + pagination.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// The untyped escape hatch, for an app's own list endpoints: it takes the URL
|
|
4
|
+
// per call site, so nothing needs to know the endpoint in advance.
|
|
5
|
+
// `useResourceList` is the typed default over the platform's own endpoints,
|
|
6
|
+
// which the resource registry already knows the base of.
|
|
7
|
+
//
|
|
8
|
+
// What both send is one decision and lives in `list-resource.ts` — the query
|
|
9
|
+
// string, the page bounds, and how an answer is read. Only the state around it
|
|
10
|
+
// differs, and it differs on purpose: this one keeps the last known `total`
|
|
11
|
+
// when an answer omits it, and has no guard against a superseded load.
|
|
6
12
|
|
|
7
13
|
import { ref, watch, type Ref } from 'vue';
|
|
14
|
+
import {
|
|
15
|
+
LIST_FIRST_PAGE,
|
|
16
|
+
LIST_PAGE_SIZE_DEFAULT,
|
|
17
|
+
clampListPage,
|
|
18
|
+
clampListPageSize,
|
|
19
|
+
listUrl,
|
|
20
|
+
readListPage,
|
|
21
|
+
type ResourceListPage,
|
|
22
|
+
} from '../client/resources/list-resource.js';
|
|
8
23
|
import { defaultHttpClient, type HttpClient } from '../client/types.js';
|
|
9
24
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
25
|
+
/**
|
|
26
|
+
* The envelope a list endpoint may answer with.
|
|
27
|
+
*
|
|
28
|
+
* @deprecated Use `ResourceListPage`, which is this shape under the name the
|
|
29
|
+
* resource layer works with. Kept as an alias so an app that typed its own
|
|
30
|
+
* endpoint against it keeps compiling; one shape, one definition.
|
|
31
|
+
*/
|
|
32
|
+
export type ApiListResponse<T> = ResourceListPage<T>;
|
|
16
33
|
|
|
17
34
|
export interface UseApiListOptions<TFilter> {
|
|
18
35
|
endpoint: string;
|
|
@@ -51,23 +68,18 @@ export function useApiList<T, TFilter extends Record<string, unknown> = Record<s
|
|
|
51
68
|
): UseApiListResult<T> {
|
|
52
69
|
const http = options.http ?? defaultHttpClient();
|
|
53
70
|
const items = ref<T[]>([]) as Ref<T[]>;
|
|
54
|
-
const page = ref(
|
|
55
|
-
const pageSize = ref(
|
|
71
|
+
const page = ref(LIST_FIRST_PAGE);
|
|
72
|
+
const pageSize = ref(LIST_PAGE_SIZE_DEFAULT);
|
|
56
73
|
const total = ref(0);
|
|
57
74
|
const loading = ref(false);
|
|
58
75
|
const error = ref<Error | null>(null);
|
|
59
76
|
|
|
60
77
|
function buildUrl(): string {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (v === undefined || v === null || v === '') continue;
|
|
67
|
-
params.set(k, String(v));
|
|
68
|
-
}
|
|
69
|
-
const sep = options.endpoint.includes('?') ? '&' : '?';
|
|
70
|
-
return `${options.endpoint}${sep}${params.toString()}`;
|
|
78
|
+
return listUrl(options.endpoint, {
|
|
79
|
+
page: page.value,
|
|
80
|
+
pageSize: pageSize.value,
|
|
81
|
+
filter: options.filter?.value,
|
|
82
|
+
});
|
|
71
83
|
}
|
|
72
84
|
|
|
73
85
|
async function load() {
|
|
@@ -81,25 +93,19 @@ export function useApiList<T, TFilter extends Record<string, unknown> = Record<s
|
|
|
81
93
|
if (res.status !== 200) {
|
|
82
94
|
throw new Error(`Endpoint ${options.endpoint} → HTTP ${res.status}`);
|
|
83
95
|
}
|
|
84
|
-
// Apps deliver different shapes for list endpoints
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if (typeof body.pageSize === 'number') pageSize.value = body.pageSize;
|
|
98
|
-
if (typeof body.total === 'number') total.value = body.total;
|
|
99
|
-
} else {
|
|
100
|
-
items.value = [];
|
|
101
|
-
total.value = 0;
|
|
102
|
-
}
|
|
96
|
+
// Apps deliver different shapes for list endpoints — a raw array or
|
|
97
|
+
// the `{ items, total, page, pageSize }` envelope — and `readListPage`
|
|
98
|
+
// accepts both, so a correctly delivered array is not shown as an
|
|
99
|
+
// empty list.
|
|
100
|
+
//
|
|
101
|
+
// What the answer did not state is left alone rather than reset: an
|
|
102
|
+
// envelope without `total` keeps the count from the last one that
|
|
103
|
+
// carried it, which is what this composable has always done.
|
|
104
|
+
const body = readListPage<T>(await res.json());
|
|
105
|
+
items.value = body.items;
|
|
106
|
+
if (typeof body.page === 'number') page.value = body.page;
|
|
107
|
+
if (typeof body.pageSize === 'number') pageSize.value = body.pageSize;
|
|
108
|
+
if (typeof body.total === 'number') total.value = body.total;
|
|
103
109
|
} catch (err) {
|
|
104
110
|
error.value = err instanceof Error ? err : new Error(String(err));
|
|
105
111
|
items.value = [];
|
|
@@ -110,14 +116,13 @@ export function useApiList<T, TFilter extends Record<string, unknown> = Record<s
|
|
|
110
116
|
}
|
|
111
117
|
|
|
112
118
|
async function goToPage(p: number) {
|
|
113
|
-
page.value =
|
|
119
|
+
page.value = clampListPage(p);
|
|
114
120
|
await load();
|
|
115
121
|
}
|
|
116
122
|
|
|
117
123
|
async function setPageSize(size: number) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
page.value = 1;
|
|
124
|
+
pageSize.value = clampListPageSize(size);
|
|
125
|
+
page.value = LIST_FIRST_PAGE;
|
|
121
126
|
await load();
|
|
122
127
|
}
|
|
123
128
|
|
|
@@ -125,7 +130,7 @@ export function useApiList<T, TFilter extends Record<string, unknown> = Record<s
|
|
|
125
130
|
watch(
|
|
126
131
|
options.filter,
|
|
127
132
|
() => {
|
|
128
|
-
page.value =
|
|
133
|
+
page.value = LIST_FIRST_PAGE;
|
|
129
134
|
void load();
|
|
130
135
|
},
|
|
131
136
|
{ deep: true },
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// One async mutation, with the state a page otherwise writes by hand.
|
|
2
|
+
//
|
|
3
|
+
// The pages hold this shape 20-odd times: a `ref(false)` set true before the
|
|
4
|
+
// call and false in `finally`, an error ref cleared before and written in
|
|
5
|
+
// `catch`, and a toast raised on one or both outcomes. Written out each time,
|
|
6
|
+
// the parts drift — some clear the error first and some do not, some toast on
|
|
7
|
+
// failure and some only render it, and each one re-derives its message from a
|
|
8
|
+
// raw `unknown`.
|
|
9
|
+
//
|
|
10
|
+
// `run` does not re-throw. A wrapper that recorded the failure and then threw
|
|
11
|
+
// it again would leave every call site with the `try`/`catch` this exists to
|
|
12
|
+
// remove, so the outcome is in the return value instead.
|
|
13
|
+
//
|
|
14
|
+
// It is a discriminated result rather than `T | undefined`, because the second
|
|
15
|
+
// shape cannot answer the question for the actions this package has most of.
|
|
16
|
+
// `softDelete`, `discardDraft`, `remove` and their siblings resolve
|
|
17
|
+
// `Promise<void>`: a successful call already produces `undefined`, so
|
|
18
|
+
// "`undefined` means it failed" was a signal that did not exist there — and
|
|
19
|
+
// TypeScript narrows the success branch of `void | undefined` to `never`, so
|
|
20
|
+
// the wrong call site compiles, lints clean, and silently never runs its
|
|
21
|
+
// follow-up.
|
|
22
|
+
|
|
23
|
+
import { inject, ref, type Ref } from 'vue';
|
|
24
|
+
|
|
25
|
+
import { adminErrorMessage, toAdminError, type AdminError } from '../client/admin-error.js';
|
|
26
|
+
import { useSaMessages } from './use-super-admin-i18n.js';
|
|
27
|
+
import { SUPER_ADMIN_NOTIFY_KEY, type UiNotify } from './ui-notify.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What an action did. `ok` is the branch to read — it answers for a
|
|
31
|
+
* `Promise<void>` action exactly as well as for one that returns a row.
|
|
32
|
+
*/
|
|
33
|
+
export type AsyncActionResult<T> = { ok: true; value: T } | { ok: false; error: AdminError };
|
|
34
|
+
|
|
35
|
+
export interface AsyncAction<A extends unknown[], T> {
|
|
36
|
+
/** Runs the action. Never throws; the outcome is in the result. */
|
|
37
|
+
run: (...args: A) => Promise<AsyncActionResult<T>>;
|
|
38
|
+
/** True while the call is in flight — what a submit button disables on. */
|
|
39
|
+
pending: Ref<boolean>;
|
|
40
|
+
/** The last failure, or `null`. Cleared at the start of every run. */
|
|
41
|
+
error: Ref<AdminError | null>;
|
|
42
|
+
/** Clears `error` without running anything. */
|
|
43
|
+
reset: () => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface UseAsyncActionOptions<T> {
|
|
47
|
+
/**
|
|
48
|
+
* Which outcomes raise a toast. Default `'error'` — a success that changed
|
|
49
|
+
* something visible needs no announcement, a failure always does.
|
|
50
|
+
*/
|
|
51
|
+
notifyOn?: 'error' | 'both' | 'none';
|
|
52
|
+
/** Text for the success toast. Required for `notifyOn: 'both'` to say anything. */
|
|
53
|
+
successMessage?: string | (() => string);
|
|
54
|
+
/**
|
|
55
|
+
* Overrides how a failure is worded.
|
|
56
|
+
*
|
|
57
|
+
* Not decoration: several pages map a status and an error code to a
|
|
58
|
+
* specific sentence — a 422 carrying `STRICT_MODE_VIOLATIONS` reads out the
|
|
59
|
+
* violations, a 401 says the session expired. `AdminError` carries
|
|
60
|
+
* `status`, `code` and `body`, so that mapping is a pure function of the
|
|
61
|
+
* error, and this is where it attaches.
|
|
62
|
+
*/
|
|
63
|
+
errorMessage?: (error: AdminError) => string;
|
|
64
|
+
/** Runs after a successful call, before `run` resolves. */
|
|
65
|
+
onSuccess?: (result: T) => void | Promise<void>;
|
|
66
|
+
/**
|
|
67
|
+
* Notify port to report through. Defaults to the injected one.
|
|
68
|
+
*
|
|
69
|
+
* There is no fallback beyond that: this layer must not import Quasar, so
|
|
70
|
+
* it cannot reach `quasarNotify` the way a page can. Without a bootstrap
|
|
71
|
+
* and without this option, failures are reported through `error` alone.
|
|
72
|
+
*/
|
|
73
|
+
notify?: UiNotify;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Announces an outcome without being able to change it.
|
|
78
|
+
*
|
|
79
|
+
* A report is not part of the action. A notify port whose notification centre
|
|
80
|
+
* is not mounted used to throw from inside the `try`, so a mutation the server
|
|
81
|
+
* had already applied came back as a failure — and because the catch then
|
|
82
|
+
* announced *that*, the same port threw again and `run()` rejected outright. A
|
|
83
|
+
* caller answering a failed write with a retry repeated a non-idempotent
|
|
84
|
+
* request over a toast.
|
|
85
|
+
*
|
|
86
|
+
* The throw is isolated, not swallowed: it is raised again out of band, where
|
|
87
|
+
* the app's error handler still sees it and no result is left to corrupt.
|
|
88
|
+
*/
|
|
89
|
+
function report(announce: () => void): void {
|
|
90
|
+
try {
|
|
91
|
+
announce();
|
|
92
|
+
} catch (err: unknown) {
|
|
93
|
+
queueMicrotask(() => {
|
|
94
|
+
throw err;
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function useAsyncAction<A extends unknown[], T>(
|
|
100
|
+
fn: (...args: A) => Promise<T>,
|
|
101
|
+
options: UseAsyncActionOptions<T> = {},
|
|
102
|
+
): AsyncAction<A, T> {
|
|
103
|
+
const pending = ref(false);
|
|
104
|
+
const error = ref<AdminError | null>(null);
|
|
105
|
+
const notify = options.notify ?? inject(SUPER_ADMIN_NOTIFY_KEY, undefined);
|
|
106
|
+
const messages = useSaMessages('errors');
|
|
107
|
+
const notifyOn = options.notifyOn ?? 'error';
|
|
108
|
+
// How many invocations are in flight. A second `run` before the first
|
|
109
|
+
// settles would otherwise let the first one's `finally` clear `pending`,
|
|
110
|
+
// and a button disabled from that ref becomes clickable again while the
|
|
111
|
+
// mutation it belongs to is still running — which is how a double submit
|
|
112
|
+
// happens.
|
|
113
|
+
let inFlight = 0;
|
|
114
|
+
// Which invocation the visible state belongs to. `error` is cleared at the
|
|
115
|
+
// start of every run, so without this an older call failing after a newer
|
|
116
|
+
// one started would write its failure into a state the newer call had
|
|
117
|
+
// already reset — and the newer success, which only clears at ITS start,
|
|
118
|
+
// never removed it. The operator was left looking at an error for an
|
|
119
|
+
// operation that had succeeded.
|
|
120
|
+
let generation = 0;
|
|
121
|
+
|
|
122
|
+
async function run(...args: A): Promise<AsyncActionResult<T>> {
|
|
123
|
+
const mine = ++generation;
|
|
124
|
+
inFlight++;
|
|
125
|
+
pending.value = true;
|
|
126
|
+
error.value = null;
|
|
127
|
+
try {
|
|
128
|
+
const result = await fn(...args);
|
|
129
|
+
// The continuation runs BEFORE the success toast. It is part of
|
|
130
|
+
// the action — a reload, a navigation — and when it fails the
|
|
131
|
+
// action failed. Announcing success first produced both toasts:
|
|
132
|
+
// "Saved" and then "reload failed", in that order.
|
|
133
|
+
await options.onSuccess?.(result);
|
|
134
|
+
if (notifyOn === 'both') {
|
|
135
|
+
report(() => {
|
|
136
|
+
const message =
|
|
137
|
+
typeof options.successMessage === 'function'
|
|
138
|
+
? options.successMessage()
|
|
139
|
+
: options.successMessage;
|
|
140
|
+
if (message) notify?.('positive', message);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return { ok: true, value: result };
|
|
144
|
+
} catch (err: unknown) {
|
|
145
|
+
const adminError = toAdminError(err);
|
|
146
|
+
if (mine === generation) error.value = adminError;
|
|
147
|
+
if (notifyOn !== 'none') {
|
|
148
|
+
report(() =>
|
|
149
|
+
notify?.(
|
|
150
|
+
'negative',
|
|
151
|
+
options.errorMessage?.(adminError) ??
|
|
152
|
+
adminErrorMessage(adminError, messages.value),
|
|
153
|
+
),
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
return { ok: false, error: adminError };
|
|
157
|
+
} finally {
|
|
158
|
+
if (--inFlight === 0) pending.value = false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
run,
|
|
164
|
+
pending,
|
|
165
|
+
error,
|
|
166
|
+
reset: () => {
|
|
167
|
+
error.value = null;
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|