* fix(web): UI consistency — code tokens, label-qualifier, radio card selected state
I-UI-01: Add .sync-option-card:has(input:checked) rule — border + background
feedback when a radio option card is selected. Add class sync-option-card to
all 14 radio label cards in admin_tables.html.
I-UI-02: Add .label-qualifier / .optional to style-custom.css. Remove the
duplicate local definition from admin_tables.html <style> block.
I-UI-03: Migrate inline code rule to design tokens (--font-mono, --text-sm,
--border-light, --border, --radius-sm). Add background + border so inline
code is visually distinct across all pages.
I-UI-05 (partial): Replace hardcoded #c4c4c4 / #fafafa in .btn-google:hover
with var(--border) / var(--background) so theme overrides apply.
* fix(web): expose entire Keboola edit-modal JS to all instance types
openEditKeboolaModal, closeEditKeboolaModal, saveKeboolaTabEdit,
onEditKbStrategyChange and helpers were still inside {% if keboola %}
but called from always-rendered HTML (openEditModal dispatcher,
Escape key handler, modal overlay click, Cancel/Save buttons).
Removed the Phase F2 if-guard entirely — only prefillFromKeboolaTable
stays conditional (its callers are inside {% if keboola %} HTML blocks).
* fix(ui): promote .form-textarea to global CSS with design tokens
Removes the local hardcoded .form-textarea definition from admin_tables.html
and adds it globally to style-custom.css using design tokens, making
description textareas visually consistent with other form fields.
* fix(ui): restore .form-textarea to local style block for visual consistency
Tokens --text-sm (12px) and --radius-md (6px) differ from the local override
values (13px, 8px) used by .form-input on this page, causing a visible mismatch.
.form-textarea rejoins the shared local selector so all three classes render
identically; global .form-textarea in style-custom.css remains as a baseline
for other pages.
* fix(ui): use textarea.form-textarea in global CSS to override .form-group textarea
.form-group textarea (specificity 0,1,1) was overriding .form-textarea (0,1,0)
with a legacy monospace font and different padding. Raising the selector to
textarea.form-textarea matches specificity and wins via source order, making
description textareas consistent with other form inputs. Local admin_tables.html
overrides for .form-textarea removed — styling now comes entirely from global CSS.
* fix(ui): add border:none to .code-block code + add CHANGELOG entries
Fixes light-gray border leaking into dark .code-block backgrounds.
Adds required CHANGELOG.md entries for all user-visible changes in this PR.
* fix(ui): add --border-dark token + reset border-radius in .code-block code
- Adds --border-dark: #C4C4C4 design token for hover border states
- Uses var(--border-dark) in both .btn-google:hover rules so hover border
is visually distinct from the base border (was a no-op with var(--border))
- Adds border-radius: 0 to .code-block code override to fully reset the
new global code border-radius on dark code-block backgrounds
* fix(ui): reset code border/bg inside .use-case-prompt dark container
Adds .plugin-detail .use-case-prompt code override to prevent the new
global code border and background from leaking into the dark #1e1e2e
pre block in marketplace_plugin_detail.html.
* fix(ui): reset code border in all dark-background containers
Global code { border } leaks into dark-themed containers across templates.
Adds border: none (+ border-radius: 0 where needed) to:
- marketplace_plugin_detail.html: lead-rendered pre code, sample-assistant-body code/pre code
- marketplace_item_detail.html: same three selectors
- home_onboarded.html, home_not_onboarded.html, admin_welcome.html: inline code on hero dark backgrounds
* fix(ui): uniform form typography — chip-input font, data-package desc textarea, orphan endif
- .chip-input container gets font-family/size tokens so inner input
inherits correctly (inline `font: inherit` was pulling browser default)
- cdp-desc / edp-desc switched from form-input to form-textarea so
description fields render Inter, not monospace
- Removed orphan {% endif %} left in admin_tables.html after rebase
(caused TemplateSyntaxError breaking all admin-tables tests in CI)
- .item-detail .use-case-prompt code: border/bg reset for dark container
* fix: relax test_keboola_discover_buttons assertion + CHANGELOG bullet for #347
The test_keboola_discover_buttons_hidden_on_bigquery_instance test
asserted bare-string `prefillFromKeboolaTable` not in the rendered
HTML on a non-Keboola instance. That made sense when the function
DEFINITION lived behind the keboola Jinja guard. #347 moves
several Keboola edit-modal helpers out from under the guard so
they're now defined as dead code on every instance, but the actual
call sites (`onclick="prefillFromKeboolaTable(...)"` + the
Discover buttons themselves) still respect the guard — which is
what actually matters for runtime behavior.
Updated the assertions to match `onclick="<fn>(` so they pin the
call-site contract, not the function-definition substring.
---------
Co-authored-by: ZdenekSrotyr <zdenek.srotyr@keboola.com>
396 lines
16 KiB
Python
396 lines
16 KiB
Python
"""`/admin/tables` register modal exposes the BQ Type selector + Custom SQL.
|
|
|
|
The backend supports `query_mode='materialized'` since v0.25.0. The Jinja
|
|
template at `app/web/templates/admin_tables.html` exposes it via an
|
|
operator-facing **Type** selector (Table / View / Custom SQL Query) that
|
|
maps to query_mode in the payload (Table+View → remote, Query → materialized).
|
|
|
|
Structural-only test (no headless browser): loads the template through the
|
|
running app and asserts the expected element ids + attributes are present
|
|
in the rendered HTML for a `data_source_type='bigquery'` deployment.
|
|
"""
|
|
import pytest
|
|
|
|
|
|
def _auth(token):
|
|
return {"Authorization": f"Bearer {token}"}
|
|
|
|
|
|
@pytest.fixture
|
|
def bq_instance(monkeypatch):
|
|
"""Force `data_source.type='bigquery'` so /admin/tables renders the BQ
|
|
branch of the register modal."""
|
|
fake_cfg = {
|
|
"data_source": {
|
|
"type": "bigquery",
|
|
"bigquery": {"project": "my-test-project", "location": "us"},
|
|
},
|
|
}
|
|
monkeypatch.setattr(
|
|
"app.instance_config.load_instance_config",
|
|
lambda: fake_cfg,
|
|
raising=False,
|
|
)
|
|
from app.instance_config import reset_cache
|
|
reset_cache()
|
|
yield fake_cfg
|
|
reset_cache()
|
|
|
|
|
|
def test_admin_tables_renders_two_question_radio_form(seeded_app, bq_instance):
|
|
"""Q1 = how should analysts access this data? (live / synced).
|
|
Q2 = (only when synced) what to sync? (whole / custom).
|
|
Replaces the earlier flat 4-option dropdown that mixed source-kind +
|
|
distribution-mode into one selector — both UX reviewers (info-arch +
|
|
analyst persona) flagged the conflation as the core confusion."""
|
|
c = seeded_app["client"]
|
|
token = seeded_app["admin_token"]
|
|
|
|
r = c.get("/admin/tables", headers=_auth(token))
|
|
assert r.status_code == 200, r.text
|
|
html = r.text
|
|
|
|
# Q1 radio group.
|
|
assert 'name="bqAccessMode"' in html
|
|
assert 'value="live"' in html
|
|
assert 'value="synced"' in html
|
|
assert "onBqAccessModeChange" in html
|
|
|
|
# Q2 radio group (conditional on Q1).
|
|
assert 'name="bqSyncMode"' in html
|
|
assert 'value="whole"' in html
|
|
assert 'value="custom"' in html
|
|
assert "onBqSyncModeChange" in html
|
|
|
|
# Custom-SQL textarea + "Use table as base" prefill button.
|
|
assert 'id="bqSourceQuery"' in html
|
|
assert "prefillFromTable" in html
|
|
assert "bq-source-custom" in html
|
|
|
|
# Table/dataset inputs reused across live + synced/whole.
|
|
assert 'id="bqDataset"' in html
|
|
assert 'id="bqSourceTable"' in html
|
|
assert "bq-source-table" in html
|
|
assert "bq-access-synced" in html
|
|
|
|
# Discover + List tables buttons.
|
|
assert "discoverBqDatasets" in html
|
|
assert "discoverBqTables" in html
|
|
|
|
# No leftover jargon labels from the prior Type-selector iterations.
|
|
assert "Direct query" not in html
|
|
assert "Sync to parquet" not in html
|
|
|
|
# Vendor-agnostic — no internal issue refs in operator-facing UI text.
|
|
assert "Milestone 2" not in html
|
|
assert "issue #108" not in html
|
|
|
|
# Package-centric rewrite: connector tabs were dropped. The BQ
|
|
# register modal stays in DOM as a top-level overlay reachable from
|
|
# the `+ Register new table ▾` action-bar dropdown. Anchor the
|
|
# field-scope check on the modal id instead of the deleted
|
|
# tab-content section.
|
|
bq_modal_start = html.index('id="registerBqModal"')
|
|
bq_modal_end = html.index('</div>\n </div>', bq_modal_start)
|
|
bq_modal = html[bq_modal_start:bq_modal_end]
|
|
assert 'name="bqAccessMode"' in bq_modal
|
|
assert 'id="bqDataset"' in bq_modal
|
|
assert 'id="bqSourceQuery"' in bq_modal
|
|
|
|
|
|
def test_edit_modal_has_bq_parity_fields(seeded_app, bq_instance):
|
|
"""Edit modal mirrors Register's two-question radio model (Q1 access
|
|
mode: live/synced; Q2 sync mode: whole/custom). Pre-fix Edit had only
|
|
sync_strategy+primary_key+description+folder — missing all BQ-specific
|
|
edit surface. Operator now can flip access mode, change dataset/table,
|
|
rewrite SQL, and tweak the schedule without dropping & re-adding."""
|
|
c = seeded_app["client"]
|
|
token = seeded_app["admin_token"]
|
|
|
|
r = c.get("/admin/tables", headers=_auth(token))
|
|
assert r.status_code == 200, r.text
|
|
html = r.text
|
|
|
|
# Edit Q1 + Q2 radios.
|
|
assert 'name="editBqAccessMode"' in html
|
|
assert 'name="editBqSyncMode"' in html
|
|
assert "onEditBqAccessModeChange" in html
|
|
assert "onEditBqSyncModeChange" in html
|
|
|
|
# BQ-specific edit fields.
|
|
assert 'id="editBqDataset"' in html
|
|
assert 'id="editBqSourceTable"' in html
|
|
assert 'id="editBqSourceQuery"' in html
|
|
assert 'id="editBqSyncSchedule"' in html
|
|
|
|
# Visibility classes for adaptive show/hide on access/sync mode switch.
|
|
assert "bq-edit-access-synced" in html
|
|
assert "bq-edit-source-table" in html
|
|
assert "bq-edit-source-custom" in html
|
|
|
|
# Mode-switch warning surface (filled by JS when operator flips access
|
|
# mode mid-edit).
|
|
assert 'id="editBqModeWarning"' in html
|
|
|
|
# Source-type badge so the JS branch knows whether to render BQ vs
|
|
# Keboola fields without a second round-trip.
|
|
assert 'id="editSourceTypeBadge"' in html
|
|
|
|
# No leftover Type-selector remnants.
|
|
assert 'id="editBqEntityType"' not in html
|
|
assert "onEditBqTypeChange" not in html
|
|
|
|
# Edit modal has the same Discover / List tables / Use-as-base buttons
|
|
# as Register so the operator can re-pick the source from autocomplete
|
|
# without dropping the row.
|
|
assert "discoverBqDatasets('editBqDatasetList')" in html
|
|
assert "discoverBqTables('editBqDataset', 'editBqTableList')" in html
|
|
assert "prefillFromTable('editBqSourceQuery')" in html
|
|
assert 'id="editBqDatasetList"' in html
|
|
assert 'id="editBqTableList"' in html
|
|
assert 'list="editBqDatasetList"' in html
|
|
assert 'list="editBqTableList"' in html
|
|
|
|
|
|
def test_keboola_register_form_has_three_question_radio(seeded_app, monkeypatch):
|
|
"""Phase G (v26): Keboola tab Register form gains a third radio option
|
|
'Direct extract (Storage API)' alongside the existing 'whole' and
|
|
'custom' modes.
|
|
|
|
- whole / custom → query_mode='materialized' (DuckDB Keboola extension)
|
|
- direct → query_mode='local' + v26 sync_strategy panel
|
|
(incremental / partitioned / full_refresh + where_filters)
|
|
|
|
Phase F asserted `kbStrategy` was removed; v26 re-adds it inside the
|
|
Direct-extract panel (visible only when 'direct' is selected).
|
|
"""
|
|
fake_cfg = {"data_source": {"type": "keboola", "keboola": {}}}
|
|
monkeypatch.setattr(
|
|
"app.instance_config.load_instance_config",
|
|
lambda: fake_cfg, raising=False,
|
|
)
|
|
from app.instance_config import reset_cache
|
|
reset_cache()
|
|
try:
|
|
c = seeded_app["client"]
|
|
token = seeded_app["admin_token"]
|
|
r = c.get("/admin/tables", headers=_auth(token))
|
|
html = r.text
|
|
# Package-centric rewrite: anchor on the Keboola register modal id
|
|
# (the connector tab that used to wrap this form is gone).
|
|
kb_modal_start = html.index('id="registerKeboolaModal"')
|
|
# The modal's outer wrapper closes via "</div>\n </div>"
|
|
# (modal -> modal-overlay). Use a generous slice + sanity-bound it
|
|
# to the next modal-overlay id so we don't bleed into editKeboolaModal.
|
|
next_modal_idx = html.find('id="editKeboolaModal"', kb_modal_start)
|
|
kb_tab = html[kb_modal_start:next_modal_idx] if next_modal_idx > 0 else html[kb_modal_start:]
|
|
|
|
# All three radios present.
|
|
assert 'name="kbSyncMode"' in kb_tab
|
|
assert 'value="whole"' in kb_tab
|
|
assert 'value="custom"' in kb_tab
|
|
assert 'value="direct"' in kb_tab
|
|
|
|
# Bucket + source-table inputs reused for whole + direct modes.
|
|
assert 'id="kbBucket"' in kb_tab
|
|
assert 'id="kbSourceTable"' in kb_tab
|
|
# Custom-SQL textarea + Use-table-as-base prefill button.
|
|
assert 'id="kbSourceQuery"' in kb_tab
|
|
assert 'kbPrefillFromTable' in html or "prefillFromKeboolaTable('kbSourceQuery')" in html
|
|
|
|
# Sync Schedule input.
|
|
assert 'id="kbSyncSchedule"' in kb_tab
|
|
|
|
# v26: Sync Strategy dropdown re-added (inside the Direct-extract panel)
|
|
assert 'id="kbStrategy"' in kb_tab
|
|
assert 'class="form-group kb-direct-only"' in kb_tab or \
|
|
'kb-direct-only' in kb_tab
|
|
|
|
# Primary Key — under <details>Advanced.
|
|
assert 'id="kbPrimaryKey"' in kb_tab
|
|
assert "<details" in kb_tab
|
|
assert ">Advanced" in kb_tab
|
|
|
|
# Discover datasets / List tables buttons.
|
|
assert 'kbDiscoverBuckets' in html or "discoverKeboolaBuckets(" in html
|
|
assert 'kbListTables' in html or "discoverKeboolaTables(" in html
|
|
finally:
|
|
reset_cache()
|
|
|
|
|
|
def test_keboola_register_payload_maps_to_materialized(seeded_app, monkeypatch):
|
|
"""The form's whole-table mode posts query_mode='materialized' with
|
|
a synthetic SELECT * SQL — same pattern as BQ Synced/Whole."""
|
|
fake_cfg = {"data_source": {"type": "keboola", "keboola": {}}}
|
|
monkeypatch.setattr(
|
|
"app.instance_config.load_instance_config",
|
|
lambda: fake_cfg, raising=False,
|
|
)
|
|
from app.instance_config import reset_cache
|
|
reset_cache()
|
|
try:
|
|
c = seeded_app["client"]
|
|
token = seeded_app["admin_token"]
|
|
auth = {"Authorization": f"Bearer {token}"}
|
|
r = c.post(
|
|
"/api/admin/register-table",
|
|
headers=auth,
|
|
json={
|
|
"name": "orders",
|
|
"source_type": "keboola",
|
|
"query_mode": "materialized",
|
|
"source_query": 'SELECT * FROM kbc."in.c-sales"."orders"',
|
|
"sync_schedule": "every 6h",
|
|
},
|
|
)
|
|
assert r.status_code == 201, r.text
|
|
finally:
|
|
reset_cache()
|
|
|
|
|
|
def test_keboola_edit_modal_parity(seeded_app, monkeypatch):
|
|
"""Phase G (v26): Edit modal mirrors Register's three-question structure
|
|
(whole | direct | custom) for Keboola rows.
|
|
|
|
Phase F asserted `editKbStrategy` was removed; v26 re-adds it inside
|
|
the Direct-extract panel for the same reason as the Register form."""
|
|
fake_cfg = {"data_source": {"type": "keboola", "keboola": {}}}
|
|
monkeypatch.setattr(
|
|
"app.instance_config.load_instance_config",
|
|
lambda: fake_cfg, raising=False,
|
|
)
|
|
from app.instance_config import reset_cache
|
|
reset_cache()
|
|
try:
|
|
c = seeded_app["client"]
|
|
token = seeded_app["admin_token"]
|
|
r = c.get("/admin/tables", headers=_auth(token))
|
|
html = r.text
|
|
# Q2 radio in edit (now three modes).
|
|
assert 'name="editKbSyncMode"' in html
|
|
assert 'id="editKbBucket"' in html
|
|
assert 'id="editKbSourceTable"' in html
|
|
assert 'id="editKbSourceQuery"' in html
|
|
assert 'id="editKbSyncSchedule"' in html
|
|
# Discover/List/Use-as-base buttons mirror Register.
|
|
assert "discoverKeboolaBuckets('editKbBucketList')" in html
|
|
assert "discoverKeboolaTables('editKbBucket', 'editKbTableList')" in html
|
|
assert "prefillFromKeboolaTable('editKbSourceQuery')" in html
|
|
# v26: Strategy dropdown re-added inside Direct-extract panel
|
|
assert 'id="editKbStrategy"' in html
|
|
assert 'editkb-direct-only' in html
|
|
assert 'id="editKbPrimaryKey"' in html
|
|
finally:
|
|
reset_cache()
|
|
|
|
|
|
def test_bq_edit_modal_renders_as_dom_overlay(seeded_app, bq_instance):
|
|
"""Package-centric rewrite: the connector tab that used to wrap
|
|
#editBqModal was dropped, but the modal itself stays in DOM as a
|
|
top-level overlay reachable from the per-row Edit affordance. Old
|
|
shared #editModal still exists but carries no BQ-specific fields."""
|
|
c = seeded_app["client"]
|
|
token = seeded_app["admin_token"]
|
|
r = c.get("/admin/tables", headers=_auth(token))
|
|
html = r.text
|
|
# BQ edit modal is in the DOM (as a top-level overlay now).
|
|
assert 'id="editBqModal"' in html
|
|
assert 'id="editBqDataset"' in html
|
|
assert 'id="editBqSourceQuery"' in html
|
|
# Old shared #editModal either gone or only carries non-BQ fields.
|
|
if 'id="editModal"' in html:
|
|
edit_modal_start = html.index('id="editModal"')
|
|
# rough lookahead: scan until the next modal-overlay sibling or </body>
|
|
edit_modal_end = html.index('id="toast"', edit_modal_start) \
|
|
if 'id="toast"' in html[edit_modal_start:] else len(html)
|
|
edit_modal = html[edit_modal_start:edit_modal_end]
|
|
assert 'id="editBqDataset"' not in edit_modal # BQ fields aren't here anymore
|
|
|
|
|
|
def test_keboola_discover_buttons_hidden_on_bigquery_instance(seeded_app, monkeypatch):
|
|
"""C1: Discover/List/Use-as-base buttons in the Keboola tab are
|
|
UI-hidden when the instance's data_source.type isn't keboola, because
|
|
/api/admin/discover-tables routes by instance type and would return
|
|
BQ data on a BQ instance."""
|
|
fake_cfg = {"data_source": {"type": "bigquery", "bigquery": {"project": "p"}}}
|
|
monkeypatch.setattr(
|
|
"app.instance_config.load_instance_config",
|
|
lambda: fake_cfg, raising=False,
|
|
)
|
|
from app.instance_config import reset_cache
|
|
reset_cache()
|
|
try:
|
|
c = seeded_app["client"]
|
|
token = seeded_app["admin_token"]
|
|
r = c.get("/admin/tables", headers=_auth(token))
|
|
html = r.text
|
|
# Inputs stay (manual entry works).
|
|
assert 'id="kbBucket"' in html
|
|
assert 'id="kbSourceTable"' in html
|
|
# Buttons hidden — match the actual CALL SITES, not the
|
|
# function definitions or JS comments that may reference the
|
|
# names verbatim. #347 moved several Keboola edit-modal
|
|
# helpers (incl. `prefillFromKeboolaTable`) out from under
|
|
# the keboola Jinja guard so they're now defined as dead code
|
|
# on every instance, but the `onclick="..."` call sites and
|
|
# the Discover buttons themselves still respect the guard,
|
|
# which is what actually matters for runtime behavior.
|
|
assert 'onclick="discoverKeboolaBuckets(' not in html
|
|
assert 'onclick="discoverKeboolaTables(' not in html
|
|
assert 'onclick="prefillFromKeboolaTable(' not in html
|
|
finally:
|
|
reset_cache()
|
|
|
|
|
|
def test_keboola_discover_buttons_visible_on_keboola_instance(seeded_app, monkeypatch):
|
|
"""Inverse — buttons render on a Keboola-typed instance."""
|
|
fake_cfg = {"data_source": {"type": "keboola", "keboola": {}}}
|
|
monkeypatch.setattr(
|
|
"app.instance_config.load_instance_config",
|
|
lambda: fake_cfg, raising=False,
|
|
)
|
|
from app.instance_config import reset_cache
|
|
reset_cache()
|
|
try:
|
|
c = seeded_app["client"]
|
|
token = seeded_app["admin_token"]
|
|
r = c.get("/admin/tables", headers=_auth(token))
|
|
html = r.text
|
|
assert "discoverKeboolaBuckets" in html
|
|
assert "discoverKeboolaTables" in html
|
|
assert "prefillFromKeboolaTable" in html
|
|
finally:
|
|
reset_cache()
|
|
|
|
|
|
def test_admin_tables_keboola_branch_unchanged(seeded_app, monkeypatch):
|
|
"""Phase E: the BQ form is always rendered (inside #tab-content-bigquery)
|
|
regardless of data_source.type. On a Keboola instance the BQ tab is
|
|
just hidden by default; the operator can still click into it. The
|
|
legacy Type-selector remnant (#bqEntityType) must stay gone."""
|
|
fake_cfg = {"data_source": {"type": "keboola", "keboola": {}}}
|
|
monkeypatch.setattr(
|
|
"app.instance_config.load_instance_config",
|
|
lambda: fake_cfg,
|
|
raising=False,
|
|
)
|
|
from app.instance_config import reset_cache
|
|
reset_cache()
|
|
|
|
c = seeded_app["client"]
|
|
token = seeded_app["admin_token"]
|
|
try:
|
|
r = c.get("/admin/tables", headers=_auth(token))
|
|
assert r.status_code == 200, r.text
|
|
html = r.text
|
|
# Legacy Type-selector remnant must stay gone.
|
|
assert 'id="bqEntityType"' not in html
|
|
# BQ form now always rendered inside #tab-content-bigquery.
|
|
assert 'id="bqSourceQuery"' in html
|
|
# C3: legacy #registerModal removed; the Phase F Keboola modal
|
|
# at #registerKeboolaModal owns the Keboola flow now.
|
|
assert 'id="registerModal"' not in html
|
|
assert 'id="kbBucket"' in html
|
|
assert 'id="kbViewName"' in html
|
|
finally:
|
|
reset_cache()
|