Skip to content

Commit 688911a

Browse files
feat(ui): config-overridable loader, plan-badge set, and copy strings (#4475)
* feat(ui): config-overridable loader, plan-badge set, and copy strings Three hardcoded UI spots become config-overridable so a downstream project can rebrand/reconfigure them without editing the stack view (avoids byte- drift on every /update-stack). Every key defaults to today's exact behavior. - ui.loader.component: new CoreAppSpinner component renders the built-in v-progress-circular by default, or a project loader SFC resolved via an import.meta.glob filename convention when configured. Wired into the 6 hardcoded loading-state usages (auth token view, docs article/home/ reference views, billing subscriptions + account view). - billing.planBadge.plans / billing.planBadge.fallbackColor: the plan badge's hardcoded free/starter/pro/enterprise ids + colors become the devkit default only, fully replaceable via an ordered plans array. - billing.meterPeriodWord, billing.freePlanBlurb, app.itemNoun: three hardcoded copy strings now read `this.config?.X ?? currentDefault`. Closes #4474 * fix(ui): reword loader comment to avoid leak-guard word match Non-substantive wording fix — 'becomes' contained a flagged substring. * ci(e2e): poll /api/health not /api/home (404 catch-all exposed the non-route) * test(billing): add JSDoc header to mountSubscriptions helper
1 parent a733998 commit 688911a

19 files changed

Lines changed: 487 additions & 26 deletions

.github/workflows/CI.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,10 @@ jobs:
105105
DEVKIT_NODE_cors_origin: '[http://localhost:8080]'
106106
DEVKIT_NODE_cors_credentials: "true"
107107
- name: Wait for Node backend
108-
run: npx wait-on http://127.0.0.1:3000/api/home --timeout 60000 || (echo "--- Node backend logs ---"; cat /tmp/node-backend.log; exit 1)
108+
# Poll /api/health (the real readiness route). /api/home was never a route —
109+
# it only returned 2xx via the unmatched-route fallback; the content-negotiated
110+
# 404 change made it return 404, so wait-on polled a permanent 404 until timeout.
111+
run: npx wait-on http://127.0.0.1:3000/api/health --timeout 300000 || (echo "--- Node backend logs ---"; cat /tmp/node-backend.log; echo "--- curl /api/health ---"; curl -sv --max-time 10 http://127.0.0.1:3000/api/health 2>&1 | head -30; exit 1)
109112

110113
# Install & start Vue dev server
111114
- name: Install Vue

MIGRATIONS.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,23 @@ Breaking changes and upgrade notes for downstream projects.
44

55
---
66

7+
## config-ui-hooks: overridable loader, plan-badge set, and copy strings (2026-07-18, #4474)
8+
9+
Three previously-hardcoded UI spots became config-overridable so a downstream project can rebrand/reconfigure them without editing the stack view (which caused byte-drift on every `/update-stack`). All three are no-ops for downstreams that don't opt in — every key is absent/null by default and every consumer falls back to the exact current behavior.
10+
11+
### What changed (this repo)
12+
13+
- **Loader** (new `config.ui.loader.component`, default `null`): new `CoreAppSpinner` component (`src/modules/core/components/core.appSpinner.component.vue`) renders the built-in `v-progress-circular` by default, or a project-provided loader SFC resolved via an `import.meta.glob` filename convention (`/src/modules/*/components/**/*.loader.component.vue`) when the config key is set — config values can only be strings, so the override is a Vite path, not a component reference. The 6 hardcoded `v-progress-circular` loading-state usages (auth token view, docs article/home/reference views, billing subscriptions + account view) now render `<AppSpinner>`, passing color/size/data-test through via Vue's automatic attribute fallthrough.
14+
- **Plan badge** (new `config.billing.planBadge.plans` / `config.billing.planBadge.fallbackColor`, both absent by default): `billing.planBadge.component.vue`'s hardcoded allowed-ids + color map (`free/starter/pro/enterprise`) is now the devkit default only, overridable by an ordered `{ id, color }[]` array (a downstream override fully replaces the set — `generateConfig`'s deepMerge replaces arrays wholesale, it does not union them).
15+
- **Copy** (new `config.billing.meterPeriodWord`, `config.billing.freePlanBlurb`, `config.app.itemNoun`, all absent by default): three hardcoded strings (the meter-period word in the upgrade-prompt copy, the free-plan upgrade blurb, and the "projects" noun in the workspace-setup helper text) now read `this.config?.X ?? currentDefault`. None of the three keys are seeded in any config fragment, so the generated `src/config/index.js` stays byte-identical.
16+
17+
### Action required for downstream projects (`/update-stack`)
18+
19+
1. All files are devkit-owned → arrive via `/update-stack`. No action required — every key defaults to today's exact behavior.
20+
2. To customize: add any of `ui.loader.component`, `billing.planBadge.{plans,fallbackColor}`, `billing.meterPeriodWord`, `billing.freePlanBlurb`, `app.itemNoun` to your `src/config/defaults/<project>.config.js` (or a per-module `<module>.<project>.config.js` fragment). A custom loader SFC ships inside your own project module (e.g. `src/modules/<project>/components/<project>.loader.component.vue`) and is referenced by its literal absolute-from-root Vite path.
21+
22+
---
23+
724
## docs: Redoc /api/docs reference UI decommissioned (2026-06-17, #4333)
825

