Standardize the resource modal: tabs, footer, linkable URL, Children tab, site-slug group prefixing (#120)

* Add Resource audit fields (created/updated by/on) and site-slug group prefixing

Resource had no created_by/created_on/updated_by/updated_on fields at all,
unlike proxy's Host and jump-host's ApiToken which already track this --
needed for the upcoming resource-modal footer. @simpleworkjs/orm has no
auto-timestamp hook, so these are set explicitly in the directory-admin
route handlers on every create/update.

Also: when a host/service resource is created, its two auto-created LDAP
groups (<slug>_access/_admin) now get prefixed with the nearest ancestor
site's slug (via a new Resource.findAncestorSiteSlug walk), so groups from
different sites don't collide/look identical. Falls back to today's
unprefixed naming when a resource has no site ancestor.

Included the checked-in dev inventory.sqlite's ALTER TABLE for the new
columns, since @simpleworkjs/orm's sync() only creates missing tables, never
alters existing ones -- the raw model change alone would have broken every
Resource read/write against this file with "no such column: created_by".

* Migrate Resource modal onto app.modal's tabs/footer/URL, add Children tab

The Directory's resource modal was a separate, hand-rolled, always-in-DOM
Bootstrap modal, independent of the shared app.modal singleton -- migrating
it onto app.modal (now published with tabs/footer/url support in
@simpleworkjs/frontend 0.2.6) is the pilot for standardizing entity modals
across the stack.

- General/Details/Associated LDAP Groups/Children tabs, replacing the old
  single long form (Details keeps every kind-conditional container
  unchanged; toggleFormFields() didn't need to change at all).
- Footer shows created/updated by/on (via the new Resource audit fields)
  and the Save button; Groups/Children tabs are hidden in add-mode since
  they need an existing resource id.
- New Children tab lists a resource's existing children (reusing the
  already-loaded edges/resourcesById data, no new endpoint) and an "Add
  Child Resource" button that reuses openAddModal's existing preset-parent
  support. Folded the pre-existing generic "Relationships (Graph Edges)"
  section in underneath, under an "advanced" subheading, rather than
  dropping it or giving it a 5th tab of its own.
- GET /directory/:slug (mirroring the existing /users/:uid precedent) plus
  a client-side app.modal.deepLinkSlug() check makes a resource's modal
  linkable and directly loadable.
- Converted the groups/edges lists from jq-repeat to plain manual DOM
  rendering: jq-repeat's MutationObserver-based scope (re)registration for
  an element that's destroyed and recreated on every modal open runs
  asynchronously, so populating synchronously right after open() (as
  refreshGroupsUI/refreshEdgesUI must) raced it -- on the second and later
  opens, the old scope's destroy() ran after the new data was pushed onto
  it, silently discarding it. Manual rendering (matching the new Children
  tab) sidesteps the race entirely.
- The #res-name/#res-kind auto-slug handler is now bound via
  app.modal.on() (delegated) instead of directly -- a direct bind would
  have silently stopped firing after the first Add/Edit, since the modal
  body is rebuilt from scratch on every open().

Verified live against the running dev stack: tabs/footer/groups/children
all render and populate correctly (including on a second open, confirming
the jq-repeat race fix), the address bar updates to /directory/{slug} and
reverts on close, browser Back closes the modal via popstate without a
page reload, and a resource created under a Site gets correctly
site-slug-prefixed LDAP groups.
This commit is contained in:
2026-07-28 17:42:05 -04:00
committed by GitHub
parent 69883836e1
commit 0e955abc73
8 changed files with 452 additions and 297 deletions
+63
View File
@@ -0,0 +1,63 @@
'use strict';
// findAncestorSiteSlug has no LDAP dependency (unlike most of this test
// suite, which needs a live LDAP server) -- it's pure Resource/ResourceEdge
// graph traversal against the ORM, so it's tested directly here rather than
// through the (LDAP-gated) directory-admin HTTP routes.
const { initORM } = require('../models');
const { Resource, ResourceEdge } = require('../models/resource');
const marker = 'test_site_slug_' + Date.now();
const created = [];
async function makeResource(kind, name) {
const r = await Resource.create({ kind, name, slug: `${marker}_${name}` });
created.push(r);
return r;
}
beforeAll(async () => {
await initORM();
});
afterAll(async () => {
for (const r of created) {
try { await r.delete(); } catch (_) {}
}
});
describe('Resource.findAncestorSiteSlug', () => {
test('returns the direct parent site\'s slug', async () => {
const site = await makeResource('site', 'site-direct');
const host = await makeResource('host', 'host-direct');
await ResourceEdge.create({ parentId: site.id, childId: host.id, relation: 'hosts' });
await expect(Resource.findAncestorSiteSlug(host.id)).resolves.toBe(site.slug);
});
test('walks up through an intermediate host to find the owning site', async () => {
const site = await makeResource('site', 'site-nested');
const host = await makeResource('host', 'host-nested');
const service = await makeResource('service', 'service-nested');
await ResourceEdge.create({ parentId: site.id, childId: host.id, relation: 'hosts' });
await ResourceEdge.create({ parentId: host.id, childId: service.id, relation: 'hosts' });
await expect(Resource.findAncestorSiteSlug(service.id)).resolves.toBe(site.slug);
});
test('returns null for a top-level resource with no site ancestor', async () => {
const host = await makeResource('host', 'host-orphan');
await expect(Resource.findAncestorSiteSlug(host.id)).resolves.toBeNull();
});
test('does not loop forever on a cyclic parent chain', async () => {
const a = await makeResource('host', 'host-cycle-a');
const b = await makeResource('host', 'host-cycle-b');
await ResourceEdge.create({ parentId: a.id, childId: b.id, relation: 'hosts' });
await ResourceEdge.create({ parentId: b.id, childId: a.id, relation: 'hosts' });
await expect(Resource.findAncestorSiteSlug(a.id)).resolves.toBeNull();
});
});