import {
addProjectConfiguration,
readProjectConfiguration,
Tree,
} from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import generator from './vue-admin-crud';
import { Schema } from './schema';
// Mirrors the shape vue-app's own template generates -- vue-admin-crud retrofits
// into this file, so the fixture must match what it actually looks for.
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 Admin CRUD Generator', () => {
let host: Tree;
const baseOptions: Schema = {
project: 'test',
name: 'regions',
resource: 'regions',
route: '/regions',
fields: [
{ key: 'name', label: 'Name' },
{ key: 'active', label: 'Active', type: 'checkbox' },
],
};
beforeEach(() => {
host = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
addProjectConfiguration(host, 'test', { root: 'apps/test' });
host.write('apps/test/src/router/index.ts', ROUTER_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/);
});
it('generates the list view with a Create action and per-row Edit, no pagination props', async () => {
await generator(host, baseOptions);
const view = host
.read('apps/test/src/views/RegionsListView.vue')
.toString();
expect(view).toContain('
Regions
');
expect(view).toContain("await list('regions')");
// The envelope shape is the adapter's business, not the view's.
expect(view).not.toContain('data.results');
expect(view).toContain(
"import { WorkspaceTable } from '@proj/vue-components';",
);
expect(view).toContain(' {
await generator(host, baseOptions);
const view = host
.read('apps/test/src/views/RegionsEditView.vue')
.toString();
expect(view).toContain("idParam.value === 'new' || idParam.value === ''");
expect(view).toContain(
"import { GoabInput, GoabCheckbox } from '@proj/vue-components';",
);
expect(view).toContain('name.trim()');
expect(view).toContain("errors.name = 'Name is required.';");
// Checkbox has no required-validation block.
expect(view).not.toContain('errors.active');
// Create vs. update is expressed as a null id; which verb and path that
// becomes is decided by useApi's adapter.
// payload(), not the raw form: the model holds what the controls need, the
// API wants wire types.
expect(view).toContain(
"await save('regions', isNew.value ? null : idParam.value, payload())",
);
expect(view).toContain("await get('regions', idParam.value)");
expect(view).not.toContain('apiFetch');
expect(view).not.toContain("'PUT'");
// Vue Router reuses this component across an id-only change. The reset
// branch is the point: edit/ -> edit/new must not leave the previous
// record's values in a create form.
expect(view).toContain('watch(');
expect(view).toContain('{ immediate: true }');
expect(view).toContain('function resetForm()');
expect(view).toContain('resetForm();');
expect(view).not.toContain('onMounted(');
// The 600ms success redirect must not fire after the user navigates away.
expect(view).toContain('redirectTimer = setTimeout(');
expect(view).toContain('clearTimeout(redirectTimer)');
expect(view).toContain('onUnmounted(');
expect(view).toContain("router.push('/regions')");
expect(view).toContain('Create Regions');
expect(view).toContain('Edit Regions');
}, 30000);
describe('field types', () => {
const allTypes = JSON.stringify([
{ key: 'name', label: 'Name' },
{ key: 'notes', label: 'Notes', type: 'textarea', required: false },
{ key: 'quota', label: 'Quota', type: 'number' },
{ key: 'effective', label: 'Effective', type: 'date' },
{
key: 'region',
label: 'Region',
type: 'select',
options: [
{ value: 'north', label: 'North' },
{ value: 'south', label: 'South' },
],
},
{ key: 'active', label: 'Active', type: 'checkbox' },
]);
it('renders the right control for each type', async () => {
await generator(host, { ...baseOptions, fields: allTypes });
const view = host
.read('apps/test/src/views/RegionsEditView.vue')
.toString();
expect(view).toContain('',
);
expect(view).toContain(' {
await generator(host, {
...baseOptions,
fields: JSON.stringify([{ key: 'name', label: 'Name' }]),
});
const view = host
.read('apps/test/src/views/RegionsEditView.vue')
.toString();
expect(view).toContain('GoabInput');
for (const unused of ['GoabTextarea', 'GoabDatePicker', 'GoabDropdown']) {
expect(view).not.toContain(unused);
}
});
it('submits a number as a number and a date as a local-calendar string', async () => {
await generator(host, { ...baseOptions, fields: allTypes });
const view = host
.read('apps/test/src/views/RegionsEditView.vue')
.toString();
expect(view).toContain(
"quota: form.quota === '' ? null : Number(form.quota)",
);
expect(view).toContain(
'effective: toIsoDateString(form.effective) || null',
);
// A stored YYYY-MM-DD comes back as a Date the picker can redisplay.
expect(view).toContain("fromIsoDateString(data['effective'])");
});
it('validates by type, not by assuming every value is a string', async () => {
await generator(host, { ...baseOptions, fields: allTypes });
const view = host
.read('apps/test/src/views/RegionsEditView.vue')
.toString();
// A number field's required check cannot be !value.trim().
expect(view).toContain("if (form.quota === '')");
expect(view).toContain('Quota must be a number.');
// A date field holds a Date, so truthiness is the check.
expect(view).toContain('if (!form.effective)');
});
it('does not label a checkbox twice', async () => {
await generator(host, { ...baseOptions, fields: allTypes });
const view = host
.read('apps/test/src/views/RegionsEditView.vue')
.toString();
// goa-form-item must not repeat the label the checkbox already carries.
expect(view).not.toContain(' {
await expect(
generator(host, {
...baseOptions,
fields: JSON.stringify([{ key: 'x', label: 'X', type: 'colour' }]),
}),
).rejects.toThrow('got "colour" for "x"');
});
it('requires options for a select, since it cannot know the choices', async () => {
await expect(
generator(host, {
...baseOptions,
fields: JSON.stringify([{ key: 'r', label: 'R', type: 'select' }]),
}),
).rejects.toThrow(/options is required for the select field "r"/);
});
});
it('uses --singularLabel over --heading for Create/Edit headings when both are set', async () => {
await generator(host, {
...baseOptions,
heading: 'Regions',
singularLabel: 'Region',
});
const list = host
.read('apps/test/src/views/RegionsListView.vue')
.toString();
const edit = host
.read('apps/test/src/views/RegionsEditView.vue')
.toString();
expect(list).toContain('Regions
');
expect(list).toContain('Create Region');
expect(edit).toContain('Create Region');
expect(edit).toContain('Edit Region');
}, 30000);
it('marks a field as not required with --fields[].required=false', async () => {
await generator(host, {
...baseOptions,
fields: [{ key: 'name', label: 'Name', required: false }],
});
const edit = host
.read('apps/test/src/views/RegionsEditView.vue')
.toString();
expect(edit).not.toContain("errors.name = 'Name is required.'");
expect(edit).not.toContain('requirement="required"');
}, 30000);
it('accepts --fields as a JSON string, the form the real CLI produces', async () => {
await generator(host, {
...baseOptions,
fields: JSON.stringify(baseOptions.fields),
});
const view = host
.read('apps/test/src/views/RegionsListView.vue')
.toString();
expect(view).toContain("{ key: 'name', label: 'Name' }");
}, 30000);
it('throws a clear error when --fields is not valid JSON', async () => {
await expect(
generator(host, { ...baseOptions, fields: '{not json' }),
).rejects.toThrow();
});
it('throws a clear error when --fields parses to an empty array', async () => {
await expect(
generator(host, { ...baseOptions, fields: '[]' }),
).rejects.toThrow(/non-empty/);
});
it('inserts both the list and edit routes, requiring auth by default', async () => {
await generator(host, baseOptions);
const routerTs = host.read('apps/test/src/router/index.ts').toString();
expect(routerTs).toContain("path: '/regions'");
expect(routerTs).toContain("path: '/regions/:id'");
expect(routerTs).toContain(
"component: () => import('../views/RegionsListView.vue')",
);
expect(routerTs).toContain(
"component: () => import('../views/RegionsEditView.vue')",
);
expect(routerTs.split('requiresAuth: true').length - 1).toBe(2);
// The existing route is untouched, not replaced.
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: both actions were gated on `kc.authenticated` unconditionally,
// ignoring --requiresAuth. With --requiresAuth=false a consumer got a fillable
// form whose only button was Cancel, and a list with no Create button, and no
// message explaining why. Access control belongs to the route guard (which
// already honours requiresAuth via route meta), not to markup in the view --
// and vue-intake-view never had this gate, so the two disagreed.
it('does not gate its actions on kc.authenticated', async () => {
await generator(host, { ...baseOptions, requiresAuth: false });
const list = host
.read('apps/test/src/views/RegionsListView.vue')
.toString();
const edit = host
.read('apps/test/src/views/RegionsEditView.vue')
.toString();
for (const view of [list, edit]) {
expect(view).not.toContain('kc.authenticated');
expect(view).not.toContain('useKeycloak');
}
// The actions themselves are still there, unconditionally. Matched loosely
// because formatFiles reflows the long button tag across lines.
expect(list).toContain('');
expect(edit).toMatch(/ {
await generator(host, {
...baseOptions,
fields: [
{ key: 'name', label: 'Name' },
{
key: 'species',
label: 'Species',
type: 'select',
options: [{ value: 'cattle-mature', label: 'Cattle (mature)' }],
},
],
});
const list = host
.read('apps/test/src/views/RegionsListView.vue')
.toString();
expect(list).toContain(
"optionLabel(fieldOptions['species'], row['species'])",
);
expect(list).toContain('Cattle (mature)');
}, 30000);
// Regression: goa-form-item's `mb` defaults to none, so stacked items rendered
// flush against each other -- a field's label sat directly on the previous
// field's input. `l` is the design system's own convention for vertical forms
// (110 uses against 11 of `xl` in GoA's reference app).
it('spaces stacked form items so fields do not sit flush', async () => {
await generator(host, baseOptions);
const edit = host
.read('apps/test/src/views/RegionsEditView.vue')
.toString();
// Counted per tag rather than as a contiguous string: formatFiles reflows a
// multi-attribute tag across lines, so ` fragment.slice(0, fragment.indexOf('>')));
expect(tags.length).toBeGreaterThan(0);
for (const tag of tags) {
expect(tag).toContain('mb="l"');
}
}, 30000);
});