926
The docs module's in-theme OpenAPI reference (`/docs/api`) renders the merged spec at `GET /api/spec.json` natively — it is now the **only** reference surface. The dead "Open in Redoc" affordance (a header button + an empty-state fallback link, both pointing at the decommissioned backend `/api/docs` Redoc UI) has been removed.

src/config/defaults/development.config.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,4 +101,16 @@ export default {
101101
capturePageleave: false, // opt-in: capture page-leave events
102102
},
103103
},
104+
ui: {
105+
loader: {
106+
component: null, // Vite path of a custom loader SFC matching /src/modules/*/components/**/*.loader.component.vue; null = built-in v-progress-circular (see CoreAppSpinner)
107+
},
108+
},
109+
// Config-driven copy — intentionally absent here (each consumer falls back to its
110+
// current hardcoded default via `config.X ?? default`), so the generated config stays
111+
// byte-identical until a downstream opts in via `src/config/defaults/<project>.config.js`:
112+
// app.itemNoun — noun for the items a workspace groups, default 'projects'
113+
// billing.meterPeriodWord — billing period word in meter-mode upgrade copy, default 'monthly'
114+
// billing.freePlanBlurb — full free-plan upgrade paragraph (see billing.subscriptions.component.vue)
115+
// billing.planBadge.plans / billing.planBadge.fallbackColor — see billing.planBadge.component.vue
104116
};

src/modules/auth/components/organizationSetup.component.vue

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
<v-icon icon="fa-solid fa-building" size="x-large" color="primary" class="mb-4"></v-icon>
77
<h3 class="text-headline-small font-weight-bold mb-2">Set up your workspace</h3>
88
<p class="text-body-medium text-medium-emphasis">
9-
A workspace keeps your projects and teammates together — create one or join an existing organization to get started.
9+
A workspace keeps your {{ itemNoun }} and teammates together — create one or join an existing organization to get started.
1010
</p>
1111
</div>
1212

@@ -141,6 +141,16 @@ export default {
141141
},
142142
};
143143
},
144+
computed: {
145+
/**
146+
* @desc Noun for the items a workspace groups (e.g. "projects"), config-overridable
147+
* app-wide vocabulary.
148+
* @returns {string}
149+
*/
150+
itemNoun() {
151+
return this.config?.app?.itemNoun ?? 'projects';
152+
},
153+
},
144154
methods: {
145155
/**
146156
* @desc Submit the organization creation form.

src/modules/auth/tests/auth.organizationSetup.component.unit.tests.js

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,15 @@ const makeFormStub = (valid = true) => ({
3535
* Mount the organization setup component with Vuetify and stubbed form.
3636
* @param {object} formStub - VForm component definition controlling validation outcome.
3737
* @param {object} props - Component props (e.g. suggestedOrganization).
38+
* @param {object} [config] - `this.config` override (defaults to the shared mockConfig).
3839
* @returns {import('@vue/test-utils').VueWrapper} mounted wrapper
3940
*/
40-
const mountComponent = (formStub = makeFormStub(), props = {}) =>
41+
const mountComponent = (formStub = makeFormStub(), props = {}, config = mockConfig) =>
4142
mount(AuthOrganizationSetupComponent, {
4243
props,
4344
global: {
4445
plugins: [createVuetify()],
45-
mocks: { config: mockConfig },
46+
mocks: { config },
4647
stubs: { VForm: formStub },
4748
},
4849
});
@@ -286,4 +287,22 @@ describe('auth.organizationSetup.component', () => {
286287

287288
expect(createJoinRequestMock).not.toHaveBeenCalled();
288289
});
290+
291+
// --- config-driven item noun (#4474) ---
292+
293+
it('defaults the workspace helper text to "projects" when config.app.itemNoun is unset', async () => {
294+
const wrapper = mountComponent();
295+
await flushPromises();
296+
297+
expect(wrapper.text()).toContain('keeps your projects and teammates together');
298+
});
299+
300+
it('uses the configured item noun in the workspace helper text', async () => {
301+
const overrideConfig = { ...mockConfig, app: { itemNoun: 'boards' } };
302+
const wrapper = mountComponent(makeFormStub(), {}, overrideConfig);
303+
await flushPromises();
304+
305+
expect(wrapper.text()).toContain('keeps your boards and teammates together');
306+
expect(wrapper.text()).not.toContain('keeps your projects and teammates together');
307+
});
289308
});

