Merge remote-tracking branch 'origin/main' into wilm

This commit is contained in:
benjamin.baker
2026-08-25 16:48:23 -04:00
51 changed files with 2806 additions and 608 deletions

View File

@@ -251,13 +251,15 @@ def register_blueprints(app: Flask):
def register_cli_commands(app: Flask):
"""Register Flask CLI commands."""
from .plugins.cli import plugin_cli
from .cli import db_cli, seed_cli, relationships_cli, csv_cli
from .cli import (db_cli, seed_cli, relationships_cli, csv_cli,
catalog_cli)
app.cli.add_command(plugin_cli)
app.cli.add_command(db_cli)
app.cli.add_command(seed_cli)
app.cli.add_command(relationships_cli)
app.cli.add_command(csv_cli)
app.cli.add_command(catalog_cli)
def register_error_handlers(app: Flask):

View File

@@ -1535,3 +1535,303 @@ def audit_relationships():
'knowing which side is authoritative.')
else:
click.echo(click.style('No relationship faults found.', fg='green'))
# --- catalog hygiene --------------------------------------------------------
@click.group('catalog')
def catalog_cli():
"""Clean up the shared vendor/model/OS catalog."""
pass
# Catalog tables that carry a human-typed name, with the column that names them.
# Each is free vocabulary somebody can add to, which is how twins appear.
_CATALOG_TABLES = (
('vendors', 'vendor'),
('models', 'modelnumber'),
('operatingsystems', 'osname'),
)
def _catalog_norm(value):
"""Collapse a name to what it would be with no spacing or punctuation.
Only whitespace and punctuation are removed, and case is folded. That is
deliberately narrow: 'Dell Inc.' and 'DellInc.' are the SAME name typed two
ways, while 'Dell' and 'Dell Inc.' are a naming CHOICE and must never be
merged automatically. Use `catalog merge` for that.
"""
import re
return re.sub(r'[\s\-_.,/]+', '', str(value or '').strip().lower())
def _inbound_foreign_keys(inspector, table):
"""Every (table, column) in the schema that points at `table`'s key.
Discovered from the live schema rather than listed here, because a plugin
can add one: `machines.controllervendorid` and `printerdrivers.
modelnumberid` both reference the catalog and neither is obvious.
"""
refs = []
for candidate in inspector.get_table_names():
for fk in inspector.get_foreign_keys(candidate):
if fk['referred_table'] == table:
refs.append((candidate,
fk['constrained_columns'][0],
fk['referred_columns'][0]))
return refs
def _reference_counts(connection, refs, keyvalue):
"""How many rows across the schema point at one catalog row."""
import sqlalchemy as sa
total = 0
detail = []
for tablename, column, _ in refs:
count = connection.execute(
sa.text(f'SELECT COUNT(*) FROM {tablename} WHERE {column} = :k'),
{'k': keyvalue}).scalar()
if count:
detail.append(f'{tablename}.{column}={count}')
total += count
return total, detail
def _merge_catalog_rows(connection, table, keycolumn, refs, loser, winner):
"""Repoint every reference off `loser` onto `winner`, then delete it."""
import sqlalchemy as sa
moved = 0
for tablename, column, _ in refs:
result = connection.execute(
sa.text(f'UPDATE {tablename} SET {column} = :w WHERE {column} = :l'),
{'w': winner, 'l': loser})
moved += result.rowcount or 0
connection.execute(
sa.text(f'DELETE FROM {table} WHERE {keycolumn} = :l'), {'l': loser})
return moved
@catalog_cli.command('dedupe')
@click.option('--apply', 'apply_changes', is_flag=True,
help='Write the changes. Without this the command only reports.')
@with_appcontext
def catalog_dedupe(apply_changes):
"""Merge catalog rows that are the same name typed two ways.
Legacy data arrived carrying space-stripped twins - 'DellInc.' beside
'Dell Inc.', 'Windows10' beside 'Windows 10' - which then rode into the
shipped seed catalog and out to every site that ran `seed catalog`. They
are invisible until somebody opens a vendor picker and finds the same
company twice.
ONLY whitespace-and-punctuation twins are touched. 'Dell' and 'Dell Inc.'
are a naming choice, not a typo, and are left alone; use `catalog merge`
to make that call explicitly.
The surviving row is the one with the most references, tie-broken toward
the spaced spelling, because that is the one a person typed deliberately.
Every foreign key pointing at the loser is repointed first, so nothing is
orphaned; references are counted across the LIVE schema, which picks up
plugin tables such as machines.controllervendorid.
Reports only unless --apply is passed. Idempotent.
"""
import sqlalchemy as sa
from collections import defaultdict
from shopdb.extensions import db
engine = db.session.get_bind()
inspector = sa.inspect(engine)
existing = set(inspector.get_table_names())
planned = []
with engine.begin() as connection:
for table, namecolumn in _CATALOG_TABLES:
if table not in existing:
continue
keycolumn = inspector.get_pk_constraint(table)['constrained_columns'][0]
refs = [r for r in _inbound_foreign_keys(inspector, table)
if r[0] in existing]
rows = connection.execute(sa.text(
f'SELECT {keycolumn}, {namecolumn} FROM {table}')).fetchall()
groups = defaultdict(list)
for key, name in rows:
if name:
groups[_catalog_norm(name)].append((key, name))
for _, members in sorted(groups.items()):
if len(members) < 2:
continue
scored = []
for key, name in members:
count, detail = _reference_counts(connection, refs, key)
# most-referenced wins; then the spelling with whitespace,
# which is the one somebody typed on purpose; then longest.
scored.append((count, ' ' in str(name), len(str(name)),
key, name, detail))
scored.sort(reverse=True)
winner = scored[0]
for loser in scored[1:]:
planned.append({
'table': table, 'keycolumn': keycolumn, 'refs': refs,
'winner_key': winner[3], 'winner_name': winner[4],
'loser_key': loser[3], 'loser_name': loser[4],
'loser_refs': loser[0], 'loser_detail': loser[5],
})
if not planned:
click.echo(click.style('No catalog twins found.', fg='green'))
return
click.echo(f'{len(planned)} duplicate row(s):')
click.echo('')
for item in planned:
where = (', '.join(item['loser_detail']) if item['loser_detail']
else 'nothing references it')
click.echo(f" {item['table']}: {item['loser_name']!r} "
f"-> {item['winner_name']!r}")
click.echo(f" {item['loser_refs']} reference(s): {where}")
if not apply_changes:
click.echo('')
click.echo(click.style(
'Dry run. Nothing was changed. Re-run with --apply to write.',
fg='yellow'))
return
moved = 0
for item in planned:
moved += _merge_catalog_rows(
connection, item['table'], item['keycolumn'], item['refs'],
item['loser_key'], item['winner_key'])
click.echo('')
click.echo(click.style(
f'Merged {len(planned)} row(s), repointed {moved} reference(s).',
fg='green'))
@catalog_cli.command('merge')
@click.argument('table', type=click.Choice([t for t, _ in _CATALOG_TABLES]))
@click.argument('source')
@click.argument('target')
@click.option('--apply', 'apply_changes', is_flag=True,
help='Write the changes. Without this the command only reports.')
@with_appcontext
def catalog_merge(table, source, target, apply_changes):
"""Merge one catalog row into another by name: SOURCE is absorbed by TARGET.
For the judgment calls `catalog dedupe` deliberately refuses to make -
'Dell' and 'Dell Inc.' are different names, and only a person knows the
site means one company.
flask catalog merge vendors "Dell" "Dell Inc." --apply
Every reference to SOURCE is repointed to TARGET, then SOURCE is deleted.
Reports only unless --apply is passed.
"""
import sqlalchemy as sa
from shopdb.extensions import db
namecolumn = dict(_CATALOG_TABLES)[table]
engine = db.session.get_bind()
inspector = sa.inspect(engine)
existing = set(inspector.get_table_names())
keycolumn = inspector.get_pk_constraint(table)['constrained_columns'][0]
refs = [r for r in _inbound_foreign_keys(inspector, table) if r[0] in existing]
with engine.begin() as connection:
def lookup(value):
return connection.execute(
sa.text(f'SELECT {keycolumn} FROM {table} '
f'WHERE {namecolumn} = :v'), {'v': value}).fetchone()
sourcerow, targetrow = lookup(source), lookup(target)
if not sourcerow:
click.echo(click.style(f'No {table} row named {source!r}.', fg='yellow'))
return
if not targetrow:
click.echo(click.style(f'No {table} row named {target!r}. '
f'Refusing to invent it.', fg='red'))
raise SystemExit(1)
if sourcerow[0] == targetrow[0]:
click.echo(click.style('Source and target are the same row.', fg='yellow'))
return
count, detail = _reference_counts(connection, refs, sourcerow[0])
where = ', '.join(detail) if detail else 'nothing references it'
click.echo(f'{table}: {source!r} -> {target!r}')
click.echo(f' {count} reference(s): {where}')
if not apply_changes:
click.echo(click.style(
'Dry run. Nothing was changed. Re-run with --apply to write.',
fg='yellow'))
return
moved = _merge_catalog_rows(connection, table, keycolumn, refs,
sourcerow[0], targetrow[0])
click.echo(click.style(
f'Merged {source!r} into {target!r}, repointed {moved} reference(s).',
fg='green'))
@catalog_cli.command('rename')
@click.argument('table', type=click.Choice([t for t, _ in _CATALOG_TABLES]))
@click.argument('source')
@click.argument('target')
@click.option('--apply', 'apply_changes', is_flag=True,
help='Write the change. Without this the command only reports.')
@with_appcontext
def catalog_rename(table, source, target, apply_changes):
"""Rename one catalog row in place, keeping every reference to it.
A space-stripped name with NO twin cannot be deduped - there is nothing to
merge it into - so 'OptiPlex5000' would survive a `catalog dedupe` that
correctly removed 'OptiPlex7080'. This fixes that case.
flask catalog rename models "OptiPlex5000" "OptiPlex 5000" --apply
Refuses when the target name already exists, because that is a merge and
has to move references rather than rename a row: use `catalog merge`.
"""
import sqlalchemy as sa
from shopdb.extensions import db
namecolumn = dict(_CATALOG_TABLES)[table]
engine = db.session.get_bind()
inspector = sa.inspect(engine)
keycolumn = inspector.get_pk_constraint(table)['constrained_columns'][0]
with engine.begin() as connection:
def lookup(value):
return connection.execute(
sa.text(f'SELECT {keycolumn} FROM {table} '
f'WHERE {namecolumn} = :v'), {'v': value}).fetchone()
sourcerow = lookup(source)
if not sourcerow:
click.echo(click.style(f'No {table} row named {source!r}.', fg='yellow'))
return
if lookup(target):
click.echo(click.style(
f'{table} already has a row named {target!r}. That is a merge, '
f'not a rename - use `catalog merge`.', fg='red'))
raise SystemExit(1)
click.echo(f'{table}: rename {source!r} -> {target!r} '
f'(id {sourcerow[0]}, references unaffected)')
if not apply_changes:
click.echo(click.style(
'Dry run. Nothing was changed. Re-run with --apply to write.',
fg='yellow'))
return
connection.execute(
sa.text(f'UPDATE {table} SET {namecolumn} = :t WHERE {keycolumn} = :k'),
{'t': target, 'k': sourcerow[0]})
click.echo(click.style(f'Renamed {source!r} to {target!r}.', fg='green'))

