diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 4c706c901..ce86f0069 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -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 diff --git a/MIGRATIONS.md b/MIGRATIONS.md index b77dec064..bba5d69ba 100644 --- a/MIGRATIONS.md +++ b/MIGRATIONS.md @@ -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 ``, 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/.config.js` (or a per-module `..config.js` fragment). A custom loader SFC ships inside your own project module (e.g. `src/modules//components/.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. diff --git a/src/config/defaults/development.config.js b/src/config/defaults/development.config.js index 6d56a738e..65fdb99f4 100644 --- a/src/config/defaults/development.config.js +++ b/src/config/defaults/development.config.js @@ -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/.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 }; diff --git a/src/modules/auth/components/organizationSetup.component.vue b/src/modules/auth/components/organizationSetup.component.vue index 68160b175..44275c385 100644 --- a/src/modules/auth/components/organizationSetup.component.vue +++ b/src/modules/auth/components/organizationSetup.component.vue @@ -6,7 +6,7 @@

Set up your workspace

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

@@ -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. diff --git a/src/modules/auth/tests/auth.organizationSetup.component.unit.tests.js b/src/modules/auth/tests/auth.organizationSetup.component.unit.tests.js index adf761885..5cf5139e1 100644 --- a/src/modules/auth/tests/auth.organizationSetup.component.unit.tests.js +++ b/src/modules/auth/tests/auth.organizationSetup.component.unit.tests.js @@ -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 }, }, }); @@ -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'); + }); }); diff --git a/src/modules/auth/views/token.view.vue b/src/modules/auth/views/token.view.vue index 4f53f99fe..7f76eff16 100644 --- a/src/modules/auth/views/token.view.vue +++ b/src/modules/auth/views/token.view.vue @@ -4,7 +4,7 @@ @@ -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'); @@ -60,6 +61,9 @@ function asNonEmptyString(value) { * Component definition. */ export default { + components: { + AppSpinner, + }, data() { const theme = useTheme(); return { diff --git a/src/modules/billing/README.md b/src/modules/billing/README.md index 6cb3cccf7..9a7c3cc05 100644 --- a/src/modules/billing/README.md +++ b/src/modules/billing/README.md @@ -50,6 +50,24 @@ Auto-colored progress bar (green/orange/red). Shows "Unlimited" for uncapped pla ``` +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/.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') + }, +}, +``` + ### `` Sidenav compute-usage indicator (meter mode). A color-coded ring + `X% used` diff --git a/src/modules/billing/components/billing.planBadge.component.vue b/src/modules/billing/components/billing.planBadge.component.vue index 8a95b07aa..f35812b84 100644 --- a/src/modules/billing/components/billing.planBadge.component.vue +++ b/src/modules/billing/components/billing.planBadge.component.vue @@ -11,11 +11,42 @@ diff --git a/src/modules/core/components/tests/core.appSpinner.component.unit.tests.js b/src/modules/core/components/tests/core.appSpinner.component.unit.tests.js new file mode 100644 index 000000000..4cfbbb85a --- /dev/null +++ b/src/modules/core/components/tests/core.appSpinner.component.unit.tests.js @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createVuetify } from 'vuetify'; +import * as components from 'vuetify/components'; +import * as directives from 'vuetify/directives'; +import CoreAppSpinner, { resolveLoaderComponent } from '../core.appSpinner.component.vue'; + +/** + * Vuetify instance with all components registered for component mount. + * @returns {import('vuetify').Vuetify} + */ +const makeVuetify = () => createVuetify({ components, directives }); + +/** + * Mount CoreAppSpinner with sensible defaults; callers override via opts. + * @param {object} [opts] + * @returns {import('@vue/test-utils').VueWrapper} + */ +const mountIt = (opts = {}) => + mount(CoreAppSpinner, { + props: opts.props, + attrs: opts.attrs, + global: { + plugins: [makeVuetify()], + }, + }); + +describe('CoreAppSpinner — default rendering (config unset)', () => { + it('renders the built-in v-progress-circular with indeterminate=true', () => { + const wrapper = mountIt(); + const spinner = wrapper.findComponent({ name: 'VProgressCircular' }); + expect(spinner.exists()).toBe(true); + expect(spinner.props('indeterminate')).toBe(true); + }); +}); + +describe('CoreAppSpinner — attribute fallthrough', () => { + it('forwards color/size/data-test to the rendered root (single-root branches, no declared visual props)', () => { + const wrapper = mountIt({ attrs: { color: 'primary', size: '48', 'data-test': 'x' } }); + const spinner = wrapper.findComponent({ name: 'VProgressCircular' }); + expect(spinner.props('color')).toBe('primary'); + expect(spinner.props('size')).toBe('48'); + expect(wrapper.attributes('data-test')).toBe('x'); + }); +}); + +describe('CoreAppSpinner — override via loader prop', () => { + it('renders the provided loader component instead of the built-in spinner', () => { + const wrapper = mountIt({ + props: { loader: { template: '
' } }, + }); + expect(wrapper.find('.custom-loader').exists()).toBe(true); + expect(wrapper.findComponent({ name: 'VProgressCircular' }).exists()).toBe(false); + }); +}); + +describe('resolveLoaderComponent — pure resolver', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns null when the configured path is unset', () => { + expect(resolveLoaderComponent({}, null)).toBeNull(); + expect(resolveLoaderComponent({}, undefined)).toBeNull(); + expect(resolveLoaderComponent({}, '')).toBeNull(); + }); + + it('warns and returns null when the path has no glob match (fail-soft)', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const result = resolveLoaderComponent({}, '/src/modules/x/components/x.loader.component.vue'); + expect(result).toBeNull(); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it('returns the matched glob loader for a known key', () => { + const key = '/src/modules/x/components/x.loader.component.vue'; + const globMap = { [key]: () => Promise.resolve({ default: { template: '
' } }) }; + const result = resolveLoaderComponent(globMap, key); + expect(result).toBe(globMap[key]); + expect(typeof result).toBe('function'); + }); +}); diff --git a/src/modules/docs/views/docs.article.view.vue b/src/modules/docs/views/docs.article.view.vue index 625173cfe..d58ebb6b6 100644 --- a/src/modules/docs/views/docs.article.view.vue +++ b/src/modules/docs/views/docs.article.view.vue @@ -17,7 +17,7 @@ - + @@ -97,6 +97,7 @@ import { useDocsPage, EXAMPLE_MARKER_SOURCE } from '../composables/useDocsPage'; import DocsNav from '../components/docs.nav.component.vue'; import DocsToc from '../components/docs.toc.component.vue'; import DocsCodeblock from '../components/docs.codeblock.component.vue'; +import AppSpinner from '../../core/components/core.appSpinner.component.vue'; const route = useRoute(); const store = useDocsStore(); diff --git a/src/modules/docs/views/docs.home.view.vue b/src/modules/docs/views/docs.home.view.vue index c9f70cc57..274cbaacb 100644 --- a/src/modules/docs/views/docs.home.view.vue +++ b/src/modules/docs/views/docs.home.view.vue @@ -18,7 +18,7 @@ - + @@ -174,6 +174,7 @@ import { useDocsStore } from '../stores/docs.store'; import { resolveDocsTarget } from '../composables/useDocsNav'; import DocsSearch from '../components/docs.search.component.vue'; import PageHeader from '../../core/components/core.pageHeader.component.vue'; +import AppSpinner from '../../core/components/core.appSpinner.component.vue'; import config from '@/config'; const home = config?.docs?.home || { icon: 'fa-solid fa-book', title: 'Documentation', subtitle: '' }; diff --git a/src/modules/docs/views/docs.reference.view.vue b/src/modules/docs/views/docs.reference.view.vue index 940639679..edaab060b 100644 --- a/src/modules/docs/views/docs.reference.view.vue +++ b/src/modules/docs/views/docs.reference.view.vue @@ -17,8 +17,7 @@ -