feat(KAN-46): rebuild reusable project template management
This commit is contained in:
@@ -163,6 +163,26 @@ class ProjectTemplateControllerTest extends TestCase
|
||||
->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_unlinked_templates_can_be_updated_without_an_impact_confirmation(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$source = Project::factory()->create();
|
||||
$template = ProjectTemplate::factory()->create([
|
||||
'source_project_id' => $source->id,
|
||||
]);
|
||||
$this->syncGlobalPermissions($user, ['project:create']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->patch(route('project-templates.update', $template), [
|
||||
'name' => 'Updated reusable template',
|
||||
'copy_areas' => ['project_settings'],
|
||||
])
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect(route('project-templates.index'));
|
||||
|
||||
$this->assertSame('Updated reusable template', $template->fresh()->name);
|
||||
}
|
||||
|
||||
public function test_updating_a_template_requires_warning_acknowledgement_when_projects_are_linked(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Projects;
|
||||
|
||||
use Symfony\Component\Process\Process;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectTemplatesUiTest extends TestCase
|
||||
{
|
||||
public function test_template_pages_show_reusable_configuration_and_protect_linked_projects(): void
|
||||
{
|
||||
$process = new Process(['node', 'tests/Fixtures/project-templates.mjs'], base_path());
|
||||
$process->setTimeout(60);
|
||||
$process->run();
|
||||
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||
$this->assertStringContainsString('Project template checks passed.', $process->getOutput());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import process from 'node:process';
|
||||
const { chromium } = await import(
|
||||
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
|
||||
);
|
||||
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 1000 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(10000);
|
||||
const base = 'http://127.0.0.1:4191';
|
||||
const errors = [];
|
||||
const mutations = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
page.on('request', (request) => {
|
||||
if (['POST', 'PATCH', 'DELETE'].includes(request.method())) {
|
||||
mutations.push(request);
|
||||
}
|
||||
});
|
||||
const button = (name) => page.getByRole('button', { name, exact: true });
|
||||
const result = (value) =>
|
||||
context.addCookies([{ name: 'template_test_result', value, url: base }]);
|
||||
const visit = async (path) => {
|
||||
await result('success');
|
||||
await page.goto(base + path);
|
||||
await page.locator('h1').waitFor();
|
||||
};
|
||||
const noOverflow = async () =>
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
true,
|
||||
);
|
||||
const feedback = (state) =>
|
||||
page
|
||||
.locator('[data-form-state="' + state + '"]')
|
||||
.waitFor({ state: 'visible' });
|
||||
try {
|
||||
for (const width of [1440, 390, 320]) {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await page.emulateMedia({ colorScheme: theme });
|
||||
for (const path of [
|
||||
'/project-templates?reset',
|
||||
'/projects/1/templates/create?reset',
|
||||
'/project-templates/2/edit?reset',
|
||||
]) {
|
||||
await visit(path);
|
||||
await noOverflow();
|
||||
assert.equal(
|
||||
await page
|
||||
.locator('html')
|
||||
.evaluate((el) => el.classList.contains('dark')),
|
||||
theme === 'dark',
|
||||
);
|
||||
if (width === 390 || width === 1440) {
|
||||
await page.screenshot({
|
||||
path:
|
||||
'/private/tmp/KAN-46-' +
|
||||
width +
|
||||
'-' +
|
||||
theme +
|
||||
'-' +
|
||||
(path.includes('/edit')
|
||||
? 'edit'
|
||||
: path.includes('/create')
|
||||
? 'create'
|
||||
: 'index') +
|
||||
'.png',
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
assert.equal(await page.locator('#slug').isDisabled(), true);
|
||||
assert.equal(
|
||||
await button('Save template changes').isDisabled(),
|
||||
true,
|
||||
);
|
||||
const checkbox = page.locator('#confirm-linked');
|
||||
await checkbox.focus();
|
||||
await page.keyboard.press('Space');
|
||||
assert.equal(await checkbox.isChecked(), true);
|
||||
assert.equal(
|
||||
await button('Save template changes').isEnabled(),
|
||||
true,
|
||||
);
|
||||
await visit('/project-templates?reset');
|
||||
assert.equal(
|
||||
await page
|
||||
.getByText('Internal baseline', { exact: true })
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
const trigger = button('Delete Foundation');
|
||||
await trigger.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.waitFor();
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(
|
||||
await dialog.evaluate((el) =>
|
||||
el.contains(document.activeElement),
|
||||
),
|
||||
true,
|
||||
);
|
||||
await noOverflow();
|
||||
await page.keyboard.press('Escape');
|
||||
await dialog.waitFor({ state: 'hidden' });
|
||||
assert.equal(
|
||||
await trigger.evaluate((el) => document.activeElement === el),
|
||||
true,
|
||||
);
|
||||
assert.equal(mutations.length, 0);
|
||||
}
|
||||
}
|
||||
await visit('/projects/1/templates/create?reset');
|
||||
await page.locator('#name').fill('Release setup');
|
||||
await page.locator('#slug').fill('release-setup');
|
||||
const groups = page.locator('fieldset input[type="checkbox"]');
|
||||
for (const group of await groups.all()) {
|
||||
await group.uncheck();
|
||||
}
|
||||
await page
|
||||
.getByRole('checkbox', { name: 'Project settings', exact: true })
|
||||
.check();
|
||||
await page
|
||||
.getByRole('checkbox', { name: 'Ticket types', exact: true })
|
||||
.check();
|
||||
await result('validation');
|
||||
await button('Save new template').click();
|
||||
await feedback('validation');
|
||||
assert.equal(await page.locator('#name').inputValue(), 'Release setup');
|
||||
assert.equal(
|
||||
await page.locator('#name').getAttribute('aria-invalid'),
|
||||
'true',
|
||||
);
|
||||
await page
|
||||
.getByText('Choose a valid configuration group.', { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
await page
|
||||
.locator('[data-form-state="validation"]')
|
||||
.evaluate((el) => el === document.activeElement),
|
||||
true,
|
||||
);
|
||||
await result('server');
|
||||
await button('Save new template').click();
|
||||
await feedback('server');
|
||||
await result('success');
|
||||
const created = page.waitForRequest(
|
||||
(request) => request.method() === 'POST',
|
||||
);
|
||||
await button('Try again').click();
|
||||
assert.deepEqual((await created).postDataJSON(), {
|
||||
name: 'Release setup',
|
||||
slug: 'release-setup',
|
||||
copy_areas: ['project_settings', 'ticket_types'],
|
||||
});
|
||||
await page
|
||||
.getByRole('heading', { name: 'Project templates', exact: true })
|
||||
.waitFor();
|
||||
|
||||
await visit('/project-templates/2/edit?reset');
|
||||
const before = mutations.length;
|
||||
await page
|
||||
.locator('form')
|
||||
.evaluate((el) =>
|
||||
el.dispatchEvent(
|
||||
new Event('submit', { bubbles: true, cancelable: true }),
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
mutations.length,
|
||||
before,
|
||||
'Linked updates must wait for acknowledgement',
|
||||
);
|
||||
await page.locator('#confirm-linked').check();
|
||||
await page.locator('#name').fill('Updated foundation');
|
||||
await page.route('**/project-templates/2', (route) =>
|
||||
route.abort('failed'),
|
||||
);
|
||||
await button('Save template changes').click();
|
||||
await feedback('network');
|
||||
assert.equal(await page.locator('#confirm-linked').isChecked(), true);
|
||||
await page.unroute('**/project-templates/2');
|
||||
const updated = page.waitForRequest(
|
||||
(request) => request.method() === 'PATCH',
|
||||
);
|
||||
await button('Try again').click();
|
||||
assert.deepEqual((await updated).postDataJSON(), {
|
||||
name: 'Updated foundation',
|
||||
copy_areas: ['project_settings', 'ticket_types'],
|
||||
confirm_linked_projects_update: true,
|
||||
});
|
||||
await page
|
||||
.getByRole('heading', { name: 'Project templates', exact: true })
|
||||
.waitFor();
|
||||
|
||||
await visit('/project-templates/2/edit?reset&unlinked');
|
||||
assert.equal(await page.locator('#confirm-linked').count(), 0);
|
||||
const unlinked = page.waitForRequest(
|
||||
(request) => request.method() === 'PATCH',
|
||||
);
|
||||
await button('Save template changes').click();
|
||||
assert.deepEqual((await unlinked).postDataJSON(), {
|
||||
name: 'Foundation',
|
||||
copy_areas: ['project_settings', 'ticket_types'],
|
||||
});
|
||||
await page
|
||||
.getByRole('heading', { name: 'Project templates', exact: true })
|
||||
.waitFor();
|
||||
await visit('/project-templates/2/edit?reset&missing');
|
||||
await page
|
||||
.getByText('This template cannot be refreshed', { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(await button('Save template changes').isDisabled(), true);
|
||||
|
||||
await visit('/projects/1/templates/create?reset');
|
||||
await page.locator('#name').fill('Unsaved work');
|
||||
let prompted = false;
|
||||
page.once('dialog', async (dialog) => {
|
||||
prompted = true;
|
||||
await dialog.dismiss();
|
||||
});
|
||||
await page.getByRole('link', { name: 'Cancel', exact: true }).click();
|
||||
assert.equal(prompted, true);
|
||||
assert.equal(await page.locator('#name').inputValue(), 'Unsaved work');
|
||||
// Reload starts the next isolated scenario; accept the browser's unload prompt.
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await visit('/project-templates?reset');
|
||||
await button('Delete Foundation').click();
|
||||
await result('server');
|
||||
await button('Delete template').click();
|
||||
await feedback('server');
|
||||
await result('success');
|
||||
const deleted = page.waitForRequest(
|
||||
(request) => request.method() === 'DELETE',
|
||||
);
|
||||
await button('Try again').click();
|
||||
assert.equal(
|
||||
new URL((await deleted).url()).pathname,
|
||||
'/project-templates/2',
|
||||
);
|
||||
await page.getByRole('dialog').waitFor({ state: 'hidden' });
|
||||
await page
|
||||
.getByText('No published templates yet', { exact: true })
|
||||
.waitFor();
|
||||
await visit('/project-templates?reset');
|
||||
await page.getByRole('link', { name: 'Use template', exact: true }).click();
|
||||
await page.getByRole('heading', { name: 'Destination reached' }).waitFor();
|
||||
assert.match(page.url(), /\/projects\/create\?template=2$/);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'Project template browser checks passed: create/edit payloads, confirmations, slug lock, validation/network/server retries, missing source, deletion, navigation, keyboard, 320/390/1440px and both themes.',
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createApp, h } from 'vue';
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
import '../../resources/css/app.css';
|
||||
import Index from '../../resources/js/pages/project-templates/Index.vue';
|
||||
import Create from '../../resources/js/pages/project-templates/Create.vue';
|
||||
import Edit from '../../resources/js/pages/project-templates/Edit.vue';
|
||||
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||
import { createLocalization } from '../../resources/js/lib/localization';
|
||||
createInertiaApp({
|
||||
resolve: (name) =>
|
||||
({ Index, Create, Edit })[name] ?? {
|
||||
render: () => h('h1', 'Destination reached'),
|
||||
},
|
||||
setup: ({ el, App, props, plugin }) =>
|
||||
createApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.use(createLocalization())
|
||||
.use(createAppearance())
|
||||
.mount(el),
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import tailwind from '@tailwindcss/vite';
|
||||
import { createServer } from 'vite';
|
||||
import { createSSRApp, h } from 'vue';
|
||||
import { renderToString } from 'vue/server-renderer';
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
|
||||
const serve = process.argv.includes('--serve');
|
||||
const server = await createServer({
|
||||
configFile: false,
|
||||
cacheDir: 'node_modules/.vite/project-templates-fixture',
|
||||
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: serve
|
||||
? { host: '127.0.0.1', port: 4191, strictPort: true, hmr: false }
|
||||
: { middlewareMode: true, hmr: false, watch: null },
|
||||
appType: 'custom',
|
||||
});
|
||||
const project = { id: 1, name: 'Core platform', key: 'CORE' };
|
||||
const areas = [
|
||||
'project_settings',
|
||||
'board_columns',
|
||||
'ticket_statuses',
|
||||
'status_transitions',
|
||||
'ticket_types',
|
||||
'ticket_priorities',
|
||||
'ticket_fields',
|
||||
'ticket_link_types',
|
||||
'repository_connections',
|
||||
'notification_actions',
|
||||
];
|
||||
const template = {
|
||||
id: 2,
|
||||
name: 'Foundation',
|
||||
slug: 'foundation-template',
|
||||
is_published: true,
|
||||
source_project: project,
|
||||
copy_areas: ['project_settings', 'ticket_types'],
|
||||
linked_project_count: 2,
|
||||
};
|
||||
const initial = () => ({
|
||||
project,
|
||||
copyAreaOptions: areas.map((value) => ({
|
||||
value,
|
||||
label: value,
|
||||
description: value,
|
||||
})),
|
||||
template: structuredClone(template),
|
||||
templates: [
|
||||
structuredClone(template),
|
||||
{ ...template, id: 3, name: 'Internal baseline', is_published: false },
|
||||
],
|
||||
});
|
||||
function makePage(component = 'Index', extra = {}, url = '/project-templates') {
|
||||
return {
|
||||
component,
|
||||
url,
|
||||
version: null,
|
||||
clearHistory: false,
|
||||
encryptHistory: false,
|
||||
props: {
|
||||
name: 'KanbanGit',
|
||||
errors: {},
|
||||
sidebarOpen: true,
|
||||
appearance: 'system',
|
||||
auth: {
|
||||
user: {
|
||||
id: 1,
|
||||
name: 'Alex Morgan',
|
||||
email: 'alex@example.test',
|
||||
avatar: null,
|
||||
avatar_color: '#10B981',
|
||||
avatar_is_generated: true,
|
||||
},
|
||||
permissions: {
|
||||
globalAdmin: false,
|
||||
projectCreate: true,
|
||||
canManageTeams: false,
|
||||
},
|
||||
},
|
||||
navigation: { project: null },
|
||||
...initial(),
|
||||
...extra,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (serve) {
|
||||
let data = initial();
|
||||
server.middlewares.use(async (request, response, next) => {
|
||||
if (!request.url.startsWith('/project')) {
|
||||
return next();
|
||||
}
|
||||
const url = new URL(request.url, 'http://localhost');
|
||||
let component = url.pathname.endsWith('/edit')
|
||||
? 'Edit'
|
||||
: url.pathname.includes('/templates/create')
|
||||
? 'Create'
|
||||
: url.pathname === '/project-templates'
|
||||
? 'Index'
|
||||
: 'Destination';
|
||||
if (url.searchParams.has('reset')) {
|
||||
data = initial();
|
||||
}
|
||||
if (url.searchParams.has('unlinked')) {
|
||||
data.template.linked_project_count = 0;
|
||||
}
|
||||
if (url.searchParams.has('missing')) {
|
||||
data.template.source_project = null;
|
||||
}
|
||||
if (url.searchParams.has('empty')) {
|
||||
data.templates = [];
|
||||
}
|
||||
let errors = {};
|
||||
if (request.method !== 'GET') {
|
||||
let body = '';
|
||||
for await (const chunk of request) {
|
||||
body += chunk;
|
||||
}
|
||||
const submitted = JSON.parse(body || '{}');
|
||||
const result =
|
||||
request.headers.cookie?.match(
|
||||
/template_test_result=(\w+)/,
|
||||
)?.[1] ?? 'success';
|
||||
if (result === 'server') {
|
||||
response.statusCode = 500;
|
||||
response.end('Unavailable');
|
||||
return;
|
||||
}
|
||||
if (result === 'validation') {
|
||||
component =
|
||||
request.method === 'POST'
|
||||
? 'Create'
|
||||
: request.method === 'PATCH'
|
||||
? 'Edit'
|
||||
: 'Index';
|
||||
errors = {
|
||||
name: 'This template name is unavailable.',
|
||||
'copy_areas.0': 'Choose a valid configuration group.',
|
||||
};
|
||||
} else {
|
||||
if (request.method === 'DELETE') {
|
||||
data.templates = data.templates.filter(
|
||||
(item) => item.id !== 2,
|
||||
);
|
||||
} else {
|
||||
data.templates = [{ ...data.template, ...submitted }];
|
||||
}
|
||||
component = 'Index';
|
||||
}
|
||||
}
|
||||
const page = makePage(
|
||||
component,
|
||||
{
|
||||
...data,
|
||||
errors,
|
||||
status:
|
||||
request.method !== 'GET' && !Object.keys(errors).length
|
||||
? 'Saved'
|
||||
: null,
|
||||
},
|
||||
component === 'Index' ? '/project-templates' : request.url,
|
||||
);
|
||||
response.setHeader('Vary', 'X-Inertia');
|
||||
if (request.headers['x-inertia']) {
|
||||
response.setHeader('X-Inertia', 'true');
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(JSON.stringify(page));
|
||||
return;
|
||||
}
|
||||
const escaped = JSON.stringify(page)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<');
|
||||
response.setHeader('Content-Type', 'text/html');
|
||||
response.end(
|
||||
await server.transformIndexHtml(
|
||||
request.url,
|
||||
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
|
||||
escaped +
|
||||
'"></div><script type="module" src="/tests/Fixtures/project-templates-preview.mjs"></script></body></html>',
|
||||
),
|
||||
);
|
||||
});
|
||||
await server.listen();
|
||||
console.log('Project template fixture ready.');
|
||||
} else {
|
||||
try {
|
||||
const { createLocalization } = await server.ssrLoadModule(
|
||||
'/resources/js/lib/localization.ts',
|
||||
);
|
||||
const { createAppearance } = await server.ssrLoadModule(
|
||||
'/resources/js/composables/useAppearance.ts',
|
||||
);
|
||||
const { en } = await server.ssrLoadModule(
|
||||
'/resources/js/locales/en.ts',
|
||||
);
|
||||
const render = async (
|
||||
name,
|
||||
extra = {},
|
||||
locale = createLocalization(),
|
||||
) => {
|
||||
const component = (
|
||||
await server.ssrLoadModule(
|
||||
'/resources/js/pages/project-templates/' + name + '.vue',
|
||||
)
|
||||
).default;
|
||||
return (
|
||||
await createInertiaApp({
|
||||
page: makePage(name, extra),
|
||||
resolve: () => component,
|
||||
render: renderToString,
|
||||
setup: ({ App, props, plugin }) =>
|
||||
createSSRApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.use(locale)
|
||||
.use(createAppearance()),
|
||||
})
|
||||
).body.replace(/ data-page="[^"]*"/, '');
|
||||
};
|
||||
const list = await render('Index');
|
||||
assert.match(list, /Foundation/);
|
||||
assert.doesNotMatch(list, /Internal baseline/);
|
||||
assert.match(list, /2 linked projects/);
|
||||
assert.match(list, /Project settings/);
|
||||
assert.match(list, /href="\/projects\/create\?template=2"/);
|
||||
assert.match(
|
||||
await render('Index', { templates: [] }),
|
||||
/No published templates yet/,
|
||||
);
|
||||
const create = await render('Create');
|
||||
assert.match(create, /value="Core platform template"/);
|
||||
assert.equal((create.match(/type="checkbox"/g) ?? []).length, 10);
|
||||
assert.doesNotMatch(
|
||||
create.match(/<input[^>]*id="slug"[^>]*>/)?.[0] ?? '',
|
||||
/ disabled(?:[ =>])/,
|
||||
);
|
||||
const edit = await render('Edit');
|
||||
assert.match(
|
||||
edit.match(/<input[^>]*id="slug"[^>]*>/)?.[0] ?? '',
|
||||
/ disabled(?:[ =>])/,
|
||||
);
|
||||
assert.match(edit, /Linked projects will be affected/);
|
||||
assert.match(
|
||||
edit.match(/<button[^>]*type="submit"[^>]*>/)?.[0] ?? '',
|
||||
/ disabled(?:[ =>])/,
|
||||
);
|
||||
const unlinked = await render('Edit', {
|
||||
template: { ...template, linked_project_count: 0 },
|
||||
});
|
||||
assert.doesNotMatch(unlinked, /id="confirm-linked"/);
|
||||
assert.doesNotMatch(
|
||||
unlinked.match(/<button[^>]*type="submit"[^>]*>/)?.[0] ?? '',
|
||||
/ disabled(?:[ =>])/,
|
||||
);
|
||||
assert.match(
|
||||
await render('Edit', {
|
||||
template: { ...template, source_project: null },
|
||||
}),
|
||||
/This template cannot be refreshed/,
|
||||
);
|
||||
assert.match(
|
||||
await render(
|
||||
'Index',
|
||||
{},
|
||||
createLocalization(
|
||||
{ locale: 'de' },
|
||||
{
|
||||
de: {
|
||||
...en,
|
||||
'navigation.templates': 'Projektvorlagen',
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
/Projektvorlagen/,
|
||||
);
|
||||
console.log('Project template checks passed.');
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user