@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
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
// (e.g. `/api/admin` or `/api/v1/admin`).
|
|
6
6
|
|
|
7
7
|
import { ref, type Ref } from 'vue';
|
|
8
|
+
import { markEmptyResponse, markPlatformError } from '../client/admin-error.js';
|
|
8
9
|
import type { CreatePromotionData, PromotionRow, UpdatePromotionData } from '@saasicat/types';
|
|
9
|
-
import {
|
|
10
|
+
import { requireServerAnswer } from '../client/http-json.js';
|
|
10
11
|
import { defaultHttpClient, type HttpClient } from '../client/types.js';
|
|
11
|
-
import { useSaMessages } from './use-super-admin-i18n.js';
|
|
12
12
|
|
|
13
13
|
export interface UsePromotionsOptions {
|
|
14
14
|
adminEndpoint: string;
|
|
@@ -18,6 +18,12 @@ export interface UsePromotionsOptions {
|
|
|
18
18
|
autoLoad?: boolean;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* A promotions call failed. Its `message` is a diagnostic for the log, in
|
|
23
|
+
* English like every other developer-facing string in the repository — the
|
|
24
|
+
* sentence a user is shown comes from the `errors` catalog through
|
|
25
|
+
* `adminErrorMessage`, in whichever language the shell speaks.
|
|
26
|
+
*/
|
|
21
27
|
export class PromotionsApiError extends Error {
|
|
22
28
|
constructor(
|
|
23
29
|
public readonly status: number,
|
|
@@ -26,6 +32,9 @@ export class PromotionsApiError extends Error {
|
|
|
26
32
|
) {
|
|
27
33
|
super(message);
|
|
28
34
|
this.name = 'PromotionsApiError';
|
|
35
|
+
// Identity, so `toAdminError` can tell this diagnostic from a
|
|
36
|
+
// consumer error whose message an operator needs to read.
|
|
37
|
+
markPlatformError(this);
|
|
29
38
|
}
|
|
30
39
|
}
|
|
31
40
|
|
|
@@ -47,7 +56,6 @@ export function usePromotions(options: UsePromotionsOptions): UsePromotionsResul
|
|
|
47
56
|
throw new Error('usePromotions: `projectKey` is required.');
|
|
48
57
|
}
|
|
49
58
|
|
|
50
|
-
const msg = useSaMessages('promos');
|
|
51
59
|
const http = options.http ?? defaultHttpClient();
|
|
52
60
|
const promotions = ref<PromotionRow[]>([]);
|
|
53
61
|
const loading = ref(false);
|
|
@@ -62,18 +70,28 @@ export function usePromotions(options: UsePromotionsOptions): UsePromotionsResul
|
|
|
62
70
|
}
|
|
63
71
|
|
|
64
72
|
async function fetchJson<T>(url: string, init?: Parameters<HttpClient>[1]): Promise<T | null> {
|
|
73
|
+
const method = init?.method ?? 'GET';
|
|
65
74
|
const res = await http(url, {
|
|
66
|
-
method
|
|
75
|
+
method,
|
|
67
76
|
headers: { 'content-type': 'application/json', ...authHeaders(), ...init?.headers },
|
|
68
77
|
body: init?.body,
|
|
69
78
|
});
|
|
79
|
+
// Before any body is read: `null` below has to mean "the server
|
|
80
|
+
// answered without one", which is what the callers' empty-response
|
|
81
|
+
// sentinels claim.
|
|
82
|
+
requireServerAnswer(
|
|
83
|
+
res.status,
|
|
84
|
+
method,
|
|
85
|
+
url,
|
|
86
|
+
(diagnostic) => new PromotionsApiError(res.status, null, diagnostic),
|
|
87
|
+
);
|
|
70
88
|
if (res.status === 204) return null;
|
|
71
89
|
const body = await res.json().catch(() => null);
|
|
72
90
|
if (res.status >= 400) {
|
|
73
91
|
throw new PromotionsApiError(
|
|
74
92
|
res.status,
|
|
75
93
|
body,
|
|
76
|
-
|
|
94
|
+
`Promotions API responded with HTTP ${res.status}`,
|
|
77
95
|
);
|
|
78
96
|
}
|
|
79
97
|
return body as T;
|
|
@@ -97,7 +115,8 @@ export function usePromotions(options: UsePromotionsOptions): UsePromotionsResul
|
|
|
97
115
|
method: 'POST',
|
|
98
116
|
body: JSON.stringify(data),
|
|
99
117
|
});
|
|
100
|
-
if (!created)
|
|
118
|
+
if (!created)
|
|
119
|
+
throw markEmptyResponse(new PromotionsApiError(0, null, 'Create returned no body'));
|
|
101
120
|
promotions.value = [...promotions.value, created];
|
|
102
121
|
return created;
|
|
103
122
|
}
|
|
@@ -107,7 +126,8 @@ export function usePromotions(options: UsePromotionsOptions): UsePromotionsResul
|
|
|
107
126
|
method: 'PATCH',
|
|
108
127
|
body: JSON.stringify(data),
|
|
109
128
|
});
|
|
110
|
-
if (!updated)
|
|
129
|
+
if (!updated)
|
|
130
|
+
throw markEmptyResponse(new PromotionsApiError(0, null, 'Update returned no body'));
|
|
111
131
|
promotions.value = promotions.value.map((p) => (p.id === id ? updated : p));
|
|
112
132
|
return updated;
|
|
113
133
|
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// One page of a platform list, typed, with no endpoint to pass in.
|
|
2
|
+
//
|
|
3
|
+
// The difference from `useApiList` is where the URL comes from. `useApiList`
|
|
4
|
+
// takes the endpoint per call site, which is why every page built on it carries
|
|
5
|
+
// an `endpoint` prop and every consumer app spells the same path again — and
|
|
6
|
+
// why the row type is whatever the caller claims. This asks the resource
|
|
7
|
+
// registry, which already knows the API base, the project and the locale, and
|
|
8
|
+
// the row type follows from the operation rather than from an assertion.
|
|
9
|
+
//
|
|
10
|
+
// It adds exactly one thing to `useAsyncData`: the pagination — two refs and
|
|
11
|
+
// the two controls that move them. The in-flight flag, the error, the reset to
|
|
12
|
+
// `initial` on failure, and the generation guard that keeps a superseded load
|
|
13
|
+
// from overwriting a newer one are all `useAsyncData`'s, deliberately not
|
|
14
|
+
// rewritten here. That last one is not cosmetic: `useApiList` has no such
|
|
15
|
+
// guard, so two quick filter keystrokes there can land the loser last.
|
|
16
|
+
//
|
|
17
|
+
// `useApiList` stays as the untyped escape hatch for an app's own endpoints.
|
|
18
|
+
|
|
19
|
+
import { computed, ref, watch, type ComputedRef, type Ref } from 'vue';
|
|
20
|
+
|
|
21
|
+
import { AdminError } from '../client/admin-error.js';
|
|
22
|
+
import type { ResourceOps } from '../client/resources/define-resource.js';
|
|
23
|
+
import type { PlatformResources } from '../client/resources/index.js';
|
|
24
|
+
import {
|
|
25
|
+
LIST_FIRST_PAGE,
|
|
26
|
+
LIST_PAGE_SIZE_DEFAULT,
|
|
27
|
+
LIST_PAGINATION_PARAMS,
|
|
28
|
+
clampListPage,
|
|
29
|
+
clampListPageSize,
|
|
30
|
+
isSentInQuery,
|
|
31
|
+
type ListQuery,
|
|
32
|
+
type ResourceListPage,
|
|
33
|
+
} from '../client/resources/list-resource.js';
|
|
34
|
+
import { useAsyncData } from './use-async-data.js';
|
|
35
|
+
import { useResource, type ResourceMap } from './resource-registry.js';
|
|
36
|
+
|
|
37
|
+
/** The default operation name, and the only one the platform resources offer. */
|
|
38
|
+
const DEFAULT_LIST_OP = 'list';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The state before the first load and after a failed one.
|
|
42
|
+
*
|
|
43
|
+
* Frozen and shared: `useAsyncData` restores this exact object, so a page that
|
|
44
|
+
* pushed into it would be pushing into every other list's empty state. Frozen,
|
|
45
|
+
* that is a thrown error instead of a row appearing somewhere else.
|
|
46
|
+
*/
|
|
47
|
+
const EMPTY_PAGE = Object.freeze({
|
|
48
|
+
items: Object.freeze([]) as readonly never[],
|
|
49
|
+
total: 0,
|
|
50
|
+
}) as ResourceListPage<never>;
|
|
51
|
+
|
|
52
|
+
/** The operations of a resource that answer with one page of a list. */
|
|
53
|
+
export type ListOpNames<TOps extends ResourceOps> = {
|
|
54
|
+
[N in keyof TOps]: Awaited<ReturnType<TOps[N]>> extends ResourceListPage<unknown> ? N : never;
|
|
55
|
+
}[keyof TOps];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The resources that have such an operation at all.
|
|
59
|
+
*
|
|
60
|
+
* Without this, `useResourceList('plans')` compiled: `plans.list` answers with
|
|
61
|
+
* a plain `PlanRow[]`, the row type collapsed to `never`, and the first thing
|
|
62
|
+
* to notice was `items.value` being `undefined` at runtime. Derived from the
|
|
63
|
+
* operations rather than listed, so a resource that grows a list operation
|
|
64
|
+
* becomes available here by being written, not by being remembered.
|
|
65
|
+
*/
|
|
66
|
+
export type ListResourceKey<TMap extends ResourceMap> = {
|
|
67
|
+
[K in keyof TMap]: [ListOpNames<TMap[K]['ops']>] extends [never] ? never : K;
|
|
68
|
+
}[keyof TMap];
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The row type a named list operation yields — read off the operation, not
|
|
72
|
+
* declared next to it. A hand-maintained map of resource to row is the same
|
|
73
|
+
* defect one level up: it can be right today and silently wrong tomorrow.
|
|
74
|
+
*/
|
|
75
|
+
export type RowOf<TOps extends ResourceOps, N extends keyof TOps> =
|
|
76
|
+
Awaited<ReturnType<TOps[N]>> extends ResourceListPage<infer TRow> ? TRow : never;
|
|
77
|
+
|
|
78
|
+
/** A paginated read: what a standard admin list binds its table to. */
|
|
79
|
+
export interface AsyncList<T> {
|
|
80
|
+
/** The rows of the current page. */
|
|
81
|
+
items: ComputedRef<T[]>;
|
|
82
|
+
/**
|
|
83
|
+
* Rows matching the filter when the endpoint reports a total, and rows on
|
|
84
|
+
* this page when it does not. Several admin controllers answer with a bare
|
|
85
|
+
* array, and there is no honest way to tell the two apart afterwards — see
|
|
86
|
+
* `ResourceListPage.total`.
|
|
87
|
+
*/
|
|
88
|
+
total: ComputedRef<number>;
|
|
89
|
+
/** 1-based. Moved by `goToPage`, and reset to 1 by a filter change. */
|
|
90
|
+
page: Ref<number>;
|
|
91
|
+
pageSize: Ref<number>;
|
|
92
|
+
/**
|
|
93
|
+
* True while a load is in flight. Named as in `useAsyncData` and
|
|
94
|
+
* `useAsyncAction`, whose flag this is — `useApiList` calls its own
|
|
95
|
+
* `loading`.
|
|
96
|
+
*/
|
|
97
|
+
pending: Ref<boolean>;
|
|
98
|
+
/** The last failure, or `null`. Cleared at the start of every load. */
|
|
99
|
+
error: Ref<AdminError | null>;
|
|
100
|
+
/** Loads the current page again — after a mutation, or from a retry button. */
|
|
101
|
+
reload: () => Promise<void>;
|
|
102
|
+
/** Jumps to a page (1-based, clamped) and loads. */
|
|
103
|
+
goToPage: (page: number) => Promise<void>;
|
|
104
|
+
/** Changes the page size, returns to page 1, and loads. */
|
|
105
|
+
setPageSize: (size: number) => Promise<void>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface UseResourceListOptions<TOps extends ResourceOps, N extends ListOpNames<TOps>> {
|
|
109
|
+
/** Which operation to page through. Defaults to `list`. */
|
|
110
|
+
op?: N;
|
|
111
|
+
/**
|
|
112
|
+
* Reactive filter. A change returns to page 1 and reloads — a filter that
|
|
113
|
+
* narrows the list while the operator is on page 4 would otherwise ask for
|
|
114
|
+
* a page that no longer exists and render nothing.
|
|
115
|
+
*
|
|
116
|
+
* `page` and `pageSize` are not filter keys; see the guard below.
|
|
117
|
+
*/
|
|
118
|
+
filter?: Ref<Record<string, unknown>>;
|
|
119
|
+
/**
|
|
120
|
+
* Rows per page to start with. Defaults to `LIST_PAGE_SIZE_DEFAULT`.
|
|
121
|
+
*
|
|
122
|
+
* Here rather than through `setPageSize` after construction, which is what
|
|
123
|
+
* a page with a `pageSize` prop had to do: `setPageSize` loads, and the
|
|
124
|
+
* first load was already queued, so the list fetched the same rows twice on
|
|
125
|
+
* every mount.
|
|
126
|
+
*/
|
|
127
|
+
pageSize?: number;
|
|
128
|
+
/** Load once on creation. Default `true`. */
|
|
129
|
+
immediate?: boolean;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The failure for a filter that carries the pagination's own parameters.
|
|
134
|
+
*
|
|
135
|
+
* An `AdminError` with a diagnostic `message` and no `detail`, because the two
|
|
136
|
+
* are not the same thing: `message` is for the log, and leaving `detail` unset
|
|
137
|
+
* is what lets `adminErrorMessage` answer an operator with its translated
|
|
138
|
+
* wording instead of an English sentence about a filter key.
|
|
139
|
+
*/
|
|
140
|
+
function paginationCollision(key: string, op: string, claimed: string[]): AdminError {
|
|
141
|
+
return new AdminError({
|
|
142
|
+
message:
|
|
143
|
+
`useResourceList("${key}", { op: "${op}" }): the filter carries ` +
|
|
144
|
+
`${claimed.join(' and ')}, which the list already sends. The filter is serialised ` +
|
|
145
|
+
`after the pagination, so its value wins on the wire while page.value keeps the ` +
|
|
146
|
+
`one goToPage() set — the list would report a page it is not showing. Drive the ` +
|
|
147
|
+
`page with goToPage()/setPageSize() and leave those keys out of the filter.`,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The pagination keys a filter is currently sending, if any. */
|
|
152
|
+
function paginationKeysIn(filter: Record<string, unknown>): string[] {
|
|
153
|
+
return LIST_PAGINATION_PARAMS.filter((param) => isSentInQuery(filter[param]));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The named operation, or a failure that says what the resource does offer.
|
|
158
|
+
*
|
|
159
|
+
* `Object.hasOwn` rather than an indexed read: indexing walks the prototype
|
|
160
|
+
* chain, so `op: 'toString'` would find `Object.prototype`'s and call it. The
|
|
161
|
+
* registry documents the same trap for overrides, where a typo failed loudly
|
|
162
|
+
* while a prototype name passed silently.
|
|
163
|
+
*/
|
|
164
|
+
function readListOp<TRow>(
|
|
165
|
+
key: string,
|
|
166
|
+
ops: object,
|
|
167
|
+
name: string,
|
|
168
|
+
): (query: ListQuery) => Promise<ResourceListPage<TRow>> {
|
|
169
|
+
const op = Object.hasOwn(ops, name) ? (ops as Record<string, unknown>)[name] : undefined;
|
|
170
|
+
if (typeof op !== 'function') {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`useResourceList("${key}", { op: "${name}" }): that resource has no such ` +
|
|
173
|
+
`operation. It offers: ${Object.keys(ops).join(', ')}.`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return op as (query: ListQuery) => Promise<ResourceListPage<TRow>>;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function useResourceList<
|
|
180
|
+
K extends ListResourceKey<TMap> & keyof TMap & string,
|
|
181
|
+
TMap extends ResourceMap = PlatformResources,
|
|
182
|
+
N extends ListOpNames<TMap[K]['ops']> = ListOpNames<TMap[K]['ops']>,
|
|
183
|
+
>(key: K, opts?: UseResourceListOptions<TMap[K]['ops'], N>): AsyncList<RowOf<TMap[K]['ops'], N>> {
|
|
184
|
+
type Row = RowOf<TMap[K]['ops'], N>;
|
|
185
|
+
|
|
186
|
+
const ops = useResource<K, TMap>(key);
|
|
187
|
+
const opName = (opts?.op ?? DEFAULT_LIST_OP) as string;
|
|
188
|
+
const load = readListOp<Row>(key, ops as object, opName);
|
|
189
|
+
|
|
190
|
+
// Once, synchronously, so the ordinary case — a filter type that still
|
|
191
|
+
// declares `page` — fails where it is written rather than on the first
|
|
192
|
+
// load. The per-load check below covers a filter that gains the key later.
|
|
193
|
+
if (opts?.filter) {
|
|
194
|
+
const claimed = paginationKeysIn(opts.filter.value);
|
|
195
|
+
if (claimed.length > 0) throw paginationCollision(key, opName, claimed);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const page = ref(LIST_FIRST_PAGE);
|
|
199
|
+
const pageSize = ref(clampListPageSize(opts?.pageSize ?? LIST_PAGE_SIZE_DEFAULT));
|
|
200
|
+
|
|
201
|
+
function query(): ListQuery {
|
|
202
|
+
const filter = opts?.filter?.value;
|
|
203
|
+
if (filter) {
|
|
204
|
+
const claimed = paginationKeysIn(filter);
|
|
205
|
+
if (claimed.length > 0) throw paginationCollision(key, opName, claimed);
|
|
206
|
+
}
|
|
207
|
+
return { page: page.value, pageSize: pageSize.value, filter };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const state = useAsyncData<ResourceListPage<Row>>(() => load(query()), {
|
|
211
|
+
initial: EMPTY_PAGE,
|
|
212
|
+
immediate: opts?.immediate,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// The server decides which page it served, and says so. Asking for page 99
|
|
216
|
+
// of a list that has two returns page 2, and the next request has to ask
|
|
217
|
+
// for the page being shown — otherwise `reload` asks for 99 again while the
|
|
218
|
+
// paginator reads "99 of 1" over the rows of page 2. `useApiList` adopts
|
|
219
|
+
// the echo for the same reason; the descriptor carries it and this was the
|
|
220
|
+
// only reader that dropped it.
|
|
221
|
+
//
|
|
222
|
+
// Adopted from `state.data` rather than from inside the loader, because
|
|
223
|
+
// only the load that won writes there. Doing it in the loader put the
|
|
224
|
+
// assignment ahead of the generation check: two overlapping navigations
|
|
225
|
+
// then discarded the stale rows but kept the stale echo, leaving page 2's
|
|
226
|
+
// rows under a paginator reading 3 and a reload aimed at 3.
|
|
227
|
+
//
|
|
228
|
+
// An answer that says nothing about the page leaves the requested one
|
|
229
|
+
// standing. Silence is not a statement about what was served, and moving
|
|
230
|
+
// the paginator from it would move it on its own.
|
|
231
|
+
watch(state.data, (answer) => {
|
|
232
|
+
if (typeof answer.page === 'number') page.value = answer.page;
|
|
233
|
+
if (typeof answer.pageSize === 'number') pageSize.value = answer.pageSize;
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
async function goToPage(next: number): Promise<void> {
|
|
237
|
+
page.value = clampListPage(next);
|
|
238
|
+
await state.reload();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function setPageSize(size: number): Promise<void> {
|
|
242
|
+
pageSize.value = clampListPageSize(size);
|
|
243
|
+
page.value = LIST_FIRST_PAGE;
|
|
244
|
+
await state.reload();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (opts?.filter) {
|
|
248
|
+
// Deep, and the page reset happens before the reload. `useAsyncData`'s
|
|
249
|
+
// own `watch` option is neither — it is a plain watch that calls
|
|
250
|
+
// `reload` — so the watcher lives here rather than being passed down.
|
|
251
|
+
watch(
|
|
252
|
+
opts.filter,
|
|
253
|
+
() => {
|
|
254
|
+
page.value = LIST_FIRST_PAGE;
|
|
255
|
+
void state.reload();
|
|
256
|
+
},
|
|
257
|
+
{ deep: true },
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
items: computed(() => state.data.value.items),
|
|
263
|
+
total: computed(() => state.data.value.total ?? state.data.value.items.length),
|
|
264
|
+
page,
|
|
265
|
+
pageSize,
|
|
266
|
+
pending: state.pending,
|
|
267
|
+
error: state.error,
|
|
268
|
+
reload: state.reload,
|
|
269
|
+
goToPage,
|
|
270
|
+
setPageSize,
|
|
271
|
+
};
|
|
272
|
+
}
|
package/src/vue/use-sa-theme.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import {
|
|
19
19
|
computed,
|
|
20
20
|
inject,
|
|
21
|
+
isReadonly,
|
|
21
22
|
isRef,
|
|
22
23
|
ref,
|
|
23
24
|
watch,
|
|
@@ -31,14 +32,23 @@ import { defaultKvStore, type KvStore } from '../client/types.js';
|
|
|
31
32
|
export type SaColorScheme = 'light' | 'dark' | 'system';
|
|
32
33
|
export type SaResolvedScheme = 'light' | 'dark';
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
const
|
|
35
|
+
/** The schemes the switcher offers, in the order it lists them. */
|
|
36
|
+
export const SA_COLOR_SCHEMES: readonly SaColorScheme[] = ['light', 'dark', 'system'];
|
|
37
|
+
|
|
38
|
+
/** `KvStore` key holding the scheme the operator picked in the switcher. */
|
|
39
|
+
export const SA_THEME_STORAGE_KEY = 'saasicat.theme.scheme';
|
|
36
40
|
|
|
37
41
|
export interface SaTheme {
|
|
38
42
|
/** What the operator picked. Writable — assigning switches the theme. */
|
|
39
43
|
scheme: Ref<SaColorScheme>;
|
|
40
44
|
/** What that means right now: `'system'` resolved against the OS. */
|
|
41
45
|
resolved: ComputedRef<SaResolvedScheme>;
|
|
46
|
+
/**
|
|
47
|
+
* Whether the shell renders its theme switcher. `false` when the app opted
|
|
48
|
+
* out or when `scheme` cannot be written to — a switcher that cannot change
|
|
49
|
+
* anything is worse than none.
|
|
50
|
+
*/
|
|
51
|
+
switcherEnabled: boolean;
|
|
42
52
|
/**
|
|
43
53
|
* Releases the `prefers-color-scheme` subscription and the persistence
|
|
44
54
|
* watcher. Idempotent.
|
|
@@ -65,10 +75,22 @@ export interface SaThemeOptions {
|
|
|
65
75
|
persist?: boolean;
|
|
66
76
|
/** Injection seam for tests. */
|
|
67
77
|
storage?: KvStore;
|
|
78
|
+
/**
|
|
79
|
+
* Prefix for the persisted key, mirroring `createSuperAdminI18n`. Set it
|
|
80
|
+
* when several admin apps share one origin and must not inherit each
|
|
81
|
+
* other's colour scheme.
|
|
82
|
+
*/
|
|
83
|
+
storageKeyPrefix?: string;
|
|
84
|
+
/**
|
|
85
|
+
* Renders the theme switcher in the shell chrome, default `true`. Set
|
|
86
|
+
* `false` for a deployment that ships one appearance. A readonly `scheme`
|
|
87
|
+
* ref disables it on its own — no need to set this as well.
|
|
88
|
+
*/
|
|
89
|
+
switcher?: boolean;
|
|
68
90
|
}
|
|
69
91
|
|
|
70
92
|
function isScheme(value: unknown): value is SaColorScheme {
|
|
71
|
-
return typeof value === 'string' &&
|
|
93
|
+
return typeof value === 'string' && SA_COLOR_SCHEMES.includes(value as SaColorScheme);
|
|
72
94
|
}
|
|
73
95
|
|
|
74
96
|
/**
|
|
@@ -113,18 +135,25 @@ export function createSaTheme(options: SaThemeOptions = {}): SaTheme {
|
|
|
113
135
|
const provided = options.scheme;
|
|
114
136
|
const appOwnsIt = isRef(provided);
|
|
115
137
|
|
|
116
|
-
const
|
|
138
|
+
const key = `${options.storageKeyPrefix ?? ''}${SA_THEME_STORAGE_KEY}`;
|
|
139
|
+
|
|
140
|
+
const stored = persist && !appOwnsIt ? storage.get(key) : null;
|
|
117
141
|
const scheme = isRef(provided)
|
|
118
142
|
? provided
|
|
119
143
|
: ref<SaColorScheme>(isScheme(stored) ? stored : (provided ?? 'system'));
|
|
120
144
|
const { value: system, unsubscribe } = systemScheme();
|
|
121
145
|
|
|
122
146
|
const stopPersisting =
|
|
123
|
-
persist && !appOwnsIt ? watch(scheme, (next) => storage.set(
|
|
147
|
+
persist && !appOwnsIt ? watch(scheme, (next) => storage.set(key, next)) : null;
|
|
124
148
|
|
|
125
149
|
return {
|
|
126
150
|
scheme,
|
|
127
151
|
resolved: computed(() => (scheme.value === 'system' ? system.value : scheme.value)),
|
|
152
|
+
// A readonly ref (typically a `computed` derived from a profile
|
|
153
|
+
// setting) silently swallows writes, so the switcher would be a dead
|
|
154
|
+
// control. TypeScript ignores `readonly` when checking assignability,
|
|
155
|
+
// so the guard has to be a runtime one — same as the locale next door.
|
|
156
|
+
switcherEnabled: (options.switcher ?? true) && !isReadonly(scheme),
|
|
128
157
|
dispose: () => {
|
|
129
158
|
unsubscribe();
|
|
130
159
|
stopPersisting?.();
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
// `SubscriptionBundleModule.forRoot({ controller: {...} })`).
|
|
5
5
|
|
|
6
6
|
import { ref, type Ref } from 'vue';
|
|
7
|
+
import { markEmptyResponse, markPlatformError } from '../client/admin-error.js';
|
|
7
8
|
import type { SubscriptionBundleRecord } from '@saasicat/types';
|
|
9
|
+
import { requireServerAnswer } from '../client/http-json.js';
|
|
8
10
|
import { defaultHttpClient, type HttpClient } from '../client/types.js';
|
|
9
11
|
|
|
10
12
|
export interface UseTenantSubscriptionBundlesOptions {
|
|
@@ -40,6 +42,9 @@ export class TenantSubscriptionBundlesApiError extends Error {
|
|
|
40
42
|
) {
|
|
41
43
|
super(message);
|
|
42
44
|
this.name = 'TenantSubscriptionBundlesApiError';
|
|
45
|
+
// Identity, so `toAdminError` can tell this diagnostic from a
|
|
46
|
+
// consumer error whose message an operator needs to read.
|
|
47
|
+
markPlatformError(this);
|
|
43
48
|
}
|
|
44
49
|
}
|
|
45
50
|
|
|
@@ -64,11 +69,21 @@ export function useTenantSubscriptionBundles(
|
|
|
64
69
|
}
|
|
65
70
|
|
|
66
71
|
async function fetchJson<T>(url: string, init?: Parameters<HttpClient>[1]): Promise<T | null> {
|
|
72
|
+
const method = init?.method ?? 'GET';
|
|
67
73
|
const res = await http(url, {
|
|
68
|
-
method
|
|
74
|
+
method,
|
|
69
75
|
headers: { 'content-type': 'application/json', ...authHeaders(), ...init?.headers },
|
|
70
76
|
body: init?.body,
|
|
71
77
|
});
|
|
78
|
+
// Before any body is read: `null` below has to mean "the server
|
|
79
|
+
// answered without one", which is what the callers' empty-response
|
|
80
|
+
// sentinels claim.
|
|
81
|
+
requireServerAnswer(
|
|
82
|
+
res.status,
|
|
83
|
+
method,
|
|
84
|
+
url,
|
|
85
|
+
(diagnostic) => new TenantSubscriptionBundlesApiError(res.status, null, diagnostic),
|
|
86
|
+
);
|
|
72
87
|
if (res.status === 204) return null;
|
|
73
88
|
const body = await res.json().catch(() => null);
|
|
74
89
|
if (res.status >= 400) {
|
|
@@ -103,7 +118,9 @@ export function useTenantSubscriptionBundles(
|
|
|
103
118
|
body: JSON.stringify(data),
|
|
104
119
|
});
|
|
105
120
|
if (!result) {
|
|
106
|
-
throw
|
|
121
|
+
throw markEmptyResponse(
|
|
122
|
+
new TenantSubscriptionBundlesApiError(0, null, 'add returned no body'),
|
|
123
|
+
);
|
|
107
124
|
}
|
|
108
125
|
const hydrated = rehydrateDates(result);
|
|
109
126
|
bundles.value = [hydrated, ...bundles.value];
|
|
@@ -119,7 +136,9 @@ export function useTenantSubscriptionBundles(
|
|
|
119
136
|
{ method: 'DELETE', body: JSON.stringify(opts) },
|
|
120
137
|
);
|
|
121
138
|
if (!result) {
|
|
122
|
-
throw
|
|
139
|
+
throw markEmptyResponse(
|
|
140
|
+
new TenantSubscriptionBundlesApiError(0, null, 'cancel returned no body'),
|
|
141
|
+
);
|
|
123
142
|
}
|
|
124
143
|
const hydrated = rehydrateDates(result);
|
|
125
144
|
bundles.value = bundles.value.map((b) => (b.id === subscriptionBundleId ? hydrated : b));
|