View File

@@ -658,7 +658,10 @@ def _search_subnets(query):
'id': subnet.subnetid,
'title': f'{subnet.name} ({subnet.cidr})',
'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
})
except ValueError:
@@ -779,7 +782,9 @@ def _search_notifications(query, search_term):
'id': notif.notificationid,
'title': notif.title,
'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),
'ticketnumber': notif.ticketnumber,
'iscurrent': notif.is_current

View File

@@ -777,6 +777,13 @@ def build_default_settings():
# QR codes and any absolute link the app emits - and facility name shown on
# the shopfloor dashboard. Blank site_base_url falls back to the browsing
# origin so nothing breaks before a site configures it.
#
# THE SUBPATH BELONGS IN THE VALUE. Consumers append the in-app route and
# add nothing themselves (resolveCodeText in print/assetLabel.js, buildQrUrl
# in utils/qrTarget.js), so a site served at /shopdb must carry that here or
# every generated link 404s. This said "scheme + host" for a while, which is
# true only of a site mounted at the root - and the email copy of the same
# setting has always said https://host/ops.
sitedefaults = [
{
'key': 'setup_complete',
@@ -811,7 +818,7 @@ def build_default_settings():
'value': '',
'valuetype': 'string',
'category': 'site',
'description': 'Public base URL of this site (scheme + host), e.g. https://shopdb.example.net. Used for QR codes and absolute links. Blank = use the browsing origin.'
'description': 'Public base URL of this site INCLUDING any subpath it is served under, e.g. https://shopdb.example.net or https://host/shopdb. Consumers append the in-app route to this and add nothing themselves, so a subpath left off here produces links that 404. Blank = use the browsing origin.'
},
{
'key': 'facility_name',

View File

@@ -383,9 +383,6 @@
{
"osname": "Microsoft Windows 11 Enterprise"
},
{
"osname": "MicrosoftWindows10Enterprise"
},
{
"osname": "TBD"
},
@@ -404,9 +401,6 @@
{
"osname": "Windows Server 2022",
"osversion": "2022"
},
{
"osname": "Windows10"
}
],
"vendors": [
@@ -432,15 +426,9 @@
{
"vendor": "Cisco"
},
{
"vendor": "Dell"
},
{
"vendor": "Dell Inc."
},
{
"vendor": "DellInc."
},
{
"vendor": "DMG Mori"
},
@@ -620,8 +608,8 @@
"modeltype": "Switch"
},
{
"modelnumber": "OptiPlex5000",
"vendor": "Dell"
"modelnumber": "OptiPlex 5000",
"vendor": "Dell Inc."
},
{
"modelnumber": "Dell Pro 13 Plus PB13250",
@@ -775,10 +763,6 @@
"vendor": "Dell Inc.",
"modeltype": "LocationOnly"
},
{
"modelnumber": "OptiPlex7080",
"vendor": "DellInc."
},
{
"modelnumber": "NT-4300",
"vendor": "DMG Mori",

View File

@@ -104,7 +104,12 @@ def create_plugin_tables(plugin_name: str):
this is safe on an existing database that has the table from the pre-cutover
core baseline as well as on a fresh install (ADR-014 Phase 2).
Called from each plugin's 0001 baseline.py upgrade().
NOT actually called from anywhere: every post-cutover baseline emits
explicit Alembic ops instead, because this helper's per-plugin MetaData
cannot resolve a foreign key to a CORE table at CreateTable-compile time.
Kept because it is still the reference for what idempotent means here, and
because create_table_if_missing below is the same rule in a form the
baselines can use. See that function for what went wrong without it.
"""
from alembic import op
from sqlalchemy import inspect
@@ -120,6 +125,67 @@ def create_plugin_tables(plugin_name: str):
op.execute(str(CreateTable(table).compile(dialect=bind.dialect)))
def create_table_if_missing(table_name: str, *columns, **kwargs) -> bool:
"""op.create_table, skipped when the table is already there.
WHY EVERY POST-CUTOVER BASELINE NEEDS THIS. A baseline is the sole creator
of its plugin's tables, so it was written as a plain op.create_table - which
is correct only if the table's existence and the plugin's Alembic stamp can
never disagree. They can, and routinely do:
* `flask db-utils create-all` runs db.create_all(), which builds EVERY
table registered on the metadata - plugin tables included - and stamps
NOTHING. `flask plugin upgrade-all` then runs the baseline against
tables that already exist.
* A run that creates the table and then fails before its stamp commits
leaves the same split, and re-running is the obvious thing to try.
Reported as `flask plugin upgrade-all` failing the tools plugin with
"table securesendsecrets already exists". Nothing caught it because the
migration test drops the post-cutover tables db.create_all() made before
running upgrade-all, which arranges the one state where an unguarded create
works and never the state a real database is in.
Returns True when it created the table, so a caller can skip the indexes
that belong to it.
"""
from alembic import op
from sqlalchemy import inspect
if inspect(op.get_bind()).has_table(table_name):
logger.info('%s already exists, skipping create', table_name)
return False
op.create_table(table_name, *columns, **kwargs)
return True
def create_index_if_missing(index_name: str, table_name: str, columns, **kwargs):
"""op.create_index, skipped when an equivalent index already exists.
Matched on the COLUMN LIST rather than the name, because the two creators
do not agree on names. db.create_all() takes them from SQLAlchemy's default
convention (`ix_securesendsecrets_token`) while a baseline spells them the
way this repo's naming rules require (`ixsecuresendsecretstoken`). Matching
on the name would call the index missing and add a second one over the same
column - MySQL permits that, with only a warning, so the duplicate would
accumulate silently on every affected site.
"""
from alembic import op
from sqlalchemy import inspect
wanted = [str(column) for column in columns]
inspector = inspect(op.get_bind())
if not inspector.has_table(table_name):
op.create_index(index_name, table_name, columns, **kwargs)
return
for existing in inspector.get_indexes(table_name):
if list(existing.get('column_names') or []) == wanted:
logger.info('index over %s(%s) already exists as %s, skipping',
table_name, ', '.join(wanted), existing.get('name'))
return
op.create_index(index_name, table_name, columns, **kwargs)
def drop_plugin_tables(plugin_name: str):
"""Mirror of create_plugin_tables for downgrade(). Drops in reverse FK
order."""

View File

@@ -81,18 +81,28 @@ def _scaffold_frontend(
name: str,
substitutions: dict,
plugin_target: Path,
frontend_dir: Path,
template_root: Path,
overwrite: bool,
) -> None:
"""Render the frontend starting points for a scaffolded plugin.
Writes the paste-in api-client snippet into the plugin directory, then the
Vue views and the router route file into the real frontend tree. The
snippet is emitted regardless of whether the frontend tree exists, because
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 normal case for a plugin developed in its own repository.
Everything lands INSIDE the plugin, under `plugins/<name>/frontend/`:
`routes.js` plus `views/<Name>{List,Detail,Form}.vue`, and the paste-in
api-client snippet at the plugin root.
The plugin owns its frontend because that is what makes it prunable.
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
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']
# 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'
if snippet_template.exists():
_render_template(
@@ -114,11 +124,8 @@ def _scaffold_frontend(
overwrite,
)
# views and route need the real frontend tree; external repos skip these
if not frontend_dir.exists():
return
views_dir = frontend_dir / 'views' / name
frontend_dir = plugin_target / 'frontend'
views_dir = frontend_dir / 'views'
# views first: route file lazy-imports them, missing views 500 vite
view_templates = {
@@ -141,7 +148,7 @@ def _scaffold_frontend(
if route_template.exists():
_render_template(
route_template,
frontend_dir / 'router' / 'routes' / f'{name}.js',
frontend_dir / 'routes.js',
substitutions,
overwrite,
)
@@ -154,7 +161,6 @@ def scaffold_plugin(
template_root: Optional[Path] = None,
overwrite: bool = False,
frontend: bool = True,
frontend_dir: Optional[Path] = None,
) -> Path:
"""Generate a new plugin from templates.
@@ -166,11 +172,10 @@ def scaffold_plugin(
overwrite: If True, overwrite an existing plugin directory and any
existing generated frontend files
frontend: If True, also render the Vue frontend starting points (list,
detail, form views, a route file, and a paste-in api-client snippet)
frontend_dir: Frontend src directory to render views/routes into
(default: <plugins_dir>/../frontend/src). Views and the route file
are skipped when this directory does not exist, which is the normal
case for a plugin developed in its own repository.
detail, form views, a route file, and a paste-in api-client
snippet) into `plugins/<name>/frontend/`. There is no separate
output directory: the plugin owns its frontend so that
stage-frontend.mjs can stage it and a lean build can prune it.
Returns:
Path to the generated plugin directory.
@@ -205,7 +210,9 @@ def scaffold_plugin(
for template_path in template_root.rglob('*.tmpl'):
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':
continue
@@ -222,13 +229,10 @@ def scaffold_plugin(
out_path.write_text(rendered)
if frontend:
if frontend_dir is None:
frontend_dir = plugins_dir.parent / 'frontend' / 'src'
_scaffold_frontend(
name=name,
substitutions=substitutions,
plugin_target=target,
frontend_dir=Path(frontend_dir),
template_root=template_root,
overwrite=overwrite,
)

View File

@@ -1,35 +1,49 @@
/**
* ${Name} plugin routes.
*
* Auto-discovered by the router via import.meta.glob, so no registration
* edit is needed. 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.
* This file lives in the plugin, not in the core frontend tree.
* scripts/stage-frontend.mjs copies plugins/${name}/frontend/ into
* frontend/src/.plugins-staged/${name}/ and aggregates this default export
* into routes.gen.js, which the router spreads in - so no registration 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 [
{
path: '${name}',
name: '${name}',
component: () => import('../../views/${name}/${Name}List.vue'),
component: () => import('./views/${Name}List.vue'),
meta: { plugin: '${name}' }
},
{
path: '${name}/new',
name: '${name}-new',
component: () => import('../../views/${name}/${Name}Form.vue'),
component: () => import('./views/${Name}Form.vue'),
meta: { requiresAuth: true, plugin: '${name}' }
},
{
path: '${name}/:id',
name: '${name}-detail',
component: () => import('../../views/${name}/${Name}Detail.vue'),
component: () => import('./views/${Name}Detail.vue'),
meta: { plugin: '${name}' }
},
{
path: '${name}/:id/edit',
name: '${name}-edit',
component: () => import('../../views/${name}/${Name}Form.vue'),
component: () => import('./views/${Name}Form.vue'),
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 = []

View File

@@ -78,12 +78,12 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import api from '../../api'
import { useAuthStore } from '../../stores/auth'
import api from '@/api'
import { useAuthStore } from '@/stores/auth'
// local api client. move into src/api/index.js (see
// plugins/${name}/frontend-api-snippet.js) then swap for:
// import { ${name}Api } from '../../api'
// import { ${name}Api } from '@/api'
const ${name}Api = {
list(params = {}) { return api.get('/${name}', { params }) },
get(itemId) { return api.get(`/${name}/${itemId}`) },

View File

@@ -77,11 +77,11 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import api from '../../api'
import api from '@/api'
// local api client. move into src/api/index.js (see
// plugins/${name}/frontend-api-snippet.js) then swap for:
// import { ${name}Api } from '../../api'
// import { ${name}Api } from '@/api'
const ${name}Api = {
list(params = {}) { return api.get('/${name}', { params }) },
get(itemId) { return api.get(`/${name}/${itemId}`) },

View File

@@ -66,12 +66,12 @@
<script setup>
import { ref, onMounted } from 'vue'
import api from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import api from '@/api'
import PaginationBar from '@/components/PaginationBar.vue'
// local api client. move into src/api/index.js (see
// plugins/${name}/frontend-api-snippet.js) then swap for:
// import { ${name}Api } from '../../api'
// import { ${name}Api } from '@/api'
const ${name}Api = {
list(params = {}) { return api.get('/${name}', { params }) },
get(itemId) { return api.get(`/${name}/${itemId}`) },