import {
addProjectConfiguration,
readProjectConfiguration,
Tree,
} from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import generator from './vue-workspace-view';
import { Schema } from './schema';
// Mirrors the shape vue-app's own template generates -- vue-workspace-view
// retrofits into this file, so the fixture must match what it actually looks for.
const APP_VUE_INTERNAL_FIXTURE = `
`;
const ROUTER_FIXTURE = `import { createRouter, createWebHistory } from 'vue-router';
import HomeView from '../views/HomeView.vue';
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ path: '/', component: HomeView },
],
});
export default router;
`;
describe('Vue Workspace View Generator', () => {
let host: Tree;
const baseOptions: Schema = {
project: 'test',
name: 'applications',
resource: 'applications',
route: '/applications',
columns: [
{ key: 'status', label: 'Status', type: 'badge' },
{ key: 'lastSaved', label: 'Last saved', type: 'date', sortable: true },
{ key: 'requestTotal', label: 'Request total', type: 'currency' },
{ key: 'serviceModel', label: 'Service Model' },
],
};
beforeEach(() => {
host = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
addProjectConfiguration(host, 'test', { root: 'apps/test' });
host.write('apps/test/src/router/index.ts', ROUTER_FIXTURE);
host.write('apps/test/src/App.vue', APP_VUE_INTERNAL_FIXTURE);
});
it('throws when --project does not exist', async () => {
await expect(
generator(host, { ...baseOptions, project: 'no-such-app' }),
).rejects.toThrow();
});
it("throws a clear error when the project isn't a vue-app (no router/index.ts)", async () => {
addProjectConfiguration(host, 'not-vue', { root: 'apps/not-vue' });
await expect(
generator(host, { ...baseOptions, project: 'not-vue' }),
).rejects.toThrow(/router\/index\.ts/);
});
// Regression guard: Nx's own CLI option coercion for `"type": "array"` splits
// on commas, not JSON -- --columns is `"type": "string"` in schema.json
// specifically so the real CLI's JSON string survives unmangled. Assert the
// string form the CLI actually produces, not just the array form direct
// (unit-test) callers use.
it('accepts --columns as a JSON string, the form the real CLI produces', async () => {
await generator(host, {
...baseOptions,
columns: JSON.stringify(baseOptions.columns),
});
const view = host
.read('apps/test/src/views/ApplicationsListView.vue')
.toString();
expect(view).toContain(
"{ key: 'status', label: 'Status', sortable: false }",
);
}, 30000);
it('throws a clear error when --columns is not valid JSON', async () => {
await expect(
generator(host, { ...baseOptions, columns: '{not json' }),
).rejects.toThrow();
});
it('throws a clear error when --columns parses to an empty array', async () => {
await expect(
generator(host, { ...baseOptions, columns: '[]' }),
).rejects.toThrow(/non-empty/);
});
it('generates the view with columns, sort, and per-type cell slots', async () => {
await generator(host, baseOptions);
const view = host
.read('apps/test/src/views/ApplicationsListView.vue')
.toString();
expect(view).toContain('
Applications
');
expect(view).toContain(
"{ key: 'status', label: 'Status', sortable: false }",
);
expect(view).toContain(
"{ key: 'lastSaved', label: 'Last saved', sortable: true }",
);
// Goes through useApi's adapter in domain terms -- no query-param name and
// no response-envelope key appears in the view.
expect(view).toContain("await list('applications', {");
expect(view).toContain('pageSize: PAGE_SIZE');
expect(view).toContain('rows.value = result.rows');
// The element decides the direction, so the view records rather than toggles.
expect(view).toContain("function onSort(key: string, dir: 'asc' | 'desc')");
expect(view).not.toContain("sortDir.value === 'asc' ? 'desc' : 'asc'");
expect(view).toContain('itemCount.value = result.total');
expect(view).not.toContain('apiFetch');
expect(view).not.toContain('URLSearchParams');
expect(view).not.toContain('data.results');
// Out-of-order responses: load() fires from page change, sort and the
// debounced search, so only the most recent call may apply its result.
expect(view).toContain('let loadSequence = 0');
expect(view).toContain('const sequence = ++loadSequence');
expect(view).toContain('if (sequence !== loadSequence) return;');
expect(view).toContain(
'if (sequence === loadSequence) loading.value = false;',
);
// The debounce timer must not outlive the component.
expect(view).toContain('onUnmounted(');
expect(view).toContain('clearTimeout(searchDebounce)');
// Dates come from the shared formatter, not a per-view copy.
expect(view).toContain('formatDate');
expect(view).not.toContain('function formatDate');
expect(view).not.toContain('toLocaleString');
expect(view).not.toContain('Intl.');
expect(view).not.toContain('function formatCurrency');
expect(view).toContain(
"badgeType(columnBadgeTypes['status'], row['status'])",
);
// A type: 'date' column is a calendar date -- formatDate, not
// formatDateTime, which would show an invented midnight.
expect(view).toContain("formatDate(row['lastSaved'])");
expect(view).toContain("formatCurrency(row['requestTotal'])");
// Uses the shared table shell, not hand-rolled loading/pagination markup.
// Read the import's contents rather than an exact line: formatFiles wraps a
// long import list and adds a trailing comma.
const goaImport =
view
.replace(/\s+/g, ' ')
.match(/import \{[^}]*\} from '@proj\/vue-components';/)?.[0] ?? '';
for (const name of ['WorkspaceTable', 'formatCurrency', 'formatDate']) {
expect(goaImport).toContain(name);
}
expect(view).toContain(' {
const filters = JSON.stringify([
{
key: 'status',
label: 'Status',
type: 'dropdown',
options: [{ value: 'open', label: 'Open' }],
anyLabel: 'All statuses',
},
{ key: 'from', label: 'From', type: 'date' },
]);
it('wires the shared FilterBar and sends the values as query filters', async () => {
await generator(host, { ...baseOptions, filters });
const view = host
.read('apps/test/src/views/ApplicationsListView.vue')
.toString();
expect(view).toContain('FilterBar');
expect(view).toContain(':filters="filterDescriptors"');
expect(view).toContain('@update:model-value="onFiltersChange"');
// The values object goes straight through useApi's adapter.
expect(view).toContain('filters: filterValues.value');
// The view owns the page reset, not FilterBar -- it owns `page`.
expect(view).toContain('page.value = 1');
// Descriptors are a ref so fetched dropdown options can be assigned in.
expect(view).toContain('const filterDescriptors = ref([');
expect(view).toContain("anyLabel: 'All statuses'");
expect(view).toContain("type: 'date' as const");
});
it('leaves output byte-identical when --filters is omitted', async () => {
await generator(host, baseOptions);
const without = host
.read('apps/test/src/views/ApplicationsListView.vue')
.toString();
expect(without).not.toContain('FilterBar');
expect(without).not.toContain('filterValues');
expect(without).not.toContain('onFiltersChange');
});
it('rejects a filter type it cannot render, naming the offender', async () => {
await expect(
generator(host, {
...baseOptions,
filters: JSON.stringify([{ key: 'x', label: 'X', type: 'checkbox' }]),
}),
).rejects.toThrow(
'--filters[].type must be "dropdown" or "date"; got "checkbox" for "x".',
);
});
it('accepts a real array, the form a programmatic caller passes', async () => {
await generator(host, {
...baseOptions,
filters: [{ key: 'status', label: 'Status', type: 'dropdown' }],
});
const view = host
.read('apps/test/src/views/ApplicationsListView.vue')
.toString();
expect(view).toContain('FilterBar');
expect(view).toContain('options: []');
});
});
it('omits onUnmounted along with the debounce when --filterable=false', async () => {
// onUnmounted exists only to clear the search debounce, so importing it
// unconditionally would be an unused import and fail the generated lint.
await generator(host, { ...baseOptions, filterable: false });
const view = host
.read('apps/test/src/views/ApplicationsListView.vue')
.toString();
expect(view).not.toContain('onUnmounted');
expect(view).not.toContain('searchDebounce');
// The out-of-order guard is not search-specific -- page and sort still race.
expect(view).toContain('let loadSequence = 0');
});
it('omits the search input and its wiring when --filterable=false', async () => {
await generator(host, { ...baseOptions, filterable: false });
const view = host
.read('apps/test/src/views/ApplicationsListView.vue')
.toString();
expect(view).not.toContain('type="search"');
expect(view).not.toContain('searchDebounce');
}, 30000);
it('generates a View action linking to --detailRoute when set', async () => {
await generator(host, { ...baseOptions, detailRoute: '/applications' });
const view = host
.read('apps/test/src/views/ApplicationsListView.vue')
.toString();
expect(view).toContain('#actions="{ row }"');
expect(view).toContain(':to="`/applications/${row.id}`"');
}, 30000);
it('omits the actions slot entirely when --detailRoute is not set', async () => {
await generator(host, baseOptions);
const view = host
.read('apps/test/src/views/ApplicationsListView.vue')
.toString();
expect(view).not.toContain('#actions');
}, 30000);
it('respects an explicit --heading and --pageSize', async () => {
await generator(host, {
...baseOptions,
heading: 'Grant Applications',
pageSize: 50,
});
const view = host
.read('apps/test/src/views/ApplicationsListView.vue')
.toString();
expect(view).toContain('Grant Applications
');
expect(view).toContain('PAGE_SIZE = 50');
expect(view).toContain(':per-page-count="50"');
}, 30000);
it('adds nothing to the side menu when the app has no App.vue', async () => {
host.delete('apps/test/src/App.vue');
// A header-layout app has no side menu; adding a staff view to one must not
// fail, it simply has no nav to join.
await expect(generator(host, baseOptions)).resolves.not.toThrow();
});
it('inserts the route into router/index.ts, requiring auth by default', async () => {
await generator(host, baseOptions);
const routerTs = host.read('apps/test/src/router/index.ts').toString();
expect(routerTs).toContain("path: '/applications'");
expect(routerTs).toContain(
"component: () => import('../views/ApplicationsListView.vue')",
);
expect(routerTs).toContain('requiresAuth: true');
// A data table asks for the wide variant -- the generator knows it emitted one.
expect(routerTs).toContain("layout: 'wide'");
// A staff queue is a nav destination, so the generator adds it to the
// app's side-menu list rather than leaving the route reachable only by URL.
const appVue = host.read('apps/test/src/App.vue').toString();
expect(appVue).toContain("to: '/applications'");
expect(routerTs).toContain("{ path: '/', component: HomeView }");
}, 30000);
it('omits the requiresAuth meta when --requiresAuth=false', async () => {
await generator(host, { ...baseOptions, requiresAuth: false });
const routerTs = host.read('apps/test/src/router/index.ts').toString();
expect(routerTs).not.toContain('requiresAuth');
}, 30000);
it('ensures the shared WorkspaceTable pattern component exists', async () => {
await generator(host, baseOptions);
expect(
host.exists('libs/vue-components/src/lib/patterns/WorkspaceTable.vue'),
).toBeTruthy();
}, 30000);
it('does not touch the target project configuration', async () => {
const before = readProjectConfiguration(host, 'test');
await generator(host, baseOptions);
const after = readProjectConfiguration(host, 'test');
expect(after).toEqual(before);
}, 30000);
// Regression: every badge column was hardcoded type="information", so a
// declined claim rendered neutral blue -- a badge's colour is the fastest thing
// a reader takes in, so the wrong one actively misinforms.
it('maps badge values to semantic types and labels', async () => {
await generator(host, {
...baseOptions,
columns: [
{ key: 'reference', label: 'Reference' },
{
key: 'status',
label: 'Status',
type: 'badge',
options: [{ value: 'declined', label: 'Declined' }],
badgeMap: { approved: 'success', declined: 'emergency' },
},
],
});
const view = host
.read('apps/test/src/views/ApplicationsListView.vue')
.toString();
// Quote style depends on whether formatFiles ran, so match the pairs.
expect(view).toMatch(/["']?declined["']?\s*:\s*["']emergency["']/);
expect(view).toMatch(/["']?approved["']?\s*:\s*["']success["']/);
expect(view).toContain("badgeType(columnBadgeTypes['status']");
expect(view).toContain("optionLabel(columnOptions['status']");
}, 30000);
it('renders a coded non-badge column using its label', async () => {
await generator(host, {
...baseOptions,
columns: [
{
key: 'region',
label: 'Region',
options: [{ value: 'northwest', label: 'North west' }],
},
],
});
const view = host
.read('apps/test/src/views/ApplicationsListView.vue')
.toString();
expect(view).toContain(
"optionLabel(columnOptions['region'], row['region'])",
);
expect(view).toContain('North west');
}, 30000);
it('omits the lookup maps entirely when no column needs them', async () => {
await generator(host, {
...baseOptions,
columns: [{ key: 'reference', label: 'Reference' }],
});
const view = host
.read('apps/test/src/views/ApplicationsListView.vue')
.toString();
expect(view).not.toContain('columnOptions');
expect(view).not.toContain('columnBadgeTypes');
expect(view).not.toContain('optionLabel');
}, 30000);
});