@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,176 @@
|
|
|
1
|
+
// What a paginated admin list asks for, and what it gets back.
|
|
2
|
+
//
|
|
3
|
+
// One decision, written once. `useApiList` assembles `?page=…&pageSize=…` plus
|
|
4
|
+
// the filter, `createAdminResourceClient` assembles the filter half of the same
|
|
5
|
+
// thing for the endpoints that do not page, and every list descriptor would
|
|
6
|
+
// have been a third and fourth expression of it. They agreed by coincidence
|
|
7
|
+
// rather than by construction: three copies of "an empty value is omitted" is
|
|
8
|
+
// three chances for one of them to start sending `status=null` and filter every
|
|
9
|
+
// row away.
|
|
10
|
+
//
|
|
11
|
+
// Framework-free like the rest of `src/client/`: a URL, a body shape and the
|
|
12
|
+
// arithmetic around the page number. The reactive half — refs, watchers, the
|
|
13
|
+
// two controls — is `useResourceList`'s.
|
|
14
|
+
|
|
15
|
+
import type { ResourceContext, ResourceOp } from './define-resource.js';
|
|
16
|
+
import { requestJson } from './resource-request.js';
|
|
17
|
+
|
|
18
|
+
/** 1-based: the page a list starts on, and the one a changed filter returns to. */
|
|
19
|
+
export const LIST_FIRST_PAGE = 1;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* What the admin API falls back to when `pageSize` is absent. Sent explicitly
|
|
23
|
+
* rather than relied on, so the URL states what it asked for.
|
|
24
|
+
*/
|
|
25
|
+
export const LIST_PAGE_SIZE_DEFAULT = 50;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The largest page the admin API serves. There is no "all" to pass on, which is
|
|
29
|
+
* why `SERVER_PAGE_SIZE_OPTIONS` leaves Quasar's `ALL_ROWS` out as well.
|
|
30
|
+
*/
|
|
31
|
+
export const LIST_PAGE_SIZE_MAX = 200;
|
|
32
|
+
|
|
33
|
+
/** The two parameters the pagination owns; a filter may not carry them. */
|
|
34
|
+
export const LIST_PAGINATION_PARAMS = ['page', 'pageSize'] as const;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A filter without the two keys the pagination owns.
|
|
38
|
+
*
|
|
39
|
+
* The platform's filter types (`TenantListFilter`, `AuditQuery`) declare
|
|
40
|
+
* `page` and `pageSize` because they were once passed as one object. Serialised
|
|
41
|
+
* after the pagination they silently overwrite it — `goToPage(3)` moves the ref
|
|
42
|
+
* while the request asks for page 7 — so a descriptor takes the filter without
|
|
43
|
+
* them and the page number travels in its own field.
|
|
44
|
+
*/
|
|
45
|
+
export type ListFilterOf<TFilter> = Omit<TFilter, 'page' | 'pageSize'>;
|
|
46
|
+
|
|
47
|
+
/** Everything one list request varies: which page, how big, filtered by what. */
|
|
48
|
+
export interface ListQuery<TFilter extends object = Record<string, unknown>> {
|
|
49
|
+
/** 1-based. Defaults to `LIST_FIRST_PAGE`. */
|
|
50
|
+
page?: number;
|
|
51
|
+
/** Defaults to `LIST_PAGE_SIZE_DEFAULT`. */
|
|
52
|
+
pageSize?: number;
|
|
53
|
+
/** Serialised as `?key=value`; see `isSentInQuery` for what is left out. */
|
|
54
|
+
filter?: TFilter;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* One page of a list, as the admin API answers it.
|
|
59
|
+
*
|
|
60
|
+
* `total` is optional because it is genuinely absent from some answers: a
|
|
61
|
+
* controller returning a bare array has reported the rows it sent and nothing
|
|
62
|
+
* about the rest, and an envelope may omit the field altogether. A number
|
|
63
|
+
* invented here would read as "rows matching the filter" under a paginator that
|
|
64
|
+
* then offers pages nobody can reach.
|
|
65
|
+
*/
|
|
66
|
+
export interface ResourceListPage<T> {
|
|
67
|
+
items: T[];
|
|
68
|
+
/** Rows matching the filter — only when the server said so. */
|
|
69
|
+
total?: number;
|
|
70
|
+
/** What the server echoed back about the page it served, when it echoed anything. */
|
|
71
|
+
page?: number;
|
|
72
|
+
pageSize?: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A list operation: the query in, one page out. */
|
|
76
|
+
export type ListOp<TRow, TFilter extends object = Record<string, unknown>> = ResourceOp<
|
|
77
|
+
[query?: ListQuery<TFilter>],
|
|
78
|
+
ResourceListPage<TRow>
|
|
79
|
+
>;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Whether a filter value reaches the query string at all.
|
|
83
|
+
*
|
|
84
|
+
* `undefined`, `null` and `''` are the three spellings of "not filtered" that
|
|
85
|
+
* a form produces, and sending them would filter on the literal text `null`.
|
|
86
|
+
* Everything else goes as `String(value)` — including `0` and `false`, which
|
|
87
|
+
* are answers, not absences.
|
|
88
|
+
*/
|
|
89
|
+
export function isSentInQuery(value: unknown): boolean {
|
|
90
|
+
return value !== undefined && value !== null && value !== '';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function appendFilter(params: URLSearchParams, filter: object | undefined): void {
|
|
94
|
+
for (const [key, value] of Object.entries(filter ?? {})) {
|
|
95
|
+
if (!isSentInQuery(value)) continue;
|
|
96
|
+
params.set(key, String(value));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* `?a=b` for a filter on its own, or `''` when nothing survives — the form the
|
|
102
|
+
* admin endpoints that do not page take their filter in.
|
|
103
|
+
*/
|
|
104
|
+
export function filterQueryString(filter: object): string {
|
|
105
|
+
const params = new URLSearchParams();
|
|
106
|
+
appendFilter(params, filter);
|
|
107
|
+
const query = params.toString();
|
|
108
|
+
return query ? `?${query}` : '';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The URL one page of a list is requested from.
|
|
113
|
+
*
|
|
114
|
+
* `page` and `pageSize` are always present and always first — an endpoint that
|
|
115
|
+
* ignores them is free to, but the request says which page it meant. The filter
|
|
116
|
+
* follows in its own insertion order, and the endpoint may already carry a
|
|
117
|
+
* query of its own.
|
|
118
|
+
*/
|
|
119
|
+
export function listUrl<TFilter extends object>(
|
|
120
|
+
endpoint: string,
|
|
121
|
+
query: ListQuery<TFilter> = {},
|
|
122
|
+
): string {
|
|
123
|
+
const params = new URLSearchParams();
|
|
124
|
+
params.set('page', String(query.page ?? LIST_FIRST_PAGE));
|
|
125
|
+
params.set('pageSize', String(query.pageSize ?? LIST_PAGE_SIZE_DEFAULT));
|
|
126
|
+
appendFilter(params, query.filter);
|
|
127
|
+
return `${endpoint}${endpoint.includes('?') ? '&' : '?'}${params.toString()}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Reads whatever a list endpoint answered into one page.
|
|
132
|
+
*
|
|
133
|
+
* Both shapes are real: some controllers return a bare array, others the
|
|
134
|
+
* `{ items, page, pageSize, total }` envelope. Accepting only the envelope is
|
|
135
|
+
* what once rendered three tenants as an empty table (see
|
|
136
|
+
* `use-api-list-shape.test.js`). What the answer did not state stays absent
|
|
137
|
+
* rather than being filled in — the caller decides what "no total" means for
|
|
138
|
+
* its paginator.
|
|
139
|
+
*/
|
|
140
|
+
export function readListPage<T>(raw: unknown): ResourceListPage<T> {
|
|
141
|
+
if (Array.isArray(raw)) return { items: raw as T[], total: raw.length };
|
|
142
|
+
if (raw === null || typeof raw !== 'object') return { items: [], total: 0 };
|
|
143
|
+
const body = raw as { items?: unknown; total?: unknown; page?: unknown; pageSize?: unknown };
|
|
144
|
+
const page: ResourceListPage<T> = {
|
|
145
|
+
items: Array.isArray(body.items) ? (body.items as T[]) : [],
|
|
146
|
+
};
|
|
147
|
+
if (typeof body.total === 'number') page.total = body.total;
|
|
148
|
+
if (typeof body.page === 'number') page.page = body.page;
|
|
149
|
+
if (typeof body.pageSize === 'number') page.pageSize = body.pageSize;
|
|
150
|
+
return page;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Keeps a requested page on the 1-based scale the API counts on. */
|
|
154
|
+
export function clampListPage(page: number): number {
|
|
155
|
+
return Math.max(LIST_FIRST_PAGE, Math.floor(page));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Keeps a requested page size inside the 1..`LIST_PAGE_SIZE_MAX` the API serves. */
|
|
159
|
+
export function clampListPageSize(size: number): number {
|
|
160
|
+
return Math.min(LIST_PAGE_SIZE_MAX, Math.max(1, Math.floor(size)));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* A list operation over one endpoint: the second of a pair, and the reason it
|
|
165
|
+
* is a function rather than two hand-written descriptors.
|
|
166
|
+
*
|
|
167
|
+
* The endpoint is a function of the context so a `context` override on the
|
|
168
|
+
* registry — an app pointing one resource at a legacy base — reaches it without
|
|
169
|
+
* the descriptor knowing.
|
|
170
|
+
*/
|
|
171
|
+
export function defineListOp<TRow, TFilter extends object = Record<string, unknown>>(
|
|
172
|
+
endpoint: (ctx: ResourceContext) => string,
|
|
173
|
+
): ListOp<TRow, TFilter> {
|
|
174
|
+
return async (http, ctx, query) =>
|
|
175
|
+
readListPage<TRow>(await requestJson(http, listUrl(endpoint(ctx), query)));
|
|
176
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// The plan catalogue endpoints.
|
|
2
|
+
//
|
|
3
|
+
// Two path stems, and the asymmetry is the server's, not a mistake here:
|
|
4
|
+
// reading and creating versions goes through the plan
|
|
5
|
+
// (`catalog/plans/:planId/versions`), while every mutation of an existing
|
|
6
|
+
// version addresses the version directly (`catalog/plan-versions/:id`).
|
|
7
|
+
//
|
|
8
|
+
// Path parameters are interpolated without encoding, which is what the
|
|
9
|
+
// composables do today. Encoding them would be the safer default — but ids
|
|
10
|
+
// containing `/`, `#` or `?` would then reach a different URL than they reach
|
|
11
|
+
// now, and that is a behaviour change, not a fix. It belongs in a change that
|
|
12
|
+
// says so.
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
CreatePlanData,
|
|
16
|
+
CreatePlanVersionDraftData,
|
|
17
|
+
PlanRow,
|
|
18
|
+
PlanVersionMutationResult,
|
|
19
|
+
PlanVersionRow,
|
|
20
|
+
UpdatePlanData,
|
|
21
|
+
UpdatePlanVersionDraftData,
|
|
22
|
+
} from '@saasicat/types';
|
|
23
|
+
|
|
24
|
+
import { defineResource, type ResourceContext } from './define-resource.js';
|
|
25
|
+
|
|
26
|
+
import { requestJson, requestJsonBody } from './resource-request.js';
|
|
27
|
+
|
|
28
|
+
/** What `publish` may override at the moment a version goes live. */
|
|
29
|
+
export interface PublishPlanVersionOptions {
|
|
30
|
+
forceRegressive?: boolean;
|
|
31
|
+
allowZeroPrice?: boolean;
|
|
32
|
+
validFrom?: string | null;
|
|
33
|
+
validUntil?: string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function plansUrl(ctx: ResourceContext): string {
|
|
37
|
+
return `${ctx.apiBase}/catalog/plans`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function versionsUrl(ctx: ResourceContext): string {
|
|
41
|
+
return `${ctx.apiBase}/catalog/plan-versions`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** `?projectKey=…` — the key is a literal, only the value is encoded. */
|
|
45
|
+
function scoped(url: string, ctx: ResourceContext): string {
|
|
46
|
+
return `${url}?projectKey=${encodeURIComponent(ctx.projectKey)}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The plan catalogue — the one platform resource that is read per project.
|
|
51
|
+
*
|
|
52
|
+
* `list`, `tenantCounts` and `create` all carry `ctx.projectKey`, so binding
|
|
53
|
+
* this against a context that names no project is refused rather than sent (see
|
|
54
|
+
* `bindResource`). Its three siblings address a plan or a version by id and are
|
|
55
|
+
* not declared here.
|
|
56
|
+
*/
|
|
57
|
+
export const plansResource = defineResource(
|
|
58
|
+
'plans',
|
|
59
|
+
{
|
|
60
|
+
list: async (http, ctx): Promise<PlanRow[]> =>
|
|
61
|
+
(await requestJson<PlanRow[]>(http, scoped(plansUrl(ctx), ctx))) ?? [],
|
|
62
|
+
|
|
63
|
+
tenantCounts: async (http, ctx): Promise<Record<string, number>> =>
|
|
64
|
+
(await requestJson<Record<string, number>>(
|
|
65
|
+
http,
|
|
66
|
+
scoped(`${plansUrl(ctx)}/tenant-counts`, ctx),
|
|
67
|
+
)) ?? {},
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The project comes from the context, not from the caller.
|
|
71
|
+
*
|
|
72
|
+
* The endpoint takes it in the body, and the composable this replaces
|
|
73
|
+
* made that the caller's job — so a page had to keep hold of the
|
|
74
|
+
* project it was administering just to create in it, and `list()` and
|
|
75
|
+
* `create()` read two different sources for one value. A stale one
|
|
76
|
+
* creates a plan outside the project the list is showing.
|
|
77
|
+
*/
|
|
78
|
+
create: (http, ctx, data: Omit<CreatePlanData, 'projectKey'>): Promise<PlanRow> =>
|
|
79
|
+
requestJsonBody<PlanRow>(http, plansUrl(ctx), 'Create returned no body', {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
body: { ...data, projectKey: ctx.projectKey },
|
|
82
|
+
}),
|
|
83
|
+
|
|
84
|
+
update: (http, ctx, planId: string, data: UpdatePlanData): Promise<PlanRow> =>
|
|
85
|
+
requestJsonBody<PlanRow>(
|
|
86
|
+
http,
|
|
87
|
+
`${plansUrl(ctx)}/${planId}`,
|
|
88
|
+
'Update returned no body',
|
|
89
|
+
{
|
|
90
|
+
method: 'PATCH',
|
|
91
|
+
body: data,
|
|
92
|
+
},
|
|
93
|
+
),
|
|
94
|
+
|
|
95
|
+
/** Marks the plan deleted. Fails while it still has versions. */
|
|
96
|
+
softDelete: async (http, ctx, planId: string): Promise<void> => {
|
|
97
|
+
await requestJson(http, `${plansUrl(ctx)}/${planId}`, { method: 'DELETE' });
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
/** Removes the plan for good. 422 `PLAN_HAS_VERSIONS` when it cannot. */
|
|
101
|
+
hardDelete: async (http, ctx, planId: string): Promise<void> => {
|
|
102
|
+
await requestJson(http, `${plansUrl(ctx)}/${planId}/purge`, { method: 'DELETE' });
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{ projectScoped: true },
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
export const planVersionsResource = defineResource('planVersions', {
|
|
109
|
+
listForPlan: async (http, ctx, planId: string): Promise<PlanVersionRow[]> =>
|
|
110
|
+
(await requestJson<PlanVersionRow[]>(http, `${plansUrl(ctx)}/${planId}/versions`)) ?? [],
|
|
111
|
+
|
|
112
|
+
createDraft: (
|
|
113
|
+
http,
|
|
114
|
+
ctx,
|
|
115
|
+
planId: string,
|
|
116
|
+
data: Omit<CreatePlanVersionDraftData, 'planId'>,
|
|
117
|
+
): Promise<PlanVersionMutationResult> =>
|
|
118
|
+
requestJsonBody<PlanVersionMutationResult>(
|
|
119
|
+
http,
|
|
120
|
+
`${plansUrl(ctx)}/${planId}/versions`,
|
|
121
|
+
'CreateDraft returned no body',
|
|
122
|
+
{ method: 'POST', body: data },
|
|
123
|
+
),
|
|
124
|
+
|
|
125
|
+
updateDraft: (
|
|
126
|
+
http,
|
|
127
|
+
ctx,
|
|
128
|
+
versionId: string,
|
|
129
|
+
data: UpdatePlanVersionDraftData,
|
|
130
|
+
): Promise<PlanVersionMutationResult> =>
|
|
131
|
+
requestJsonBody<PlanVersionMutationResult>(
|
|
132
|
+
http,
|
|
133
|
+
`${versionsUrl(ctx)}/${versionId}`,
|
|
134
|
+
'UpdateDraft returned no body',
|
|
135
|
+
{ method: 'PATCH', body: data },
|
|
136
|
+
),
|
|
137
|
+
|
|
138
|
+
publish: (
|
|
139
|
+
http,
|
|
140
|
+
ctx,
|
|
141
|
+
versionId: string,
|
|
142
|
+
options: PublishPlanVersionOptions = {},
|
|
143
|
+
): Promise<PlanVersionMutationResult> =>
|
|
144
|
+
requestJsonBody<PlanVersionMutationResult>(
|
|
145
|
+
http,
|
|
146
|
+
`${versionsUrl(ctx)}/${versionId}/publish`,
|
|
147
|
+
'Publish returned no body',
|
|
148
|
+
{ method: 'POST', body: options },
|
|
149
|
+
),
|
|
150
|
+
|
|
151
|
+
discardDraft: async (http, ctx, versionId: string): Promise<void> => {
|
|
152
|
+
await requestJson(http, `${versionsUrl(ctx)}/${versionId}`, { method: 'DELETE' });
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Ends a live version on `endsAt`, with no successor. Idempotent — a
|
|
157
|
+
* second call with another date overwrites the first.
|
|
158
|
+
*
|
|
159
|
+
* `endsAt` is the date itself, not a payload: the endpoint's body is
|
|
160
|
+
* `{ endsAt }` and wrapping it is this operation's job, not the caller's.
|
|
161
|
+
*/
|
|
162
|
+
terminate: (http, ctx, versionId: string, endsAt: string): Promise<PlanVersionRow> =>
|
|
163
|
+
requestJsonBody<PlanVersionRow>(
|
|
164
|
+
http,
|
|
165
|
+
`${versionsUrl(ctx)}/${versionId}/terminate`,
|
|
166
|
+
'Terminate returned no body',
|
|
167
|
+
{ method: 'POST', body: { endsAt } },
|
|
168
|
+
),
|
|
169
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// The JSON request/response policy the admin catalogue endpoints share.
|
|
2
|
+
//
|
|
3
|
+
// This exact function is written out three times in the package today —
|
|
4
|
+
// `use-plans.ts` contains two byte-identical copies of it in one file, and
|
|
5
|
+
// `use-bundles.ts` and `use-catalog-entries.ts` a third and fourth — differing
|
|
6
|
+
// only in the message they put on the error. The policy itself is one
|
|
7
|
+
// decision, so it is written once here.
|
|
8
|
+
//
|
|
9
|
+
// It is NOT the policy the tenant-side billing composables use: those check
|
|
10
|
+
// `>= 400` before the 204 branch, read the error body with `text()` as a
|
|
11
|
+
// fallback, and let an unparsable 2xx reject with the raw parse error. Those
|
|
12
|
+
// three are mutually incompatible and belong to a different audience — see the
|
|
13
|
+
// tenant package split. Nothing here should be pointed at them.
|
|
14
|
+
|
|
15
|
+
import { AdminError, readErrorCode, readErrorDetail } from '../admin-error.js';
|
|
16
|
+
import { requireServerAnswer } from '../http-json.js';
|
|
17
|
+
import type { HttpClient } from '../types.js';
|
|
18
|
+
|
|
19
|
+
export interface ResourceRequestInit {
|
|
20
|
+
/** Default `GET`. */
|
|
21
|
+
method?: string;
|
|
22
|
+
/** Serialised here — the `HttpClient` contract only carries strings. */
|
|
23
|
+
body?: unknown;
|
|
24
|
+
/** Merged over the defaults, so a caller's header wins. */
|
|
25
|
+
headers?: Record<string, string>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Sends a request and returns the parsed body, or `null` when there was none.
|
|
30
|
+
*
|
|
31
|
+
* `null` covers both a `204` and a 2xx whose body would not parse. The two are
|
|
32
|
+
* deliberately not distinguished: every caller treats them the same way — a
|
|
33
|
+
* list turns `null` into `[]`, a mutation turns it into a failure — and no
|
|
34
|
+
* endpoint in this family answers a mutation with an empty 200 on purpose.
|
|
35
|
+
*
|
|
36
|
+
* A non-2xx throws an `AdminError` carrying the parsed body, so a caller can
|
|
37
|
+
* still branch on `status` and `body.code` the way the pages do today.
|
|
38
|
+
*
|
|
39
|
+
* A client that resolved without an HTTP status throws before any of that: no
|
|
40
|
+
* status means the request never reached the server, which is a transport
|
|
41
|
+
* failure and not an answer this function may read.
|
|
42
|
+
*/
|
|
43
|
+
export async function requestJson<T>(
|
|
44
|
+
http: HttpClient,
|
|
45
|
+
url: string,
|
|
46
|
+
init: ResourceRequestInit = {},
|
|
47
|
+
): Promise<T | null> {
|
|
48
|
+
const method = init.method ?? 'GET';
|
|
49
|
+
const response = await http(url, {
|
|
50
|
+
method,
|
|
51
|
+
headers: { 'content-type': 'application/json', ...init.headers },
|
|
52
|
+
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Before anything is read off the response: a client that reports a
|
|
56
|
+
// network, CORS or abort failure by RESOLVING with `status: 0` — the shape
|
|
57
|
+
// `HttpClient` permits and `requireServerAnswer` exists for — otherwise
|
|
58
|
+
// arrives here as a successful answer with no body. A list would then be
|
|
59
|
+
// empty with nothing wrong on screen, and a mutation would tell the
|
|
60
|
+
// operator their change may have been applied when the request never left
|
|
61
|
+
// the machine.
|
|
62
|
+
requireServerAnswer(
|
|
63
|
+
response.status,
|
|
64
|
+
method,
|
|
65
|
+
url,
|
|
66
|
+
(diagnostic) =>
|
|
67
|
+
new AdminError({ status: response.status, url, method, message: diagnostic }),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
if (response.status === 204) return null;
|
|
71
|
+
const body = await response.json().catch(() => null);
|
|
72
|
+
if (response.status >= 400) {
|
|
73
|
+
throw new AdminError({
|
|
74
|
+
status: response.status,
|
|
75
|
+
code: readErrorCode(body),
|
|
76
|
+
body,
|
|
77
|
+
// The third place this had to be read, and the second time it was
|
|
78
|
+
// read a narrower way: without `detail` a page migrating onto these
|
|
79
|
+
// resources loses "Plan already exists" and shows the generic
|
|
80
|
+
// status sentence instead. One decision, one function.
|
|
81
|
+
detail: readErrorDetail(body),
|
|
82
|
+
url,
|
|
83
|
+
method,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return body as T | null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Reads the body of a request that must answer with one.
|
|
91
|
+
*
|
|
92
|
+
* The empty case is a failure rather than a `null` the caller has to re-check:
|
|
93
|
+
* a create that returns nothing has not told us what it created. Raised with
|
|
94
|
+
* `status: 0`, because no HTTP status went wrong — the response was a 2xx.
|
|
95
|
+
*/
|
|
96
|
+
export async function requestJsonBody<T>(
|
|
97
|
+
http: HttpClient,
|
|
98
|
+
url: string,
|
|
99
|
+
what: string,
|
|
100
|
+
init: ResourceRequestInit = {},
|
|
101
|
+
): Promise<T> {
|
|
102
|
+
const body = await requestJson<T>(http, url, init);
|
|
103
|
+
if (body === null || body === undefined) {
|
|
104
|
+
throw new AdminError({
|
|
105
|
+
status: 0,
|
|
106
|
+
url,
|
|
107
|
+
method: init.method ?? 'GET',
|
|
108
|
+
// `what` is a diagnostic — "Create returned no body" — so it goes
|
|
109
|
+
// on `message`, not `detail`. As `detail` it out-ranked the
|
|
110
|
+
// localized wording and the operator read an English internal
|
|
111
|
+
// string instead of being told the change may already have landed.
|
|
112
|
+
message: what,
|
|
113
|
+
emptyResponse: true,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return body;
|
|
117
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// The tenant list.
|
|
2
|
+
//
|
|
3
|
+
// One operation, deliberately: the list `useTenants` requests today. Tenant
|
|
4
|
+
// detail, suspend and reactivate still live in `createAdminResourceClient` and
|
|
5
|
+
// move here with the page that calls them — a descriptor nothing calls has
|
|
6
|
+
// nothing keeping it honest.
|
|
7
|
+
//
|
|
8
|
+
// The request pages; the controller does not. `AdminResourcesPort.listTenants`
|
|
9
|
+
// takes `status`/`plan`/`search` and answers with a bare array — no `page`, no
|
|
10
|
+
// `total` — while the paginated `TenantPort.list` no controller exposes returns
|
|
11
|
+
// `Paginated<TenantDto>`. So `page` and `pageSize` go out on every request and
|
|
12
|
+
// today nothing on the far side reads them. That is exactly what `useApiList`
|
|
13
|
+
// has been sending, and reproducing it is the point: what the server does with
|
|
14
|
+
// the parameters is the server's to change, and a descriptor that quietly sent
|
|
15
|
+
// less would be a different request wearing the same name.
|
|
16
|
+
|
|
17
|
+
import type { TenantDto, TenantListFilter } from '@saasicat/types';
|
|
18
|
+
|
|
19
|
+
import { defineResource } from './define-resource.js';
|
|
20
|
+
import { defineListOp, type ListFilterOf } from './list-resource.js';
|
|
21
|
+
|
|
22
|
+
/** What the tenants list can be narrowed by. The page number is not a filter. */
|
|
23
|
+
export type TenantsListFilter = ListFilterOf<TenantListFilter>;
|
|
24
|
+
|
|
25
|
+
export const tenantsResource = defineResource('tenants', {
|
|
26
|
+
list: defineListOp<TenantDto, TenantsListFilter>((ctx) => `${ctx.apiBase}/tenants`),
|
|
27
|
+
});
|
package/src/client/types.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Shared configuration types for all UI-Vue loaders/composables.
|
|
2
2
|
|
|
3
|
+
import { createFetchHttpClient } from './http/fetch-http-client.js';
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* Minimal abstraction over `fetch`. Consumers may pass their own
|
|
5
7
|
* implementation (e.g. an axios wrapper that already injects auth headers
|
|
@@ -87,9 +89,14 @@ function resolveLocalStorage(): Storage | null {
|
|
|
87
89
|
}
|
|
88
90
|
|
|
89
91
|
/**
|
|
90
|
-
* Default `HttpClient` over `fetch
|
|
91
|
-
*
|
|
92
|
+
* Default `HttpClient` over `fetch`, with no base URL and no headers of its
|
|
93
|
+
* own. Consumers pass their own variant when they need auth headers, tenant
|
|
94
|
+
* headers or retry logic — `createFetchHttpClient({ headers })` and
|
|
95
|
+
* `createAxiosHttpClient(instance)` are the two the package ships.
|
|
96
|
+
*
|
|
97
|
+
* It delegates rather than calling `fetch` itself so that there is one
|
|
98
|
+
* implementation to reason about instead of two that drift.
|
|
92
99
|
*/
|
|
93
100
|
export function defaultHttpClient(): HttpClient {
|
|
94
|
-
return (
|
|
101
|
+
return createFetchHttpClient();
|
|
95
102
|
}
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
|
|
51
51
|
<div>
|
|
52
52
|
<q-toggle v-model="allowZeroPrice" :label="msg.publishDialog.allowZeroPrice" />
|
|
53
|
-
<p class="bvpd__label" style="margin-top:
|
|
53
|
+
<p class="bvpd__label" style="margin-top: var(--sa-space-3)">
|
|
54
54
|
{{ msg.publishDialog.allowZeroPriceHint }}
|
|
55
55
|
</p>
|
|
56
56
|
</div>
|