@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.
Files changed (90) hide show
  1. package/README.md +32 -8
  2. package/dist/{catalog-DJcfm5N2.d.cts → catalog-Dch1Ryw0.d.cts} +353 -69
  3. package/dist/{catalog-DJcfm5N2.d.ts → catalog-Dch1Ryw0.d.ts} +353 -69
  4. package/dist/{chunk-F7NRM6KI.js → chunk-BPF2BMCQ.js} +748 -24
  5. package/dist/{chunk-D74FFCCP.js → chunk-NEXTZZRQ.js} +95 -177
  6. package/dist/{chunk-TQQMKWN6.js → chunk-O3J3ITF2.js} +119 -7
  7. package/dist/client/index.cjs +758 -83
  8. package/dist/client/index.d.cts +414 -9
  9. package/dist/client/index.d.ts +414 -9
  10. package/dist/client/index.js +84 -12
  11. package/dist/index.cjs +1325 -294
  12. package/dist/index.d.cts +205 -15
  13. package/dist/index.d.ts +205 -15
  14. package/dist/index.js +566 -240
  15. package/dist/quasar/index.cjs +574 -62
  16. package/dist/quasar/index.d.cts +61 -6
  17. package/dist/quasar/index.d.ts +61 -6
  18. package/dist/quasar/index.js +83 -19
  19. package/dist/{use-sa-theme-OUBlaqgl.d.ts → resource-registry-D7Xjs-5f.d.cts} +157 -7
  20. package/dist/{use-sa-theme-B5t7oXph.d.cts → resource-registry-TaR7rtq0.d.ts} +157 -7
  21. package/package.json +4 -3
  22. package/src/client/admin-error.ts +552 -0
  23. package/src/client/admin-resource-client.ts +9 -12
  24. package/src/client/batch-column-fetcher.ts +2 -2
  25. package/src/client/boot-loader.ts +12 -1
  26. package/src/client/http/axios-http-client.ts +383 -0
  27. package/src/client/http/fetch-http-client.ts +89 -0
  28. package/src/client/http/index.ts +6 -0
  29. package/src/client/http-json.ts +82 -16
  30. package/src/client/i18n/messages/discovery.ts +0 -6
  31. package/src/client/i18n/messages/errors.ts +44 -0
  32. package/src/client/i18n/messages/marketing.ts +0 -6
  33. package/src/client/i18n/messages/promos.ts +0 -2
  34. package/src/client/i18n/messages/shell.ts +12 -0
  35. package/src/client/i18n/messages.ts +3 -0
  36. package/src/client/index.ts +6 -0
  37. package/src/client/manifest-loader.ts +65 -15
  38. package/src/client/resources/audit.resource.ts +25 -0
  39. package/src/client/resources/define-resource.ts +166 -0
  40. package/src/client/resources/index.ts +42 -0
  41. package/src/client/resources/list-resource.ts +176 -0
  42. package/src/client/resources/plans.resource.ts +169 -0
  43. package/src/client/resources/resource-request.ts +117 -0
  44. package/src/client/resources/tenants.resource.ts +27 -0
  45. package/src/client/types.ts +10 -3
  46. package/src/components/BundleVersionPublishDialog.vue +1 -1
  47. package/src/components/MarketingPromotionsTab.vue +263 -241
  48. package/src/components/ThemeSwitcher.vue +85 -0
  49. package/src/components/admin-page/AdminAccordion.vue +125 -0
  50. package/src/components/bundle-editor/BundleCreatePanel.vue +1 -1
  51. package/src/components/dialogs/PromoCodeDialogFields.vue +20 -20
  52. package/src/components/plan/PlanCycleToggle.vue +1 -1
  53. package/src/components/plan-detail/PlanVersionsPanel.vue +5 -2
  54. package/src/index.ts +5 -0
  55. package/src/pages-standard/AdminLayout.vue +57 -0
  56. package/src/pages-standard/AdminManifestErrorPage.vue +4 -3
  57. package/src/pages-standard/BundlesPage.vue +5 -35
  58. package/src/pages-standard/DiscoveryPage.vue +8 -1
  59. package/src/pages-standard/MarketingCatalogPage.vue +14 -3
  60. package/src/pages-standard/SuperAdminLoginPage.vue +23 -2
  61. package/src/pages-standard/SuperAdminSetupWizard.vue +37 -3
  62. package/src/pages-standard/bundles-page/BundleAccordionList.vue +30 -32
  63. package/src/pages-standard/discovery-page/DiscoveryFeatureCard.vue +80 -88
  64. package/src/pages-standard/discovery-page/DiscoveryQuotaCard.vue +73 -79
  65. package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +44 -6
  66. package/src/pages-standard/marketing-catalog/MarketingCatalogPreview.vue +8 -2
  67. package/src/pages-tenant/PackageSnapshotPanel.vue +31 -2
  68. package/src/quasar/confirm.ts +83 -0
  69. package/src/quasar/create-super-admin-app.ts +84 -10
  70. package/src/quasar/dark-bridge.ts +47 -10
  71. package/src/quasar/index.ts +1 -0
  72. package/src/ui/theme/components/accordion.css +118 -0
  73. package/src/ui/theme/index.css +1 -0
  74. package/src/vue/create-admin-routes.ts +9 -1
  75. package/src/vue/platform-loaders.ts +12 -2
  76. package/src/vue/resource-registry.ts +211 -0
  77. package/src/vue/super-admin-context.ts +20 -5
  78. package/src/vue/ui-confirm.ts +76 -0
  79. package/src/vue/use-api-list.ts +50 -45
  80. package/src/vue/use-async-action.ts +170 -0
  81. package/src/vue/use-async-data.ts +83 -0
  82. package/src/vue/use-bundles.ts +34 -7
  83. package/src/vue/use-catalog-entries.ts +37 -12
  84. package/src/vue/use-discovery.ts +27 -6
  85. package/src/vue/use-marketing-projections.ts +29 -7
  86. package/src/vue/use-plans.ts +36 -8
  87. package/src/vue/use-promotions.ts +27 -7
  88. package/src/vue/use-resource-list.ts +272 -0
  89. package/src/vue/use-sa-theme.ts +34 -5
  90. 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. Its own documented