src/modules/auth/views/token.view.vue

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<v-card class="mt-8 pa-8" width="100%" :style="{ background: theme.current.colors.surface }" :flat="config.vuetify.theme.flat">
55
<template v-if="loading">
66
<v-col cols="12" class="text-center py-8">
7-
<v-progress-circular indeterminate color="primary" size="48" />
7+
<AppSpinner color="primary" size="48" />
88
<p class="mt-4">Signing you in…</p>
99
</v-col>
1010
</template>
@@ -44,6 +44,7 @@
4444
import { useTheme } from 'vuetify';
4545
import { useAuthStore } from '../stores/auth.store';
4646
import { createLogger } from '../../../lib/helpers/logger';
47+
import AppSpinner from '../../core/components/core.appSpinner.component.vue';
4748
4849
const logger = createLogger('auth');
4950
@@ -60,6 +61,9 @@ function asNonEmptyString(value) {
6061
* Component definition.
6162
*/
6263
export default {
64+
components: {
65+
AppSpinner,
66+
},
6367
data() {
6468
const theme = useTheme();
6569
return {

src/modules/billing/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,24 @@ Auto-colored progress bar (green/orange/red). Shows "Unlimited" for uncapped pla
5050
<BillingPlanBadgeComponent :plan="currentPlan" />
5151
```
5252

53+
Allowed plan ids + chip colors default to `free`/`starter`/`pro`/`enterprise`. A
54+
downstream project can replace the whole set via config — the array replaces
55+
(does not merge with) the devkit default, so a downstream fully owns its plan
56+
vocabulary:
57+
58+
```js
59+
// src/config/defaults/<project>.config.js
60+
billing: {
61+
planBadge: {
62+
plans: [
63+
{ id: 'team', color: 'info' },
64+
{ id: 'scale', color: 'error' },
65+
],
66+
fallbackColor: 'grey', // color for a `plan` prop value not in `plans` (optional, default 'grey')
67+
},
68+
},
69+
```
70+
5371
### `<BillingNavComputeGaugeComponent>`
5472

5573
Sidenav compute-usage indicator (meter mode). A color-coded ring + `X% used`

src/modules/billing/components/billing.planBadge.component.vue

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,42 @@
1111

1212
<script>
1313
/**
14-
* Validate supported billing plan identifiers.
14+
* Module dependencies.
15+
*/
16+
import config from '../../../lib/services/config';
17+
18+
/**
19+
* Devkit default plan set — { id, color } pairs, ordered.
20+
* MUST NEVER be declared in any config fragment/defaults file: it lives here so the
21+
* generated config stays byte-identical, and so a downstream `billing.planBadge.plans`
22+
* array fully replaces this set (generateConfig's deepMerge REPLACES arrays wholesale —
23+
* it does not union them, so a devkit default landing in a config layer would let a
24+
* downstream shrink but never fully own the allowed set).
25+
*/
26+
const DEFAULT_PLANS = [
27+
{ id: 'free', color: 'grey' },
28+
{ id: 'starter', color: 'primary' },
29+
{ id: 'pro', color: 'secondary' },
30+
{ id: 'enterprise', color: 'warning' },
31+
];
32+
33+
/**
34+
* Resolve the effective plan definitions — a project override (config.billing.planBadge.plans)
35+
* when present and non-empty, otherwise the devkit default. A lazy accessor (not a module-eval
36+
* snapshot) so config can be mocked/mutated per test.
37+
* @returns {Array<{id: string, color: string}>}
38+
*/
39+
const planDefinitions = () => {
40+
const plans = config?.billing?.planBadge?.plans;
41+
return Array.isArray(plans) && plans.length ? plans : DEFAULT_PLANS;
42+
};
43+
44+
/**
45+
* Validate supported billing plan identifiers against the resolved (config-aware) plan set.
1546
* @param {string} value Plan identifier.
1647
* @returns {boolean} True when the identifier is allowed.
1748
*/
18-
const isAllowedPlan = (value) => ['free', 'starter', 'pro', 'enterprise'].includes(value);
49+
const isAllowedPlan = (value) => planDefinitions().some((definition) => definition && definition.id === value);
1950
2051
/**
2152
* Component definition.
@@ -31,17 +62,14 @@ export default {
3162
},
3263
computed: {
3364
/**
34-
* @desc Map plan name to Vuetify theme color.
65+
* @desc Map plan name to Vuetify theme color, from the resolved (config-aware) plan set.
66+
* Falls back to config.billing.planBadge.fallbackColor, then 'grey', for a plan id
67+
* that isn't in the resolved set.
3568
* @returns {string} Vuetify color name
3669
*/
3770
color() {
38-
const colors = {
39-
free: 'grey',
40-
starter: 'primary',
41-
pro: 'secondary',
42-
enterprise: 'warning',
43-
};
44-
return colors[this.plan] || 'grey';
71+
const definition = planDefinitions().find((d) => d && d.id === this.plan);
72+
return (definition && definition.color) || config?.billing?.planBadge?.fallbackColor || 'grey';
4573
},
4674
},
4775
};

src/modules/billing/components/billing.subscriptions.component.vue

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959

6060
<!-- ── Loading ──────────────────────────────────────────────────────── -->
6161
<v-row v-if="fetchLoading" justify="center" class="py-10">
62-
<v-progress-circular indeterminate color="primary" />
62+
<AppSpinner color="primary" />
6363
</v-row>
6464

6565
<!-- ── P1-1: Subscription fetch error ─────────────────────────────── -->
@@ -111,7 +111,7 @@
111111
</div>
112112
</div>
113113
<p class="text-body-medium text-medium-emphasis mb-6">
114-
You're on the free plan. Upgrade to unlock more projects, team members, and advanced features.
114+
{{ freePlanBlurb }}
115115
</p>
116116
<v-btn
117117
color="primary"
@@ -302,6 +302,7 @@ import BillingMeterProgressComponent from './billing.meterProgress.component.vue
302302
import BillingMeterBreakdownChartComponent from './billing.meterBreakdownChart.component.vue';
303303
import BillingExtrasLedgerComponent from './billing.extrasLedger.component.vue';
304304
import BillingExtrasCheckoutModalComponent from './billing.extrasCheckoutModal.component.vue';
305+
import AppSpinner from '../../core/components/core.appSpinner.component.vue';
305306
306307
const { plans: plansConfig, packs: packsConfig } = resolveStaticContent();
307308
@@ -316,6 +317,7 @@ export default {
316317
BillingMeterBreakdownChartComponent,
317318
BillingExtrasLedgerComponent,
318319
BillingExtrasCheckoutModalComponent,
320+
AppSpinner,
319321
},
320322
/**
321323
* @desc Wires billingStore + authStore + reactive meterMode + useMeter derived refs.
@@ -404,6 +406,18 @@ export default {
404406
fetchLoading() {
405407
return this.billingStore.loading;
406408
},
409+
/**
410+
* @desc Free-plan upgrade blurb, config-overridable. Devkit default: exact current
411+
* copy (full paragraph, not just the second sentence) so a downstream missing a
412+
* feature (e.g. no team members) can rewrite the whole thing.
413+
* @returns {string}
414+
*/
415+
freePlanBlurb() {
416+
return (
417+
this.config?.billing?.freePlanBlurb ??
418+
"You're on the free plan. Upgrade to unlock more projects, team members, and advanced features."
419+
);
420+
},
407421
subscription() {
408422
return this.billingStore.subscription;
409423
},

src/modules/billing/components/billing.upgradePrompt.component.vue

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
<v-alert v-else type="info" variant="tonal" prominent class="my-4">
3737
<template #text>
3838
<span v-if="hasUsageInfo">{{ `You've used ${current} of ${limit} ${displayLabel}.` }}</span>
39-
<span v-else-if="mode === 'meter'">You're out of compute. Buy more units or upgrade for monthly compute.</span>
39+
<span v-else-if="mode === 'meter'">{{ `You're out of compute. Buy more units or upgrade for ${meterPeriodWord} compute.` }}</span>
4040
<span v-else>{{ `This feature requires the ${requiredPlan} plan.` }}</span>
4141
</template>
4242
<template #append>
@@ -195,7 +195,16 @@ export default {
195195
*/
196196
grantDepletedMessage() {
197197
const packPhrase = this.primaryPack ? `a ${this.primaryPack.title}` : 'a compute pack';
198-
return `You used your ${signupGrantConfig.label}. Buy ${packPhrase} to keep going, or upgrade for monthly compute.`;
198+
return `You used your ${signupGrantConfig.label}. Buy ${packPhrase} to keep going, or upgrade for ${this.meterPeriodWord} compute.`;
199+
},
200+
/**
201+
* @desc Billing period word used in the meter-mode upgrade copy (e.g. "monthly").
202+
* Config-overridable so a downstream on a different billing period can rebrand this
203+
* without editing the view.
204+
* @returns {string}
205+
*/
206+
meterPeriodWord() {
207+
return this.config?.billing?.meterPeriodWord ?? 'monthly';
199208
},
200209
/**
201210
* @desc Primary pack CTA label, config-sourced from the resolved pack's own `cta` +

0 commit comments

Comments
 (0)