@saasicat/ui-vue 0.2.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/LICENSE +202 -0
- package/README.md +47 -0
- package/dist/index.cjs +3776 -0
- package/dist/index.d.cts +2395 -0
- package/dist/index.d.ts +2395 -0
- package/dist/index.js +3659 -0
- package/dist/testing-e2e/admin-pages-suite.cjs +134 -0
- package/dist/testing-e2e/admin-pages-suite.d.cts +37 -0
- package/dist/testing-e2e/admin-pages-suite.d.ts +37 -0
- package/dist/testing-e2e/admin-pages-suite.js +109 -0
- package/package.json +87 -0
- package/src/action-registry.ts +110 -0
- package/src/batch-column-fetcher.ts +152 -0
- package/src/boot-loader.ts +60 -0
- package/src/components/BundleVersionPublishDialog.vue +359 -0
- package/src/components/BusinessTypeVersionEditorDialog.vue +417 -0
- package/src/components/BusinessTypeVersionPublishDialog.vue +258 -0
- package/src/components/FeatureGate.vue +56 -0
- package/src/components/KpiCard.vue +53 -0
- package/src/components/KvBlock.vue +36 -0
- package/src/components/MarketingPromotionsTab.vue +861 -0
- package/src/components/MfaPromptDialog.vue +104 -0
- package/src/components/TenantActionConfirmDialog.vue +252 -0
- package/src/components/VersionDiffPreview.vue +150 -0
- package/src/components/bundle-editor/BundleCreatePanel.vue +681 -0
- package/src/components/bundle-editor/BundleFeaturesEditor.vue +215 -0
- package/src/components/bundle-editor/BundlePlanCompatPicker.vue +316 -0
- package/src/components/bundle-editor/BundleQuotasEditor.vue +219 -0
- package/src/components/bundle-editor/BundleStatusBanner.vue +189 -0
- package/src/components/bundle-editor/BundleVersionInlineEditor.vue +659 -0
- package/src/components/bundle-editor/BundleVersionStrip.vue +207 -0
- package/src/components/bundle-editor/bundle-version-status.ts +164 -0
- package/src/components/bundle-editor/catalog-i18n.ts +85 -0
- package/src/components/dialogs/PilotCreateDialog.vue +780 -0
- package/src/components/dialogs/PilotEditDialog.vue +548 -0
- package/src/components/dialogs/PromoCodeCreateDialog.vue +737 -0
- package/src/components/dialogs/PromoCodeEditDialog.vue +859 -0
- package/src/components/dialogs/types.ts +126 -0
- package/src/components/plan/PlanCycleToggle.vue +99 -0
- package/src/components/plan/PlanGrid.vue +211 -0
- package/src/components/plan/PriceSummary.vue +293 -0
- package/src/components/plan/PromoCodeInput.vue +192 -0
- package/src/components/plan/PublicBundleGrid.vue +287 -0
- package/src/components/plan-cockpit/PlanCockpit.vue +993 -0
- package/src/components/plan-cockpit/PlanCockpitAuditLog.vue +65 -0
- package/src/components/plan-cockpit/PlanCockpitDiffPanel.vue +91 -0
- package/src/components/plan-cockpit/PlanCockpitHeader.vue +98 -0
- package/src/components/plan-cockpit/PlanCockpitImpactPanel.vue +99 -0
- package/src/components/plan-cockpit/PlanCockpitKpis.vue +57 -0
- package/src/components/plan-cockpit/PlanCockpitVersions.vue +226 -0
- package/src/components/plan-cockpit/types.ts +72 -0
- package/src/components/plan-create-dialog/PlanCreateDialog.vue +508 -0
- package/src/components/plan-detail/PlanAuditLog.vue +59 -0
- package/src/components/plan-detail/PlanDetail.vue +1195 -0
- package/src/components/plan-detail/PlanDetailHeader.vue +185 -0
- package/src/components/plan-detail/PlanDetailKpis.vue +57 -0
- package/src/components/plan-detail/PlanTerminateDialog.vue +76 -0
- package/src/components/plan-detail/PlanVersionDiffPanel.vue +149 -0
- package/src/components/plan-detail/PlanVersionsPanel.vue +255 -0
- package/src/components/plan-detail/types.ts +59 -0
- package/src/components/plan-list/PlanList.vue +1067 -0
- package/src/components/plan-matrix/PlanMatrix.vue +1113 -0
- package/src/components/plan-review/PlanReview.vue +742 -0
- package/src/components/plan-version-editor/PlanCatalogPreview.vue +184 -0
- package/src/components/plan-version-editor/PlanComponentPool.vue +272 -0
- package/src/components/plan-version-editor/PlanVersionBasket.vue +281 -0
- package/src/components/plan-version-editor/PlanVersionDiffDialog.vue +84 -0
- package/src/components/plan-version-editor/PlanVersionEditor.vue +1728 -0
- package/src/components/plan-version-editor/PlanVersionEditorHeader.vue +94 -0
- package/src/components/plan-version-editor/types.ts +96 -0
- package/src/components/wizard-stepper/WizardStepper.vue +107 -0
- package/src/create-admin-routes.ts +68 -0
- package/src/create-super-admin-app.ts +345 -0
- package/src/entitlement-provider.ts +36 -0
- package/src/feature-router-guard.ts +77 -0
- package/src/http-json.ts +62 -0
- package/src/index.ts +98 -0
- package/src/manifest-loader.ts +139 -0
- package/src/manifest-store-factory.ts +114 -0
- package/src/nav-builder.ts +274 -0
- package/src/pages-standard/AdminLayout.vue +411 -0
- package/src/pages-standard/AdminManifestErrorPage.vue +79 -0
- package/src/pages-standard/AuditPage.vue +223 -0
- package/src/pages-standard/BundlesPage.vue +910 -0
- package/src/pages-standard/BusinessTypesPage.vue +662 -0
- package/src/pages-standard/DashboardPage.vue +595 -0
- package/src/pages-standard/DiscoveryPage.vue +663 -0
- package/src/pages-standard/EmailHistoryPage.vue +576 -0
- package/src/pages-standard/MarketingCatalogPage.vue +1771 -0
- package/src/pages-standard/PilotsPage.vue +577 -0
- package/src/pages-standard/PlanVersionsPage.vue +216 -0
- package/src/pages-standard/PlansPage.vue +1222 -0
- package/src/pages-standard/PlatformEmailPage.vue +434 -0
- package/src/pages-standard/PromoCodeDetailPage.vue +331 -0
- package/src/pages-standard/PromoCodesPage.vue +589 -0
- package/src/pages-standard/SubscriptionsPage.vue +159 -0
- package/src/pages-standard/SuperAdminLoginPage.vue +299 -0
- package/src/pages-standard/SuperAdminSetupWizard.vue +405 -0
- package/src/pages-standard/TenantDetailPage.vue +432 -0
- package/src/pages-standard/TenantsPage.vue +732 -0
- package/src/pages-standard/UsersPage.vue +504 -0
- package/src/pages-standard/bundles-page/BundleAccordionList.vue +116 -0
- package/src/pages-standard/bundles-page/BundleDetailPanel.vue +193 -0
- package/src/pages-standard/bundles-page/BundlesFilterBar.vue +48 -0
- package/src/pages-standard/bundles-page/BundlesHeader.vue +67 -0
- package/src/pages-standard/bundles-page/BundlesKpis.vue +39 -0
- package/src/pages-standard/bundles-page/types.ts +15 -0
- package/src/pages-standard/discovery-page/CatalogEntryTransPanel.vue +246 -0
- package/src/pages-standard/discovery-page/DiscoveryCapList.vue +171 -0
- package/src/pages-standard/discovery-page/DiscoveryFeatureCard.vue +410 -0
- package/src/pages-standard/discovery-page/DiscoveryHeader.vue +31 -0
- package/src/pages-standard/discovery-page/DiscoveryKpis.vue +47 -0
- package/src/pages-standard/discovery-page/DiscoveryMetaBanner.vue +26 -0
- package/src/pages-standard/discovery-page/DiscoveryQuotaCard.vue +247 -0
- package/src/pages-standard/discovery-page/DiscoveryStatusControl.vue +73 -0
- package/src/pages-standard/discovery-page/discovery-ui.ts +166 -0
- package/src/pages-standard/email-history.types.ts +48 -0
- package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +448 -0
- package/src/pages-standard/marketing-catalog/MarketingCatalogHeader.vue +93 -0
- package/src/pages-standard/marketing-catalog/MarketingCatalogPreview.vue +138 -0
- package/src/pages-standard/marketing-catalog/MarketingCatalogToolbar.vue +58 -0
- package/src/pages-standard/marketing-catalog/types.ts +43 -0
- package/src/pages-standard/plan-versions/PlanDiffCard.vue +132 -0
- package/src/pages-standard/plan-versions/PlanVersionHeader.vue +293 -0
- package/src/pages-standard/plan-versions/PlanVersionStatusBadge.vue +48 -0
- package/src/pages-standard/plan-versions/PlanVersionsAudit.vue +307 -0
- package/src/pages-standard/plan-versions/PlanVersionsDiff.vue +269 -0
- package/src/pages-standard/plan-versions/PlanVersionsKpi.vue +65 -0
- package/src/pages-standard/plan-versions/PlanVersionsList.vue +292 -0
- package/src/pages-standard/plan-versions/PlanVersionsMatrix.vue +261 -0
- package/src/pages-standard/plan-versions/PlanVersionsTimeline.vue +300 -0
- package/src/pages-standard/plan-versions/VersionDiffPreview.vue +167 -0
- package/src/pages-standard/plan-versions/format.ts +53 -0
- package/src/pages-standard/plan-versions/types.ts +33 -0
- package/src/pages-standard/plans-page/PlanArchiveDialog.vue +50 -0
- package/src/pages-standard/plans-page/PlanBundleOverview.vue +147 -0
- package/src/pages-standard/plans-page/PlanDiscardDraftDialog.vue +46 -0
- package/src/pages-standard/plans-page/PlanPublishDialog.vue +106 -0
- package/src/pages-standard/plans-page/PlansPageToast.vue +69 -0
- package/src/pages-standard/plans-page/types.ts +18 -0
- package/src/pages-standard/platform-email.types.ts +41 -0
- package/src/pages-standard/sa-theme.css +223 -0
- package/src/pages-standard/tenants/StatusPill.vue +62 -0
- package/src/pages-standard/tenants/format.ts +38 -0
- package/src/pages-tenant/LimitsRow.vue +67 -0
- package/src/pages-tenant/MySubscriptionBundlesPage.vue +604 -0
- package/src/pages-tenant/OnboardingConfigurator.vue +417 -0
- package/src/pages-tenant/PackageSnapshotPanel.vue +241 -0
- package/src/pages-tenant/PendingVersionBanner.vue +126 -0
- package/src/pages-tenant/PlanChangeWizard.vue +630 -0
- package/src/pages-tenant/TenantPlanSection.vue +729 -0
- package/src/pages-tenant/UsageBar.vue +107 -0
- package/src/pages-tenant/default-i18n.ts +280 -0
- package/src/pages-tenant/tenant-plan-section/BundlePreviewDialog.vue +304 -0
- package/src/pages-tenant/tenant-plan-section/TenantBundleStore.vue +323 -0
- package/src/pages-tenant/tenant-plan-section/TenantFeatureMatrix.vue +122 -0
- package/src/pages-tenant/tenant-plan-section/TenantPlanCardHeader.vue +67 -0
- package/src/pages-tenant/tenant-plan-section/TenantUsageGrid.vue +31 -0
- package/src/plan-versions-catalog.ts +368 -0
- package/src/platform-loaders.ts +68 -0
- package/src/project-page-host.ts +108 -0
- package/src/testing-e2e/admin-pages-suite.ts +187 -0
- package/src/types.ts +58 -0
- package/src/use-actions.ts +25 -0
- package/src/use-api-list.ts +151 -0
- package/src/use-audit-entries.ts +48 -0
- package/src/use-batch-columns.ts +59 -0
- package/src/use-bulk-publish.ts +150 -0
- package/src/use-bundle-versions-map.ts +112 -0
- package/src/use-bundles.ts +297 -0
- package/src/use-business-types.ts +269 -0
- package/src/use-catalog-entries.ts +243 -0
- package/src/use-discovery.ts +158 -0
- package/src/use-entitlement.ts +87 -0
- package/src/use-live-plan-versions.ts +126 -0
- package/src/use-manifest.ts +57 -0
- package/src/use-marketing-projections.ts +160 -0
- package/src/use-nav.ts +33 -0
- package/src/use-plan-editor.ts +162 -0
- package/src/use-plan-versions.ts +83 -0
- package/src/use-plans.ts +375 -0
- package/src/use-platform-tenant-actions.ts +286 -0
- package/src/use-promotions.ts +126 -0
- package/src/use-public-boot.ts +40 -0
- package/src/use-subscription-draft.ts +355 -0
- package/src/use-super-admin-context.ts +93 -0
- package/src/use-tenant-action-flow.ts +238 -0
- package/src/use-tenant-billing-catalog.ts +160 -0
- package/src/use-tenant-billing.ts +453 -0
- package/src/use-tenant-manifest.ts +94 -0
- package/src/use-tenant-subscription-bundles.ts +148 -0
- package/src/use-tenants.ts +61 -0
- package/src/version.ts +10 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/testing-e2e/admin-pages-suite.ts
|
|
21
|
+
var admin_pages_suite_exports = {};
|
|
22
|
+
__export(admin_pages_suite_exports, {
|
|
23
|
+
runAdminPagesSuite: () => runAdminPagesSuite
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(admin_pages_suite_exports);
|
|
26
|
+
var RESOURCE_FAILURE_MARKER = /Failed to load resource:/;
|
|
27
|
+
function attachListeners(page, consoleAllowlist = [], networkAllowlist = []) {
|
|
28
|
+
const out = { pageErrors: [], consoleErrors: [], networkErrors: [] };
|
|
29
|
+
page.on("pageerror", (err) => out.pageErrors.push(err.message));
|
|
30
|
+
page.on("console", (msg) => {
|
|
31
|
+
if (msg.type() !== "error") return;
|
|
32
|
+
const text = msg.text();
|
|
33
|
+
if (RESOURCE_FAILURE_MARKER.test(text)) return;
|
|
34
|
+
if (consoleAllowlist.some((re) => re.test(text))) return;
|
|
35
|
+
out.consoleErrors.push(text);
|
|
36
|
+
});
|
|
37
|
+
page.on("response", (resp) => {
|
|
38
|
+
if (resp.status() < 400) return;
|
|
39
|
+
const url = resp.url();
|
|
40
|
+
if (!url.includes("/api/")) return;
|
|
41
|
+
if (networkAllowlist.some((re) => re.test(url))) return;
|
|
42
|
+
out.networkErrors.push(`HTTP ${resp.status()} ${url}`);
|
|
43
|
+
});
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
async function loginIfNeeded(page, config) {
|
|
47
|
+
if (!page.url().includes("/login")) {
|
|
48
|
+
await page.goto(config.loginUrl, { waitUntil: "networkidle" });
|
|
49
|
+
}
|
|
50
|
+
if (!page.url().includes("/login")) return;
|
|
51
|
+
await page.locator('input[type="email"]').first().fill(config.email);
|
|
52
|
+
const pw = page.locator('input[type="password"]').first();
|
|
53
|
+
await pw.fill(config.password);
|
|
54
|
+
await pw.press("Enter");
|
|
55
|
+
await page.waitForURL((u) => !u.toString().includes("/login"), {
|
|
56
|
+
timeout: 15e3
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
function runAdminPagesSuite(test, expect, config) {
|
|
60
|
+
const dashboardPath = config.dashboardPath ?? "/admin/";
|
|
61
|
+
test.describe(`${config.appName}: SuperAdmin-Pages Smoke`, () => {
|
|
62
|
+
for (const pageDef of config.pages) {
|
|
63
|
+
test(`${pageDef.name} rendert ohne Fehler`, async ({ page }) => {
|
|
64
|
+
const errors = attachListeners(
|
|
65
|
+
page,
|
|
66
|
+
config.consoleErrorAllowlist,
|
|
67
|
+
pageDef.allowedFailures
|
|
68
|
+
);
|
|
69
|
+
await loginIfNeeded(page, config);
|
|
70
|
+
const origin = new URL(config.loginUrl).origin;
|
|
71
|
+
await page.goto(`${origin}${pageDef.path}`, {
|
|
72
|
+
waitUntil: "networkidle",
|
|
73
|
+
timeout: 15e3
|
|
74
|
+
});
|
|
75
|
+
await page.waitForTimeout(800);
|
|
76
|
+
if (pageDef.expectVisible) {
|
|
77
|
+
await expect(page.locator(pageDef.expectVisible).first()).toBeVisible({
|
|
78
|
+
timeout: 5e3
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
expect(errors.pageErrors, `pageerror-Events auf ${pageDef.path}`).toHaveLength(0);
|
|
82
|
+
expect(
|
|
83
|
+
errors.consoleErrors,
|
|
84
|
+
`console.error-Events auf ${pageDef.path}`
|
|
85
|
+
).toHaveLength(0);
|
|
86
|
+
expect(
|
|
87
|
+
errors.networkErrors,
|
|
88
|
+
`HTTP-Errors auf /api/ w\xE4hrend ${pageDef.path}`
|
|
89
|
+
).toHaveLength(0);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
test("Dashboard zeigt erwartete KPIs, Distributions und Shortcuts", async ({ page }) => {
|
|
93
|
+
attachListeners(page, config.consoleErrorAllowlist);
|
|
94
|
+
await loginIfNeeded(page, config);
|
|
95
|
+
const origin = new URL(config.loginUrl).origin;
|
|
96
|
+
await page.goto(`${origin}${dashboardPath}`, {
|
|
97
|
+
waitUntil: "networkidle",
|
|
98
|
+
timeout: 15e3
|
|
99
|
+
});
|
|
100
|
+
const kpiLabels = await page.locator(".sa-kpi__label").allTextContents();
|
|
101
|
+
const normalizedKpis = kpiLabels.map((s) => s.trim());
|
|
102
|
+
for (const expected of config.expectedKpiLabels) {
|
|
103
|
+
expect(normalizedKpis, `KPI '${expected}' fehlt auf Dashboard`).toContain(expected);
|
|
104
|
+
}
|
|
105
|
+
if (config.expectedDistributionTitles?.length) {
|
|
106
|
+
const distTitles = await page.locator(".sa-dashboard__row-head h2").allTextContents();
|
|
107
|
+
const normalizedDist = distTitles.map((s) => s.trim());
|
|
108
|
+
for (const expected of config.expectedDistributionTitles) {
|
|
109
|
+
expect(
|
|
110
|
+
normalizedDist,
|
|
111
|
+
`Distribution '${expected}' fehlt auf Dashboard`
|
|
112
|
+
).toContain(expected);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const shortcutTitles = await page.locator(".sa-dashboard__shortcut-title").allTextContents();
|
|
116
|
+
const normalizedShortcuts = shortcutTitles.map((s) => s.trim());
|
|
117
|
+
for (const expected of config.expectedShortcutTitles) {
|
|
118
|
+
expect(normalizedShortcuts, `Shortcut '${expected}' fehlt auf Dashboard`).toContain(
|
|
119
|
+
expected
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
for (const forbidden of config.forbiddenShortcutTitles ?? []) {
|
|
123
|
+
expect(
|
|
124
|
+
normalizedShortcuts,
|
|
125
|
+
`Shortcut '${forbidden}' darf nicht auf Dashboard sein`
|
|
126
|
+
).not.toContain(forbidden);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
132
|
+
0 && (module.exports = {
|
|
133
|
+
runAdminPagesSuite
|
|
134
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
interface AdminPagesSuiteConfig {
|
|
4
|
+
/** Display name, e.g. 'demoapp' / 'clubapp'. */
|
|
5
|
+
appName: string;
|
|
6
|
+
/** URL of the login page (full). */
|
|
7
|
+
loginUrl: string;
|
|
8
|
+
/** Test account. */
|
|
9
|
+
email: string;
|
|
10
|
+
password: string;
|
|
11
|
+
/** Paths the smoke test should click through. */
|
|
12
|
+
pages: ReadonlyArray<{
|
|
13
|
+
/** Display name in the test description. */
|
|
14
|
+
name: string;
|
|
15
|
+
/** Path relative to the origin (e.g. '/admin/tenants'). */
|
|
16
|
+
path: string;
|
|
17
|
+
/** Optional: if set, check that this selector exists. */
|
|
18
|
+
expectVisible?: string;
|
|
19
|
+
/** Optional: HTTP patterns that are OK as 4xx/5xx (e.g. missing backend endpoints). */
|
|
20
|
+
allowedFailures?: readonly RegExp[];
|
|
21
|
+
}>;
|
|
22
|
+
/** Expected KPI card labels on the dashboard page. */
|
|
23
|
+
expectedKpiLabels: readonly string[];
|
|
24
|
+
/** Expected distribution section titles. */
|
|
25
|
+
expectedDistributionTitles?: readonly string[];
|
|
26
|
+
/** Expected shortcut titles on the dashboard. */
|
|
27
|
+
expectedShortcutTitles: readonly string[];
|
|
28
|
+
/** Shortcuts that must NOT appear (e.g. "Dashboard"). */
|
|
29
|
+
forbiddenShortcutTitles?: readonly string[];
|
|
30
|
+
/** Console error patterns that should be ignored. */
|
|
31
|
+
consoleErrorAllowlist?: readonly RegExp[];
|
|
32
|
+
/** Path to the dashboard page (default `/admin/`). */
|
|
33
|
+
dashboardPath?: string;
|
|
34
|
+
}
|
|
35
|
+
declare function runAdminPagesSuite(test: typeof test, expect: typeof expect, config: AdminPagesSuiteConfig): void;
|
|
36
|
+
|
|
37
|
+
export { type AdminPagesSuiteConfig, runAdminPagesSuite };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
interface AdminPagesSuiteConfig {
|
|
4
|
+
/** Display name, e.g. 'demoapp' / 'clubapp'. */
|
|
5
|
+
appName: string;
|
|
6
|
+
/** URL of the login page (full). */
|
|
7
|
+
loginUrl: string;
|
|
8
|
+
/** Test account. */
|
|
9
|
+
email: string;
|
|
10
|
+
password: string;
|
|
11
|
+
/** Paths the smoke test should click through. */
|
|
12
|
+
pages: ReadonlyArray<{
|
|
13
|
+
/** Display name in the test description. */
|
|
14
|
+
name: string;
|
|
15
|
+
/** Path relative to the origin (e.g. '/admin/tenants'). */
|
|
16
|
+
path: string;
|
|
17
|
+
/** Optional: if set, check that this selector exists. */
|
|
18
|
+
expectVisible?: string;
|
|
19
|
+
/** Optional: HTTP patterns that are OK as 4xx/5xx (e.g. missing backend endpoints). */
|
|
20
|
+
allowedFailures?: readonly RegExp[];
|
|
21
|
+
}>;
|
|
22
|
+
/** Expected KPI card labels on the dashboard page. */
|
|
23
|
+
expectedKpiLabels: readonly string[];
|
|
24
|
+
/** Expected distribution section titles. */
|
|
25
|
+
expectedDistributionTitles?: readonly string[];
|
|
26
|
+
/** Expected shortcut titles on the dashboard. */
|
|
27
|
+
expectedShortcutTitles: readonly string[];
|
|
28
|
+
/** Shortcuts that must NOT appear (e.g. "Dashboard"). */
|
|
29
|
+
forbiddenShortcutTitles?: readonly string[];
|
|
30
|
+
/** Console error patterns that should be ignored. */
|
|
31
|
+
consoleErrorAllowlist?: readonly RegExp[];
|
|
32
|
+
/** Path to the dashboard page (default `/admin/`). */
|
|
33
|
+
dashboardPath?: string;
|
|
34
|
+
}
|
|
35
|
+
declare function runAdminPagesSuite(test: typeof test, expect: typeof expect, config: AdminPagesSuiteConfig): void;
|
|
36
|
+
|
|
37
|
+
export { type AdminPagesSuiteConfig, runAdminPagesSuite };
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// src/testing-e2e/admin-pages-suite.ts
|
|
2
|
+
var RESOURCE_FAILURE_MARKER = /Failed to load resource:/;
|
|
3
|
+
function attachListeners(page, consoleAllowlist = [], networkAllowlist = []) {
|
|
4
|
+
const out = { pageErrors: [], consoleErrors: [], networkErrors: [] };
|
|
5
|
+
page.on("pageerror", (err) => out.pageErrors.push(err.message));
|
|
6
|
+
page.on("console", (msg) => {
|
|
7
|
+
if (msg.type() !== "error") return;
|
|
8
|
+
const text = msg.text();
|
|
9
|
+
if (RESOURCE_FAILURE_MARKER.test(text)) return;
|
|
10
|
+
if (consoleAllowlist.some((re) => re.test(text))) return;
|
|
11
|
+
out.consoleErrors.push(text);
|
|
12
|
+
});
|
|
13
|
+
page.on("response", (resp) => {
|
|
14
|
+
if (resp.status() < 400) return;
|
|
15
|
+
const url = resp.url();
|
|
16
|
+
if (!url.includes("/api/")) return;
|
|
17
|
+
if (networkAllowlist.some((re) => re.test(url))) return;
|
|
18
|
+
out.networkErrors.push(`HTTP ${resp.status()} ${url}`);
|
|
19
|
+
});
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
async function loginIfNeeded(page, config) {
|
|
23
|
+
if (!page.url().includes("/login")) {
|
|
24
|
+
await page.goto(config.loginUrl, { waitUntil: "networkidle" });
|
|
25
|
+
}
|
|
26
|
+
if (!page.url().includes("/login")) return;
|
|
27
|
+
await page.locator('input[type="email"]').first().fill(config.email);
|
|
28
|
+
const pw = page.locator('input[type="password"]').first();
|
|
29
|
+
await pw.fill(config.password);
|
|
30
|
+
await pw.press("Enter");
|
|
31
|
+
await page.waitForURL((u) => !u.toString().includes("/login"), {
|
|
32
|
+
timeout: 15e3
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
function runAdminPagesSuite(test, expect, config) {
|
|
36
|
+
const dashboardPath = config.dashboardPath ?? "/admin/";
|
|
37
|
+
test.describe(`${config.appName}: SuperAdmin-Pages Smoke`, () => {
|
|
38
|
+
for (const pageDef of config.pages) {
|
|
39
|
+
test(`${pageDef.name} rendert ohne Fehler`, async ({ page }) => {
|
|
40
|
+
const errors = attachListeners(
|
|
41
|
+
page,
|
|
42
|
+
config.consoleErrorAllowlist,
|
|
43
|
+
pageDef.allowedFailures
|
|
44
|
+
);
|
|
45
|
+
await loginIfNeeded(page, config);
|
|
46
|
+
const origin = new URL(config.loginUrl).origin;
|
|
47
|
+
await page.goto(`${origin}${pageDef.path}`, {
|
|
48
|
+
waitUntil: "networkidle",
|
|
49
|
+
timeout: 15e3
|
|
50
|
+
});
|
|
51
|
+
await page.waitForTimeout(800);
|
|
52
|
+
if (pageDef.expectVisible) {
|
|
53
|
+
await expect(page.locator(pageDef.expectVisible).first()).toBeVisible({
|
|
54
|
+
timeout: 5e3
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
expect(errors.pageErrors, `pageerror-Events auf ${pageDef.path}`).toHaveLength(0);
|
|
58
|
+
expect(
|
|
59
|
+
errors.consoleErrors,
|
|
60
|
+
`console.error-Events auf ${pageDef.path}`
|
|
61
|
+
).toHaveLength(0);
|
|
62
|
+
expect(
|
|
63
|
+
errors.networkErrors,
|
|
64
|
+
`HTTP-Errors auf /api/ w\xE4hrend ${pageDef.path}`
|
|
65
|
+
).toHaveLength(0);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
test("Dashboard zeigt erwartete KPIs, Distributions und Shortcuts", async ({ page }) => {
|
|
69
|
+
attachListeners(page, config.consoleErrorAllowlist);
|
|
70
|
+
await loginIfNeeded(page, config);
|
|
71
|
+
const origin = new URL(config.loginUrl).origin;
|
|
72
|
+
await page.goto(`${origin}${dashboardPath}`, {
|
|
73
|
+
waitUntil: "networkidle",
|
|
74
|
+
timeout: 15e3
|
|
75
|
+
});
|
|
76
|
+
const kpiLabels = await page.locator(".sa-kpi__label").allTextContents();
|
|
77
|
+
const normalizedKpis = kpiLabels.map((s) => s.trim());
|
|
78
|
+
for (const expected of config.expectedKpiLabels) {
|
|
79
|
+
expect(normalizedKpis, `KPI '${expected}' fehlt auf Dashboard`).toContain(expected);
|
|
80
|
+
}
|
|
81
|
+
if (config.expectedDistributionTitles?.length) {
|
|
82
|
+
const distTitles = await page.locator(".sa-dashboard__row-head h2").allTextContents();
|
|
83
|
+
const normalizedDist = distTitles.map((s) => s.trim());
|
|
84
|
+
for (const expected of config.expectedDistributionTitles) {
|
|
85
|
+
expect(
|
|
86
|
+
normalizedDist,
|
|
87
|
+
`Distribution '${expected}' fehlt auf Dashboard`
|
|
88
|
+
).toContain(expected);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const shortcutTitles = await page.locator(".sa-dashboard__shortcut-title").allTextContents();
|
|
92
|
+
const normalizedShortcuts = shortcutTitles.map((s) => s.trim());
|
|
93
|
+
for (const expected of config.expectedShortcutTitles) {
|
|
94
|
+
expect(normalizedShortcuts, `Shortcut '${expected}' fehlt auf Dashboard`).toContain(
|
|
95
|
+
expected
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
for (const forbidden of config.forbiddenShortcutTitles ?? []) {
|
|
99
|
+
expect(
|
|
100
|
+
normalizedShortcuts,
|
|
101
|
+
`Shortcut '${forbidden}' darf nicht auf Dashboard sein`
|
|
102
|
+
).not.toContain(forbidden);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
export {
|
|
108
|
+
runAdminPagesSuite
|
|
109
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@saasicat/ui-vue",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Vue 3 components + composables for the SuperAdmin UI shell. Provides boot loader, manifest loader (ETag cache), nav builder, action registry and standard pages.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"require": {
|
|
16
|
+
"types": "./dist/index.d.cts",
|
|
17
|
+
"default": "./dist/index.cjs"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"./pages/*": "./src/pages-standard/*",
|
|
21
|
+
"./pages-standard/*": "./src/pages-standard/*",
|
|
22
|
+
"./pages-tenant/*": "./src/pages-tenant/*",
|
|
23
|
+
"./components/*": "./src/components/*",
|
|
24
|
+
"./sa-theme.css": "./src/pages-standard/sa-theme.css",
|
|
25
|
+
"./testing-e2e/*": {
|
|
26
|
+
"types": "./dist/testing-e2e/*.d.ts",
|
|
27
|
+
"import": "./dist/testing-e2e/*.js",
|
|
28
|
+
"require": "./dist/testing-e2e/*.cjs"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"src"
|
|
34
|
+
],
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@saasicat/types": "^0.2.0"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"pinia": "^2.0.0 || ^3.0.0",
|
|
40
|
+
"quasar": "^2.18.0",
|
|
41
|
+
"vue": "^3.5.0",
|
|
42
|
+
"vue-router": "^4.5.0 || ^5.0.0"
|
|
43
|
+
},
|
|
44
|
+
"peerDependenciesMeta": {
|
|
45
|
+
"pinia": {
|
|
46
|
+
"optional": true
|
|
47
|
+
},
|
|
48
|
+
"quasar": {
|
|
49
|
+
"optional": true
|
|
50
|
+
},
|
|
51
|
+
"vue-router": {
|
|
52
|
+
"optional": true
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@playwright/test": "^1.59.1",
|
|
57
|
+
"@types/node": "^25.6.0",
|
|
58
|
+
"pinia": "^3.0.0",
|
|
59
|
+
"quasar": "^2.18.0",
|
|
60
|
+
"tsup": "^8.0.0",
|
|
61
|
+
"typescript": "^6.0.0",
|
|
62
|
+
"vue": "^3.5.0",
|
|
63
|
+
"vue-router": "^5.0.0",
|
|
64
|
+
"vue-tsc": "^3.3.3"
|
|
65
|
+
},
|
|
66
|
+
"license": "Apache-2.0",
|
|
67
|
+
"author": "Taci Uelker",
|
|
68
|
+
"homepage": "https://github.com/uelker70/saasicat",
|
|
69
|
+
"repository": {
|
|
70
|
+
"type": "git",
|
|
71
|
+
"url": "https://github.com/uelker70/saasicat.git",
|
|
72
|
+
"directory": "packages/saas-platform-ui-vue"
|
|
73
|
+
},
|
|
74
|
+
"bugs": {
|
|
75
|
+
"url": "https://github.com/uelker70/saasicat/issues"
|
|
76
|
+
},
|
|
77
|
+
"publishConfig": {
|
|
78
|
+
"access": "public"
|
|
79
|
+
},
|
|
80
|
+
"scripts": {
|
|
81
|
+
"build": "tsup src/index.ts --format esm,cjs --dts --clean --external @saasicat/types --external vue --external vue-router --external pinia --external quasar && tsup src/testing-e2e/admin-pages-suite.ts --format esm,cjs --dts --out-dir dist/testing-e2e --external @playwright/test",
|
|
82
|
+
"typecheck": "vue-tsc --noEmit -p tsconfig.json",
|
|
83
|
+
"pretest": "pnpm run build",
|
|
84
|
+
"test": "node --test tests/**/*.test.js",
|
|
85
|
+
"test:e2e": "playwright test"
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// ActionRegistry — central dispatch for `TenantActionDef`-declared
|
|
2
|
+
// operations.
|
|
3
|
+
//
|
|
4
|
+
// The consumer shell registers an `actions:` map with
|
|
5
|
+
// `actionKey → ActionHandler` bindings. When the manifest action declares
|
|
6
|
+
// e.g. `actionKey: 'TENANT_SUSPEND'`, the shell must provide a handler under
|
|
7
|
+
// the same key — otherwise `dispatch` throws a `MissingHandlerError` with a
|
|
8
|
+
// clear diagnostic.
|
|
9
|
+
//
|
|
10
|
+
// MFA prompt + confirm modal stay consumer-specific (Quasar dialog,
|
|
11
|
+
// headless UI etc.); the registry only supplies the action-definition pair
|
|
12
|
+
// `{def, handler}` so the shell UI layer can trigger the appropriate
|
|
13
|
+
// pre-flow.
|
|
14
|
+
|
|
15
|
+
import type { AdminManifest, TenantActionDef } from '@saasicat/types';
|
|
16
|
+
|
|
17
|
+
/** Consumer implementation; receives the action inputs as a generic object. */
|
|
18
|
+
export type ActionHandler<TInput = unknown, TResult = unknown> = (
|
|
19
|
+
input: TInput,
|
|
20
|
+
) => Promise<TResult>;
|
|
21
|
+
|
|
22
|
+
export interface ResolvedAction<TInput = unknown, TResult = unknown> {
|
|
23
|
+
def: TenantActionDef;
|
|
24
|
+
handler: ActionHandler<TInput, TResult>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class MissingHandlerError extends Error {
|
|
28
|
+
constructor(actionKey: string) {
|
|
29
|
+
super(
|
|
30
|
+
`Kein Handler für actionKey "${actionKey}" registriert. ` +
|
|
31
|
+
`Konsument-Shell muss \`actions["${actionKey}"]\` bereitstellen.`,
|
|
32
|
+
);
|
|
33
|
+
this.name = 'MissingHandlerError';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class ActionDefNotInManifestError extends Error {
|
|
38
|
+
constructor(actionKey: string) {
|
|
39
|
+
super(
|
|
40
|
+
`actionKey "${actionKey}" ist im Manifest nicht deklariert. ` +
|
|
41
|
+
`Bitte als TenantAction in einer ManifestContribution registrieren.`,
|
|
42
|
+
);
|
|
43
|
+
this.name = 'ActionDefNotInManifestError';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class ActionRegistry {
|
|
48
|
+
private readonly defs: Map<string, TenantActionDef> = new Map();
|
|
49
|
+
private readonly handlers: Map<string, ActionHandler> = new Map();
|
|
50
|
+
|
|
51
|
+
constructor(manifest: AdminManifest, handlers: Record<string, ActionHandler> = {}) {
|
|
52
|
+
for (const def of manifest.tenants?.actions ?? []) {
|
|
53
|
+
this.defs.set(def.actionKey, def);
|
|
54
|
+
}
|
|
55
|
+
for (const [key, handler] of Object.entries(handlers)) {
|
|
56
|
+
this.handlers.set(key, handler);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Registers a handler after the fact (e.g. when consumer code loads
|
|
62
|
+
* lazily). Throws `ActionDefNotInManifestError` if the `actionKey` is not
|
|
63
|
+
* declared in the manifest — prevents dead registrations.
|
|
64
|
+
*/
|
|
65
|
+
register<TInput, TResult>(actionKey: string, handler: ActionHandler<TInput, TResult>): void {
|
|
66
|
+
if (!this.defs.has(actionKey)) {
|
|
67
|
+
throw new ActionDefNotInManifestError(actionKey);
|
|
68
|
+
}
|
|
69
|
+
this.handlers.set(actionKey, handler as ActionHandler);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Returns the `{def, handler}` pair. Throws if the key is missing from
|
|
74
|
+
* the manifest or no handler is registered. The shell's UI layer calls
|
|
75
|
+
* the method, checks `def.requiresMfa` / `def.confirmType` for the
|
|
76
|
+
* pre-flow, and then calls `handler(input)`.
|
|
77
|
+
*/
|
|
78
|
+
get<TInput = unknown, TResult = unknown>(actionKey: string): ResolvedAction<TInput, TResult> {
|
|
79
|
+
const def = this.defs.get(actionKey);
|
|
80
|
+
if (!def) throw new ActionDefNotInManifestError(actionKey);
|
|
81
|
+
const handler = this.handlers.get(actionKey);
|
|
82
|
+
if (!handler) throw new MissingHandlerError(actionKey);
|
|
83
|
+
return { def, handler: handler as ActionHandler<TInput, TResult> };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Convenience: `get(key).handler(input)`. UI convenience for actions
|
|
88
|
+
* that need neither MFA nor confirm.
|
|
89
|
+
*/
|
|
90
|
+
async dispatch<TInput, TResult>(actionKey: string, input: TInput): Promise<TResult> {
|
|
91
|
+
return this.get<TInput, TResult>(actionKey).handler(input);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* List of actionKeys that are declared in the manifest but have no
|
|
96
|
+
* handler. Consumers use this in a doctor check to detect drift between
|
|
97
|
+
* the manifest and the shell build.
|
|
98
|
+
*/
|
|
99
|
+
listOrphanedDefs(): string[] {
|
|
100
|
+
return [...this.defs.keys()].filter((k) => !this.handlers.has(k));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* List of registered handlers that are missing from the manifest. Drift
|
|
105
|
+
* in the other direction.
|
|
106
|
+
*/
|
|
107
|
+
listOrphanedHandlers(): string[] {
|
|
108
|
+
return [...this.handlers.keys()].filter((k) => !this.defs.has(k));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// BatchColumnFetcher — loads custom TenantColumn data in ONE request per
|
|
2
|
+
// column (required by SPEC §4.6: no N+1 for the tenant page table).
|
|
3
|
+
//
|
|
4
|
+
// Manifest contract: every `TenantColumnDef.endpoint` MUST be batch-capable —
|
|
5
|
+
// no `{slug}` or `{tenantId}` placeholder in the path. The fetcher appends
|
|
6
|
+
// `?tenantIds=t1,t2,...,tN` (or `tenantIds=t1&tenantIds=t2&...` — the
|
|
7
|
+
// `paramStyle` option picks the format) and expects a response of the form
|
|
8
|
+
// `Record<tenantId, value>`.
|
|
9
|
+
//
|
|
10
|
+
// Capability filter: columns without a satisfied `requiredCapability` are not
|
|
11
|
+
// fetched at all. Manifest drift (a column `endpoint` with a `{slug}` pattern)
|
|
12
|
+
// is reported in a structured way via `BatchColumnDriftError` — the consumer
|
|
13
|
+
// shell then renders a warning.
|
|
14
|
+
|
|
15
|
+
import type { AdminManifest, TenantColumnDef } from '@saasicat/types';
|
|
16
|
+
import { defaultHttpClient, type HttpClient } from './types.js';
|
|
17
|
+
|
|
18
|
+
export type BatchColumnValue = unknown;
|
|
19
|
+
|
|
20
|
+
/** One value per `tenantId` (freely structured). */
|
|
21
|
+
export type BatchColumnRow = Record<string, BatchColumnValue>;
|
|
22
|
+
|
|
23
|
+
/** One `tenantId → value` map per column key. */
|
|
24
|
+
export type BatchColumnData = Record<string, BatchColumnRow>;
|
|
25
|
+
|
|
26
|
+
export type ParamStyle = 'comma' | 'repeat';
|
|
27
|
+
|
|
28
|
+
export interface BatchColumnFetcherOptions {
|
|
29
|
+
http?: HttpClient;
|
|
30
|
+
/**
|
|
31
|
+
* How the `tenantIds` are passed to the endpoint. Default `'comma'` →
|
|
32
|
+
* `?tenantIds=t1,t2,t3`. `'repeat'` → `?tenantIds=t1&tenantIds=t2`.
|
|
33
|
+
* Consumer backends decide based on their framework (NestJS accepts
|
|
34
|
+
* comma-separated strings by default, or via `@Query() ids: string[]`).
|
|
35
|
+
*/
|
|
36
|
+
paramStyle?: ParamStyle;
|
|
37
|
+
/**
|
|
38
|
+
* Auth-token provider for `Authorization: Bearer <token>`. The consumer
|
|
39
|
+
* supplies a function that pulls the current token from the auth store.
|
|
40
|
+
*/
|
|
41
|
+
getAuthToken?: () => string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class BatchColumnDriftError extends Error {
|
|
45
|
+
constructor(
|
|
46
|
+
public readonly column: TenantColumnDef,
|
|
47
|
+
reason: string,
|
|
48
|
+
) {
|
|
49
|
+
super(`Spalte "${column.key}": ${reason}`);
|
|
50
|
+
this.name = 'BatchColumnDriftError';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class BatchColumnFetcher {
|
|
55
|
+
private readonly http: HttpClient;
|
|
56
|
+
private readonly paramStyle: ParamStyle;
|
|
57
|
+
private readonly getAuthToken?: () => string | null;
|
|
58
|
+
|
|
59
|
+
constructor(options: BatchColumnFetcherOptions = {}) {
|
|
60
|
+
this.http = options.http ?? defaultHttpClient();
|
|
61
|
+
this.paramStyle = options.paramStyle ?? 'comma';
|
|
62
|
+
this.getAuthToken = options.getAuthToken;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Loads the data for all columns declared in the manifest in parallel
|
|
67
|
+
* (one request per column, all `tenantIds` in the batch). Columns without
|
|
68
|
+
* a satisfied capability are ignored.
|
|
69
|
+
*/
|
|
70
|
+
async fetchAll(manifest: AdminManifest, tenantIds: string[]): Promise<BatchColumnData> {
|
|
71
|
+
if (tenantIds.length === 0) return {};
|
|
72
|
+
const cols = this.eligibleColumns(manifest);
|
|
73
|
+
const results = await Promise.all(
|
|
74
|
+
cols.map((col) =>
|
|
75
|
+
this.fetchOne(col, tenantIds).then((data) => [col.key, data] as const),
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
const out: BatchColumnData = {};
|
|
79
|
+
for (const [key, data] of results) out[key] = data;
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Fetch a single column (consumers may also use this directly). */
|
|
84
|
+
async fetchOne(column: TenantColumnDef, tenantIds: string[]): Promise<BatchColumnRow> {
|
|
85
|
+
this.validateBatchEndpoint(column);
|
|
86
|
+
if (tenantIds.length === 0) return {};
|
|
87
|
+
const url = this.buildUrl(column.endpoint, tenantIds);
|
|
88
|
+
const headers: Record<string, string> = {};
|
|
89
|
+
const token = this.getAuthToken?.();
|
|
90
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
91
|
+
const res = await this.http(url, { method: 'GET', headers });
|
|
92
|
+
if (res.status !== 200) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`Spalte "${column.key}" — Endpoint ${column.endpoint} antwortete HTTP ${res.status}`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
const body = (await res.json()) as BatchColumnRow;
|
|
98
|
+
return body;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Column drift against the manifest. Consumers use this for CI smoke
|
|
103
|
+
* tests of the manifest-vs-shell build.
|
|
104
|
+
*/
|
|
105
|
+
listDriftIssues(manifest: AdminManifest): BatchColumnDriftError[] {
|
|
106
|
+
const issues: BatchColumnDriftError[] = [];
|
|
107
|
+
for (const col of manifest.tenants?.columns ?? []) {
|
|
108
|
+
try {
|
|
109
|
+
this.validateBatchEndpoint(col);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
if (err instanceof BatchColumnDriftError) issues.push(err);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return issues;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Which columns have a satisfied `requiredCapability`? Manifest =
|
|
119
|
+
* discovery, so a local check is enough.
|
|
120
|
+
*/
|
|
121
|
+
eligibleColumns(manifest: AdminManifest): TenantColumnDef[] {
|
|
122
|
+
const caps = manifest.capabilities ?? {};
|
|
123
|
+
return (manifest.tenants?.columns ?? []).filter((col) => {
|
|
124
|
+
if (!col.requiredCapability) return true;
|
|
125
|
+
return caps[col.requiredCapability] === true;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private validateBatchEndpoint(col: TenantColumnDef): void {
|
|
130
|
+
if (!col.endpoint || col.endpoint.trim().length === 0) {
|
|
131
|
+
throw new BatchColumnDriftError(col, 'endpoint ist leer');
|
|
132
|
+
}
|
|
133
|
+
if (col.endpoint.includes('{slug}') || col.endpoint.includes('{tenantId}')) {
|
|
134
|
+
throw new BatchColumnDriftError(
|
|
135
|
+
col,
|
|
136
|
+
'endpoint enthält per-Tenant-Placeholder ({slug}/{tenantId}). ' +
|
|
137
|
+
'Spalten-Endpoints müssen batchfähig sein — bitte Backend ' +
|
|
138
|
+
'auf `?tenantIds=...`-Parameter umstellen.',
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private buildUrl(endpoint: string, tenantIds: string[]): string {
|
|
144
|
+
const sep = endpoint.includes('?') ? '&' : '?';
|
|
145
|
+
if (this.paramStyle === 'comma') {
|
|
146
|
+
const enc = tenantIds.map(encodeURIComponent).join(',');
|
|
147
|
+
return `${endpoint}${sep}tenantIds=${enc}`;
|
|
148
|
+
}
|
|
149
|
+
const params = tenantIds.map((id) => `tenantIds=${encodeURIComponent(id)}`).join('&');
|
|
150
|
+
return `${endpoint}${sep}${params}`;
|
|
151
|
+
}
|
|
152
|
+
}
|