Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,10 @@ jobs:
DEVKIT_NODE_cors_origin: '[http://localhost:8080]'
DEVKIT_NODE_cors_credentials: "true"
- name: Wait for Node backend
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)
# Poll /api/health (the real readiness route). /api/home was never a route —
# it only returned 2xx via the unmatched-route fallback; the content-negotiated
# 404 change made it return 404, so wait-on polled a permanent 404 until timeout.
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)

# Install & start Vue dev server
- name: Install Vue
Expand Down
17 changes: 17 additions & 0 deletions MIGRATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@ Breaking changes and upgrade notes for downstream projects.

---

## config-ui-hooks: overridable loader, plan-badge set, and copy strings (2026-07-18, #4474)

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.

### What changed (this repo)

- **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.
- **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).
- **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.

### Action required for downstream projects (`/update-stack`)

1. All files are devkit-owned → arrive via `/update-stack`. No action required — every key defaults to today's exact behavior.
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.

---

## docs: Redoc /api/docs reference UI decommissioned (2026-06-17, #4333)

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.
Expand Down
12 changes: 12 additions & 0 deletions src/config/defaults/development.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,16 @@ export default {
capturePageleave: false, // opt-in: capture page-leave events
},
},
ui: {
loader: {
component: null, // Vite path of a custom loader SFC matching /src/modules/*/components/**/*.loader.component.vue; null = built-in v-progress-circular (see CoreAppSpinner)
},
},
// Config-driven copy — intentionally absent here (each consumer falls back to its
// current hardcoded default via `config.X ?? default`), so the generated config stays
// byte-identical until a downstream opts in via `src/config/defaults/<project>.config.js`:
// app.itemNoun — noun for the items a workspace groups, default 'projects'
// billing.meterPeriodWord — billing period word in meter-mode upgrade copy, default 'monthly'
// billing.freePlanBlurb — full free-plan upgrade paragraph (see billing.subscriptions.component.vue)
// billing.planBadge.plans / billing.planBadge.fallbackColor — see billing.planBadge.component.vue
};
12 changes: 11 additions & 1 deletion src/modules/auth/components/organizationSetup.component.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<v-icon icon="fa-solid fa-building" size="x-large" color="primary" class="mb-4"></v-icon>
<h3 class="text-headline-small font-weight-bold mb-2">Set up your workspace</h3>
<p class="text-body-medium text-medium-emphasis">
A workspace keeps your projects and teammates together — create one or join an existing organization to get started.
A workspace keeps your {{ itemNoun }} and teammates together — create one or join an existing organization to get started.
</p>
</div>

Expand Down Expand Up @@ -141,6 +141,16 @@ export default {
},
};
},
computed: {
/**
* @desc Noun for the items a workspace groups (e.g. "projects"), config-overridable
* app-wide vocabulary.
* @returns {string}
*/
itemNoun() {
return this.config?.app?.itemNoun ?? 'projects';
},
},
methods: {
/**
* @desc Submit the organization creation form.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,15 @@ const makeFormStub = (valid = true) => ({
* Mount the organization setup component with Vuetify and stubbed form.
* @param {object} formStub - VForm component definition controlling validation outcome.
* @param {object} props - Component props (e.g. suggestedOrganization).
* @param {object} [config] - `this.config` override (defaults to the shared mockConfig).
* @returns {import('@vue/test-utils').VueWrapper} mounted wrapper
*/
const mountComponent = (formStub = makeFormStub(), props = {}) =>
const mountComponent = (formStub = makeFormStub(), props = {}, config = mockConfig) =>
mount(AuthOrganizationSetupComponent, {
props,
global: {
plugins: [createVuetify()],
mocks: { config: mockConfig },
mocks: { config },
stubs: { VForm: formStub },
},
});
Expand Down Expand Up @@ -286,4 +287,22 @@ describe('auth.organizationSetup.component', () => {

expect(createJoinRequestMock).not.toHaveBeenCalled();
});

// --- config-driven item noun (#4474) ---

it('defaults the workspace helper text to "projects" when config.app.itemNoun is unset', async () => {
const wrapper = mountComponent();
await flushPromises();

expect(wrapper.text()).toContain('keeps your projects and teammates together');
});

it('uses the configured item noun in the workspace helper text', async () => {
const overrideConfig = { ...mockConfig, app: { itemNoun: 'boards' } };
const wrapper = mountComponent(makeFormStub(), {}, overrideConfig);
await flushPromises();

expect(wrapper.text()).toContain('keeps your boards and teammates together');
expect(wrapper.text()).not.toContain('keeps your projects and teammates together');
});
});
6 changes: 5 additions & 1 deletion src/modules/auth/views/token.view.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<v-card class="mt-8 pa-8" width="100%" :style="{ background: theme.current.colors.surface }" :flat="config.vuetify.theme.flat">
<template v-if="loading">
<v-col cols="12" class="text-center py-8">
<v-progress-circular indeterminate color="primary" size="48" />
<AppSpinner color="primary" size="48" />
<p class="mt-4">Signing you in…</p>
</v-col>
</template>
Expand Down Expand Up @@ -44,6 +44,7 @@
import { useTheme } from 'vuetify';
import { useAuthStore } from '../stores/auth.store';
import { createLogger } from '../../../lib/helpers/logger';
import AppSpinner from '../../core/components/core.appSpinner.component.vue';

