diff --git a/ROADMAP.md b/ROADMAP.md index 46b6373..0d35ed0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -40,7 +40,10 @@ around that is not on this list. See [CONTRIBUTING.md](CONTRIBUTING.md). - **Delegate & forwarding review, domain-wide.** "Who can read whose mailbox, and whose mail is leaving the building?" is answerable today only one user at a time, so nobody audits it. - **Shared drives** — inventory, membership, and the orphaned-drive check before deleting a - departing user. + departing user. Also: moving a folder of mixed ownership into a shared drive as one previewed + Builder recipe — a read that counts what will move (internal) and what must be copied (external), + then `move drivefile … retainsourcefolders duplicatefolders merge` and `copy drivefile … recursive + copysubfilesownedby others duplicatefolders merge` (GAM has no dry run for either). ## Later diff --git a/docs/domains/catalog-builder.md b/docs/domains/catalog-builder.md index 4f315bd..02bc6e9 100644 --- a/docs/domains/catalog-builder.md +++ b/docs/domains/catalog-builder.md @@ -12,7 +12,7 @@ - `gamgui/core/catalog/models.py` — `CatalogCommand`, `CommandSlot`, `SlotKind`, `Catalog` (sorting/search/`supports_export`). - `gamgui/core/catalog/describe.py` / `nouns.py` — deterministic fallback `gloss()` when a command has no vendored description. - `gamgui/web/routes/builder.py` — the `/builder` screen: browse/search, slot assembly (`_assemble`), preview, run, sequence, a read's result table (`/results`) and CSV export, `/pick` type-ahead. -- `gamgui/web/templates/_records_table.html` — a read's result: the filter form, `_records_meta.html` (the "N of M rows" count and the CSV link) and `_records_page.html` (one page of matching rows and the pager). +- `gamgui/web/templates/_records_table.html` — a read's result: the filter form, `_records_meta.html` (the "N of M rows" count and the CSV link) and `_records_page.html` (one page of matching rows and the pager). A cell shows `server.cell(value)` (a list joined, a name dict as the name, another dict as `key: value · …`, never Python's repr), on one line (`truncate whitespace-nowrap max-w-[24rem]`, never `break-all`, which split addresses mid-word), whole in its `title`; `builder.js` expands a cut-off cell on a click with the table's `data-expanded` classes. The CSV export keeps raw values. The screen has no footer and puts its title in the search row, so the working pane gets the height (`h-[calc(100vh-9.5rem)]`); **Hide list** (`#list-toggle`, `aria-expanded`) hides `#catalog-col` and swaps `#builder-grid` to its `data-collapsed-cols`, remembered in `localStorage` (`gamgui.builder.list`, a per-viewer convenience; the page works without it). - `scripts/build_command_catalog.py` — regenerates the committed browse-only `command_catalog.json` from `GamCommands.txt` on each GAM bump (buildable overlay is NOT here — it lives in `catalog.py`). - `docs/builder-commands.md` — the fuller domain reference (it points at CLAUDE.md for the counts rather than keeping its own copy). diff --git a/docs/guide.md b/docs/guide.md index 61d937b..b8ac05d 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -182,6 +182,10 @@ and the CSV download takes the filtered rows. Clicking an address in a result offers follow-ups for that person (delegates, forwarding, signature, suspend and more). A Builder change only ever runs from its preview. +A wide result? **Hide list** (top right) gives the result the whole width; the Builder remembers +your choice until you click it again ("Show list"). Each value stays on one line: a long one is cut short — +click it to read all of it, or hover. The CSV download always has every value whole. + ## Jobs and Stop Longer runs — a signature rollout, a bulk department change, a CSV of hires, an offboarding, a group diff --git a/gamgui/web/routes/builder.py b/gamgui/web/routes/builder.py index 597e4ee..c3abae8 100644 --- a/gamgui/web/routes/builder.py +++ b/gamgui/web/routes/builder.py @@ -30,7 +30,7 @@ from ..csvutil import csv_safe from ..jobs import start_job from ..previews import TOKEN_FIELD -from ..server import TEMPLATES +from ..server import TEMPLATES, cell from ._common import NOT_CONNECTED, app_state, as_of, error_partial, friendly router = APIRouter(prefix="/builder") @@ -178,14 +178,21 @@ async def _render_read(request: Request, out: str, cmd, argv, target: str, tenan def _columns_and_texts(last: dict) -> tuple: """Every column any row has, the first row's first (ragged JSON: the table and the CSV show the same - columns), and each row's lowercased text to filter on — worked out once per result.""" + columns), and each row's lowercased text to filter on — worked out once per result. The text is what + the table shows (``cell``: a name as "Alice Anders") and, for a structured value, its raw form too, + so a search finds what is on screen and still matches a field name.""" if "texts" not in last: records = last["records"] last["cols"] = list(dict.fromkeys(k for r in records for k in r)) - last["texts"] = ["\n".join(str(r.get(k, "")) for k in last["cols"]).lower() for r in records] + last["texts"] = ["\n".join(_filter_text(r.get(k, "")) for k in last["cols"]).lower() for r in records] return last["cols"], last["texts"] +def _filter_text(value) -> str: + shown = cell(value) + return shown if isinstance(value, str) or str(value) == shown else f"{shown}\t{value}" + + def _is_internal(address: str, domains: tuple) -> bool: """An address at one of the tenant's domains (primary, secondary, domain alias) or a subdomain of one.""" host = address.rsplit("@", 1)[-1] diff --git a/gamgui/web/server.py b/gamgui/web/server.py index 144f59c..1a0a58f 100644 --- a/gamgui/web/server.py +++ b/gamgui/web/server.py @@ -69,7 +69,26 @@ def ago(seconds: Optional[float]) -> str: return f"{s // 3600} h ago" if s < 86400 else f"{s // 86400} d ago" +def cell(value) -> str: + """A result value as a person reads it: a structured value GAM returned as JSON (a user's name, a + list of aliases) was shown as Python's repr. Lists join with ", ", a name dict shows the name + (fullName, else given + family), any other dict reads "key: value · key: value". The CSV export + keeps the raw data.""" + if value is None: + return "" + if isinstance(value, (list, tuple)): + return ", ".join(cell(v) for v in value) + if isinstance(value, dict): + if value.get("fullName"): + return str(value["fullName"]) + if value.get("givenName") or value.get("familyName"): # a name without its fullName + return " ".join(str(value[k]) for k in ("givenName", "familyName") if value.get(k)) + return " · ".join(f"{k}: {cell(v)}" for k, v in value.items()) + return str(value) + + TEMPLATES.env.filters["ago"] = ago +TEMPLATES.env.filters["cell"] = cell TEMPLATES.env.globals["jobs_tray"] = _jobs_tray TEMPLATES.env.globals["active_tenant"] = _active_tenant TOKEN_COOKIE = "gamgui_token" # noqa: S105 — a cookie name, not a secret diff --git a/gamgui/web/static/app.css b/gamgui/web/static/app.css index f388b82..170238c 100644 --- a/gamgui/web/static/app.css +++ b/gamgui/web/static/app.css @@ -874,8 +874,8 @@ video { height: 11rem; } -.h-\[calc\(100vh-14\.5rem\)\] { - height: calc(100vh - 14.5rem); +.h-\[calc\(100vh-9\.5rem\)\] { + height: calc(100vh - 9.5rem); } .h-\[calc\(100vh-9rem\)\] { @@ -1038,6 +1038,10 @@ video { min-width: 16rem; } +.min-w-\[24rem\] { + min-width: 24rem; +} + .max-w-0 { max-width: 0px; } @@ -1054,6 +1058,10 @@ video { max-width: 13rem; } +.max-w-\[24rem\] { + max-width: 24rem; +} + .max-w-none { max-width: none; } @@ -1301,6 +1309,10 @@ video { white-space: nowrap; } +.whitespace-normal { + white-space: normal; +} + .whitespace-nowrap { white-space: nowrap; } @@ -2409,4 +2421,8 @@ video { .lg\:grid-cols-\[22rem_minmax\(0\2c 1fr\)\] { grid-template-columns: 22rem minmax(0,1fr); } + + .lg\:grid-cols-\[minmax\(0\2c 1fr\)\] { + grid-template-columns: minmax(0,1fr); + } } diff --git a/gamgui/web/static/builder.js b/gamgui/web/static/builder.js index 8f47ec5..2d22401 100644 --- a/gamgui/web/static/builder.js +++ b/gamgui/web/static/builder.js @@ -3,6 +3,37 @@ (function () { "use strict"; + // Hide/show the command list: a wide result then gets the whole width. Remembered per viewer (a + // convenience: storage can be blocked, and the page works the same without it). + function setList(shown) { + var layout = document.getElementById("builder-grid"); + var col = document.getElementById("catalog-col"); + var btn = document.getElementById("list-toggle"); + if (!layout || !col || !btn) return; + col.classList.toggle("hidden", !shown); + layout.classList.toggle("lg:grid-cols-[22rem_minmax(0,1fr)]", shown); + layout.classList.toggle(layout.dataset.collapsedCols, !shown); + btn.setAttribute("aria-expanded", shown ? "true" : "false"); + btn.textContent = shown ? "Hide list" : "Show list"; + try { localStorage.setItem("gamgui.builder.list", shown ? "shown" : "hidden"); } catch (e) { /* blocked */ } + } + GamGUI.actions["builder-list"] = function (btn) { setList(btn.getAttribute("aria-expanded") !== "true"); }; + + // A result cell cut short (one line, ellipsis) shows all of itself on a click, and folds back on + // another. Its title carries the whole value too, and the CSV export keeps every value whole. + document.addEventListener("click", function (e) { + var td = e.target.closest && e.target.closest("#builder-result td[title]"); + if (!td || e.target.closest("button")) return; + var table = td.closest("table"); + var wide = (table && table.dataset.expanded || "").split(" ").filter(Boolean); + var open = td.classList.contains("truncate"); + if (open && td.scrollWidth <= td.clientWidth) return; // nothing hidden: leave it + td.classList.toggle("truncate", !open); + td.classList.toggle("whitespace-nowrap", !open); + wide.forEach(function (c) { td.classList.toggle(c, open); }); + }); + try { if (localStorage.getItem("gamgui.builder.list") === "hidden") setList(false); } catch (e) { /* blocked */ } + // After a Preview swaps the confirm panel into the result area, scroll the build pane so its // Run button is in reach (the pane scrolls as one unit, so a tall form can't leave Run below the // fold). Only when an actionable Run/confirm is present — not for a plain results table. diff --git a/gamgui/web/templates/_records_page.html b/gamgui/web/templates/_records_page.html index e975a08..81b26b2 100644 --- a/gamgui/web/templates/_records_page.html +++ b/gamgui/web/templates/_records_page.html @@ -1,12 +1,14 @@ {# One page of a read's matching rows and its pager (_records_table.html holds the filter). #} {% if oob %}{% include "_records_meta.html" %}{% endif %} +{# A cut-off value expands in place on a click (builder.js swaps in data-expanded's classes). #}
- +
{% for k in cols %}{% endfor %} {% for r in rows %} - {% for k in cols %}{% set v = r.get(k, "") | string %} - {% endfor %} + {# A value never breaks mid-word; a long one is cut short, whole in its tooltip (and the CSV). #} + {% for k in cols %}{% set v = r.get(k, "") | cell %} + {% endfor %} {% else %} {% endfor %} diff --git a/gamgui/web/templates/base.html b/gamgui/web/templates/base.html index 2658ffe..dc4ccf1 100644 --- a/gamgui/web/templates/base.html +++ b/gamgui/web/templates/base.html @@ -98,9 +98,9 @@

Background jobs

{% block content %}{% endblock %}
- {% endblock %} diff --git a/gamgui/web/templates/builder.html b/gamgui/web/templates/builder.html index 0f36474..5b7b451 100644 --- a/gamgui/web/templates/builder.html +++ b/gamgui/web/templates/builder.html @@ -1,6 +1,8 @@ {% extends "base.html" %} {% block title %}Builder — GamGUI Console{% endblock %} {% block container_class %}max-w-none{% endblock %} +{# A fixed-height workspace: the footer line's height goes to the results instead. #} +{% block footer %}{% endblock %} {% block content %} {% if not connected %}
@@ -10,16 +12,12 @@

Connect a domain first

{% else %} {% set fld = "w-full rounded-lg border border-brand-field bg-brand-white px-3 py-2 text-sm text-ink placeholder:text-brand-grayink focus:border-brand-blue focus:outline-none focus:ring-2 focus:ring-brand-blue/30" %} - -
-
-

Command builder

-

Browse every GAM command; build & run the buildable ones, or queue a sequence.

-
- - + +
+
+

Command builder

@@ -33,11 +31,15 @@

Command builder Buildable only + + -
+
-
+

Loading…

@@ -49,7 +51,7 @@

Command builder
- Pick a command on the left and click Build — its form opens here. Click a User field to choose someone. + Browse every GAM command; build & run the buildable ones, or queue a sequence. Pick a command on the left and click Build — its form opens here. Click a User field to choose someone.
diff --git a/tests/test_builder.py b/tests/test_builder.py index eb9c004..f4157a2 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -82,6 +82,47 @@ def test_curated_search_commands_run(client): assert rf.status_code == 200 and "Q4 Budget" in rf.text # a mock file name +@pytest.mark.parametrize(("value", "shown"), [ + (None, ""), (True, "True"), (3, "3"), ("a@example.com", "a@example.com"), + (["a@example.com", "b@example.com"], "a@example.com, b@example.com"), + ({"givenName": "Ada", "familyName": "Byte", "fullName": "Ada Byte"}, "Ada Byte"), + ({"givenName": "Ada", "familyName": "Byte"}, "Ada Byte"), + ({"type": "work", "value": "123"}, "type: work · value: 123"), + ([{"address": "a@example.com", "primary": True}], "address: a@example.com · primary: True"), +]) +def test_a_result_cell_reads_as_text_not_python(value, shown): + from gamgui.web.server import cell + assert cell(value) == shown + + +def test_the_builder_gives_results_the_room_the_footer_and_title_row_took(client): + page = client.get("/builder").text + assert "")[0] + assert "Command builder" in controls # the title shares the search row + toggle = controls.split('id="list-toggle"')[1].split("")[0] + assert 'aria-controls="catalog-col"' in toggle and 'aria-expanded="true"' in toggle and "Hide list" in toggle + assert 'id="catalog-col"' in page and 'data-collapsed-cols="lg:grid-cols-[minmax(0,1fr)]"' in page + assert "

{{ k }}
{% if '@' in v and '.' in v and ' ' not in v %}{% else %}{{ v }}{% endif %}
{% if '@' in v and '.' in v and ' ' not in v %}{% else %}{{ v }}{% endif %}
No row of the {{ "{:,}".format(total) }}{% if q.strip() %} contains “{{ q.strip() }}”{% endif %}{% if external %}{{ " and" if q.strip() }} names an address outside your domains{% endif %}.