fix(admin): delete feedback alerts + informative block errors (issue #127)
- locations/categories GET pass req.query.error/success to views
- locations/categories EJS render dismissible error/success alerts (modeled on catalog.ejs)
- Delete errors include blocking counts + 'Remove X first' hints
- 🔒 lock hint on rows with linked categories/products/subcategories
- 18 new tests (adminDeleteFeedback.test.js); 48 total pass
This commit is contained in:
181
src/__tests__/adminDeleteFeedback.test.js
Normal file
181
src/__tests__/adminDeleteFeedback.test.js
Normal file
@@ -0,0 +1,181 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import ejs from 'ejs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const viewsDir = join(__dirname, '..', 'admin', 'views');
|
||||
const locationsTemplate = readFileSync(join(viewsDir, 'locations.ejs'), 'utf-8');
|
||||
const categoriesTemplate = readFileSync(join(viewsDir, 'categories.ejs'), 'utf-8');
|
||||
const routesDir = join(__dirname, '..', 'admin', 'routes');
|
||||
const locationsSource = readFileSync(join(routesDir, 'locations.js'), 'utf-8');
|
||||
const categoriesSource = readFileSync(join(routesDir, 'categories.js'), 'utf-8');
|
||||
|
||||
describe('admin delete feedback UI', () => {
|
||||
describe('locations.ejs', () => {
|
||||
it('renders a danger alert when error is set', () => {
|
||||
const html = ejs.render(locationsTemplate, {
|
||||
error: 'Cannot delete location with categories (count: 3). Remove categories first.',
|
||||
success: '',
|
||||
csrfToken: 'csrf',
|
||||
locations: []
|
||||
});
|
||||
expect(html).toContain('alert-danger');
|
||||
expect(html).toContain('Cannot delete location with categories (count: 3). Remove categories first.');
|
||||
});
|
||||
|
||||
it('does not render an error alert when error is empty', () => {
|
||||
const html = ejs.render(locationsTemplate, {
|
||||
error: '',
|
||||
success: '',
|
||||
csrfToken: 'csrf',
|
||||
locations: []
|
||||
});
|
||||
expect(html).not.toContain('alert-danger');
|
||||
});
|
||||
|
||||
it('renders a success alert when success is set', () => {
|
||||
const html = ejs.render(locationsTemplate, {
|
||||
error: '',
|
||||
success: 'Location deleted',
|
||||
csrfToken: 'csrf',
|
||||
locations: []
|
||||
});
|
||||
expect(html).toContain('alert-success');
|
||||
expect(html).toContain('Location deleted');
|
||||
});
|
||||
|
||||
it('renders the lock hint when a location has categories', () => {
|
||||
const html = ejs.render(locationsTemplate, {
|
||||
error: '',
|
||||
success: '',
|
||||
csrfToken: 'csrf',
|
||||
locations: [{ id: 1, country: 'DE', city: 'Berlin', district: '', category_count: 2, product_count: 0, is_active: 1 }]
|
||||
});
|
||||
expect(html).toContain('🔒');
|
||||
});
|
||||
|
||||
it('renders the lock hint when a location has products', () => {
|
||||
const html = ejs.render(locationsTemplate, {
|
||||
error: '',
|
||||
success: '',
|
||||
csrfToken: 'csrf',
|
||||
locations: [{ id: 1, country: 'DE', city: 'Berlin', district: '', category_count: 0, product_count: 5, is_active: 1 }]
|
||||
});
|
||||
expect(html).toContain('🔒');
|
||||
});
|
||||
|
||||
it('does not render the lock hint when a location has no links', () => {
|
||||
const html = ejs.render(locationsTemplate, {
|
||||
error: '',
|
||||
success: '',
|
||||
csrfToken: 'csrf',
|
||||
locations: [{ id: 1, country: 'DE', city: 'Berlin', district: '', category_count: 0, product_count: 0, is_active: 1 }]
|
||||
});
|
||||
expect(html).not.toContain('🔒');
|
||||
});
|
||||
});
|
||||
|
||||
describe('categories.ejs', () => {
|
||||
const baseData = {
|
||||
error: '',
|
||||
success: '',
|
||||
csrfToken: 'csrf',
|
||||
locations: [],
|
||||
subcategories: [],
|
||||
subcategoriesByCategory: {}
|
||||
};
|
||||
|
||||
it('renders a danger alert when error is set', () => {
|
||||
const html = ejs.render(categoriesTemplate, {
|
||||
...baseData,
|
||||
error: 'Cannot delete category with products (count: 2). Remove products first.',
|
||||
categories: []
|
||||
});
|
||||
expect(html).toContain('alert-danger');
|
||||
expect(html).toContain('Cannot delete category with products (count: 2). Remove products first.');
|
||||
});
|
||||
|
||||
it('renders a success alert when success is set', () => {
|
||||
const html = ejs.render(categoriesTemplate, {
|
||||
...baseData,
|
||||
success: 'Category deleted',
|
||||
categories: []
|
||||
});
|
||||
expect(html).toContain('alert-success');
|
||||
expect(html).toContain('Category deleted');
|
||||
});
|
||||
|
||||
it('renders the lock hint when a category has products', () => {
|
||||
const html = ejs.render(categoriesTemplate, {
|
||||
...baseData,
|
||||
categories: [{ id: 1, name: 'Food', product_count: 3, is_active: 1, country: null, city: null }]
|
||||
});
|
||||
expect(html).toContain('🔒');
|
||||
});
|
||||
|
||||
it('renders the lock hint when a category has subcategories', () => {
|
||||
const html = ejs.render(categoriesTemplate, {
|
||||
...baseData,
|
||||
categories: [{ id: 1, name: 'Food', product_count: 0, is_active: 1, country: null, city: null }],
|
||||
subcategories: [{ id: 10, category_id: 1, name: 'Snacks', product_count: 0, is_active: 1 }],
|
||||
subcategoriesByCategory: { 1: [{ id: 10, category_id: 1, name: 'Snacks', product_count: 0, is_active: 1 }] }
|
||||
});
|
||||
expect(html).toContain('🔒');
|
||||
});
|
||||
|
||||
it('renders the lock hint for a subcategory with products', () => {
|
||||
const html = ejs.render(categoriesTemplate, {
|
||||
...baseData,
|
||||
categories: [{ id: 1, name: 'Food', product_count: 0, is_active: 1, country: null, city: null }],
|
||||
subcategories: [{ id: 10, category_id: 1, name: 'Snacks', product_count: 4, is_active: 1 }],
|
||||
subcategoriesByCategory: { 1: [{ id: 10, category_id: 1, name: 'Snacks', product_count: 4, is_active: 1 }] }
|
||||
});
|
||||
expect(html).toContain('🔒');
|
||||
});
|
||||
|
||||
it('does not render the lock hint when a category has no links', () => {
|
||||
const html = ejs.render(categoriesTemplate, {
|
||||
...baseData,
|
||||
categories: [{ id: 1, name: 'Food', product_count: 0, is_active: 1, country: null, city: null }]
|
||||
});
|
||||
expect(html).not.toContain('🔒');
|
||||
});
|
||||
});
|
||||
|
||||
describe('route source assertions', () => {
|
||||
it('locations GET passes error and success from query', () => {
|
||||
expect(locationsSource).toContain('error: req.query.error || \'\'');
|
||||
expect(locationsSource).toContain('success: req.query.success || \'\'');
|
||||
});
|
||||
|
||||
it('locations DELETE error messages include counts for categories and products', () => {
|
||||
expect(locationsSource).toContain('(count:+${count.cnt}).+Remove+categories+first.');
|
||||
expect(locationsSource).toContain('(count:+${productCount.cnt}).+Remove+products+first.');
|
||||
expect(locationsSource).toContain('Remove+categories+first');
|
||||
expect(locationsSource).toContain('Remove+products+first');
|
||||
});
|
||||
|
||||
it('locations DELETE success message is set on redirect', () => {
|
||||
expect(locationsSource).toContain("res.redirect('/locations?success=Location+deleted')");
|
||||
});
|
||||
|
||||
it('categories GET passes error and success from query', () => {
|
||||
expect(categoriesSource).toContain('error: req.query.error || \'\'');
|
||||
expect(categoriesSource).toContain('success: req.query.success || \'\'');
|
||||
});
|
||||
|
||||
it('categories DELETE error messages include counts for products and subcategories', () => {
|
||||
expect(categoriesSource).toContain('(count:+${count.cnt}).+Remove+products+first.');
|
||||
expect(categoriesSource).toContain('(count:+${subCount.cnt}).+Remove+products+first.');
|
||||
});
|
||||
|
||||
it('categories DELETE and subcategory DELETE success messages are set', () => {
|
||||
expect(categoriesSource).toContain("res.redirect('/categories?success=Category+deleted')");
|
||||
expect(categoriesSource).toContain("res.redirect('/categories?success=Subcategory+deleted')");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,15 @@ router.get('/', asyncHandler(async (req, res) => {
|
||||
subcategoriesByCategory[s.category_id].push(s);
|
||||
}
|
||||
|
||||
res.render('categories', { title: 'Categories', categories, locations, subcategories, subcategoriesByCategory });
|
||||
res.render('categories', {
|
||||
title: 'Categories',
|
||||
categories,
|
||||
locations,
|
||||
subcategories,
|
||||
subcategoriesByCategory,
|
||||
error: req.query.error || '',
|
||||
success: req.query.success || '',
|
||||
});
|
||||
}));
|
||||
|
||||
router.post('/', asyncHandler(async (req, res) => {
|
||||
@@ -48,18 +56,18 @@ router.post('/:id/toggle', asyncHandler(async (req, res) => {
|
||||
router.post('/:id/delete', asyncHandler(async (req, res) => {
|
||||
const count = await db.getAsync('SELECT COUNT(*) as cnt FROM products WHERE category_id = ?', [req.params.id]);
|
||||
if (count && count.cnt > 0) {
|
||||
return res.redirect('/categories?error=Cannot+delete+category+with+products');
|
||||
return res.redirect(`/categories?error=Cannot+delete+category+with+products+(count:+${count.cnt}).+Remove+products+first.`);
|
||||
}
|
||||
const subCount = await db.getAsync(
|
||||
'SELECT COUNT(*) as cnt FROM products WHERE subcategory_id IN (SELECT id FROM subcategories WHERE category_id = ?)',
|
||||
[req.params.id]
|
||||
);
|
||||
if (subCount && subCount.cnt > 0) {
|
||||
return res.redirect('/categories?error=Cannot+delete+category+with+products+in+subcategories');
|
||||
return res.redirect(`/categories?error=Cannot+delete+category+with+products+in+subcategories+(count:+${subCount.cnt}).+Remove+products+first.`);
|
||||
}
|
||||
await db.runAsync('DELETE FROM subcategories WHERE category_id = ?', [req.params.id]);
|
||||
await db.runAsync('DELETE FROM categories WHERE id = ?', [req.params.id]);
|
||||
res.redirect('/categories');
|
||||
res.redirect('/categories?success=Category+deleted');
|
||||
}));
|
||||
|
||||
router.post('/:id/subcategories', asyncHandler(async (req, res) => {
|
||||
@@ -93,10 +101,10 @@ router.post('/subcategories/:id/delete', asyncHandler(async (req, res) => {
|
||||
[req.params.id]
|
||||
);
|
||||
if (count && count.cnt > 0) {
|
||||
return res.redirect('/categories?error=Cannot+delete+subcategory+with+products');
|
||||
return res.redirect(`/categories?error=Cannot+delete+subcategory+with+products+(count:+${count.cnt}).+Remove+products+first.`);
|
||||
}
|
||||
await db.runAsync('DELETE FROM subcategories WHERE id = ?', [req.params.id]);
|
||||
res.redirect('/categories');
|
||||
res.redirect('/categories?success=Subcategory+deleted');
|
||||
}));
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -9,7 +9,12 @@ router.get('/', asyncHandler(async (req, res) => {
|
||||
(SELECT COUNT(*) FROM categories WHERE location_id = l.id) as category_count,
|
||||
(SELECT COUNT(*) FROM products WHERE location_id = l.id) as product_count
|
||||
FROM locations l ORDER BY l.country, l.city, l.district`);
|
||||
res.render('locations', { title: 'Locations', locations });
|
||||
res.render('locations', {
|
||||
title: 'Locations',
|
||||
locations,
|
||||
error: req.query.error || '',
|
||||
success: req.query.success || '',
|
||||
});
|
||||
}));
|
||||
|
||||
router.post('/', asyncHandler(async (req, res) => {
|
||||
@@ -49,17 +54,17 @@ router.post('/:id/delete', asyncHandler(async (req, res) => {
|
||||
[req.params.id]
|
||||
);
|
||||
if (count && count.cnt > 0) {
|
||||
return res.redirect('/locations?error=Cannot+delete+location+with+categories');
|
||||
return res.redirect(`/locations?error=Cannot+delete+location+with+categories+(count:+${count.cnt}).+Remove+categories+first.`);
|
||||
}
|
||||
const productCount = await db.getAsync(
|
||||
'SELECT COUNT(*) as cnt FROM products WHERE location_id = ?',
|
||||
[req.params.id]
|
||||
);
|
||||
if (productCount && productCount.cnt > 0) {
|
||||
return res.redirect('/locations?error=Cannot+delete+location+with+products');
|
||||
return res.redirect(`/locations?error=Cannot+delete+location+with+products+(count:+${productCount.cnt}).+Remove+products+first.`);
|
||||
}
|
||||
await db.runAsync('DELETE FROM locations WHERE id = ?', [req.params.id]);
|
||||
res.redirect('/locations');
|
||||
res.redirect('/locations?success=Location+deleted');
|
||||
}));
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
<% if (error) { %>
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<%= error %>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
<% } %>
|
||||
<% if (success) { %>
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<%= success %>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
<% } %>
|
||||
<a href="/catalog" class="btn btn-sm btn-outline-secondary mb-2">« Back to Catalog</a>
|
||||
<div class="panel panel-icon">
|
||||
<div class="panel-hdr">
|
||||
@@ -69,6 +81,9 @@
|
||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||
<button class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
<% if (c.product_count > 0 || (subcategoriesByCategory[c.id] && subcategoriesByCategory[c.id].length > 0)) { %>
|
||||
<span class="text-warning" title="Blocks deletion">🔒</span>
|
||||
<% } %>
|
||||
</td>
|
||||
</tr>
|
||||
<% var subs = subcategoriesByCategory[c.id] || []; %>
|
||||
@@ -108,6 +123,9 @@
|
||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||
<button class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
<% if (s.product_count > 0) { %>
|
||||
<span class="text-warning" title="Blocks deletion">🔒</span>
|
||||
<% } %>
|
||||
</td>
|
||||
</tr>
|
||||
<% }); %>
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
<% if (error) { %>
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<%= error %>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
<% } %>
|
||||
<% if (success) { %>
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<%= success %>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
<% } %>
|
||||
<a href="/catalog" class="btn btn-sm btn-outline-secondary mb-2">« Back to Catalog</a>
|
||||
<div class="panel panel-icon mb-3">
|
||||
<div class="panel-hdr">
|
||||
@@ -71,6 +83,9 @@
|
||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||
<button class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
<% if (l.category_count > 0 || l.product_count > 0) { %>
|
||||
<span class="text-warning" title="Blocks deletion">🔒</span>
|
||||
<% } %>
|
||||
</td>
|
||||
</tr>
|
||||
<% }); %>
|
||||
|
||||
Reference in New Issue
Block a user