const logger = createLogger('auth');

Expand All @@ -60,6 +61,9 @@ function asNonEmptyString(value) {
* Component definition.
*/
export default {
components: {
AppSpinner,
},
data() {
const theme = useTheme();
return {
Expand Down
18 changes: 18 additions & 0 deletions src/modules/billing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,24 @@ Auto-colored progress bar (green/orange/red). Shows "Unlimited" for uncapped pla
<BillingPlanBadgeComponent :plan="currentPlan" />
```

Allowed plan ids + chip colors default to `free`/`starter`/`pro`/`enterprise`. A
downstream project can replace the whole set via config — the array replaces
(does not merge with) the devkit default, so a downstream fully owns its plan
vocabulary:

```js
// src/config/defaults/<project>.config.js
billing: {
planBadge: {
plans: [
{ id: 'team', color: 'info' },
{ id: 'scale', color: 'error' },
],
fallbackColor: 'grey', // color for a `plan` prop value not in `plans` (optional, default 'grey')
},
},
```

### `<BillingNavComputeGaugeComponent>`

Sidenav compute-usage indicator (meter mode). A color-coded ring + `X% used`
Expand Down
48 changes: 38 additions & 10 deletions src/modules/billing/components/billing.planBadge.component.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,42 @@

<script>
/**
* Validate supported billing plan identifiers.
* Module dependencies.
*/
import config from '../../../lib/services/config';

/**
* Devkit default plan set — { id, color } pairs, ordered.
* MUST NEVER be declared in any config fragment/defaults file: it lives here so the
* generated config stays byte-identical, and so a downstream `billing.planBadge.plans`
* array fully replaces this set (generateConfig's deepMerge REPLACES arrays wholesale —
* it does not union them, so a devkit default landing in a config layer would let a
* downstream shrink but never fully own the allowed set).
*/
const DEFAULT_PLANS = [
{ id: 'free', color: 'grey' },
{ id: 'starter', color: 'primary' },
{ id: 'pro', color: 'secondary' },
{ id: 'enterprise', color: 'warning' },
];

/**
* Resolve the effective plan definitions — a project override (config.billing.planBadge.plans)
* when present and non-empty, otherwise the devkit default. A lazy accessor (not a module-eval
* snapshot) so config can be mocked/mutated per test.
* @returns {Array<{id: string, color: string}>}
*/
const planDefinitions = () => {
const plans = config?.billing?.planBadge?.plans;
return Array.isArray(plans) && plans.length ? plans : DEFAULT_PLANS;
};

/**
* Validate supported billing plan identifiers against the resolved (config-aware) plan set.
* @param {string} value Plan identifier.
* @returns {boolean} True when the identifier is allowed.
*/
const isAllowedPlan = (value) => ['free', 'starter', 'pro', 'enterprise'].includes(value);
const isAllowedPlan = (value) => planDefinitions().some((definition) => definition && definition.id === value);

/**
* Component definition.
Expand All @@ -31,17 +62,14 @@ export default {
},
computed: {
/**
* @desc Map plan name to Vuetify theme color.
* @desc Map plan name to Vuetify theme color, from the resolved (config-aware) plan set.
* Falls back to config.billing.planBadge.fallbackColor, then 'grey', for a plan id
* that isn't in the resolved set.
* @returns {string} Vuetify color name
*/
color() {
const colors = {
free: 'grey',
starter: 'primary',
pro: 'secondary',
enterprise: 'warning',
};
return colors[this.plan] || 'grey';
const definition = planDefinitions().find((d) => d && d.id === this.plan);
return (definition && definition.color) || config?.billing?.planBadge?.fallbackColor || 'grey';
},
},
};
Expand Down
18 changes: 16 additions & 2 deletions src/modules/billing/components/billing.subscriptions.component.vue
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@

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

<!-- ── P1-1: Subscription fetch error ─────────────────────────────── -->
Expand Down Expand Up @@ -111,7 +111,7 @@
</div>
</div>
<p class="text-body-medium text-medium-emphasis mb-6">
You're on the free plan. Upgrade to unlock more projects, team members, and advanced features.
{{ freePlanBlurb }}
</p>
<v-btn
color="primary"
Expand Down Expand Up @@ -302,6 +302,7 @@ import BillingMeterProgressComponent from './billing.meterProgress.component.vue
import BillingMeterBreakdownChartComponent from './billing.meterBreakdownChart.component.vue';
import BillingExtrasLedgerComponent from './billing.extrasLedger.component.vue';
import BillingExtrasCheckoutModalComponent from './billing.extrasCheckoutModal.component.vue';
import AppSpinner from '../../core/components/core.appSpinner.component.vue';

const { plans: plansConfig, packs: packsConfig } = resolveStaticContent();

Expand All @@ -316,6 +317,7 @@ export default {
BillingMeterBreakdownChartComponent,
BillingExtrasLedgerComponent,
BillingExtrasCheckoutModalComponent,
AppSpinner,
},
/**
* @desc Wires billingStore + authStore + reactive meterMode + useMeter derived refs.
Expand Down Expand Up @@ -404,6 +406,18 @@ export default {
fetchLoading() {
return this.billingStore.loading;
},
/**
* @desc Free-plan upgrade blurb, config-overridable. Devkit default: exact current
* copy (full paragraph, not just the second sentence) so a downstream missing a
* feature (e.g. no team members) can rewrite the whole thing.
* @returns {string}
*/
freePlanBlurb() {
return (
this.config?.billing?.freePlanBlurb ??
"You're on the free plan. Upgrade to unlock more projects, team members, and advanced features."
);
},
subscription() {
return this.billingStore.subscription;
},
Expand Down
13 changes: 11 additions & 2 deletions src/modules/billing/components/billing.upgradePrompt.component.vue
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
<v-alert v-else type="info" variant="tonal" prominent class="my-4">
<template #text>
<span v-if="hasUsageInfo">{{ `You've used ${current} of ${limit} ${displayLabel}.` }}</span>
<span v-else-if="mode === 'meter'">You're out of compute. Buy more units or upgrade for monthly compute.</span>
<span v-else-if="mode === 'meter'">{{ `You're out of compute. Buy more units or upgrade for ${meterPeriodWord} compute.` }}</span>
<span v-else>{{ `This feature requires the ${requiredPlan} plan.` }}</span>
</template>
<template #append>
Expand Down Expand Up @@ -195,7 +195,16 @@ export default {
*/
grantDepletedMessage() {
const packPhrase = this.primaryPack ? `a ${this.primaryPack.title}` : 'a compute pack';
return `You used your ${signupGrantConfig.label}. Buy ${packPhrase} to keep going, or upgrade for monthly compute.`;
return `You used your ${signupGrantConfig.label}. Buy ${packPhrase} to keep going, or upgrade for ${this.meterPeriodWord} compute.`;
},
/**
* @desc Billing period word used in the meter-mode upgrade copy (e.g. "monthly").
* Config-overridable so a downstream on a different billing period can rebrand this
* without editing the view.
* @returns {string}
*/
meterPeriodWord() {
return this.config?.billing?.meterPeriodWord ?? 'monthly';
},
/**
* @desc Primary pack CTA label, config-sourced from the resolved pack's own `cta` +
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';

// config is the generated runtime config; mock it per-test (same pattern as
// billing.resolveStaticContent.unit.tests.js).
vi.mock('../../../lib/services/config.js', () => ({ default: {} }));
import configMock from '../../../lib/services/config.js';
import BillingPlanBadgeComponent from '../components/billing.planBadge.component.vue';

const vuetify = createVuetify();
Expand All @@ -17,6 +22,8 @@ const mountComponent = (props) =>
});

describe('BillingPlanBadgeComponent', () => {
// These 5 tests run against an empty mocked config (no override) — they prove the
// unset-config default is byte-identical to the pre-hook behavior.
it('renders free plan with grey color', () => {
const wrapper = mountComponent({ plan: 'free' });
expect(wrapper.text()).toContain('free');
Expand Down Expand Up @@ -47,3 +54,57 @@ describe('BillingPlanBadgeComponent', () => {
expect(wrapper.vm.color).toBe('grey');
});
});

describe('BillingPlanBadgeComponent — config-driven plan set', () => {
beforeEach(() => {
for (const k of Object.keys(configMock)) delete configMock[k];
});

it('renders an overridden plan set and validates against it (devkit ids excluded)', () => {
configMock.billing = {
planBadge: {
plans: [
{ id: 'team', color: 'info' },
{ id: 'scale', color: 'error' },
],
},
};

const wrapper = mountComponent({ plan: 'team' });
expect(wrapper.text()).toContain('team');
expect(wrapper.vm.color).toBe('info');

const validator = BillingPlanBadgeComponent.props.plan.validator;
expect(validator('team')).toBe(true);
expect(validator('scale')).toBe(true);
expect(validator('free')).toBe(false);
expect(validator('nope')).toBe(false);
});

it('uses fallbackColor for a plan id outside the overridden set, else grey', () => {
configMock.billing = {
planBadge: {
plans: [{ id: 'team', color: 'info' }],
fallbackColor: 'surface',
},
};
let wrapper = mountComponent({ plan: 'ghost' });
expect(wrapper.vm.color).toBe('surface');

configMock.billing = {
planBadge: {
plans: [{ id: 'team', color: 'info' }],
},
};
wrapper = mountComponent({ plan: 'ghost' });
expect(wrapper.vm.color).toBe('grey');
});

it('pins the devkit default set + colors when config is unset', () => {
const validator = BillingPlanBadgeComponent.props.plan.validator;
expect(validator('enterprise')).toBe(true);

const wrapper = mountComponent({ plan: 'free' });
expect(wrapper.vm.color).toBe('grey');
});
});
Loading
Loading