Search results open the record they found; plugin scaffolder emits a prunable frontend
This commit is contained in:
21
CHANGELOG.md
21
CHANGELOG.md
@@ -75,6 +75,27 @@ ADR-007 and ADR-002.
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- **A search result opened the list it sits in, not the record it found.** A
|
||||||
|
NOTICE linked to `/notifications`, so clicking the row search had already
|
||||||
|
identified sent the reader to the list page to find it again by hand. The id
|
||||||
|
was in the payload the whole time and only the URL ignored it. The subnet
|
||||||
|
searcher had the same defect: an IP search resolves which subnet an address
|
||||||
|
falls inside, then linked to the `/network` hub rather than that subnet.
|
||||||
|
Both now link to the record. Every other result type already carried its id.
|
||||||
|
|
||||||
|
- **`flask plugin new` scaffolded a frontend that no lean build could prune.**
|
||||||
|
It wrote views to `frontend/src/views/<name>/` and a route file to
|
||||||
|
`frontend/src/router/routes/<name>.js`, while all 16 bundled plugins keep
|
||||||
|
their frontend in `plugins/<name>/frontend/`, staged by
|
||||||
|
`stage-frontend.mjs`. Nothing looked broken, which is why it lasted: the
|
||||||
|
router still globs `./routes/*.js` during the Phase 4 dual-location
|
||||||
|
transition, so the pages loaded. What a new plugin author silently lost was
|
||||||
|
ADR-013 lean builds, since pruning works off the staged tree, plus stranded
|
||||||
|
views on deleting the plugin. The templates for the right layout already
|
||||||
|
existed; only the output paths were wrong. Their core imports moved to the
|
||||||
|
`@` alias, which resolves the same before and after staging, where the
|
||||||
|
previous `../../api` was correct only in the old location.
|
||||||
|
|
||||||
- **A zero bar height printed a barcode 176 wide and 0 high.** Nothing rejected
|
- **A zero bar height printed a barcode 176 wide and 0 high.** Nothing rejected
|
||||||
it: the value went straight into a CSS length, `height: 0in` is perfectly
|
it: the value went straight into a CSS length, `height: 0in` is perfectly
|
||||||
valid, and an image at `height: 100%` of a zero-height box is zero pixels
|
valid, and an image at `height: 100%` of a zero-height box is zero pixels
|
||||||
|
|||||||
@@ -658,7 +658,10 @@ def _search_subnets(query):
|
|||||||
'id': subnet.subnetid,
|
'id': subnet.subnetid,
|
||||||
'title': f'{subnet.name} ({subnet.cidr})',
|
'title': f'{subnet.name} ({subnet.cidr})',
|
||||||
'subtitle': subnet.description or subnet.subnettype,
|
'subtitle': subnet.description or subnet.subnettype,
|
||||||
'url': f'/network',
|
# the matched subnet, not the hub: searching an IP
|
||||||
|
# already identified WHICH subnet, so landing on the
|
||||||
|
# hub makes the reader find it again by hand
|
||||||
|
'url': f'/networks/{subnet.subnetid}',
|
||||||
'relevance': 70
|
'relevance': 70
|
||||||
})
|
})
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -779,7 +782,9 @@ def _search_notifications(query, search_term):
|
|||||||
'id': notif.notificationid,
|
'id': notif.notificationid,
|
||||||
'title': notif.title,
|
'title': notif.title,
|
||||||
'subtitle': notif.notificationtype.typename if notif.notificationtype else None,
|
'subtitle': notif.notificationtype.typename if notif.notificationtype else None,
|
||||||
'url': f'/notifications',
|
# the notification itself, not the list it sits in: the
|
||||||
|
# `notification-detail` route renders one by id
|
||||||
|
'url': f'/notifications/{notif.notificationid}',
|
||||||
'relevance': min(int(base_relevance), 100),
|
'relevance': min(int(base_relevance), 100),
|
||||||
'ticketnumber': notif.ticketnumber,
|
'ticketnumber': notif.ticketnumber,
|
||||||
'iscurrent': notif.is_current
|
'iscurrent': notif.is_current
|
||||||
|
|||||||
@@ -81,18 +81,28 @@ def _scaffold_frontend(
|
|||||||
name: str,
|
name: str,
|
||||||
substitutions: dict,
|
substitutions: dict,
|
||||||
plugin_target: Path,
|
plugin_target: Path,
|
||||||
frontend_dir: Path,
|
|
||||||
template_root: Path,
|
template_root: Path,
|
||||||
overwrite: bool,
|
overwrite: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Render the frontend starting points for a scaffolded plugin.
|
"""Render the frontend starting points for a scaffolded plugin.
|
||||||
|
|
||||||
Writes the paste-in api-client snippet into the plugin directory, then the
|
Everything lands INSIDE the plugin, under `plugins/<name>/frontend/`:
|
||||||
Vue views and the router route file into the real frontend tree. The
|
`routes.js` plus `views/<Name>{List,Detail,Form}.vue`, and the paste-in
|
||||||
snippet is emitted regardless of whether the frontend tree exists, because
|
api-client snippet at the plugin root.
|
||||||
it is a plugin-directory artifact useful even for external-repo plugins.
|
|
||||||
Views and the route file are skipped when frontend_dir is missing, which is
|
The plugin owns its frontend because that is what makes it prunable.
|
||||||
the normal case for a plugin developed in its own repository.
|
scripts/stage-frontend.mjs copies each plugin's `frontend/` into
|
||||||
|
`frontend/src/.plugins-staged/<name>/` and aggregates the route modules
|
||||||
|
into `routes.gen.js`; a plugin left out of SITE_PLUGINS is simply not
|
||||||
|
copied, so its frontend leaves the build with it (ADR-013). A route file
|
||||||
|
written into the CORE tree instead is still picked up - the router globs
|
||||||
|
`./routes/*.js` during the Phase 4 dual-location transition - but it is
|
||||||
|
invisible to that pruning, and deleting the plugin strands its views.
|
||||||
|
|
||||||
|
This is also why nothing here depends on the core frontend tree existing.
|
||||||
|
A plugin developed in its own repo gets exactly the same layout, and its
|
||||||
|
frontend starts working the moment the plugin directory is dropped into
|
||||||
|
`plugins/`.
|
||||||
|
|
||||||
Ordering matters: every view is written before the route file. A route file
|
Ordering matters: every view is written before the route file. A route file
|
||||||
that lazy-imports a view that is not on disk crashes the Vite dev server,
|
that lazy-imports a view that is not on disk crashes the Vite dev server,
|
||||||
@@ -104,7 +114,7 @@ def _scaffold_frontend(
|
|||||||
|
|
||||||
plugin_name = substitutions['Name']
|
plugin_name = substitutions['Name']
|
||||||
|
|
||||||
# snippet lands in the plugin dir; author pastes it into api/index.js
|
# snippet lands at the plugin root; author pastes it into api/index.js
|
||||||
snippet_template = fe_templates / 'frontend-api-snippet.js.tmpl'
|
snippet_template = fe_templates / 'frontend-api-snippet.js.tmpl'
|
||||||
if snippet_template.exists():
|
if snippet_template.exists():
|
||||||
_render_template(
|
_render_template(
|
||||||
@@ -114,11 +124,8 @@ def _scaffold_frontend(
|
|||||||
overwrite,
|
overwrite,
|
||||||
)
|
)
|
||||||
|
|
||||||
# views and route need the real frontend tree; external repos skip these
|
frontend_dir = plugin_target / 'frontend'
|
||||||
if not frontend_dir.exists():
|
views_dir = frontend_dir / 'views'
|
||||||
return
|
|
||||||
|
|
||||||
views_dir = frontend_dir / 'views' / name
|
|
||||||
|
|
||||||
# views first: route file lazy-imports them, missing views 500 vite
|
# views first: route file lazy-imports them, missing views 500 vite
|
||||||
view_templates = {
|
view_templates = {
|
||||||
@@ -141,7 +148,7 @@ def _scaffold_frontend(
|
|||||||
if route_template.exists():
|
if route_template.exists():
|
||||||
_render_template(
|
_render_template(
|
||||||
route_template,
|
route_template,
|
||||||
frontend_dir / 'router' / 'routes' / f'{name}.js',
|
frontend_dir / 'routes.js',
|
||||||
substitutions,
|
substitutions,
|
||||||
overwrite,
|
overwrite,
|
||||||
)
|
)
|
||||||
@@ -154,7 +161,6 @@ def scaffold_plugin(
|
|||||||
template_root: Optional[Path] = None,
|
template_root: Optional[Path] = None,
|
||||||
overwrite: bool = False,
|
overwrite: bool = False,
|
||||||
frontend: bool = True,
|
frontend: bool = True,
|
||||||
frontend_dir: Optional[Path] = None,
|
|
||||||
) -> Path:
|
) -> Path:
|
||||||
"""Generate a new plugin from templates.
|
"""Generate a new plugin from templates.
|
||||||
|
|
||||||
@@ -166,11 +172,10 @@ def scaffold_plugin(
|
|||||||
overwrite: If True, overwrite an existing plugin directory and any
|
overwrite: If True, overwrite an existing plugin directory and any
|
||||||
existing generated frontend files
|
existing generated frontend files
|
||||||
frontend: If True, also render the Vue frontend starting points (list,
|
frontend: If True, also render the Vue frontend starting points (list,
|
||||||
detail, form views, a route file, and a paste-in api-client snippet)
|
detail, form views, a route file, and a paste-in api-client
|
||||||
frontend_dir: Frontend src directory to render views/routes into
|
snippet) into `plugins/<name>/frontend/`. There is no separate
|
||||||
(default: <plugins_dir>/../frontend/src). Views and the route file
|
output directory: the plugin owns its frontend so that
|
||||||
are skipped when this directory does not exist, which is the normal
|
stage-frontend.mjs can stage it and a lean build can prune it.
|
||||||
case for a plugin developed in its own repository.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Path to the generated plugin directory.
|
Path to the generated plugin directory.
|
||||||
@@ -205,7 +210,9 @@ def scaffold_plugin(
|
|||||||
for template_path in template_root.rglob('*.tmpl'):
|
for template_path in template_root.rglob('*.tmpl'):
|
||||||
rel = template_path.relative_to(template_root)
|
rel = template_path.relative_to(template_root)
|
||||||
|
|
||||||
# frontend templates render into the frontend tree, not the plugin dir
|
# frontend templates are handled by _scaffold_frontend: they are
|
||||||
|
# renamed on the way out (List.vue.tmpl -> <Name>List.vue) and are
|
||||||
|
# skipped entirely when frontend=False
|
||||||
if rel.parts and rel.parts[0] == 'frontend':
|
if rel.parts and rel.parts[0] == 'frontend':
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -222,13 +229,10 @@ def scaffold_plugin(
|
|||||||
out_path.write_text(rendered)
|
out_path.write_text(rendered)
|
||||||
|
|
||||||
if frontend:
|
if frontend:
|
||||||
if frontend_dir is None:
|
|
||||||
frontend_dir = plugins_dir.parent / 'frontend' / 'src'
|
|
||||||
_scaffold_frontend(
|
_scaffold_frontend(
|
||||||
name=name,
|
name=name,
|
||||||
substitutions=substitutions,
|
substitutions=substitutions,
|
||||||
plugin_target=target,
|
plugin_target=target,
|
||||||
frontend_dir=Path(frontend_dir),
|
|
||||||
template_root=template_root,
|
template_root=template_root,
|
||||||
overwrite=overwrite,
|
overwrite=overwrite,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,35 +1,49 @@
|
|||||||
/**
|
/**
|
||||||
* ${Name} plugin routes.
|
* ${Name} plugin routes.
|
||||||
*
|
*
|
||||||
* Auto-discovered by the router via import.meta.glob, so no registration
|
* This file lives in the plugin, not in the core frontend tree.
|
||||||
* edit is needed. Every route carries meta.plugin '${name}' so the ADR-009
|
* scripts/stage-frontend.mjs copies plugins/${name}/frontend/ into
|
||||||
* guard redirects to the dashboard when the ${name} backend plugin is
|
* frontend/src/.plugins-staged/${name}/ and aggregates this default export
|
||||||
* disabled. Form routes add requiresAuth so anonymous users cannot reach
|
* into routes.gen.js, which the router spreads in - so no registration edit
|
||||||
* create or edit.
|
* is needed, and pruning the plugin for a lean build removes its frontend
|
||||||
|
* with it (ADR-013).
|
||||||
|
*
|
||||||
|
* Every route carries meta.plugin '${name}' so the ADR-009 guard redirects to
|
||||||
|
* the dashboard when the ${name} backend plugin is disabled. Form routes add
|
||||||
|
* requiresAuth so anonymous users cannot reach create or edit.
|
||||||
|
*
|
||||||
|
* View imports are relative (./views/...) because they travel with this file.
|
||||||
|
* Anything from the CORE tree uses the @ alias (@/api, @/components/...),
|
||||||
|
* which resolves the same before and after staging.
|
||||||
*/
|
*/
|
||||||
export default [
|
export default [
|
||||||
{
|
{
|
||||||
path: '${name}',
|
path: '${name}',
|
||||||
name: '${name}',
|
name: '${name}',
|
||||||
component: () => import('../../views/${name}/${Name}List.vue'),
|
component: () => import('./views/${Name}List.vue'),
|
||||||
meta: { plugin: '${name}' }
|
meta: { plugin: '${name}' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '${name}/new',
|
path: '${name}/new',
|
||||||
name: '${name}-new',
|
name: '${name}-new',
|
||||||
component: () => import('../../views/${name}/${Name}Form.vue'),
|
component: () => import('./views/${Name}Form.vue'),
|
||||||
meta: { requiresAuth: true, plugin: '${name}' }
|
meta: { requiresAuth: true, plugin: '${name}' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '${name}/:id',
|
path: '${name}/:id',
|
||||||
name: '${name}-detail',
|
name: '${name}-detail',
|
||||||
component: () => import('../../views/${name}/${Name}Detail.vue'),
|
component: () => import('./views/${Name}Detail.vue'),
|
||||||
meta: { plugin: '${name}' }
|
meta: { plugin: '${name}' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '${name}/:id/edit',
|
path: '${name}/:id/edit',
|
||||||
name: '${name}-edit',
|
name: '${name}-edit',
|
||||||
component: () => import('../../views/${name}/${Name}Form.vue'),
|
component: () => import('./views/${Name}Form.vue'),
|
||||||
meta: { requiresAuth: true, plugin: '${name}' }
|
meta: { requiresAuth: true, plugin: '${name}' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Full-screen routes that render OUTSIDE AppLayout (kiosk, print pages).
|
||||||
|
// Declared even when empty so routes.gen.js can read `.toplevel` off the
|
||||||
|
// module without Rollup warning about a missing export.
|
||||||
|
export const toplevel = []
|
||||||
|
|||||||
@@ -78,12 +78,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import api from '../../api'
|
import api from '@/api'
|
||||||
import { useAuthStore } from '../../stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
// local api client. move into src/api/index.js (see
|
// local api client. move into src/api/index.js (see
|
||||||
// plugins/${name}/frontend-api-snippet.js) then swap for:
|
// plugins/${name}/frontend-api-snippet.js) then swap for:
|
||||||
// import { ${name}Api } from '../../api'
|
// import { ${name}Api } from '@/api'
|
||||||
const ${name}Api = {
|
const ${name}Api = {
|
||||||
list(params = {}) { return api.get('/${name}', { params }) },
|
list(params = {}) { return api.get('/${name}', { params }) },
|
||||||
get(itemId) { return api.get(`/${name}/${itemId}`) },
|
get(itemId) { return api.get(`/${name}/${itemId}`) },
|
||||||
|
|||||||
@@ -77,11 +77,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import api from '../../api'
|
import api from '@/api'
|
||||||
|
|
||||||
// local api client. move into src/api/index.js (see
|
// local api client. move into src/api/index.js (see
|
||||||
// plugins/${name}/frontend-api-snippet.js) then swap for:
|
// plugins/${name}/frontend-api-snippet.js) then swap for:
|
||||||
// import { ${name}Api } from '../../api'
|
// import { ${name}Api } from '@/api'
|
||||||
const ${name}Api = {
|
const ${name}Api = {
|
||||||
list(params = {}) { return api.get('/${name}', { params }) },
|
list(params = {}) { return api.get('/${name}', { params }) },
|
||||||
get(itemId) { return api.get(`/${name}/${itemId}`) },
|
get(itemId) { return api.get(`/${name}/${itemId}`) },
|
||||||
|
|||||||
@@ -66,12 +66,12 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import api from '../../api'
|
import api from '@/api'
|
||||||
import PaginationBar from '../../components/PaginationBar.vue'
|
import PaginationBar from '@/components/PaginationBar.vue'
|
||||||
|
|
||||||
// local api client. move into src/api/index.js (see
|
// local api client. move into src/api/index.js (see
|
||||||
// plugins/${name}/frontend-api-snippet.js) then swap for:
|
// plugins/${name}/frontend-api-snippet.js) then swap for:
|
||||||
// import { ${name}Api } from '../../api'
|
// import { ${name}Api } from '@/api'
|
||||||
const ${name}Api = {
|
const ${name}Api = {
|
||||||
list(params = {}) { return api.get('/${name}', { params }) },
|
list(params = {}) { return api.get('/${name}', { params }) },
|
||||||
get(itemId) { return api.get(`/${name}/${itemId}`) },
|
get(itemId) { return api.get(`/${name}/${itemId}`) },
|
||||||
|
|||||||
@@ -199,3 +199,70 @@ def test_a_machine_is_found_by_its_maintenance_reference(client, taggedmachine):
|
|||||||
type - a field the UI collects and search could not find."""
|
type - a field the UI collects and search could not find."""
|
||||||
rows = [r for r in search(client, 'ZZMAINT888') if r['title'] == 'ZZMACH01']
|
rows = [r for r in search(client, 'ZZMAINT888') if r['title'] == 'ZZMACH01']
|
||||||
assert rows, 'a maintenance reference matched nothing'
|
assert rows, 'a maintenance reference matched nothing'
|
||||||
|
|
||||||
|
|
||||||
|
# --- a search result must land on the record, not its list -------------------
|
||||||
|
#
|
||||||
|
# A result carries an id, so sending the reader to the plugin's list page makes
|
||||||
|
# them find by hand the row search had already identified. Both cases below
|
||||||
|
# shipped that way: the id was in the payload and the URL ignored it.
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def noticerow(db):
|
||||||
|
"""One notification with a distinctive body and its own type."""
|
||||||
|
from plugins.notifications.models import Notification, NotificationType
|
||||||
|
notice = NotificationType(typename='Notice', typecolor='#17a2b8',
|
||||||
|
isactive=True)
|
||||||
|
db.session.add(notice)
|
||||||
|
db.session.flush()
|
||||||
|
row = Notification(
|
||||||
|
notificationtypeid=notice.notificationtypeid,
|
||||||
|
notification='ZZNOTICE7788 press brake down for repair',
|
||||||
|
ticketnumber='INC7788',
|
||||||
|
isactive=True,
|
||||||
|
)
|
||||||
|
db.session.add(row)
|
||||||
|
db.session.commit()
|
||||||
|
return row.notificationid
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_notice_result_links_to_the_notice(client, noticerow):
|
||||||
|
"""Clicking a NOTICE opens that notification, not /notifications.
|
||||||
|
|
||||||
|
`/notifications/<id>` is the `notification-detail` route; the list page
|
||||||
|
cannot show which of its rows the search matched.
|
||||||
|
"""
|
||||||
|
rows = [r for r in search(client, 'ZZNOTICE7788')
|
||||||
|
if r['type'] == 'notification']
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]['id'] == noticerow
|
||||||
|
assert rows[0]['url'] == f'/notifications/{noticerow}'
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_notice_found_by_ticket_number_links_the_same_way(client, noticerow):
|
||||||
|
"""The ticket number is the strongest match and must land as precisely."""
|
||||||
|
rows = [r for r in search(client, 'INC7788')
|
||||||
|
if r['type'] == 'notification']
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]['url'] == f'/notifications/{noticerow}'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def subnetrow(db):
|
||||||
|
"""A subnet an IP search can fall inside."""
|
||||||
|
from plugins.network.models import Subnet
|
||||||
|
row = Subnet(cidr='10.77.88.0/24', name='ZZ Test subnet', isactive=True)
|
||||||
|
db.session.add(row)
|
||||||
|
db.session.commit()
|
||||||
|
return row.subnetid
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_ip_search_links_to_the_matched_subnet(client, subnetrow):
|
||||||
|
"""Searching an IP already picked WHICH subnet; land on it.
|
||||||
|
|
||||||
|
The detail route is `/networks/<id>` (plural) - `/network` is the hub.
|
||||||
|
"""
|
||||||
|
rows = [r for r in search(client, '10.77.88.42') if r['type'] == 'subnet']
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]['id'] == subnetrow
|
||||||
|
assert rows[0]['url'] == f'/networks/{subnetrow}'
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
"""Canary tests for the plugin scaffolder's frontend generation.
|
"""Canary tests for the plugin scaffolder's frontend generation.
|
||||||
|
|
||||||
`flask plugin new <name>` generates the backend skeleton and, when a frontend
|
`flask plugin new <name>` generates the backend skeleton and a matching Vue
|
||||||
tree is present, a matching Vue frontend starting point: list, detail, and form
|
frontend starting point: list, detail, and form views, a route file, and a
|
||||||
views, a router route file, and a paste-in api-client snippet. These tests
|
paste-in api-client snippet. All of it lands INSIDE the plugin, under
|
||||||
guard the file names, the substitutions, the ADR-009 plugin gating on the
|
`plugins/<name>/frontend/`, which is what lets stage-frontend.mjs stage it and
|
||||||
routes, the use of global CSS classes over per-page input styling, graceful
|
a lean build prune it (ADR-013).
|
||||||
skipping when there is no frontend tree, and the no-clobber behaviour.
|
|
||||||
|
These tests guard the file names and their location, the substitutions, the
|
||||||
|
ADR-009 plugin gating on the routes, the alias-vs-relative import split that
|
||||||
|
has to survive staging, the use of global CSS classes over per-page input
|
||||||
|
styling, and the no-clobber behaviour.
|
||||||
|
|
||||||
All generation runs against tmp_path. The real frontend/src tree is never
|
All generation runs against tmp_path. The real frontend/src tree is never
|
||||||
touched.
|
touched.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -18,178 +23,172 @@ import pytest
|
|||||||
from shopdb.plugins.scaffolder import scaffold_plugin, ScaffoldError
|
from shopdb.plugins.scaffolder import scaffold_plugin, ScaffoldError
|
||||||
|
|
||||||
|
|
||||||
def _make_dirs(tmp_path: Path):
|
def _make_plugins_dir(tmp_path: Path) -> Path:
|
||||||
"""Create a tmp plugins dir and a tmp frontend/src tree.
|
"""Create a tmp plugins dir."""
|
||||||
|
|
||||||
Returns (plugins_dir, frontend_dir).
|
|
||||||
"""
|
|
||||||
plugins_dir = tmp_path / 'plugins'
|
plugins_dir = tmp_path / 'plugins'
|
||||||
plugins_dir.mkdir()
|
plugins_dir.mkdir()
|
||||||
frontend_dir = tmp_path / 'frontend' / 'src'
|
return plugins_dir
|
||||||
frontend_dir.mkdir(parents=True)
|
|
||||||
return plugins_dir, frontend_dir
|
|
||||||
|
|
||||||
|
|
||||||
def test_frontend_files_generated_with_correct_names(tmp_path):
|
def test_frontend_files_generated_inside_the_plugin(tmp_path):
|
||||||
"""Views, route file, and api snippet appear with the expected names."""
|
"""Views, route file, and api snippet appear with the expected names.
|
||||||
plugins_dir, frontend_dir = _make_dirs(tmp_path)
|
|
||||||
|
Location is the point of the test: the frontend belongs to the plugin, not
|
||||||
|
to the core tree.
|
||||||
|
"""
|
||||||
|
plugins_dir = _make_plugins_dir(tmp_path)
|
||||||
|
|
||||||
target = scaffold_plugin(
|
target = scaffold_plugin(
|
||||||
name='widgets',
|
name='widgets',
|
||||||
description='Test widgets plugin',
|
description='Test widgets plugin',
|
||||||
plugins_dir=plugins_dir,
|
plugins_dir=plugins_dir,
|
||||||
frontend_dir=frontend_dir,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert (frontend_dir / 'views' / 'widgets' / 'WidgetsList.vue').exists()
|
assert (target / 'frontend' / 'views' / 'WidgetsList.vue').exists()
|
||||||
assert (frontend_dir / 'views' / 'widgets' / 'WidgetsDetail.vue').exists()
|
assert (target / 'frontend' / 'views' / 'WidgetsDetail.vue').exists()
|
||||||
assert (frontend_dir / 'views' / 'widgets' / 'WidgetsForm.vue').exists()
|
assert (target / 'frontend' / 'views' / 'WidgetsForm.vue').exists()
|
||||||
assert (frontend_dir / 'router' / 'routes' / 'widgets.js').exists()
|
assert (target / 'frontend' / 'routes.js').exists()
|
||||||
# paste-in snippet lands in the plugin dir, not the frontend tree
|
|
||||||
assert (target / 'frontend-api-snippet.js').exists()
|
assert (target / 'frontend-api-snippet.js').exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_nothing_is_written_into_the_core_frontend_tree(tmp_path):
|
||||||
|
"""The scaffolder never touches frontend/src.
|
||||||
|
|
||||||
|
This is the regression that prompted the move. Writing views to
|
||||||
|
`frontend/src/views/<name>/` and a route to
|
||||||
|
`frontend/src/router/routes/<name>.js` still *renders* - the router globs
|
||||||
|
that directory - but the result is invisible to stage-frontend.mjs, so the
|
||||||
|
plugin cannot be pruned from a lean build and deleting it strands its
|
||||||
|
views in the core tree.
|
||||||
|
"""
|
||||||
|
plugins_dir = _make_plugins_dir(tmp_path)
|
||||||
|
core_frontend = tmp_path / 'frontend' / 'src'
|
||||||
|
core_frontend.mkdir(parents=True)
|
||||||
|
|
||||||
|
scaffold_plugin('widgets', 'Test', plugins_dir)
|
||||||
|
|
||||||
|
assert not (core_frontend / 'views' / 'widgets').exists()
|
||||||
|
assert not (core_frontend / 'router' / 'routes' / 'widgets.js').exists()
|
||||||
|
assert list(core_frontend.iterdir()) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_frontend_generated_with_no_core_tree_at_all(tmp_path):
|
||||||
|
"""A plugin developed in its own repo gets the same frontend.
|
||||||
|
|
||||||
|
There is no core frontend tree to depend on, and none is fabricated. The
|
||||||
|
plugin's frontend starts working the moment the directory is dropped into
|
||||||
|
a host repo's `plugins/`.
|
||||||
|
"""
|
||||||
|
plugins_dir = _make_plugins_dir(tmp_path)
|
||||||
|
|
||||||
|
target = scaffold_plugin('widgets', 'Test', plugins_dir)
|
||||||
|
|
||||||
|
assert (target / 'frontend' / 'routes.js').exists()
|
||||||
|
assert (target / 'frontend' / 'views' / 'WidgetsList.vue').exists()
|
||||||
|
assert not (tmp_path / 'frontend').exists()
|
||||||
|
|
||||||
|
|
||||||
def test_frontend_substitutions_applied(tmp_path):
|
def test_frontend_substitutions_applied(tmp_path):
|
||||||
"""Name and Name placeholders are substituted, none left raw."""
|
"""Name and Name substitutions land in the route file and the views."""
|
||||||
plugins_dir, frontend_dir = _make_dirs(tmp_path)
|
plugins_dir = _make_plugins_dir(tmp_path)
|
||||||
scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir)
|
target = scaffold_plugin('widgets', 'Test', plugins_dir)
|
||||||
|
|
||||||
route = (frontend_dir / 'router' / 'routes' / 'widgets.js').read_text()
|
route = (target / 'frontend' / 'routes.js').read_text()
|
||||||
assert "import('../../views/widgets/WidgetsList.vue')" in route
|
assert "import('./views/WidgetsList.vue')" in route
|
||||||
assert "import('../../views/widgets/WidgetsForm.vue')" in route
|
assert "import('./views/WidgetsForm.vue')" in route
|
||||||
assert "import('../../views/widgets/WidgetsDetail.vue')" in route
|
assert "import('./views/WidgetsDetail.vue')" in route
|
||||||
|
|
||||||
list_view = (frontend_dir / 'views' / 'widgets' / 'WidgetsList.vue').read_text()
|
list_view = (target / 'frontend' / 'views' / 'WidgetsList.vue').read_text()
|
||||||
assert 'widgetsApi' in list_view
|
assert 'widgetsApi' in list_view
|
||||||
assert "api.get('/widgets'" in list_view
|
assert "api.get('/widgets'" in list_view
|
||||||
# no unresolved template placeholders for our known substitution keys
|
# No unresolved placeholders for our known substitution keys. Checked by
|
||||||
|
# key, not for a bare '${': the views carry real JS template literals
|
||||||
|
# (`/widgets/${item.assetid}`) that must survive.
|
||||||
assert '${name}' not in list_view
|
assert '${name}' not in list_view
|
||||||
assert '${Name}' not in list_view
|
assert '${Name}' not in list_view
|
||||||
|
|
||||||
|
|
||||||
def test_route_file_has_plugin_gating_and_requiresauth(tmp_path):
|
def test_route_file_has_plugin_gating_and_requiresauth(tmp_path):
|
||||||
"""Every route carries meta.plugin; form routes add requiresAuth."""
|
"""ADR-009 gating on every route; requiresAuth on the form routes."""
|
||||||
plugins_dir, frontend_dir = _make_dirs(tmp_path)
|
plugins_dir = _make_plugins_dir(tmp_path)
|
||||||
scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir)
|
target = scaffold_plugin('widgets', 'Test', plugins_dir)
|
||||||
|
|
||||||
route = (frontend_dir / 'router' / 'routes' / 'widgets.js').read_text()
|
route = (target / 'frontend' / 'routes.js').read_text()
|
||||||
|
# every route is gated on the plugin being enabled
|
||||||
# ADR-009 gating on every route
|
|
||||||
assert route.count("plugin: 'widgets'") == 4
|
assert route.count("plugin: 'widgets'") == 4
|
||||||
# form routes (new + edit) require auth; list + detail do not
|
# create and edit are behind auth
|
||||||
assert route.count('requiresAuth: true') == 2
|
assert route.count('requiresAuth: true') == 2
|
||||||
assert "path: 'widgets/new'" in route
|
|
||||||
assert "path: 'widgets/:id/edit'" in route
|
|
||||||
|
def test_route_file_exports_toplevel(tmp_path):
|
||||||
|
"""`toplevel` is exported even when empty.
|
||||||
|
|
||||||
|
routes.gen.js reads `.toplevel` off every staged route module; a module
|
||||||
|
without the export is a Rollup warning under strict ESM.
|
||||||
|
"""
|
||||||
|
plugins_dir = _make_plugins_dir(tmp_path)
|
||||||
|
target = scaffold_plugin('widgets', 'Test', plugins_dir)
|
||||||
|
|
||||||
|
route = (target / 'frontend' / 'routes.js').read_text()
|
||||||
|
assert 'export const toplevel' in route
|
||||||
|
|
||||||
|
|
||||||
|
def test_view_imports_survive_staging(tmp_path):
|
||||||
|
"""Core imports use the @ alias; sibling views are relative.
|
||||||
|
|
||||||
|
stage-frontend.mjs copies `plugins/<name>/frontend/` to
|
||||||
|
`frontend/src/.plugins-staged/<name>/`, so a `../../api` written for the
|
||||||
|
old location resolves somewhere else entirely once staged. `@/api` is
|
||||||
|
correct in both places, and `./views/...` travels with the route file.
|
||||||
|
"""
|
||||||
|
plugins_dir = _make_plugins_dir(tmp_path)
|
||||||
|
target = scaffold_plugin('widgets', 'Test', plugins_dir)
|
||||||
|
|
||||||
|
views = target / 'frontend' / 'views'
|
||||||
|
for view in views.iterdir():
|
||||||
|
text = view.read_text()
|
||||||
|
assert '../../api' not in text
|
||||||
|
assert '../../components' not in text
|
||||||
|
assert '../../stores' not in text
|
||||||
|
|
||||||
|
route = (target / 'frontend' / 'routes.js').read_text()
|
||||||
|
assert './views/' in route
|
||||||
|
assert '../../views' not in route
|
||||||
|
|
||||||
|
|
||||||
def test_views_use_global_classes_not_custom_input_css(tmp_path):
|
def test_views_use_global_classes_not_custom_input_css(tmp_path):
|
||||||
"""Views lean on global .filters / .form-control / .card classes and never
|
"""Views lean on the global form/filter styles, not per-page input CSS."""
|
||||||
redefine input styling in a scoped block."""
|
plugins_dir = _make_plugins_dir(tmp_path)
|
||||||
plugins_dir, frontend_dir = _make_dirs(tmp_path)
|
target = scaffold_plugin('widgets', 'Test', plugins_dir)
|
||||||
scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir)
|
|
||||||
|
|
||||||
views = frontend_dir / 'views' / 'widgets'
|
views = target / 'frontend' / 'views'
|
||||||
list_view = (views / 'WidgetsList.vue').read_text()
|
list_view = (views / 'WidgetsList.vue').read_text()
|
||||||
form_view = (views / 'WidgetsForm.vue').read_text()
|
form_view = (views / 'WidgetsForm.vue').read_text()
|
||||||
|
|
||||||
assert 'class="filters"' in list_view
|
assert 'form-control' in form_view
|
||||||
assert 'class="form-control"' in list_view
|
|
||||||
assert 'class="card"' in list_view
|
|
||||||
assert 'class="form-control"' in form_view
|
|
||||||
|
|
||||||
# per-page input styling is forbidden: no scoped rule targets .form-control
|
# per-page input styling is forbidden: no scoped rule targets .form-control
|
||||||
# on its own (a descendant layout helper like `.filters .form-control` is ok)
|
# on its own (a descendant layout helper like `.filters .form-control` is ok)
|
||||||
import re
|
|
||||||
for view in (list_view, form_view):
|
for view in (list_view, form_view):
|
||||||
assert not re.search(r'(?m)^\s*\.form-control\s*\{', view)
|
assert not re.search(r'(?m)^\s*\.form-control\s*\{', view)
|
||||||
|
|
||||||
|
|
||||||
def test_default_frontend_dir_resolves_relative_to_plugins(tmp_path):
|
|
||||||
"""With no frontend_dir arg, views land in <plugins_dir>/../frontend/src."""
|
|
||||||
plugins_dir, frontend_dir = _make_dirs(tmp_path)
|
|
||||||
|
|
||||||
scaffold_plugin('widgets', 'Test', plugins_dir)
|
|
||||||
|
|
||||||
assert (frontend_dir / 'views' / 'widgets' / 'WidgetsList.vue').exists()
|
|
||||||
assert (frontend_dir / 'router' / 'routes' / 'widgets.js').exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_skip_when_no_frontend_dir(tmp_path):
|
|
||||||
"""No frontend tree: views and route are skipped, backend still generated.
|
|
||||||
|
|
||||||
The api snippet is still emitted because it is a plugin-directory artifact.
|
|
||||||
"""
|
|
||||||
plugins_dir = tmp_path / 'plugins'
|
|
||||||
plugins_dir.mkdir()
|
|
||||||
missing_frontend = tmp_path / 'nowhere' / 'src'
|
|
||||||
|
|
||||||
target = scaffold_plugin(
|
|
||||||
name='widgets',
|
|
||||||
description='Test',
|
|
||||||
plugins_dir=plugins_dir,
|
|
||||||
frontend_dir=missing_frontend,
|
|
||||||
)
|
|
||||||
|
|
||||||
# backend skeleton is intact
|
|
||||||
assert (target / 'manifest.json').exists()
|
|
||||||
assert (target / 'plugin.py').exists()
|
|
||||||
# frontend views and route were skipped
|
|
||||||
assert not missing_frontend.exists()
|
|
||||||
# snippet still lands in the plugin dir
|
|
||||||
assert (target / 'frontend-api-snippet.js').exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_frontend_can_be_disabled(tmp_path):
|
def test_frontend_can_be_disabled(tmp_path):
|
||||||
"""frontend=False generates neither views, route, nor snippet."""
|
"""frontend=False generates neither views, route, nor snippet."""
|
||||||
plugins_dir, frontend_dir = _make_dirs(tmp_path)
|
plugins_dir = _make_plugins_dir(tmp_path)
|
||||||
|
|
||||||
target = scaffold_plugin(
|
target = scaffold_plugin(
|
||||||
name='widgets',
|
name='widgets',
|
||||||
description='Test',
|
description='Test',
|
||||||
plugins_dir=plugins_dir,
|
plugins_dir=plugins_dir,
|
||||||
frontend_dir=frontend_dir,
|
|
||||||
frontend=False,
|
frontend=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert not (frontend_dir / 'views' / 'widgets').exists()
|
assert not (target / 'frontend').exists()
|
||||||
assert not (frontend_dir / 'router' / 'routes' / 'widgets.js').exists()
|
|
||||||
assert not (target / 'frontend-api-snippet.js').exists()
|
assert not (target / 'frontend-api-snippet.js').exists()
|
||||||
|
# backend skeleton is still intact
|
||||||
|
assert (target / 'manifest.json').exists()
|
||||||
def test_no_clobber_without_overwrite(tmp_path):
|
assert (target / 'plugin.py').exists()
|
||||||
"""An existing frontend view is left untouched unless overwrite is set."""
|
|
||||||
plugins_dir, frontend_dir = _make_dirs(tmp_path)
|
|
||||||
|
|
||||||
# author already hand-edited a view before re-scaffolding
|
|
||||||
views_dir = frontend_dir / 'views' / 'widgets'
|
|
||||||
views_dir.mkdir(parents=True)
|
|
||||||
existing = views_dir / 'WidgetsList.vue'
|
|
||||||
existing.write_text('SENTINEL do not clobber')
|
|
||||||
|
|
||||||
scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir)
|
|
||||||
|
|
||||||
# untouched without overwrite
|
|
||||||
assert existing.read_text() == 'SENTINEL do not clobber'
|
|
||||||
|
|
||||||
|
|
||||||
def test_overwrite_replaces_existing_frontend(tmp_path):
|
|
||||||
"""overwrite=True regenerates an existing frontend view."""
|
|
||||||
plugins_dir, frontend_dir = _make_dirs(tmp_path)
|
|
||||||
|
|
||||||
views_dir = frontend_dir / 'views' / 'widgets'
|
|
||||||
views_dir.mkdir(parents=True)
|
|
||||||
existing = views_dir / 'WidgetsList.vue'
|
|
||||||
existing.write_text('SENTINEL do not clobber')
|
|
||||||
|
|
||||||
# plugin dir must also exist to reach overwrite path
|
|
||||||
scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir)
|
|
||||||
scaffold_plugin(
|
|
||||||
'widgets', 'Test', plugins_dir,
|
|
||||||
frontend_dir=frontend_dir, overwrite=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert existing.read_text() != 'SENTINEL do not clobber'
|
|
||||||
assert 'widgetsApi' in existing.read_text()
|
|
||||||
|
|
||||||
|
|
||||||
def test_route_references_only_existing_views(tmp_path):
|
def test_route_references_only_existing_views(tmp_path):
|
||||||
@@ -198,55 +197,54 @@ def test_route_references_only_existing_views(tmp_path):
|
|||||||
Guards the write-views-before-route ordering: a route pointing at a missing
|
Guards the write-views-before-route ordering: a route pointing at a missing
|
||||||
view crashes the Vite dev server.
|
view crashes the Vite dev server.
|
||||||
"""
|
"""
|
||||||
plugins_dir, frontend_dir = _make_dirs(tmp_path)
|
plugins_dir = _make_plugins_dir(tmp_path)
|
||||||
scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir)
|
target = scaffold_plugin('widgets', 'Test', plugins_dir)
|
||||||
|
|
||||||
route = (frontend_dir / 'router' / 'routes' / 'widgets.js').read_text()
|
route_dir = target / 'frontend'
|
||||||
import re
|
route = (route_dir / 'routes.js').read_text()
|
||||||
imported = re.findall(r"import\('(\.\./\.\./views/[^']+)'\)", route)
|
imported = re.findall(r"import\('(\./views/[^']+)'\)", route)
|
||||||
assert imported
|
assert imported
|
||||||
for rel in imported:
|
for rel in imported:
|
||||||
# rel is relative to router/routes/; resolve against that dir
|
# rel is relative to the route file, which sits at frontend/routes.js
|
||||||
resolved = (frontend_dir / 'router' / 'routes' / rel).resolve()
|
assert (route_dir / rel).resolve().exists(), \
|
||||||
assert resolved.exists(), f'route imports missing view: {rel}'
|
f'route imports missing view: {rel}'
|
||||||
|
|
||||||
|
|
||||||
def test_generated_frontend_is_ascii(tmp_path):
|
def test_generated_frontend_is_ascii(tmp_path):
|
||||||
"""Generated frontend files contain plain ASCII only."""
|
"""Generated frontend files contain plain ASCII only."""
|
||||||
plugins_dir, frontend_dir = _make_dirs(tmp_path)
|
plugins_dir = _make_plugins_dir(tmp_path)
|
||||||
target = scaffold_plugin(
|
target = scaffold_plugin('widgets', 'Test', plugins_dir)
|
||||||
'widgets', 'Test', plugins_dir, frontend_dir=frontend_dir,
|
|
||||||
)
|
|
||||||
|
|
||||||
generated = [
|
generated = [
|
||||||
frontend_dir / 'views' / 'widgets' / 'WidgetsList.vue',
|
target / 'frontend' / 'views' / 'WidgetsList.vue',
|
||||||
frontend_dir / 'views' / 'widgets' / 'WidgetsDetail.vue',
|
target / 'frontend' / 'views' / 'WidgetsDetail.vue',
|
||||||
frontend_dir / 'views' / 'widgets' / 'WidgetsForm.vue',
|
target / 'frontend' / 'views' / 'WidgetsForm.vue',
|
||||||
frontend_dir / 'router' / 'routes' / 'widgets.js',
|
target / 'frontend' / 'routes.js',
|
||||||
target / 'frontend-api-snippet.js',
|
target / 'frontend-api-snippet.js',
|
||||||
]
|
]
|
||||||
for path in generated:
|
for path in generated:
|
||||||
text = path.read_text()
|
path.read_text().encode('ascii') # raises if any non-ASCII slipped in
|
||||||
text.encode('ascii') # raises if any non-ASCII slipped in
|
|
||||||
|
|
||||||
|
|
||||||
def test_frontend_dir_not_created_when_absent(tmp_path):
|
|
||||||
"""Scaffolder does not fabricate a frontend tree that was not there."""
|
|
||||||
plugins_dir = tmp_path / 'plugins'
|
|
||||||
plugins_dir.mkdir()
|
|
||||||
|
|
||||||
# no frontend dir exists; default resolves to <tmp>/frontend/src (absent)
|
|
||||||
scaffold_plugin('widgets', 'Test', plugins_dir)
|
|
||||||
|
|
||||||
assert not (tmp_path / 'frontend').exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_scaffold_still_raises_on_existing_plugin(tmp_path):
|
def test_scaffold_still_raises_on_existing_plugin(tmp_path):
|
||||||
"""Frontend generation does not weaken the plugin-dir overwrite guard."""
|
"""Frontend generation does not weaken the plugin-dir overwrite guard."""
|
||||||
plugins_dir, frontend_dir = _make_dirs(tmp_path)
|
plugins_dir = _make_plugins_dir(tmp_path)
|
||||||
scaffold_plugin('widgets', 'first', plugins_dir, frontend_dir=frontend_dir)
|
scaffold_plugin('widgets', 'first', plugins_dir)
|
||||||
|
|
||||||
with pytest.raises(ScaffoldError, match='already exists'):
|
with pytest.raises(ScaffoldError, match='already exists'):
|
||||||
scaffold_plugin(
|
scaffold_plugin('widgets', 'second', plugins_dir)
|
||||||
'widgets', 'second', plugins_dir, frontend_dir=frontend_dir,
|
|
||||||
)
|
|
||||||
|
def test_overwrite_replaces_existing_frontend(tmp_path):
|
||||||
|
"""--overwrite regenerates the plugin's frontend files."""
|
||||||
|
plugins_dir = _make_plugins_dir(tmp_path)
|
||||||
|
target = scaffold_plugin('widgets', 'Test', plugins_dir)
|
||||||
|
|
||||||
|
list_view = target / 'frontend' / 'views' / 'WidgetsList.vue'
|
||||||
|
list_view.write_text('CLOBBER ME')
|
||||||
|
|
||||||
|
scaffold_plugin('widgets', 'Test', plugins_dir, overwrite=True)
|
||||||
|
|
||||||
|
regenerated = list_view.read_text()
|
||||||
|
assert regenerated != 'CLOBBER ME'
|
||||||
|
assert 'widgetsApi' in regenerated
|
||||||
|
|||||||
Reference in New Issue
Block a user