138
- * failure "server returned 304 but the cache body is missing" — survives
139
- * a full page reload: the next request sends the same `If-None-Match`,
140
- * gets another 304, and lands back on the error page. Retry is a dead end
141
- * for that case unless something clears the ETag first.
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
+ );
@@ -1,18 +1,35 @@
1
1
  // useApiList — generic reactive list composable with filter + pagination.
2
2
  //
3
- // Consumer components ($-table, $-list, …) consume the typed wrappers
4
- // (`useTenants`, `useAuditEntries`, …), which are based on `useApiList`.
5
- // Direct use is also allowed for custom endpoints.
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
- export interface ApiListResponse<T> {
11
- items: T[];
12
- page?: number;
13
- pageSize?: number;
14
- total?: number;
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(1);
55
- const pageSize = ref(50);
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
- const params = new URLSearchParams();
62
- params.set('page', String(page.value));
63
- params.set('pageSize', String(pageSize.value));
64
- const f = options.filter?.value ?? ({} as TFilter);
65
- for (const [k, v] of Object.entries(f)) {
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
- // - raw array `[{...}, …]` (no wrapper).
86
- // - paginated: `{ items, total, page, pageSize }`.
87
- // The platform composable accepts both — otherwise correctly
88
- // delivered array responses would be shown as an empty list.
89
- const raw = (await res.json()) as unknown;
90
- if (Array.isArray(raw)) {
91
- items.value = raw as T[];
92
- total.value = raw.length;
93
- } else if (raw !== null && typeof raw === 'object') {
94
- const body = raw as ApiListResponse<T>;
95
- items.value = body.items ?? [];
96
- if (typeof body.page === 'number') page.value = body.page;
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 = Math.max(1, Math.floor(p));
119
+ page.value = clampListPage(p);
114
120
  await load();
115
121
  }
116
122
 
117
123
  async function setPageSize(size: number) {
118
- // The admin API's pageSize is 1..200; there is no "all" to pass on.
119
- pageSize.value = Math.min(200, Math.max(1, Math.floor(size)));
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 = 1;
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
+ }