This is an automated email from the ASF dual-hosted git repository.
bbovenzi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 5999abe9ba1 UI: Consolidate table controls into a header row above the
table (#71554)
5999abe9ba1 is described below
commit 5999abe9ba137022bffcfae73678a6a1d3d59643
Author: Ryan Hamilton <[email protected]>
AuthorDate: Thu Aug 13 16:26:17 2026 -0400
UI: Consolidate table controls into a header row above the table (#71554)
The column visibility menu was rendered inside the last column header cell,
so
its position shifted with column order and it vanished whenever a table had
no
rows. Hidden columns persist in local storage, so a user who filtered down
to an
empty result was left with no control that could restore them. Each list
page
also hand-rolled its own row of controls, leaving the display toggle in a
different row from the heading it belongs beside, and some pages duplicated
the
row count the table already renders.
Row counts were drawn from page-title translation keys on several tables, so
they read "1 Jobs" or "950 XCom" whatever the count, and the plural form
Russian
translators supplied could never be reached because no integer count
selects it.
Tables whose endpoint reports no total previously claimed zero rows instead
of
naming their model.
---
.../airflow/ui/public/i18n/locales/ar/admin.json | 3 +-
.../airflow/ui/public/i18n/locales/ar/browse.json | 3 +-
.../airflow/ui/public/i18n/locales/ca/admin.json | 3 +-
.../airflow/ui/public/i18n/locales/ca/browse.json | 3 +-
.../airflow/ui/public/i18n/locales/de/admin.json | 3 +-
.../airflow/ui/public/i18n/locales/de/browse.json | 3 +-
.../airflow/ui/public/i18n/locales/el/admin.json | 3 +-
.../airflow/ui/public/i18n/locales/el/browse.json | 3 +-
.../airflow/ui/public/i18n/locales/en/admin.json | 13 +-
.../airflow/ui/public/i18n/locales/en/assets.json | 2 +
.../airflow/ui/public/i18n/locales/en/browse.json | 5 +-
.../airflow/ui/public/i18n/locales/en/common.json | 2 +
.../src/airflow/ui/public/i18n/locales/en/dag.json | 2 +
.../airflow/ui/public/i18n/locales/es/admin.json | 3 +-
.../airflow/ui/public/i18n/locales/es/browse.json | 3 +-
.../airflow/ui/public/i18n/locales/fr/admin.json | 3 +-
.../airflow/ui/public/i18n/locales/fr/browse.json | 3 +-
.../airflow/ui/public/i18n/locales/he/admin.json | 3 +-
.../airflow/ui/public/i18n/locales/he/browse.json | 3 +-
.../airflow/ui/public/i18n/locales/hi/admin.json | 3 +-
.../airflow/ui/public/i18n/locales/hi/browse.json | 3 +-
.../airflow/ui/public/i18n/locales/hu/admin.json | 3 +-
.../airflow/ui/public/i18n/locales/hu/browse.json | 3 +-
.../airflow/ui/public/i18n/locales/it/admin.json | 3 +-
.../airflow/ui/public/i18n/locales/it/browse.json | 3 +-
.../airflow/ui/public/i18n/locales/ja/admin.json | 3 +-
.../airflow/ui/public/i18n/locales/ja/browse.json | 3 +-
.../airflow/ui/public/i18n/locales/ko/admin.json | 3 +-
.../airflow/ui/public/i18n/locales/ko/browse.json | 3 +-
.../airflow/ui/public/i18n/locales/nl/admin.json | 3 +-
.../airflow/ui/public/i18n/locales/nl/browse.json | 3 +-
.../airflow/ui/public/i18n/locales/pl/admin.json | 3 +-
.../airflow/ui/public/i18n/locales/pl/browse.json | 3 +-
.../airflow/ui/public/i18n/locales/pt/admin.json | 3 +-
.../airflow/ui/public/i18n/locales/pt/browse.json | 3 +-
.../airflow/ui/public/i18n/locales/ru/admin.json | 3 +-
.../airflow/ui/public/i18n/locales/ru/browse.json | 3 +-
.../airflow/ui/public/i18n/locales/th/admin.json | 3 +-
.../airflow/ui/public/i18n/locales/th/browse.json | 3 +-
.../airflow/ui/public/i18n/locales/tr/admin.json | 3 +-
.../airflow/ui/public/i18n/locales/tr/browse.json | 3 +-
.../ui/public/i18n/locales/zh-CN/admin.json | 3 +-
.../ui/public/i18n/locales/zh-CN/browse.json | 3 +-
.../ui/public/i18n/locales/zh-TW/admin.json | 3 +-
.../ui/public/i18n/locales/zh-TW/browse.json | 3 +-
.../components/ActionAccordion/ActionAccordion.tsx | 2 +-
.../ui/src/components/Assets/AssetEvents.tsx | 1 +
.../src/components/Banner/BackfillBanner.test.tsx | 1 +
.../ui/src/components/DataTable/CardList.tsx | 37 ++--
.../ui/src/components/DataTable/DataTable.test.tsx | 242 ++++++++++++++++++++-
.../ui/src/components/DataTable/DataTable.tsx | 153 ++++++++-----
.../src/components/DataTable/FilterMenuButton.tsx | 6 +-
.../ui/src/components/DataTable/TableList.tsx | 69 +++---
.../components/DataTable/ToggleTableDisplay.tsx | 1 -
.../ui/src/components/DataTable/modelName.test.ts | 106 +++++++++
.../src/components/DataTable/useRowSelection.tsx | 4 +
.../components/HITLReview/HITLReviewModal.test.tsx | 1 +
.../airflow/ui/src/components/SearchBar.test.tsx | 17 ++
.../src/airflow/ui/src/components/SearchBar.tsx | 4 +-
.../components/TriggerDag/TriggerDAGForm.test.tsx | 1 +
.../Asset/AssetStateStore/AssetStateStore.tsx | 4 +-
.../airflow/ui/src/pages/AssetsList/AssetsList.tsx | 7 +-
.../src/airflow/ui/src/pages/Configs/Configs.tsx | 20 +-
.../pages/Dag/Backfills/BackfillDagRunsModal.tsx | 1 -
.../ui/src/pages/Dag/Backfills/Backfills.tsx | 9 +-
.../ui/src/pages/Dag/DeadlineAlertsBadge.test.tsx | 1 +
.../airflow/ui/src/pages/DagRuns/DagRuns.test.tsx | 27 ++-
.../src/airflow/ui/src/pages/DagRuns/DagRuns.tsx | 27 ++-
.../src/airflow/ui/src/pages/DagsList/DagsList.tsx | 22 +-
.../pages/DagsList/PartitionScheduleModal.test.tsx | 1 +
.../src/pages/DagsList/PartitionScheduleModal.tsx | 2 +-
.../ui/src/pages/Dashboard/AlertContent.test.tsx | 1 +
.../ui/src/pages/Dashboard/Dashboard.test.tsx | 1 +
.../Dashboard/PoolSummary/PoolSummary.test.tsx | 1 +
.../src/airflow/ui/src/pages/Deadlines/index.tsx | 6 +-
.../src/airflow/ui/src/pages/Events/Events.tsx | 37 ++--
.../HITLTaskInstances/HITLResponseForm.test.tsx | 1 +
.../HITLTaskInstances/HITLTaskInstances.test.tsx | 1 +
airflow-core/src/airflow/ui/src/pages/Jobs.tsx | 2 +-
airflow-core/src/airflow/ui/src/pages/Plugins.tsx | 12 +-
.../src/airflow/ui/src/pages/Pools/Pools.tsx | 45 ++--
.../src/airflow/ui/src/pages/Providers.tsx | 8 +-
.../src/pages/TaskInstances/TaskInstances.test.tsx | 1 +
.../ui/src/pages/TaskStateStore/TaskStateStore.tsx | 4 +-
.../airflow/ui/src/pages/Variables/Variables.tsx | 26 ++-
.../src/airflow/ui/src/pages/XCom/XCom.tsx | 52 +++--
.../airflow/ui/tests/e2e/pages/AssetListPage.ts | 3 +-
.../src/airflow/ui/tests/e2e/pages/BackfillPage.ts | 3 +-
.../ui/tests/e2e/pages/ConfigurationPage.ts | 3 +-
.../airflow/ui/tests/e2e/pages/ConnectionsPage.ts | 9 +-
.../src/airflow/ui/tests/e2e/pages/DagRunsPage.ts | 7 +-
.../airflow/ui/tests/e2e/pages/DagRunsTabPage.ts | 3 +-
.../src/airflow/ui/tests/e2e/pages/DagsPage.ts | 3 +-
.../src/airflow/ui/tests/e2e/pages/EventsPage.ts | 6 +-
.../src/airflow/ui/tests/e2e/pages/PluginsPage.ts | 3 +-
.../airflow/ui/tests/e2e/pages/ProvidersPage.ts | 6 +-
.../ui/tests/e2e/pages/TaskInstancesPage.ts | 13 +-
.../src/airflow/ui/tests/e2e/pages/VariablePage.ts | 3 +-
.../src/airflow/ui/tests/e2e/pages/XComsPage.ts | 7 +-
.../e2e/utils/ui/selectors.ts} | 35 +--
100 files changed, 793 insertions(+), 419 deletions(-)
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/ar/admin.json
b/airflow-core/src/airflow/ui/public/i18n/locales/ar/admin.json
index 59a5af4ebc0..333e9729e1d 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/ar/admin.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/ar/admin.json
@@ -9,8 +9,7 @@
"config": {
"columns": {
"section": "القسم"
- },
- "title": "Airflow إعدادات"
+ }
},
"connections": {
"add": "إضافة موَّصل",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/ar/browse.json
b/airflow-core/src/airflow/ui/public/i18n/locales/ar/browse.json
index 082332f5463..bcb3a7d0ce1 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/ar/browse.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/ar/browse.json
@@ -8,8 +8,7 @@
},
"filters": {
"eventType": "نوع الأصل"
- },
- "title": "سجل المراجعة"
+ }
},
"deadlines": {
"columns": {
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/ca/admin.json
b/airflow-core/src/airflow/ui/public/i18n/locales/ca/admin.json
index d7971977234..e3e5bc7b3cf 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/ca/admin.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/ca/admin.json
@@ -9,8 +9,7 @@
"config": {
"columns": {
"section": "Secció"
- },
- "title": "Configuració d'Airflow"
+ }
},
"connections": {
"add": "Afegir connexió",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/ca/browse.json
b/airflow-core/src/airflow/ui/public/i18n/locales/ca/browse.json
index b4a63b4fc53..011a500bb3b 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/ca/browse.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/ca/browse.json
@@ -8,8 +8,7 @@
},
"filters": {
"eventType": "Tipus d'esdeveniment"
- },
- "title": "Registre d'auditoria"
+ }
},
"deadlines": {
"columns": {
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/de/admin.json
b/airflow-core/src/airflow/ui/public/i18n/locales/de/admin.json
index c7f0b9fb112..d14686021f6 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/de/admin.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/de/admin.json
@@ -9,8 +9,7 @@
"config": {
"columns": {
"section": "Abschnitt"
- },
- "title": "Airflow Konfiguration"
+ }
},
"connections": {
"add": "Verbindung hinzufügen",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/de/browse.json
b/airflow-core/src/airflow/ui/public/i18n/locales/de/browse.json
index 2bfc31d8298..c7302923b71 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/de/browse.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/de/browse.json
@@ -8,8 +8,7 @@
},
"filters": {
"eventType": "Ereignis Typ"
- },
- "title": "Prüf-Log"
+ }
},
"deadlines": {
"columns": {
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/el/admin.json
b/airflow-core/src/airflow/ui/public/i18n/locales/el/admin.json
index 179a7257972..80101be8f3f 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/el/admin.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/el/admin.json
@@ -9,8 +9,7 @@
"config": {
"columns": {
"section": "Ενότητα"
- },
- "title": "Ρυθμίσεις Airflow"
+ }
},
"connections": {
"add": "Προσθήκη Σύνδεσης",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/el/browse.json
b/airflow-core/src/airflow/ui/public/i18n/locales/el/browse.json
index a63c8fa9361..a26fcb4d792 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/el/browse.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/el/browse.json
@@ -8,8 +8,7 @@
},
"filters": {
"eventType": "Τύπος Συμβάντος"
- },
- "title": "Καταγραφή Ελέγχου"
+ }
},
"deadlines": {
"columns": {
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/admin.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/admin.json
index 483a0d71069..ae0f534150e 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/admin.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/admin.json
@@ -10,7 +10,8 @@
"columns": {
"section": "Section"
},
- "title": "Airflow Configuration"
+ "config_one": "Config",
+ "config_other": "Configs"
},
"connections": {
"add": "Add $t(connections.connection_one)",
@@ -96,7 +97,9 @@
"dagProcessorJob": "DagProcessorJob",
"schedulerJob": "SchedulerJob",
"triggererJob": "TriggererJob"
- }
+ },
+ "job_one": "Job",
+ "job_other": "Jobs"
},
"plugins": {
"columns": {
@@ -104,6 +107,8 @@
},
"importError_one": "Plugin Import Error",
"importError_other": "Plugin Import Errors",
+ "plugin_one": "Plugin",
+ "plugin_other": "Plugins",
"searchPlaceholder": "Search by file"
},
"pools": {
@@ -137,7 +142,9 @@
"columns": {
"packageName": "Package Name",
"version": "Version"
- }
+ },
+ "provider_one": "Provider",
+ "provider_other": "Providers"
},
"variables": {
"add": "Add $t(variables.variable_one)",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/assets.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/assets.json
index 273a15f57e7..5445bc71c49 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/assets.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/assets.json
@@ -16,6 +16,8 @@
"deleteWarning": "The asset will lose this persisted state store entry.",
"edit": "Edit Asset State Store",
"emptyState": "Asset state store stores values scoped to an asset
identity, shared across all Dag runs. Workers can write asset state store via
the Task SDK.",
+ "entry_one": "Entry",
+ "entry_other": "Entries",
"lastUpdatedBy": "Last Updated By",
"lastUpdatedByApi": "API",
"lastUpdatedByWatcher": "Watcher",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/browse.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/browse.json
index 6c6b1016b5b..4e0ad88f1e9 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/browse.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/browse.json
@@ -8,8 +8,7 @@
},
"filters": {
"eventType": "Event Type"
- },
- "title": "Audit Log"
+ }
},
"deadlines": {
"columns": {
@@ -57,6 +56,8 @@
"successTitle": "XCom Updated",
"title": "Edit XCom"
},
+ "entry_one": "XCom Entry",
+ "entry_other": "XCom Entries",
"key": "Key",
"title": "XCom",
"value": "Value"
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
index 684a0a674de..741495ba110 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
@@ -121,6 +121,8 @@
"title": "Access Denied"
}
},
+ "event_one": "Event",
+ "event_other": "Events",
"expand": {
"collapse": "Collapse",
"expand": "Expand",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
index 6712444e12e..78959630f75 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
@@ -237,6 +237,8 @@
"deleteWarning": "The task will lose this persisted memory. If the task is
using this key to track external work (e.g. an external job ID), it will not be
able to resume it.",
"edit": "Edit Task State Store",
"emptyStore": "Task state store stores values that persist across retries.
Workers can write task state store via the Task SDK.",
+ "entry_one": "Entry",
+ "entry_other": "Entries",
"expiresAt": {
"column": "Expires At",
"custom": "Custom",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/es/admin.json
b/airflow-core/src/airflow/ui/public/i18n/locales/es/admin.json
index 22a888915a7..419920d709a 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/es/admin.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/es/admin.json
@@ -9,8 +9,7 @@
"config": {
"columns": {
"section": "Sección"
- },
- "title": "Configuración de Airflow"
+ }
},
"connections": {
"add": "Agregar Conexión",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/es/browse.json
b/airflow-core/src/airflow/ui/public/i18n/locales/es/browse.json
index 8f694587159..83c201e92a8 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/es/browse.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/es/browse.json
@@ -8,8 +8,7 @@
},
"filters": {
"eventType": "Tipo de evento"
- },
- "title": "Auditar Log"
+ }
},
"deadlines": {
"columns": {
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/fr/admin.json
b/airflow-core/src/airflow/ui/public/i18n/locales/fr/admin.json
index 745fed99419..ebbd92d3b75 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/fr/admin.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/fr/admin.json
@@ -9,8 +9,7 @@
"config": {
"columns": {
"section": "Section"
- },
- "title": "Configuration d'Airflow"
+ }
},
"connections": {
"add": "Ajouter une connexion",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/fr/browse.json
b/airflow-core/src/airflow/ui/public/i18n/locales/fr/browse.json
index 80c8ee092c1..9b8e4e9ca4d 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/fr/browse.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/fr/browse.json
@@ -8,8 +8,7 @@
},
"filters": {
"eventType": "Type d'événement"
- },
- "title": "Journal d'Audit"
+ }
},
"deadlines": {
"columns": {
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/he/admin.json
b/airflow-core/src/airflow/ui/public/i18n/locales/he/admin.json
index 3587bcd775b..b37d0763911 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/he/admin.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/he/admin.json
@@ -9,8 +9,7 @@
"config": {
"columns": {
"section": "קטגוריה"
- },
- "title": "תצורת Airflow"
+ }
},
"connections": {
"add": "הוסף חיבור חדש",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/he/browse.json
b/airflow-core/src/airflow/ui/public/i18n/locales/he/browse.json
index 8b679f73868..23e30c3b6c7 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/he/browse.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/he/browse.json
@@ -8,8 +8,7 @@
},
"filters": {
"eventType": "סוג אירוע"
- },
- "title": "יומן מערכת"
+ }
},
"deadlines": {
"columns": {
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/hi/admin.json
b/airflow-core/src/airflow/ui/public/i18n/locales/hi/admin.json
index 27d20a30af8..be6ce0b48c9 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/hi/admin.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/hi/admin.json
@@ -9,8 +9,7 @@
"config": {
"columns": {
"section": "सेक्शन"
- },
- "title": "Airflow विन्यास"
+ }
},
"connections": {
"add": "कनेक्शन जोड़ें",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/hi/browse.json
b/airflow-core/src/airflow/ui/public/i18n/locales/hi/browse.json
index b2de2995842..1b4be326d28 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/hi/browse.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/hi/browse.json
@@ -8,8 +8,7 @@
},
"filters": {
"eventType": "इवेंट प्रकार"
- },
- "title": "ऑडिट लॉग"
+ }
},
"deadlines": {
"columns": {
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/hu/admin.json
b/airflow-core/src/airflow/ui/public/i18n/locales/hu/admin.json
index 3c789809a38..20e946afbc6 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/hu/admin.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/hu/admin.json
@@ -9,8 +9,7 @@
"config": {
"columns": {
"section": "Szakasz"
- },
- "title": "Airflow konfiguráció"
+ }
},
"connections": {
"add": "Kapcsolat hozzáadása",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/hu/browse.json
b/airflow-core/src/airflow/ui/public/i18n/locales/hu/browse.json
index e8db641c224..b1d78e34f22 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/hu/browse.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/hu/browse.json
@@ -8,8 +8,7 @@
},
"filters": {
"eventType": "Esemény típusa"
- },
- "title": "Naplózás"
+ }
},
"deadlines": {
"columns": {
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/it/admin.json
b/airflow-core/src/airflow/ui/public/i18n/locales/it/admin.json
index c890103bd31..d31a165d423 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/it/admin.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/it/admin.json
@@ -8,8 +8,7 @@
"config": {
"columns": {
"section": "Sezione"
- },
- "title": "Configurazione di Airflow"
+ }
},
"connections": {
"add": "Aggiungi Connessione",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/it/browse.json
b/airflow-core/src/airflow/ui/public/i18n/locales/it/browse.json
index ace4e7d408b..a30e7bbb5c6 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/it/browse.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/it/browse.json
@@ -8,8 +8,7 @@
},
"filters": {
"eventType": "Tipo di Evento"
- },
- "title": "Registro di Controllo (Logs)"
+ }
},
"xcom": {
"columns": {
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/ja/admin.json
b/airflow-core/src/airflow/ui/public/i18n/locales/ja/admin.json
index f183357c0b4..5599888fdfb 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/ja/admin.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/ja/admin.json
@@ -8,8 +8,7 @@
"config": {
"columns": {
"section": "セクション"
- },
- "title": "Airflow 設定"
+ }
},
"connections": {
"add": "接続を追加",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/ja/browse.json
b/airflow-core/src/airflow/ui/public/i18n/locales/ja/browse.json
index 86b6d705a31..9f92cdc63d8 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/ja/browse.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/ja/browse.json
@@ -8,8 +8,7 @@
},
"filters": {
"eventType": "イベントタイプ"
- },
- "title": "監査ログ"
+ }
},
"xcom": {
"columns": {
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/ko/admin.json
b/airflow-core/src/airflow/ui/public/i18n/locales/ko/admin.json
index fba3b7d5fb1..ad0216de2c8 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/ko/admin.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/ko/admin.json
@@ -9,8 +9,7 @@
"config": {
"columns": {
"section": "섹션"
- },
- "title": "Airflow 구성"
+ }
},
"connections": {
"add": "커넥션 추가",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/ko/browse.json
b/airflow-core/src/airflow/ui/public/i18n/locales/ko/browse.json
index 6e2f4ee0ce3..20baeea7ba1 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/ko/browse.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/ko/browse.json
@@ -8,8 +8,7 @@
},
"filters": {
"eventType": "이벤트 유형"
- },
- "title": "감사 로그"
+ }
},
"deadlines": {
"columns": {
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/nl/admin.json
b/airflow-core/src/airflow/ui/public/i18n/locales/nl/admin.json
index 5602ca74e8e..8fe68c13cef 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/nl/admin.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/nl/admin.json
@@ -9,8 +9,7 @@
"config": {
"columns": {
"section": "Sectie"
- },
- "title": "Airflow configuratie"
+ }
},
"connections": {
"add": "Connectie toevoegen",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/nl/browse.json
b/airflow-core/src/airflow/ui/public/i18n/locales/nl/browse.json
index ddd2ece1836..ecf8b158352 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/nl/browse.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/nl/browse.json
@@ -8,8 +8,7 @@
},
"filters": {
"eventType": "Gebeurtenis type"
- },
- "title": "Audit Log"
+ }
},
"deadlines": {
"columns": {
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/pl/admin.json
b/airflow-core/src/airflow/ui/public/i18n/locales/pl/admin.json
index 84868e95131..646a9d89b7e 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/pl/admin.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/pl/admin.json
@@ -9,8 +9,7 @@
"config": {
"columns": {
"section": "Sekcja"
- },
- "title": "Konfiguracja Airflowa"
+ }
},
"connections": {
"add": "Dodaj połączenie",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/pl/browse.json
b/airflow-core/src/airflow/ui/public/i18n/locales/pl/browse.json
index 2526f5f0e08..a9557c07be2 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/pl/browse.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/pl/browse.json
@@ -8,8 +8,7 @@
},
"filters": {
"eventType": "Typ zdarzenia"
- },
- "title": "Dziennik audytu"
+ }
},
"deadlines": {
"columns": {
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/pt/admin.json
b/airflow-core/src/airflow/ui/public/i18n/locales/pt/admin.json
index a9ddaa16f5c..81978d5e785 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/pt/admin.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/pt/admin.json
@@ -9,8 +9,7 @@
"config": {
"columns": {
"section": "Secção"
- },
- "title": "Configuração do Airflow"
+ }
},
"connections": {
"add": "Adicionar Conexão",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/pt/browse.json
b/airflow-core/src/airflow/ui/public/i18n/locales/pt/browse.json
index 44a70dddad3..bb976259928 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/pt/browse.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/pt/browse.json
@@ -8,8 +8,7 @@
},
"filters": {
"eventType": "Tipo de Evento"
- },
- "title": "Log de Auditoria"
+ }
},
"xcom": {
"add": {
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/ru/admin.json
b/airflow-core/src/airflow/ui/public/i18n/locales/ru/admin.json
index 734dc18623a..15232731020 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/ru/admin.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/ru/admin.json
@@ -9,8 +9,7 @@
"config": {
"columns": {
"section": "Раздел"
- },
- "title": "Конфигурация Airflow"
+ }
},
"connections": {
"add": "Добавить соединение",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/ru/browse.json
b/airflow-core/src/airflow/ui/public/i18n/locales/ru/browse.json
index 4e18a73f06b..f00e9cc2a36 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/ru/browse.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/ru/browse.json
@@ -8,8 +8,7 @@
},
"filters": {
"eventType": "Тип события"
- },
- "title": "Журнал аудита"
+ }
},
"xcom": {
"add": {
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/th/admin.json
b/airflow-core/src/airflow/ui/public/i18n/locales/th/admin.json
index 3e42221d2c7..d75abf63e81 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/th/admin.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/th/admin.json
@@ -8,8 +8,7 @@
"config": {
"columns": {
"section": "ส่วน"
- },
- "title": "การตั้งค่า Airflow"
+ }
},
"connections": {
"add": "เพิ่มการเชื่อมต่อ",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/th/browse.json
b/airflow-core/src/airflow/ui/public/i18n/locales/th/browse.json
index 953dd46d608..18df5c6c58c 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/th/browse.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/th/browse.json
@@ -8,8 +8,7 @@
},
"filters": {
"eventType": "ประเภทของอีเวนต์"
- },
- "title": "บันทึกการตรวจสอบ (Audit Log)"
+ }
},
"xcom": {
"columns": {
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/tr/admin.json
b/airflow-core/src/airflow/ui/public/i18n/locales/tr/admin.json
index 4c2d68066a9..0ebff63e4fa 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/tr/admin.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/tr/admin.json
@@ -9,8 +9,7 @@
"config": {
"columns": {
"section": "Bölüm"
- },
- "title": "Airflow Yapılandırması"
+ }
},
"connections": {
"add": "Bağlantı Ekle",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/tr/browse.json
b/airflow-core/src/airflow/ui/public/i18n/locales/tr/browse.json
index 29e69e937ec..f8a0403f086 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/tr/browse.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/tr/browse.json
@@ -8,8 +8,7 @@
},
"filters": {
"eventType": "Etkinlik Türü"
- },
- "title": "Denetim Günlüğü"
+ }
},
"deadlines": {
"columns": {
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/zh-CN/admin.json
b/airflow-core/src/airflow/ui/public/i18n/locales/zh-CN/admin.json
index 6e9664e06f9..0bffb553133 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/zh-CN/admin.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/zh-CN/admin.json
@@ -9,8 +9,7 @@
"config": {
"columns": {
"section": "段落"
- },
- "title": "Airflow 配置"
+ }
},
"connections": {
"add": "添加连接",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/zh-CN/browse.json
b/airflow-core/src/airflow/ui/public/i18n/locales/zh-CN/browse.json
index 2a806604ac9..9e6a99ce093 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/zh-CN/browse.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/zh-CN/browse.json
@@ -8,8 +8,7 @@
},
"filters": {
"eventType": "事件类型"
- },
- "title": "审计日志"
+ }
},
"deadlines": {
"columns": {
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/admin.json
b/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/admin.json
index 8479fde62d5..22035ea4392 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/admin.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/admin.json
@@ -9,8 +9,7 @@
"config": {
"columns": {
"section": "區段"
- },
- "title": "Airflow 設定"
+ }
},
"connections": {
"add": "新增連線",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/browse.json
b/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/browse.json
index e40c1947f56..e69cc3bca77 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/browse.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/browse.json
@@ -8,8 +8,7 @@
},
"filters": {
"eventType": "事件類型"
- },
- "title": "稽核日誌事件"
+ }
},
"deadlines": {
"columns": {
diff --git
a/airflow-core/src/airflow/ui/src/components/ActionAccordion/ActionAccordion.tsx
b/airflow-core/src/airflow/ui/src/components/ActionAccordion/ActionAccordion.tsx
index 0a956162954..f8cc8145bf6 100644
---
a/airflow-core/src/airflow/ui/src/components/ActionAccordion/ActionAccordion.tsx
+++
b/airflow-core/src/airflow/ui/src/components/ActionAccordion/ActionAccordion.tsx
@@ -56,9 +56,9 @@ const TasksTable = ({
columns={columns}
data={tasks}
displayMode="table"
+ hideRowCountHeading
modelName="common:taskInstance"
noRowsMessage={noRowsMessage}
- showRowCountHeading={false}
total={tasks.length}
/>
);
diff --git a/airflow-core/src/airflow/ui/src/components/Assets/AssetEvents.tsx
b/airflow-core/src/airflow/ui/src/components/Assets/AssetEvents.tsx
index f0f1e6173ad..0996416fd08 100644
--- a/airflow-core/src/airflow/ui/src/components/Assets/AssetEvents.tsx
+++ b/airflow-core/src/airflow/ui/src/components/Assets/AssetEvents.tsx
@@ -118,6 +118,7 @@ export const AssetEvents = ({
columns={[]}
data={data?.asset_events ?? []}
displayMode="card"
+ hideRowCountHeading
initialState={tableUrlState}
isLoading={isLoading}
modelName="common:assetEvent"
diff --git
a/airflow-core/src/airflow/ui/src/components/Banner/BackfillBanner.test.tsx
b/airflow-core/src/airflow/ui/src/components/Banner/BackfillBanner.test.tsx
index 3c34a38d178..eb136b0cc1d 100644
--- a/airflow-core/src/airflow/ui/src/components/Banner/BackfillBanner.test.tsx
+++ b/airflow-core/src/airflow/ui/src/components/Banner/BackfillBanner.test.tsx
@@ -43,6 +43,7 @@ vi.mock("openapi/queries", () => ({
vi.mock("react-i18next", () => ({
useTranslation: () => ({
+ i18n: { language: "en" },
// eslint-disable-next-line id-length
t: (key: string) => (key === "banner.backfillInProgress" ? "Backfill in
progress" : key),
}),
diff --git a/airflow-core/src/airflow/ui/src/components/DataTable/CardList.tsx
b/airflow-core/src/airflow/ui/src/components/DataTable/CardList.tsx
index 04e6d400fbd..085eafe2923 100644
--- a/airflow-core/src/airflow/ui/src/components/DataTable/CardList.tsx
+++ b/airflow-core/src/airflow/ui/src/components/DataTable/CardList.tsx
@@ -16,27 +16,34 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Box, SimpleGrid, Skeleton } from "@chakra-ui/react";
+import { Box, SimpleGrid, Skeleton, Text } from "@chakra-ui/react";
import { flexRender, type Row } from "@tanstack/react-table";
+import type { ReactNode } from "react";
import type { CardDef } from "./types";
-type DataTableProps<TData> = {
+type CardListProps<TData> = {
readonly cardDef: CardDef<TData>;
readonly isLoading?: boolean;
+ readonly noRowsMessage?: ReactNode;
readonly rows: Array<Row<TData>>;
};
-export const CardList = <TData,>({ cardDef, isLoading, rows }:
DataTableProps<TData>) => (
- <SimpleGrid data-testid="card-list" {...{ columns: { base: 1 }, gap: 2,
...cardDef.gridProps }}>
- {rows.map((row) => (
- <Box key={row.id}>
- {Boolean(isLoading) &&
- (cardDef.meta?.customSkeleton ?? (
- <Skeleton data-testid="skeleton" display="inline-block"
height={80} width="100%" />
- ))}
- {!Boolean(isLoading) && flexRender(cardDef.card, { row: row.original
})}
- </Box>
- ))}
- </SimpleGrid>
-);
+export const CardList = <TData,>({ cardDef, isLoading, noRowsMessage, rows }:
CardListProps<TData>) =>
+ rows.length === 0 ? (
+ <Text as="div" data-testid="card-no-rows" pl={4} pt={1}>
+ {noRowsMessage}
+ </Text>
+ ) : (
+ <SimpleGrid data-testid="card-list" {...{ columns: { base: 1 }, gap: 2,
...cardDef.gridProps }}>
+ {rows.map((row) => (
+ <Box key={row.id}>
+ {Boolean(isLoading) &&
+ (cardDef.meta?.customSkeleton ?? (
+ <Skeleton data-testid="skeleton" display="inline-block"
height={80} width="100%" />
+ ))}
+ {!Boolean(isLoading) && flexRender(cardDef.card, { row: row.original
})}
+ </Box>
+ ))}
+ </SimpleGrid>
+ );
diff --git
a/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.test.tsx
b/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.test.tsx
index aed86cfa8fb..db41d22c46e 100644
--- a/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.test.tsx
+++ b/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.test.tsx
@@ -19,7 +19,7 @@
import { Text } from "@chakra-ui/react";
import type { ColumnDef, PaginationState } from "@tanstack/react-table";
import "@testing-library/jest-dom";
-import { render, screen } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor, within } from
"@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ChakraWrapper } from "src/utils/ChakraWrapper.tsx";
@@ -35,6 +35,18 @@ const columns: Array<ColumnDef<{ name: string }>> = [
},
];
+// The columns menu is only shown by default once a table has many columns
+const wideColumns: Array<ColumnDef<{ name: string }>> = [
+ ...columns,
+ ...["Second", "Third", "Fourth", "Fifth", "Sixth"].map((header) => ({
+ cell: () => header,
+ header,
+ id: header,
+ })),
+];
+
+const columnsMenuLabel = "table.filterColumns";
+
const data = [{ name: "John Doe" }, { name: "Jane Doe" }];
const pagination: PaginationState = { pageIndex: 0, pageSize: 1 };
@@ -122,6 +134,16 @@ describe("DataTable", () => {
expect(screen.queryByTestId("next")).toBeNull();
});
+ it("renders no offset pagination when no total is provided", () => {
+ render(
+ <DataTable columns={columns} data={data} initialState={{ pagination,
sorting: [] }} modelName="task" />,
+ { wrapper: ChakraWrapper },
+ );
+
+ expect(screen.queryByTestId("prev")).toBeNull();
+ expect(screen.queryByTestId("next")).toBeNull();
+ });
+
it("when isLoading renders skeleton columns", () => {
render(<DataTable columns={columns} data={data} isLoading modelName="task"
/>, {
wrapper: ChakraWrapper,
@@ -130,6 +152,14 @@ describe("DataTable", () => {
expect(screen.getAllByTestId("skeleton")).toHaveLength(10);
});
+ it("renders table skeletons when card mode falls back to a table", () => {
+ render(<DataTable columns={columns} data={data} displayMode="card"
isLoading modelName="task" />, {
+ wrapper: ChakraWrapper,
+ });
+
+ expect(screen.getAllByTestId("skeleton")).toHaveLength(10);
+ });
+
it("still displays table if mode is card but there is no cardDef", () => {
render(<DataTable columns={columns} data={data} displayMode="card"
modelName="task" />, {
wrapper: ChakraWrapper,
@@ -168,7 +198,7 @@ describe("DataTable", () => {
expect(screen.getAllByTestId("skeleton")).toHaveLength(5);
});
- it("renders row count heading by default when total > 0", () => {
+ it("renders the row count heading by default", () => {
render(
<DataTable
columns={columns}
@@ -201,6 +231,40 @@ describe("DataTable", () => {
expect(screen.getByRole("heading")).toHaveTextContent("2 task");
});
+ it("groups thousands in the row count heading", () => {
+ render(<DataTable columns={columns} data={data} modelName="task"
total={1234} />, {
+ wrapper: ChakraWrapper,
+ });
+
+ expect(screen.getByRole("heading")).toHaveTextContent("1,234 task");
+ });
+
+ it("renders a zero row count heading when there are no records", () => {
+ render(<DataTable columns={columns} data={[]} modelName="task" total={0}
/>, {
+ wrapper: ChakraWrapper,
+ });
+
+ expect(screen.getByRole("heading")).toHaveTextContent("0 task");
+ });
+
+ // i18next is not initialised here, so keys come back raw. The `_other`
suffix is the point: the
+ // count-free label must read that key directly rather than passing a
stand-in count.
+ it("names the model without a count when no total is provided", () => {
+ render(<DataTable columns={columns} data={data} modelName="task" />, {
+ wrapper: ChakraWrapper,
+ });
+
+ expect(screen.getByRole("heading")).toHaveTextContent(/^task_other$/u);
+ });
+
+ it("keeps the row count heading while refetching", () => {
+ render(<DataTable columns={columns} data={data} isFetching
modelName="task" total={2} />, {
+ wrapper: ChakraWrapper,
+ });
+
+ expect(screen.getByRole("heading")).toHaveTextContent("2 task");
+ });
+
it("renders a capped row count heading when total reaches
totalEntriesLimit", () => {
render(
<DataTable
@@ -219,14 +283,22 @@ describe("DataTable", () => {
expect(screen.getByRole("heading")).toHaveTextContent("50,000+ task");
});
- it("does not render row count heading when showRowCountHeading is false", ()
=> {
+ it("does not render row count heading during the initial load", () => {
+ render(<DataTable columns={columns} data={[]} isLoading modelName="task"
total={0} />, {
+ wrapper: ChakraWrapper,
+ });
+
+ expect(screen.queryByRole("heading")).toBeNull();
+ });
+
+ it("does not render row count heading when hideRowCountHeading is set", ()
=> {
render(
<DataTable
columns={columns}
data={data}
+ hideRowCountHeading
initialState={{ pagination, sorting: [] }}
modelName="task"
- showRowCountHeading={false}
total={2}
/>,
{ wrapper: ChakraWrapper },
@@ -250,6 +322,40 @@ describe("DataTable", () => {
expect(screen.getByText(/noitemsFound/iu)).toBeInTheDocument();
});
+ it("keeps column headers and spans all columns for the empty message in
table display", () => {
+ render(
+ <DataTable columns={wideColumns} data={[]} modelName="task"
noRowsMessage="nothing here" total={0} />,
+ { wrapper: ChakraWrapper },
+ );
+
+ const table = screen.getByTestId("table-list");
+
+ expect(within(table).getByText("Name")).toBeInTheDocument();
+ expect(within(table).getByText("Sixth")).toBeInTheDocument();
+
+ const cell =
within(screen.getByTestId("table-no-rows")).getByText("nothing here");
+
+ expect(cell.closest("td")).toHaveAttribute("colspan",
String(wideColumns.length));
+ });
+
+ it("renders the empty message in card display", () => {
+ render(
+ <DataTable
+ cardDef={cardDef}
+ columns={columns}
+ data={[]}
+ displayMode="card"
+ modelName="task"
+ noRowsMessage="nothing here"
+ total={0}
+ />,
+ { wrapper: ChakraWrapper },
+ );
+
+ expect(within(screen.getByTestId("card-no-rows")).getByText("nothing
here")).toBeInTheDocument();
+ expect(screen.queryByTestId("table-list")).toBeNull();
+ });
+
it("renders display toggle when showDisplayToggle and onDisplayToggleChange
are provided", () => {
const handleToggle = vi.fn();
@@ -285,4 +391,132 @@ describe("DataTable", () => {
expect(screen.queryByLabelText(/toggleTableView/iu)).toBeNull();
});
+
+ it("renders columns menu in the header row rather than inside the table", ()
=> {
+ render(<DataTable columns={wideColumns} data={data} modelName="task"
total={2} />, {
+ wrapper: ChakraWrapper,
+ });
+
+ expect(
+
within(screen.getByTestId("data-table-header")).getByLabelText(columnsMenuLabel),
+ ).toBeInTheDocument();
+
expect(within(screen.getByTestId("table-list")).queryByLabelText(columnsMenuLabel)).toBeNull();
+ });
+
+ it("does not render columns menu in card display", () => {
+ render(
+ <DataTable
+ cardDef={cardDef}
+ columns={wideColumns}
+ data={data}
+ displayMode="card"
+ modelName="task"
+ total={2}
+ />,
+ { wrapper: ChakraWrapper },
+ );
+
+ expect(screen.queryByLabelText(columnsMenuLabel)).toBeNull();
+ });
+
+ it("keeps columns menu available when there are no rows", () => {
+ render(<DataTable columns={wideColumns} data={[]} modelName="task"
total={0} />, {
+ wrapper: ChakraWrapper,
+ });
+
+ expect(screen.getByLabelText(columnsMenuLabel)).toBeInTheDocument();
+ expect(screen.getByText(/noitemsFound/iu)).toBeInTheDocument();
+ });
+
+ it("hides columns menu when showColumnsMenu is false", () => {
+ render(
+ <DataTable columns={wideColumns} data={data} modelName="task"
showColumnsMenu={false} total={2} />,
+ { wrapper: ChakraWrapper },
+ );
+
+ expect(screen.queryByLabelText(columnsMenuLabel)).toBeNull();
+ });
+
+ it("shows columns menu for few columns when showColumnsMenu is true", () => {
+ render(<DataTable columns={columns} data={data} modelName="task"
showColumnsMenu total={2} />, {
+ wrapper: ChakraWrapper,
+ });
+
+ expect(screen.getByLabelText(columnsMenuLabel)).toBeInTheDocument();
+ });
+
+ it("hides a column when unchecked in the columns menu", async () => {
+ render(<DataTable columns={wideColumns} data={data} modelName="task"
total={2} />, {
+ wrapper: ChakraWrapper,
+ });
+
+ const trigger = screen.getByLabelText(columnsMenuLabel);
+
+ fireEvent.pointerDown(trigger);
+ fireEvent.click(trigger);
+
+ const menuItem = await waitFor(() => screen.getByRole("menuitem", { name:
"Second" }));
+
+ fireEvent.click(menuItem);
+
+ await waitFor(() =>
expect(within(screen.getByTestId("table-list")).queryByText("Second")).toBeNull());
+ });
+
+ it("renders actions independently of rows and heading", () => {
+ render(
+ <DataTable
+ actions={<button type="button">custom action</button>}
+ columns={columns}
+ data={[]}
+ hideRowCountHeading
+ modelName="task"
+ total={0}
+ />,
+ { wrapper: ChakraWrapper },
+ );
+
+ expect(screen.getByText("custom action")).toBeInTheDocument();
+ });
+
+ it("renders headingExtra next to the row count heading", () => {
+ render(
+ <DataTable
+ columns={columns}
+ data={data}
+ headingExtra={<Text>heading extra</Text>}
+ initialState={{ pagination, sorting: [] }}
+ modelName="task"
+ total={2}
+ />,
+ { wrapper: ChakraWrapper },
+ );
+
+ expect(screen.getByRole("heading")).toHaveTextContent("2 task");
+ expect(screen.getByText("heading extra")).toBeInTheDocument();
+ });
+
+ it("renders headingExtra when the row count heading is hidden", () => {
+ render(
+ <DataTable
+ columns={columns}
+ data={[]}
+ headingExtra={<Text>heading extra</Text>}
+ hideRowCountHeading
+ modelName="task"
+ total={0}
+ />,
+ { wrapper: ChakraWrapper },
+ );
+
+ expect(screen.queryByRole("heading")).toBeNull();
+ expect(screen.getByText("heading extra")).toBeInTheDocument();
+ });
+
+ it("renders no header row when there is nothing to show", () => {
+ render(<DataTable columns={columns} data={data} hideRowCountHeading
modelName="task" total={2} />, {
+ wrapper: ChakraWrapper,
+ });
+
+ expect(screen.queryByTestId("data-table-header")).toBeNull();
+ });
});
diff --git a/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.tsx
b/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.tsx
index fbcec76a677..8e26f1024a8 100644
--- a/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.tsx
+++ b/airflow-core/src/airflow/ui/src/components/DataTable/DataTable.tsx
@@ -16,66 +16,82 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Box, Heading, HStack, Text } from "@chakra-ui/react";
+import { Box, Flex, Heading, HStack } from "@chakra-ui/react";
import {
getCoreRowModel,
- getExpandedRowModel,
getPaginationRowModel,
- useReactTable,
- type VisibilityState,
type OnChangeFn,
type TableState as ReactTableState,
- type Row,
type Table as TanStackTable,
type Updater,
+ useReactTable,
+ type VisibilityState,
} from "@tanstack/react-table";
-import React, { type ReactNode, useRef, useCallback } from "react";
+import { type ReactNode, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { HiChevronLeft, HiChevronRight } from "react-icons/hi2";
import { useLocalStorage } from "usehooks-ts";
import { CardList } from "src/components/DataTable/CardList";
+import { FilterMenuButton } from "src/components/DataTable/FilterMenuButton";
import { TableList } from "src/components/DataTable/TableList";
import { ToggleTableDisplay } from
"src/components/DataTable/ToggleTableDisplay";
import { createSkeletonMock } from "src/components/DataTable/skeleton";
import type { CardDef, MetaColumn, TableState } from
"src/components/DataTable/types";
-import { IconButton, ProgressBar, Pagination, Toaster } from
"src/components/ui";
+import { IconButton, Pagination, ProgressBar, Toaster } from
"src/components/ui";
type DataTableProps<TData> = {
- readonly allowFiltering?: boolean;
+ /**
+ * Table-level controls (sort selects, expand/collapse) rendered at the
start of the header row's
+ * right-hand group, before the columns menu and the display toggle. Pass
`undefined` rather than
+ * an empty fragment when there is nothing to show, otherwise the header row
renders empty.
+ */
+ readonly actions?: ReactNode;
readonly cardDef?: CardDef<TData>;
readonly columns: Array<MetaColumn<TData>>;
readonly data: Array<TData>;
readonly displayMode?: "card" | "table";
readonly errorMessage?: ReactNode | string;
- readonly getRowCanExpand?: (row: Row<TData>) => boolean;
+ /** Rendered next to the row count heading, and on its own when the heading
is hidden. */
+ readonly headingExtra?: ReactNode;
+ readonly hideRowCountHeading?: boolean;
readonly initialState?: TableState;
readonly isFetching?: boolean;
readonly isLoading?: boolean;
+ /**
+ * i18n key naming the model this table lists — namespaced (`common:dagRun`)
or bare for `common`.
+ *
+ * Pass the base key, never a plural variant: the heading resolves it as
`t(modelName, { count })`,
+ * so i18next appends `_one`/`_other` itself and `common:event_one` would
render "Event" at every
+ * count. Both variants must exist, and `_other` is also read on its own for
tables with no `total`
+ * — `modelName.test.ts` checks this for every call site.
+ *
+ * It also keys the persisted column visibility, so tables sharing a
`modelName` share hidden
+ * columns, and changing it resets that state for existing users.
+ */
readonly modelName: string;
readonly nextCursor?: string | null;
readonly noRowsMessage?: ReactNode;
readonly onDisplayToggleChange?: (mode: "card" | "table") => void;
readonly onStateChange?: (state: TableState) => void;
readonly previousCursor?: string | null;
- readonly renderSubComponent?: (props: { row: Row<TData> }) =>
React.ReactElement;
+ /** Defaults to showing the column visibility menu only when there are many
columns. */
+ readonly showColumnsMenu?: boolean;
readonly showDisplayToggle?: boolean;
- readonly showRowCountHeading?: boolean;
readonly skeletonCount?: number;
readonly total?: number;
readonly totalEntriesLimit?: number;
};
-const defaultGetRowCanExpand = () => false;
-
export const DataTable = <TData,>({
- allowFiltering,
+ actions,
cardDef,
columns,
data,
displayMode = "table",
errorMessage,
- getRowCanExpand = defaultGetRowCanExpand,
+ headingExtra,
+ hideRowCountHeading,
initialState,
isFetching,
isLoading,
@@ -85,10 +101,10 @@ export const DataTable = <TData,>({
onDisplayToggleChange,
onStateChange,
previousCursor,
+ showColumnsMenu,
showDisplayToggle,
- showRowCountHeading = true,
skeletonCount = 10,
- total = 0,
+ total,
totalEntriesLimit,
}: DataTableProps<TData>) => {
"use no memo"; // remove if https://github.com/TanStack/table/issues/5567 is
resolved
@@ -122,21 +138,25 @@ export const DataTable = <TData,>({
initialState?.columnVisibility ?? {},
);
- const rest = Boolean(isLoading) ? createSkeletonMock(displayMode,
skeletonCount, columns) : {};
+ // An absent total means the endpoint gives no count (e.g. cursor
pagination), which the heading
+ // reflects by naming the model without a number. Everything else still
needs a real number.
+ const rowTotal = total ?? 0;
+
+ // Card mode is only reachable with a cardDef; without one the table renders
and needs table skeletons
+ const display = displayMode === "card" && Boolean(cardDef) ? "card" :
"table";
+ const rest = Boolean(isLoading) ? createSkeletonMock(display, skeletonCount,
columns) : {};
const table = useReactTable({
columns,
data,
enableHiding: true,
getCoreRowModel: getCoreRowModel(),
- getExpandedRowModel: getExpandedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
- getRowCanExpand,
manualPagination: true,
manualSorting: true,
onColumnVisibilityChange: setColumnVisibility,
onStateChange: handleStateChange,
- rowCount: total,
+ rowCount: rowTotal,
// We need to manually set the sort toggle buttons for undefined values
sortDescFirst: false,
state: { ...initialState, columnVisibility },
@@ -153,58 +173,79 @@ export const DataTable = <TData,>({
const { pagination } = table.getState();
const { pageIndex, pageSize } = pagination;
- const display = displayMode === "card" && Boolean(cardDef) ? "card" :
"table";
- const hasRows = rows.length > 0;
const hasNext = nextCursor !== undefined && nextCursor !== null;
const hasPrevious = previousCursor !== undefined && previousCursor !== null;
const hasCursorPagination = hasNext || hasPrevious;
+ // Page count is derived from the total, so without one there is nothing to
paginate over
const hasOffsetPagination =
!hasCursorPagination &&
+ total !== undefined &&
initialState?.pagination !== undefined &&
- (pageIndex !== 0 || rows.length !== total);
-
- // Default to show columns filter only if there are actually many columns
displayed
- const showColumnsFilter = allowFiltering ?? columns.length > 5;
+ (pageIndex !== 0 || rows.length !== rowTotal);
- const translateModelName = useCallback(
- (count: number) => translate(modelName, { count }),
- [modelName, translate],
- );
- const showRowCount = Boolean(
- showRowCountHeading && !Boolean(isLoading) && !Boolean(isFetching) &&
total > 0,
- );
+ const translateModelName = (count: number) => translate(modelName, { count
});
+ // During the initial load there is nothing to count yet
+ const showRowCount = !Boolean(hideRowCountHeading) && !Boolean(isLoading);
+ const noRowsNode = noRowsMessage ?? translate("noItemsFound", { modelName:
translateModelName(0) });
+ // i18next derives the plural form from the count, but in some languages
(Russian) no integer count
+ // ever selects `_other`, so read the count-free plural key directly instead
of passing a stand-in.
+ const pluralModelName = translate(`${modelName}_other`);
// Cursor pagination reports the total capped at totalEntriesLimit, so a
total that reaches the
// cap means "at least this many" and is rendered as "N+".
- const isCapped = totalEntriesLimit !== undefined && total >=
totalEntriesLimit;
- const totalLabel = `${total.toLocaleString(i18n.language)}${isCapped ? "+" :
""}`;
- const noRowsModelName = translateModelName(0);
+ const isCapped = total !== undefined && totalEntriesLimit !== undefined &&
total >= totalEntriesLimit;
- const rowCountHeading = showRowCount ? (
- <Heading py={3} size="md">
- {`${totalLabel} ${translateModelName(total)}`}
+ // Default to showing the columns menu only if there are actually many
columns displayed
+ const renderColumnsMenu =
+ display === "table" &&
+ (showColumnsMenu ?? columns.length > 5) &&
+ table.getAllLeafColumns().some((column) => column.getCanHide());
+ const headingNode = showRowCount ? (
+ <Heading size="md">
+ {total === undefined
+ ? pluralModelName
+ : `${total.toLocaleString(i18n.language)}${isCapped ? "+" : ""}
${translateModelName(total)}`}
</Heading>
) : undefined;
+ const renderHeaderRow =
+ headingNode !== undefined ||
+ headingExtra !== undefined ||
+ actions !== undefined ||
+ renderColumnsMenu ||
+ (Boolean(showDisplayToggle) && onDisplayToggleChange !== undefined);
return (
- <Box display="flex" flex={1} flexDirection="column" minH={0}>
- <ProgressBar size="xs" visibility={Boolean(isFetching) &&
!Boolean(isLoading) ? "visible" : "hidden"} />
- {showDisplayToggle && onDisplayToggleChange ? (
- <ToggleTableDisplay display={display}
setDisplay={onDisplayToggleChange} />
- ) : undefined}
+ <Box display="flex" flex={1} flexDirection="column" minH={0} w="100%">
<Toaster />
+ {renderHeaderRow ? (
+ <Flex
+ alignItems="center"
+ data-testid="data-table-header"
+ gap={2}
+ justifyContent="space-between"
+ minH={10}
+ mt={2} // This offsets the spacing below created by the ProgressBar
+ >
+ <HStack gap={2}>
+ {headingNode}
+ {headingExtra}
+ </HStack>
+ {/* Margin keeps the group right-aligned when nothing renders on the
left */}
+ <HStack gap={2} ms="auto">
+ {actions}
+ {renderColumnsMenu ? <FilterMenuButton table={table} /> :
undefined}
+ {showDisplayToggle && onDisplayToggleChange ? (
+ <ToggleTableDisplay display={display}
setDisplay={onDisplayToggleChange} />
+ ) : undefined}
+ </HStack>
+ </Flex>
+ ) : undefined}
{errorMessage}
- {rowCountHeading}
+ <ProgressBar size="xs" visibility={Boolean(isFetching) &&
!Boolean(isLoading) ? "visible" : "hidden"} />
<Box flex={1} minH={0} overflow="auto">
- {hasRows && display === "table" ? (
- <TableList allowFiltering={showColumnsFilter} table={table} />
- ) : undefined}
- {hasRows && display === "card" && cardDef !== undefined ? (
- <CardList cardDef={cardDef} isLoading={isLoading} rows={rows} />
- ) : undefined}
- {!hasRows && !Boolean(isLoading) && (
- <Text as="div" pl={4} pt={1}>
- {noRowsMessage ?? translate("noItemsFound", { modelName:
noRowsModelName })}
- </Text>
+ {display === "card" && cardDef !== undefined ? (
+ <CardList cardDef={cardDef} isLoading={isLoading}
noRowsMessage={noRowsNode} rows={rows} />
+ ) : (
+ <TableList noRowsMessage={noRowsNode} table={table} />
)}
</Box>
{hasOffsetPagination ? (
diff --git
a/airflow-core/src/airflow/ui/src/components/DataTable/FilterMenuButton.tsx
b/airflow-core/src/airflow/ui/src/components/DataTable/FilterMenuButton.tsx
index bee7b9f8be4..0acca76c468 100644
--- a/airflow-core/src/airflow/ui/src/components/DataTable/FilterMenuButton.tsx
+++ b/airflow-core/src/airflow/ui/src/components/DataTable/FilterMenuButton.tsx
@@ -27,7 +27,7 @@ type Props<TData> = {
readonly table: Table<TData>;
};
-const FilterMenuButton = <TData,>({ table }: Props<TData>) => {
+export const FilterMenuButton = <TData,>({ table }: Props<TData>) => {
"use no memo"; // remove if https://github.com/TanStack/table/issues/5567 is
resolved
const { t: translate } = useTranslation("common");
@@ -35,7 +35,7 @@ const FilterMenuButton = <TData,>({ table }: Props<TData>) =>
{
return (
<Menu.Root closeOnSelect={false}
tooltipLabel={translate("table.filterColumns")}>
<Menu.Trigger asChild>
- <IconButton aria-label={translate("table.filterColumns")}>
+ <IconButton aria-label={translate("table.filterColumns")}
variant="outline">
<LuColumns3 />
</IconButton>
</Menu.Trigger>
@@ -69,5 +69,3 @@ const FilterMenuButton = <TData,>({ table }: Props<TData>) =>
{
</Menu.Root>
);
};
-
-export default FilterMenuButton;
diff --git a/airflow-core/src/airflow/ui/src/components/DataTable/TableList.tsx
b/airflow-core/src/airflow/ui/src/components/DataTable/TableList.tsx
index 41e4f6907fd..ce948bbefa6 100644
--- a/airflow-core/src/airflow/ui/src/components/DataTable/TableList.tsx
+++ b/airflow-core/src/airflow/ui/src/components/DataTable/TableList.tsx
@@ -16,68 +16,74 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Button, Table } from "@chakra-ui/react";
-import { flexRender, type Row, type Table as TanStackTable } from
"@tanstack/react-table";
-import React, { Fragment } from "react";
+import { Button, Icon, Table } from "@chakra-ui/react";
+import { flexRender, type Table as TanStackTable } from
"@tanstack/react-table";
+import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { TiArrowSortedDown, TiArrowSortedUp, TiArrowUnsorted } from
"react-icons/ti";
-import FilterMenuButton from "./FilterMenuButton";
-
-type DataTableProps<TData> = {
- readonly allowFiltering: boolean;
- readonly renderSubComponent?: (props: { row: Row<TData> }) =>
React.ReactElement;
+type TableListProps<TData> = {
+ readonly noRowsMessage?: ReactNode;
readonly table: TanStackTable<TData>;
};
-export const TableList = <TData,>({ allowFiltering, renderSubComponent, table
}: DataTableProps<TData>) => {
+export const TableList = <TData,>({ noRowsMessage, table }:
TableListProps<TData>) => {
"use no memo"; // remove if https://github.com/TanStack/table/issues/5567 is
resolved
const { t: translate } = useTranslation("components");
+ const { rows } = table.getRowModel();
return (
- <Table.Root data-testid="table-list" striped>
+ <Table.Root data-testid="table-list" size="sm" striped>
<Table.Header bg="chakra-body-bg" position="sticky" top={0} zIndex={1}>
{table.getHeaderGroups().map((headerGroup) => (
<Table.Row key={headerGroup.id}>
- {headerGroup.headers.map(({ colSpan, column, getContext, id,
isPlaceholder }, index) => {
+ {headerGroup.headers.map(({ colSpan, column, getContext, id,
isPlaceholder }) => {
const sort = column.getIsSorted();
const canSort = column.getCanSort();
const text = flexRender(column.columnDef.header, getContext());
let rightIcon;
- const showFilters = allowFiltering && index ===
headerGroup.headers.length - 1;
-
if (canSort) {
if (sort === "desc") {
- rightIcon = <TiArrowSortedDown
aria-label={translate("sortedDescending")} />;
+ rightIcon = (
+ <Icon aria-label={translate("sortedDescending")}
as={TiArrowSortedDown} boxSize={3} />
+ );
} else if (sort === "asc") {
- rightIcon = <TiArrowSortedUp
aria-label={translate("sortedAscending")} />;
+ rightIcon = (
+ <Icon aria-label={translate("sortedAscending")}
as={TiArrowSortedUp} boxSize={3} />
+ );
} else {
- rightIcon = <TiArrowUnsorted
aria-label={translate("sortedUnsorted")} />;
+ rightIcon = (
+ <Icon aria-label={translate("sortedUnsorted")}
as={TiArrowUnsorted} boxSize={3} />
+ );
}
return (
- <Table.ColumnHeader colSpan={colSpan} key={id}
whiteSpace="nowrap">
+ <Table.ColumnHeader colSpan={colSpan} key={id}
paddingBlock={1} whiteSpace="nowrap">
{isPlaceholder ? undefined : (
<Button
+ _focus={{ color: "brand.500" }}
+ _hover={{ color: "brand.500" }}
aria-label={translate("sort")}
+ border={0}
+ color={sort === false ? undefined : "brand.500"}
disabled={!canSort}
+ gap={1}
onClick={column.getToggleSortingHandler()}
+ p={0}
variant="plain"
>
{text}
{rightIcon}
</Button>
)}
- {showFilters ? <FilterMenuButton table={table} /> :
undefined}
</Table.ColumnHeader>
);
}
return (
- <Table.ColumnHeader colSpan={colSpan} key={id}
whiteSpace="nowrap">
+ <Table.ColumnHeader colSpan={colSpan} key={id}
paddingBlock={1} whiteSpace="nowrap">
{isPlaceholder ? undefined : text}
- {showFilters ? <FilterMenuButton table={table} /> :
undefined}
</Table.ColumnHeader>
);
})}
@@ -85,26 +91,21 @@ export const TableList = <TData,>({ allowFiltering,
renderSubComponent, table }:
))}
</Table.Header>
<Table.Body>
- {table.getRowModel().rows.map((row) => (
- <Fragment key={row.id}>
- <Table.Row>
- {/* first row is a normal row */}
+ {rows.length === 0 ? (
+ <Table.Row data-testid="table-no-rows">
+ <Table.Cell
colSpan={table.getVisibleLeafColumns().length}>{noRowsMessage}</Table.Cell>
+ </Table.Row>
+ ) : (
+ rows.map((row) => (
+ <Table.Row key={row.id}>
{row.getVisibleCells().map((cell) => (
<Table.Cell data-testid={`table-cell-${cell.column.id}`}
key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</Table.Cell>
))}
</Table.Row>
- {row.getIsExpanded() && (
- <Table.Row>
- {/* 2nd row is a custom 1 cell row */}
- <Table.Cell colSpan={row.getVisibleCells().length}>
- {renderSubComponent?.({ row })}
- </Table.Cell>
- </Table.Row>
- )}
- </Fragment>
- ))}
+ ))
+ )}
</Table.Body>
</Table.Root>
);
diff --git
a/airflow-core/src/airflow/ui/src/components/DataTable/ToggleTableDisplay.tsx
b/airflow-core/src/airflow/ui/src/components/DataTable/ToggleTableDisplay.tsx
index 01c7314e1d1..a5c9af0fd49 100644
---
a/airflow-core/src/airflow/ui/src/components/DataTable/ToggleTableDisplay.tsx
+++
b/airflow-core/src/airflow/ui/src/components/DataTable/ToggleTableDisplay.tsx
@@ -39,7 +39,6 @@ export const ToggleTableDisplay = ({ display, setDisplay }:
Props) => {
{ label: <FiGrid />, title: translate("toggleCardView"), value: "card"
},
{ label: <FiAlignJustify />, title: translate("toggleTableView"),
value: "table" },
]}
- pb={2}
value={display}
/>
);
diff --git
a/airflow-core/src/airflow/ui/src/components/DataTable/modelName.test.ts
b/airflow-core/src/airflow/ui/src/components/DataTable/modelName.test.ts
new file mode 100644
index 00000000000..95dea1afcdf
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/DataTable/modelName.test.ts
@@ -0,0 +1,106 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { createInstance, type i18n as I18n } from "i18next";
+import fs from "node:fs";
+import path from "node:path";
+import { beforeAll, describe, expect, it } from "vitest";
+
+import { i18nBaseOptions } from "src/i18n/config";
+
+// DataTable renders its heading as `t(modelName, { count })`, so i18next
derives the plural suffix.
+// Nothing else validates these keys: the i18n lint rules only compare locale
files against each
+// other, and eslint-plugin-i18next only forbids literal strings. A key that
lacks `_one`/`_other`
+// silently renders a key path or the same word for every count.
+const SRC_DIR = path.resolve(import.meta.dirname, "../..");
+const LOCALES_DIR = path.resolve(SRC_DIR, "../public/i18n/locales/en");
+
+const collectSourceFiles = (dir: string): Array<string> =>
+ fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
+ const entryPath = path.join(dir, entry.name);
+
+ if (entry.isDirectory()) {
+ return collectSourceFiles(entryPath);
+ }
+
+ return entry.name.endsWith(".tsx") && !entry.name.endsWith(".test.tsx") ?
[entryPath] : [];
+ });
+
+const findModelNames = (): Array<string> => {
+ const found = new Set<string>();
+
+ for (const file of collectSourceFiles(SRC_DIR)) {
+ for (const match of fs.readFileSync(file,
"utf8").matchAll(/modelName="(?<key>[^"]+)"/gu)) {
+ const key = match.groups?.key;
+
+ if (key !== undefined) {
+ found.add(key);
+ }
+ }
+ }
+
+ return [...found].sort();
+};
+
+const modelNames = findModelNames();
+
+let instance: I18n;
+
+beforeAll(async () => {
+ const resources = Object.fromEntries(
+ fs
+ .readdirSync(LOCALES_DIR)
+ .filter((file) => file.endsWith(".json"))
+ .map((file) => [
+ path.basename(file, ".json"),
+ JSON.parse(fs.readFileSync(path.join(LOCALES_DIR, file), "utf8")) as
Record<string, unknown>,
+ ]),
+ );
+
+ instance = createInstance();
+ // Use the production options so the test guards the real defaultNS/fallback
behaviour
+ await instance.init({ ...i18nBaseOptions, lng: "en", resources: { en:
resources } });
+});
+
+describe("DataTable modelName keys", () => {
+ it("finds the modelName props to check", () => {
+ expect(modelNames.length).toBeGreaterThan(15);
+ });
+
+ it.each(modelNames)("%s resolves to singular and plural labels", (modelName)
=> {
+ const separator = modelName.indexOf(":");
+ const namespace = separator === -1 ? i18nBaseOptions.defaultNS :
modelName.slice(0, separator);
+ const keyPath = separator === -1 ? modelName : modelName.slice(separator +
1);
+
+ // Checked structurally rather than by comparing rendered output. An
invariant noun ("1 fish" /
+ // "3 fish") legitimately renders the same string for both counts, so
equality proves nothing —
+ // only the keys' presence distinguishes a real model name from a page
title used as one, which
+ // is the mistake this guards. English needs both forms; a language whose
CLDR rules omit `one`
+ // would not, so do not extend this assertion to other locales as-is.
+ expect(instance.getResource("en", namespace,
`${keyPath}_one`)).toBeDefined();
+ expect(instance.getResource("en", namespace,
`${keyPath}_other`)).toBeDefined();
+
+ // A key that resolves to nothing comes back as the key itself, minus its
namespace. This also
+ // catches a broken `$t(...)` reference inside a plural form.
+ expect(instance.t(modelName, { count: 1 })).not.toBe(keyPath);
+ expect(instance.t(modelName, { count: 2 })).not.toBe(keyPath);
+ // DataTable reads this key directly for the count-free label, because no
integer count selects
+ // `_other` in every language
+ expect(instance.t(`${modelName}_other`)).not.toBe(`${keyPath}_other`);
+ });
+});
diff --git
a/airflow-core/src/airflow/ui/src/components/DataTable/useRowSelection.tsx
b/airflow-core/src/airflow/ui/src/components/DataTable/useRowSelection.tsx
index 23048cba77a..11bebdc907b 100644
--- a/airflow-core/src/airflow/ui/src/components/DataTable/useRowSelection.tsx
+++ b/airflow-core/src/airflow/ui/src/components/DataTable/useRowSelection.tsx
@@ -82,6 +82,8 @@ export const SelectionRowCheckbox = ({ colorPalette, rowKey
}: SelectionRowCheck
checked={selectedRows.has(rowKey)}
colorPalette={colorPalette}
onCheckedChange={(event) => onRowSelect(rowKey, Boolean(event.checked))}
+ size="xs"
+ verticalAlign="middle"
/>
);
};
@@ -99,6 +101,8 @@ export const SelectionHeaderCheckbox = ({ colorPalette }:
SelectionHeaderCheckbo
checked={allRowsSelected}
colorPalette={colorPalette}
onCheckedChange={(event) => onSelectAll(Boolean(event.checked))}
+ size="xs"
+ verticalAlign="middle"
/>
);
};
diff --git
a/airflow-core/src/airflow/ui/src/components/HITLReview/HITLReviewModal.test.tsx
b/airflow-core/src/airflow/ui/src/components/HITLReview/HITLReviewModal.test.tsx
index ed10180ad41..30e06d2ebae 100644
---
a/airflow-core/src/airflow/ui/src/components/HITLReview/HITLReviewModal.test.tsx
+++
b/airflow-core/src/airflow/ui/src/components/HITLReview/HITLReviewModal.test.tsx
@@ -27,6 +27,7 @@ import { HITLReviewModal } from "./HITLReviewModal";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
+ i18n: { language: "en" },
// eslint-disable-next-line id-length
t: (key: string) => key,
}),
diff --git a/airflow-core/src/airflow/ui/src/components/SearchBar.test.tsx
b/airflow-core/src/airflow/ui/src/components/SearchBar.test.tsx
index dba492e5f8a..a727a13f471 100644
--- a/airflow-core/src/airflow/ui/src/components/SearchBar.test.tsx
+++ b/airflow-core/src/airflow/ui/src/components/SearchBar.test.tsx
@@ -186,4 +186,21 @@ describe("Test SearchBar", () => {
expect(screen.getByTestId("advanced-search-toggle").getAttribute("aria-pressed")).toBe("true");
});
+
+ it("appends the hotkey hint to the placeholder by default", () => {
+ render(<SearchBar defaultValue="" onChange={vi.fn()} placeholder="Search
Dags" />, {
+ wrapper: Wrapper,
+ });
+
+
expect(screen.getByTestId("search-dags").getAttribute("placeholder")).toContain("Search
Dags (");
+ });
+
+ // Interpolating the suffix conditionally used to stringify `undefined` into
the placeholder
+ it("shows only the placeholder when the hotkey is disabled", () => {
+ render(<SearchBar defaultValue="" hotkeyDisabled onChange={vi.fn()}
placeholder="Search Dags" />, {
+ wrapper: Wrapper,
+ });
+
+
expect(screen.getByTestId("search-dags").getAttribute("placeholder")).toBe("Search
Dags");
+ });
});
diff --git a/airflow-core/src/airflow/ui/src/components/SearchBar.tsx
b/airflow-core/src/airflow/ui/src/components/SearchBar.tsx
index d16251af8e3..f431e3e55ef 100644
--- a/airflow-core/src/airflow/ui/src/components/SearchBar.tsx
+++ b/airflow-core/src/airflow/ui/src/components/SearchBar.tsx
@@ -112,7 +112,9 @@ export const SearchBar = ({
<Input
data-testid="search-dags"
onChange={onSearchChange}
- placeholder={`${placeholder}${hotkeyDisabled ? undefined : `
(${metaKey}${translate("search.hotkey")})`}`}
+ placeholder={
+ hotkeyDisabled ? placeholder : `${placeholder}
(${metaKey}${translate("search.hotkey")})`
+ }
ref={searchRef}
value={value}
/>
diff --git
a/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGForm.test.tsx
b/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGForm.test.tsx
index 2181842f681..aeff7a8aa45 100644
---
a/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGForm.test.tsx
+++
b/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGForm.test.tsx
@@ -39,6 +39,7 @@ const dagParams = vi.hoisted(() => ({
vi.mock("react-i18next", () => ({
useTranslation: () => ({
+ i18n: { language: "en" },
// eslint-disable-next-line id-length
t: (translationKey: string) =>
({
diff --git
a/airflow-core/src/airflow/ui/src/pages/Asset/AssetStateStore/AssetStateStore.tsx
b/airflow-core/src/airflow/ui/src/pages/Asset/AssetStateStore/AssetStateStore.tsx
index 1d924335a20..36dc8de6906 100644
---
a/airflow-core/src/airflow/ui/src/pages/Asset/AssetStateStore/AssetStateStore.tsx
+++
b/airflow-core/src/airflow/ui/src/pages/Asset/AssetStateStore/AssetStateStore.tsx
@@ -147,13 +147,13 @@ export const AssetStateStore = () => {
columns={columns}
data={data?.asset_state_store ?? []}
displayMode="table"
+ hideRowCountHeading
initialState={tableURLState}
isFetching={isFetching}
isLoading={isLoading}
- modelName="assets:assetStateStore.title"
+ modelName="assets:assetStateStore.entry"
noRowsMessage={translate("assetStateStore.emptyState")}
onStateChange={setTableURLState}
- showRowCountHeading={false}
total={data?.total_entries ?? 0}
/>
</>
diff --git a/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.tsx
b/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.tsx
index 0f37d43db4b..76ed8537832 100644
--- a/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.tsx
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Heading, VStack } from "@chakra-ui/react";
+import { VStack } from "@chakra-ui/react";
import type { ColumnDef } from "@tanstack/react-table";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
@@ -194,10 +194,6 @@ export const AssetsList = () => {
initialValues={initialValues}
onFiltersChange={handleFiltersChange}
/>
-
- <Heading py={3} size="md">
- {totalEntries} {translate("common:asset", { count: totalEntries })}
- </Heading>
</VStack>
<DataTable
columns={columns}
@@ -207,7 +203,6 @@ export const AssetsList = () => {
isLoading={isLoading}
modelName="common:asset"
onStateChange={setTableURLState}
- showRowCountHeading={false}
total={totalEntries}
/>
</>
diff --git a/airflow-core/src/airflow/ui/src/pages/Configs/Configs.tsx
b/airflow-core/src/airflow/ui/src/pages/Configs/Configs.tsx
index 4e7cc724bf7..a4dac7dc6c4 100644
--- a/airflow-core/src/airflow/ui/src/pages/Configs/Configs.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Configs/Configs.tsx
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Heading, Separator } from "@chakra-ui/react";
+import { Box } from "@chakra-ui/react";
import type { ColumnDef } from "@tanstack/react-table";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
@@ -67,14 +67,14 @@ export const Configs = () => {
) ?? [];
return (
- <>
- <Heading mb={4}>{translate("config.title")}</Heading>
- <Separator />
- {error === null ? (
- <DataTable columns={columns} data={render}
modelName="common:admin.Config" />
- ) : (
- <ErrorAlert error={error} />
- )}
- </>
+ <Box px={2}>
+ <DataTable
+ columns={columns}
+ data={render}
+ errorMessage={<ErrorAlert error={error} />}
+ modelName="admin:config.config"
+ total={render.length}
+ />
+ </Box>
);
};
diff --git
a/airflow-core/src/airflow/ui/src/pages/Dag/Backfills/BackfillDagRunsModal.tsx
b/airflow-core/src/airflow/ui/src/pages/Dag/Backfills/BackfillDagRunsModal.tsx
index 77236913d15..85538325688 100644
---
a/airflow-core/src/airflow/ui/src/pages/Dag/Backfills/BackfillDagRunsModal.tsx
+++
b/airflow-core/src/airflow/ui/src/pages/Dag/Backfills/BackfillDagRunsModal.tsx
@@ -209,7 +209,6 @@ export const BackfillDagRunsModal = ({ backfillId, dagId,
onClose, open }: Backf
isLoading={isBackfillLoading || isLoading}
modelName="common:slot"
onStateChange={(state) => setPageIndex(state.pagination.pageIndex)}
- showRowCountHeading
total={data?.total_entries ?? 0}
/>
</Dialog.Body>
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Backfills/Backfills.tsx
b/airflow-core/src/airflow/ui/src/pages/Dag/Backfills/Backfills.tsx
index 41009ffe2e6..935d8ded53c 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Backfills/Backfills.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Backfills/Backfills.tsx
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Box, Button, Heading, Text } from "@chakra-ui/react";
+import { Button, Text } from "@chakra-ui/react";
import type { ColumnDef } from "@tanstack/react-table";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
@@ -154,11 +154,8 @@ export const Backfills = () => {
const columns = getColumns(onSelectBackfill, translate);
return (
- <Box>
+ <>
<ErrorAlert error={error} />
- <Heading my={1} size="md">
- {translate("backfill", { count: data ? data.total_entries : 0 })}
- </Heading>
<DataTable
columns={columns}
data={data ? data.backfills : []}
@@ -174,6 +171,6 @@ export const Backfills = () => {
onClose={onClose}
open={hasSelectedBackfill}
/>
- </Box>
+ </>
);
};
diff --git
a/airflow-core/src/airflow/ui/src/pages/Dag/DeadlineAlertsBadge.test.tsx
b/airflow-core/src/airflow/ui/src/pages/Dag/DeadlineAlertsBadge.test.tsx
index cd1885f3f1a..7ebdfbbb548 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/DeadlineAlertsBadge.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/DeadlineAlertsBadge.test.tsx
@@ -28,6 +28,7 @@ import { DeadlineAlertsBadge } from "./DeadlineAlertsBadge";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
+ i18n: { language: "en" },
// eslint-disable-next-line id-length
t: (key: string, options?: { interval?: string; reference?: string }) => {
if (options?.reference === undefined) {
diff --git a/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.test.tsx
b/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.test.tsx
index 3b9b19e0eab..ca2ad5ada3a 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.test.tsx
@@ -18,7 +18,7 @@
*/
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
import { AppWrapper } from "src/utils/AppWrapper";
@@ -56,14 +56,8 @@ describe("DagRuns logical date filter", () => {
});
describe("DagRuns conf expand/collapse", () => {
- beforeEach(() => {
- // The conf column is hidden by default; reveal it so the JSON viewer
renders.
- globalThis.localStorage.setItem(
- "dataTable:common:dagRun:columnVisibility",
- JSON.stringify({ conf: true }),
- );
- });
-
+ // Relies on the conf column being visible by default, which is what renders
the JSON viewer.
+ // useTableURLState persists sorting to localStorage, so clear it between
cases.
afterEach(() => {
globalThis.localStorage.clear();
});
@@ -85,4 +79,19 @@ describe("DagRuns conf expand/collapse", () => {
expect(screen.getByTestId("rendered-json-field")).toHaveAttribute("data-collapsed",
"true"),
);
});
+
+ it("hides the expand/collapse buttons when no runs match the filter", async
() => {
+ render(
+ <AppWrapper
+ initialEntries={[
+
"/dag_runs?logical_date_gte=2030-01-01T00%3A00%3A00Z&logical_date_lte=2030-01-31T23%3A59%3A59Z",
+ ]}
+ />,
+ );
+
+ await waitFor(() =>
expect(screen.getByTestId("table-no-rows")).toBeInTheDocument());
+
+ expect(screen.queryByTestId("expand-all-button")).toBeNull();
+ expect(screen.queryByTestId("collapse-all-button")).toBeNull();
+ });
});
diff --git a/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx
b/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx
index eb868302896..b1ef85769a7 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx
@@ -342,6 +342,7 @@ export const DagRuns = () => {
},
);
+ const dagRuns = data?.dag_runs ?? [];
const nextCursor = data?.next_cursor ?? undefined;
const previousCursor = data?.previous_cursor ?? undefined;
@@ -351,7 +352,7 @@ export const DagRuns = () => {
getKey: getRowKey,
});
- const selectedDagRuns = (data?.dag_runs ?? []).filter((dagRun) =>
selectedRows.has(getRowKey(dagRun)));
+ const selectedDagRuns = dagRuns.filter((dagRun) =>
selectedRows.has(getRowKey(dagRun)));
const columns = runColumns({
dagId,
@@ -367,19 +368,21 @@ export const DagRuns = () => {
onSelectAll={handleSelectAll}
selectedRows={selectedRows}
>
- <Flex alignItems="center" justifyContent="space-between">
- <DagRunsFilters dagId={dagId} />
- <ExpandCollapseButtons
- collapseLabel={translate("common:collapseAllExtra")}
- expandLabel={translate("common:expandAllExtra")}
- isExpanded={open}
- onCollapse={onClose}
- onExpand={onOpen}
- />
- </Flex>
+ <DagRunsFilters dagId={dagId} />
<DataTable
+ actions={
+ dagRuns.length > 0 ? (
+ <ExpandCollapseButtons
+ collapseLabel={translate("common:collapseAllExtra")}
+ expandLabel={translate("common:expandAllExtra")}
+ isExpanded={open}
+ onCollapse={onClose}
+ onExpand={onOpen}
+ />
+ ) : undefined
+ }
columns={columns}
- data={data?.dag_runs ?? []}
+ data={dagRuns}
errorMessage={<ErrorAlert error={error} />}
initialState={tableURLState}
isLoading={isLoading}
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
index a3a621f41ff..bcdbff464c5 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Heading, HStack, Skeleton, VStack, type SelectValueChangeDetails, Box
} from "@chakra-ui/react";
+import { Skeleton, VStack, type SelectValueChangeDetails, Box } from
"@chakra-ui/react";
import type { ColumnDef } from "@tanstack/react-table";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
@@ -365,34 +365,26 @@ export const DagsList = () => {
placeholder={translate("dags:search.dags")}
/>
<DagsFilters />
- <HStack justifyContent="space-between">
- <HStack>
- <Heading py={3} size="md">
- {`${totalEntries} ${translate("dag", { count: totalEntries })}`}
- </Heading>
- <DagImportErrors iconOnly />
- </HStack>
- <HStack>
- {display === "card" ? (
- <SortSelect handleSortChange={handleSortChange}
orderBy={orderBy} />
- ) : undefined}
- </HStack>
- </HStack>
</VStack>
<Box pb={8}>
<DataTable
+ actions={
+ display === "card" ? (
+ <SortSelect handleSortChange={handleSortChange}
orderBy={orderBy} />
+ ) : undefined
+ }
cardDef={cardDef}
columns={columns}
data={data?.dags ?? []}
displayMode={display}
errorMessage={<ErrorAlert error={error} />}
+ headingExtra={<DagImportErrors iconOnly />}
initialState={tableURLState}
isLoading={isLoading}
modelName="common:dag"
onDisplayToggleChange={setDisplay}
onStateChange={setTableURLState}
showDisplayToggle
- showRowCountHeading={false}
skeletonCount={display === "card" ? 5 : undefined}
total={totalEntries}
/>
diff --git
a/airflow-core/src/airflow/ui/src/pages/DagsList/PartitionScheduleModal.test.tsx
b/airflow-core/src/airflow/ui/src/pages/DagsList/PartitionScheduleModal.test.tsx
index cfabb2ae747..bad26fe378d 100644
---
a/airflow-core/src/airflow/ui/src/pages/DagsList/PartitionScheduleModal.test.tsx
+++
b/airflow-core/src/airflow/ui/src/pages/DagsList/PartitionScheduleModal.test.tsx
@@ -31,6 +31,7 @@ import { PartitionScheduleModal } from
"./PartitionScheduleModal";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
+ i18n: { language: "en" },
// eslint-disable-next-line id-length
t: (key: string, options?: { count?: number }) =>
options?.count === undefined ? key : `${key}:${options.count}`,
diff --git
a/airflow-core/src/airflow/ui/src/pages/DagsList/PartitionScheduleModal.tsx
b/airflow-core/src/airflow/ui/src/pages/DagsList/PartitionScheduleModal.tsx
index 8a4901ceecb..8f5d6140d3b 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/PartitionScheduleModal.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/PartitionScheduleModal.tsx
@@ -120,12 +120,12 @@ export const PartitionScheduleModal = ({ dagId, onClose,
open }: PartitionSchedu
<DataTable
columns={columns}
data={partitionedDagRuns}
+ hideRowCountHeading
initialState={tableState}
isFetching={isFetching}
isLoading={isLoading}
modelName="partitionedDagRun"
onStateChange={(state) => setPageIndex(state.pagination.pageIndex)}
- showRowCountHeading={false}
total={total}
/>
</Dialog.Body>
diff --git
a/airflow-core/src/airflow/ui/src/pages/Dashboard/AlertContent.test.tsx
b/airflow-core/src/airflow/ui/src/pages/Dashboard/AlertContent.test.tsx
index dbbfa96a1c4..7613c36943b 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dashboard/AlertContent.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dashboard/AlertContent.test.tsx
@@ -27,6 +27,7 @@ import { AlertContent } from "./AlertContent";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
+ i18n: { language: "en" },
// eslint-disable-next-line id-length
t: (key: string) =>
key === "alerts.seeMoreContext" ? "See more" : key ===
"alerts.seeLessContext" ? "See less" : key,
diff --git a/airflow-core/src/airflow/ui/src/pages/Dashboard/Dashboard.test.tsx
b/airflow-core/src/airflow/ui/src/pages/Dashboard/Dashboard.test.tsx
index 5a5dcb79e67..50a6a049a2a 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dashboard/Dashboard.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dashboard/Dashboard.test.tsx
@@ -38,6 +38,7 @@ vi.mock("openapi/queries", () => ({
vi.mock("react-i18next", () => ({
useTranslation: () => ({
+ i18n: { language: "en" },
// eslint-disable-next-line id-length
t: (key: string, options?: { count?: number }) =>
key === "alerts.showMoreAlerts"
diff --git
a/airflow-core/src/airflow/ui/src/pages/Dashboard/PoolSummary/PoolSummary.test.tsx
b/airflow-core/src/airflow/ui/src/pages/Dashboard/PoolSummary/PoolSummary.test.tsx
index 39bcbbcb181..cb4cffc0aa1 100644
---
a/airflow-core/src/airflow/ui/src/pages/Dashboard/PoolSummary/PoolSummary.test.tsx
+++
b/airflow-core/src/airflow/ui/src/pages/Dashboard/PoolSummary/PoolSummary.test.tsx
@@ -47,6 +47,7 @@ vi.mock("src/utils", () => ({
vi.mock("react-i18next", () => ({
useTranslation: (namespace: string) => ({
+ i18n: { language: "en" },
// eslint-disable-next-line id-length
t: (key: string, options?: { count?: number }) => {
if (namespace === "dashboard" && key === "deferredSlotsNotCounted") {
diff --git a/airflow-core/src/airflow/ui/src/pages/Deadlines/index.tsx
b/airflow-core/src/airflow/ui/src/pages/Deadlines/index.tsx
index 54a01f5cdb0..c4a7c5efb30 100644
--- a/airflow-core/src/airflow/ui/src/pages/Deadlines/index.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Deadlines/index.tsx
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Badge, Box, Heading, Link, VStack } from "@chakra-ui/react";
+import { Badge, Box, Link, VStack } from "@chakra-ui/react";
import type { ColumnDef } from "@tanstack/react-table";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
@@ -133,8 +133,7 @@ export const Deadlines = () => {
});
return (
- <Box p={2}>
- <Heading>{translate("browse:deadlines.title")}</Heading>
+ <Box px={2}>
<VStack align="start" gap={4} paddingY="4px">
<FilterBar
configs={filterConfigs}
@@ -151,7 +150,6 @@ export const Deadlines = () => {
isLoading={isLoading}
modelName="browse:deadlines.deadline"
onStateChange={setTableURLState}
- showRowCountHeading={false}
total={data?.total_entries}
/>
</Box>
diff --git a/airflow-core/src/airflow/ui/src/pages/Events/Events.tsx
b/airflow-core/src/airflow/ui/src/pages/Events/Events.tsx
index e193ac00dd6..51a4dc4b679 100644
--- a/airflow-core/src/airflow/ui/src/pages/Events/Events.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Events/Events.tsx
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Code, Flex, Heading, useDisclosure, VStack } from "@chakra-ui/react";
+import { Code, useDisclosure } from "@chakra-ui/react";
import type { ColumnDef } from "@tanstack/react-table";
import dayjs from "dayjs";
import { useTranslation } from "react-i18next";
@@ -245,38 +245,37 @@ export const Events = () => {
undefined,
);
+ const eventLogs = data?.event_logs ?? [];
const columns = eventsColumn({ dagId, open, runId, taskId }, translate);
return (
- <VStack alignItems="stretch">
- {dagId === undefined && runId === undefined && taskId === undefined ? (
- <Heading size="md">{translate("auditLog.title")}</Heading>
- ) : undefined}
- <Flex alignItems="center" justifyContent="space-between">
- <EventsFilters urlDagId={dagId} urlRunId={runId} urlTaskId={taskId} />
- <ExpandCollapseButtons
- collapseLabel={translate("common:collapseAllExtra")}
- expandLabel={translate("common:expandAllExtra")}
- isExpanded={open}
- onCollapse={onClose}
- onExpand={onOpen}
- />
- </Flex>
+ <>
+ <EventsFilters urlDagId={dagId} urlRunId={runId} urlTaskId={taskId} />
<ErrorAlert error={error} />
<DataTable
+ actions={
+ eventLogs.length > 0 ? (
+ <ExpandCollapseButtons
+ collapseLabel={translate("common:collapseAllExtra")}
+ expandLabel={translate("common:expandAllExtra")}
+ isExpanded={open}
+ onCollapse={onClose}
+ onExpand={onOpen}
+ />
+ ) : undefined
+ }
columns={columns}
- data={data?.event_logs ?? []}
+ data={eventLogs}
displayMode="table"
initialState={tableURLState}
isFetching={isFetching}
isLoading={isLoading}
- modelName="browse:auditLog.columns.event"
+ modelName="common:event"
onStateChange={setTableURLState}
- showRowCountHeading={false}
skeletonCount={undefined}
total={data?.total_entries ?? 0}
/>
- </VStack>
+ </>
);
};
diff --git
a/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLResponseForm.test.tsx
b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLResponseForm.test.tsx
index 4627a326703..e8d8a134357 100644
---
a/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLResponseForm.test.tsx
+++
b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLResponseForm.test.tsx
@@ -31,6 +31,7 @@ import { HITLResponseForm } from "./HITLResponseForm";
// ---------------------------------------------------------------------------
vi.mock("react-i18next", () => ({
useTranslation: () => ({
+ i18n: { language: "en" },
// eslint-disable-next-line id-length
t: (key: string) => key,
}),
diff --git
a/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.test.tsx
b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.test.tsx
index 534a7aa216c..8c88b4b2780 100644
---
a/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.test.tsx
+++
b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.test.tsx
@@ -40,6 +40,7 @@ vi.mock("react-i18next", async (importOriginal) => {
return {
...actual,
useTranslation: () => ({
+ i18n: { language: "en" },
// eslint-disable-next-line id-length
t: (key: string) => key,
}),
diff --git a/airflow-core/src/airflow/ui/src/pages/Jobs.tsx
b/airflow-core/src/airflow/ui/src/pages/Jobs.tsx
index 992328c4cd0..65ef827fad2 100644
--- a/airflow-core/src/airflow/ui/src/pages/Jobs.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Jobs.tsx
@@ -148,7 +148,7 @@ export const Jobs = () => {
initialState={tableURLState}
isFetching={isFetching}
isLoading={isLoading}
- modelName="common:browse.jobs"
+ modelName="admin:jobs.job"
onStateChange={setTableURLState}
total={data?.total_entries}
/>
diff --git a/airflow-core/src/airflow/ui/src/pages/Plugins.tsx
b/airflow-core/src/airflow/ui/src/pages/Plugins.tsx
index cde75c253d1..d9a8033a6e0 100644
--- a/airflow-core/src/airflow/ui/src/pages/Plugins.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Plugins.tsx
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Box, Heading, HStack } from "@chakra-ui/react";
+import { Box } from "@chakra-ui/react";
import { useTranslation } from "react-i18next";
import { usePluginServiceGetPlugins } from "openapi/queries";
@@ -53,19 +53,15 @@ export const Plugins = () => {
];
return (
- <Box p={2}>
- <HStack>
- <Heading>{translate("common:admin.Plugins")}</Heading>
- <PluginImportErrors iconOnly />
- </HStack>
+ <Box px={2}>
+ <PluginImportErrors iconOnly />
<DataTable
columns={columns}
data={data?.plugins ?? []}
errorMessage={<ErrorAlert error={error} />}
initialState={tableURLState}
- modelName="common:admin.Plugins"
+ modelName="admin:plugins.plugin"
onStateChange={setTableURLState}
- showRowCountHeading={false}
total={data?.total_entries}
/>
</Box>
diff --git a/airflow-core/src/airflow/ui/src/pages/Pools/Pools.tsx
b/airflow-core/src/airflow/ui/src/pages/Pools/Pools.tsx
index 1fab831ac49..71644fc08a8 100644
--- a/airflow-core/src/airflow/ui/src/pages/Pools/Pools.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Pools/Pools.tsx
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Box, HStack, Skeleton, Spacer } from "@chakra-ui/react";
+import { Box, HStack, Skeleton } from "@chakra-ui/react";
import { createListCollection } from "@chakra-ui/react/collection";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@@ -106,31 +106,32 @@ export const Pools = () => {
onChange={handleSearchChange}
placeholder={translate("pools.searchPlaceholder")}
/>
- <HStack gap={4} mt={4}>
- <Select.Root
- borderWidth={0}
- collection={poolSortOptions}
- defaultValue={["name"]}
- onValueChange={handleSortChange}
- width={130}
- >
- <Select.Trigger>
- <Select.ValueText
placeholder={translate("pools.sort.placeholder")} />
- </Select.Trigger>
-
- <Select.Content>
- {poolSortOptions.items.map((option) => (
- <Select.Item item={option} key={option.value}>
- {option.label}
- </Select.Item>
- ))}
- </Select.Content>
- </Select.Root>
- <Spacer />
+ <HStack gap={4} justifyContent="flex-end" mt={4}>
<AddPoolButton />
</HStack>
<Box mt={4}>
<DataTable
+ actions={
+ <Select.Root
+ borderWidth={0}
+ collection={poolSortOptions}
+ defaultValue={["name"]}
+ onValueChange={handleSortChange}
+ width={130}
+ >
+ <Select.Trigger>
+ <Select.ValueText
placeholder={translate("pools.sort.placeholder")} />
+ </Select.Trigger>
+
+ <Select.Content>
+ {poolSortOptions.items.map((option) => (
+ <Select.Item item={option} key={option.value}>
+ {option.label}
+ </Select.Item>
+ ))}
+ </Select.Content>
+ </Select.Root>
+ }
cardDef={cardDef()}
columns={[]}
data={data ? data.pools : []}
diff --git a/airflow-core/src/airflow/ui/src/pages/Providers.tsx
b/airflow-core/src/airflow/ui/src/pages/Providers.tsx
index 96834c948b1..c93546c08ae 100644
--- a/airflow-core/src/airflow/ui/src/pages/Providers.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Providers.tsx
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Box, Heading, Link } from "@chakra-ui/react";
+import { Box, Link } from "@chakra-ui/react";
import type { ColumnDef } from "@tanstack/react-table";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
@@ -93,16 +93,14 @@ export const Providers = () => {
});
return (
- <Box p={2}>
- <Heading>{translate("common:admin.Providers")}</Heading>
+ <Box px={2}>
<DataTable
columns={columns}
data={data?.providers ?? []}
errorMessage={<ErrorAlert error={error} />}
initialState={tableURLState}
- modelName="common:admin.Providers"
+ modelName="admin:providers.provider"
onStateChange={setTableURLState}
- showRowCountHeading={false}
total={data?.total_entries}
/>
</Box>
diff --git
a/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.test.tsx
b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.test.tsx
index 1bf4f98f496..b2b7d73f2df 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.test.tsx
@@ -41,6 +41,7 @@ vi.mock("react-i18next", async (importOriginal) => {
return {
...actual,
useTranslation: () => ({
+ i18n: { language: "en" },
// eslint-disable-next-line id-length
t: (key: string) => key,
}),
diff --git
a/airflow-core/src/airflow/ui/src/pages/TaskStateStore/TaskStateStore.tsx
b/airflow-core/src/airflow/ui/src/pages/TaskStateStore/TaskStateStore.tsx
index 6a1b659c444..009f910f635 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskStateStore/TaskStateStore.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskStateStore/TaskStateStore.tsx
@@ -151,13 +151,13 @@ export const TaskStateStore = () => {
columns={columns}
data={data?.task_state_store ?? []}
displayMode="table"
+ hideRowCountHeading
initialState={tableURLState}
isFetching={isFetching}
isLoading={isLoading}
- modelName="dag:taskStateStore.title"
+ modelName="dag:taskStateStore.entry"
noRowsMessage={translate("taskStateStore.emptyStore")}
onStateChange={setTableURLState}
- showRowCountHeading={false}
total={data?.total_entries ?? 0}
/>
</>
diff --git a/airflow-core/src/airflow/ui/src/pages/Variables/Variables.tsx
b/airflow-core/src/airflow/ui/src/pages/Variables/Variables.tsx
index fbb44813cf4..e7da03ade00 100644
--- a/airflow-core/src/airflow/ui/src/pages/Variables/Variables.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Variables/Variables.tsx
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Box, Flex, HStack, Spacer, useDisclosure, VStack } from
"@chakra-ui/react";
+import { Box, Flex, HStack, useDisclosure, VStack } from "@chakra-ui/react";
import type { ColumnDef } from "@tanstack/react-table";
import type { TFunction } from "i18next";
import { useState } from "react";
@@ -169,6 +169,7 @@ export const Variables = () => {
getKey: (variable) => variable.key,
});
+ const variables = data?.variables ?? [];
const columns = getColumns({
hasSelection: selectedRows.size > 0,
multiTeam: multiTeamEnabled,
@@ -205,22 +206,25 @@ export const Variables = () => {
onChange={handleSearchChange}
placeholder={translate("variables.searchPlaceholder")}
/>
- <HStack gap={4} mt={2}>
- <ExpandCollapseButtons
- collapseLabel={translate("common:expand.collapse")}
- expandLabel={translate("common:expand.expand")}
- isExpanded={open}
- onCollapse={onClose}
- onExpand={onOpen}
- />
- <Spacer />
+ <HStack gap={4} justifyContent="flex-end" mt={2}>
<ImportVariablesButton disabled={selectedRows.size > 0} />
<AddVariableButton disabled={selectedRows.size > 0} />
</HStack>
</VStack>
<DataTable
+ actions={
+ variables.length > 0 ? (
+ <ExpandCollapseButtons
+ collapseLabel={translate("common:expand.collapse")}
+ expandLabel={translate("common:expand.expand")}
+ isExpanded={open}
+ onCollapse={onClose}
+ onExpand={onOpen}
+ />
+ ) : undefined
+ }
columns={columns}
- data={data?.variables ?? []}
+ data={variables}
errorMessage={<ErrorAlert error={error} />}
initialState={tableURLState}
isFetching={isFetching}
diff --git a/airflow-core/src/airflow/ui/src/pages/XCom/XCom.tsx
b/airflow-core/src/airflow/ui/src/pages/XCom/XCom.tsx
index 851095c182d..fa7294e99cc 100644
--- a/airflow-core/src/airflow/ui/src/pages/XCom/XCom.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/XCom/XCom.tsx
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Box, Flex, Heading, useDisclosure } from "@chakra-ui/react";
+import { Flex, useDisclosure } from "@chakra-ui/react";
import type { ColumnDef } from "@tanstack/react-table";
import { useTranslation } from "react-i18next";
import { useParams, useSearchParams } from "react-router-dom";
@@ -225,6 +225,7 @@ export const XCom = () => {
const { data, error, isFetching, isLoading } =
useXcomServiceGetXcomEntries(apiParams, undefined);
+ const xcomEntries = data?.xcom_entries ?? [];
const isTaskInstancePage = dagId !== "~" && runId !== "~" && taskId !== "~";
const columns = getColumns({
@@ -234,46 +235,43 @@ export const XCom = () => {
});
return (
- <Box>
- {dagId === "~" && runId === "~" && taskId === "~" ? (
- <Heading size="md">{translate("xcom.title")}</Heading>
- ) : undefined}
-
+ <>
<Flex alignItems="center" justifyContent="space-between">
<XComFilters />
- <Flex gap={2}>
- {isTaskInstancePage ? (
- <AddXComButton
- dagId={dagId}
- mapIndex={mapIndex === "~" || mapIndex === "-1" ? -1 :
parseInt(mapIndex, 10)}
- runId={runId}
- taskId={taskId}
- />
- ) : undefined}
- <ExpandCollapseButtons
- collapseLabel={translate("common:collapseAllExtra")}
- expandLabel={translate("common:expandAllExtra")}
- isExpanded={open}
- onCollapse={onClose}
- onExpand={onOpen}
+ {isTaskInstancePage ? (
+ <AddXComButton
+ dagId={dagId}
+ mapIndex={mapIndex === "~" || mapIndex === "-1" ? -1 :
parseInt(mapIndex, 10)}
+ runId={runId}
+ taskId={taskId}
/>
- </Flex>
+ ) : undefined}
</Flex>
<ErrorAlert error={error} />
<DataTable
+ actions={
+ xcomEntries.length > 0 ? (
+ <ExpandCollapseButtons
+ collapseLabel={translate("common:collapseAllExtra")}
+ expandLabel={translate("common:expandAllExtra")}
+ isExpanded={open}
+ onCollapse={onClose}
+ onExpand={onOpen}
+ />
+ ) : undefined
+ }
columns={columns}
- data={data ? data.xcom_entries : []}
+ data={xcomEntries}
displayMode="table"
initialState={tableURLState}
isFetching={isFetching}
isLoading={isLoading}
- modelName="browse:xcom.title"
+ modelName="browse:xcom.entry"
onStateChange={setTableURLState}
- showRowCountHeading={false}
skeletonCount={undefined}
- total={data ? data.total_entries : 0}
+ total={data?.total_entries ?? 0}
/>
- </Box>
+ </>
);
};
diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/AssetListPage.ts
b/airflow-core/src/airflow/ui/tests/e2e/pages/AssetListPage.ts
index e179d2a3c06..d14cbc8eea6 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/pages/AssetListPage.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/pages/AssetListPage.ts
@@ -17,6 +17,7 @@
* under the License.
*/
import { expect, type Locator, type Page } from "@playwright/test";
+import { DATA_ROWS } from "tests/e2e/utils/ui/selectors";
import { BasePage } from "./BasePage";
@@ -34,7 +35,7 @@ export class AssetListPage extends BasePage {
name: /\d+\s+asset/i,
});
this.table = page.getByTestId("table-list");
- this.rows = this.table.locator("tbody tr").filter({
+ this.rows = this.table.locator(DATA_ROWS).filter({
has: page.locator("td"),
});
diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/BackfillPage.ts
b/airflow-core/src/airflow/ui/tests/e2e/pages/BackfillPage.ts
index c351f471c59..e8a89812797 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/pages/BackfillPage.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/pages/BackfillPage.ts
@@ -25,6 +25,7 @@ import {
apiCancelBackfill,
apiWaitForNoActiveBackfill,
} from "tests/e2e/utils/api/backfills";
+import { DATA_ROWS } from "tests/e2e/utils/ui/selectors";
export const REPROCESS_API_TO_UI = {
completed: "All Runs",
@@ -286,7 +287,7 @@ export class BackfillPage extends BasePage {
return false;
}
- const rows = this.backfillsTable.locator("tbody tr");
+ const rows = this.backfillsTable.locator(DATA_ROWS);
const rowCount = await rows.count();
for (let i = 0; i < rowCount; i++) {
diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/ConfigurationPage.ts
b/airflow-core/src/airflow/ui/tests/e2e/pages/ConfigurationPage.ts
index 3683ce7e093..03024f8defc 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/pages/ConfigurationPage.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/pages/ConfigurationPage.ts
@@ -18,6 +18,7 @@
*/
import { expect } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
+import { DATA_ROWS } from "tests/e2e/utils/ui/selectors";
import { BasePage } from "./BasePage";
@@ -33,7 +34,7 @@ export class ConfigurationPage extends BasePage {
name: /config/i,
});
this.table = page.getByTestId("table-list");
- this.rows = this.table.locator("tbody tr").filter({
+ this.rows = this.table.locator(DATA_ROWS).filter({
has: page.locator("td"),
});
}
diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/ConnectionsPage.ts
b/airflow-core/src/airflow/ui/tests/e2e/pages/ConnectionsPage.ts
index d16a89702ca..0caa25d3fea 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/pages/ConnectionsPage.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/pages/ConnectionsPage.ts
@@ -18,6 +18,7 @@
*/
import { expect, type Locator, type Page } from "@playwright/test";
import { BasePage } from "tests/e2e/pages/BasePage";
+import { DATA_ROWS } from "tests/e2e/utils/ui/selectors";
import { waitForStableRowCount } from "tests/e2e/utils/ui/waits";
type ConnectionDetails = {
@@ -259,7 +260,7 @@ export class ConnectionsPage extends BasePage {
}
public async getConnectionIds(): Promise<Array<string>> {
- const rowLocator = this.page.locator("tbody tr");
+ const rowLocator = this.page.locator(DATA_ROWS);
const stableRowCount = await waitForStableRowCount(rowLocator).catch(() =>
0);
if (stableRowCount === 0) {
@@ -273,7 +274,7 @@ export class ConnectionsPage extends BasePage {
throw new Error(`"Connection ID" column not found in headers:
${JSON.stringify(headerTexts)}`);
}
- const rows = this.page.locator("tbody tr");
+ const rows = this.page.locator(DATA_ROWS);
const connectionIds: Array<string> = [];
for (let i = 0; i < stableRowCount; i++) {
@@ -379,7 +380,7 @@ export class ConnectionsPage extends BasePage {
return undefined;
}
- const row = this.page.locator("tbody tr").filter({ hasText: connectionId
}).first();
+ const row = this.page.locator(DATA_ROWS).filter({ hasText: connectionId
}).first();
const rowExists = await row.isVisible({ timeout: 3000 }).catch(() =>
false);
@@ -401,7 +402,7 @@ export class ConnectionsPage extends BasePage {
const isTableVisible = await table.isVisible();
if (isTableVisible) {
- const firstRow = this.page.locator("tbody tr").first();
+ const firstRow = this.page.locator(DATA_ROWS).first();
await expect(firstRow.or(this.emptyState)).toBeVisible({ timeout: 15_000
});
}
diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/DagRunsPage.ts
b/airflow-core/src/airflow/ui/tests/e2e/pages/DagRunsPage.ts
index f60ae0db3db..b26aab6e902 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/pages/DagRunsPage.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/pages/DagRunsPage.ts
@@ -18,6 +18,7 @@
*/
import { expect, type Locator, type Page } from "@playwright/test";
import { BasePage } from "tests/e2e/pages/BasePage";
+import { DATA_ROWS } from "tests/e2e/utils/ui/selectors";
export class DagRunsPage extends BasePage {
public static get dagRunsUrl(): string {
@@ -61,7 +62,7 @@ export class DagRunsPage extends BasePage {
}).toPass({ intervals: [2000], timeout: 60_000 });
await expect(this.dagRunsTable).toBeVisible();
- const rows = this.dagRunsTable.locator("tbody tr");
+ const rows = this.dagRunsTable.locator(DATA_ROWS);
await expect(rows).not.toHaveCount(0);
@@ -88,7 +89,7 @@ export class DagRunsPage extends BasePage {
* Verify that run details are displayed in the table row
*/
public async verifyRunDetailsDisplay(): Promise<void> {
- const firstRow = this.dagRunsTable.locator("tbody tr").first();
+ const firstRow = this.dagRunsTable.locator(DATA_ROWS).first();
await expect(firstRow).toBeVisible();
@@ -139,7 +140,7 @@ export class DagRunsPage extends BasePage {
}).toPass({ intervals: [2000], timeout: 60_000 });
await expect(this.dagRunsTable).toBeVisible();
- const rows = this.dagRunsTable.locator("tbody tr");
+ const rows = this.dagRunsTable.locator(DATA_ROWS);
await expect(rows).not.toHaveCount(0);
diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/DagRunsTabPage.ts
b/airflow-core/src/airflow/ui/tests/e2e/pages/DagRunsTabPage.ts
index df8cc6fdbc3..847153a464f 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/pages/DagRunsTabPage.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/pages/DagRunsTabPage.ts
@@ -18,6 +18,7 @@
*/
import { expect, type Locator, type Page } from "@playwright/test";
import { BasePage } from "tests/e2e/pages/BasePage";
+import { DATA_ROWS } from "tests/e2e/utils/ui/selectors";
export class DagRunsTabPage extends BasePage {
public readonly markRunAsButton: Locator;
@@ -29,7 +30,7 @@ export class DagRunsTabPage extends BasePage {
super(page);
this.markRunAsButton = page.getByTestId("mark-run-as-button").first();
this.runsTable = page.getByTestId("table-list");
- this.tableRows = this.runsTable.locator("tbody").getByRole("row");
+ this.tableRows = this.runsTable.locator(DATA_ROWS);
this.triggerButton = page.getByTestId("trigger-dag-button");
}
diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts
b/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts
index bcf9d0114d3..31dbd5960c4 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts
@@ -19,6 +19,7 @@
import { expect, type Locator, type Page, type Response } from
"@playwright/test";
import { HITLReviewModal } from "tests/e2e/components/HITLReviewModal";
import { BasePage } from "tests/e2e/pages/BasePage";
+import { DATA_ROWS } from "tests/e2e/utils/ui/selectors";
import type { DAGRunResponse } from "openapi/requests/types.gen";
@@ -221,7 +222,7 @@ export class DagsPage extends BasePage {
return this.page.locator('[data-testid="dag-id"]').count();
}
- return this.page.getByTestId("table-list").locator("tbody tr").count();
+ return this.page.getByTestId("table-list").locator(DATA_ROWS).count();
}
public async getFilterOptions(filter: Locator): Promise<Array<string>> {
diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/EventsPage.ts
b/airflow-core/src/airflow/ui/tests/e2e/pages/EventsPage.ts
index ef95c74a436..5b1da1b9aee 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/pages/EventsPage.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/pages/EventsPage.ts
@@ -19,6 +19,7 @@
import type { Locator, Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { BasePage } from "tests/e2e/pages/BasePage";
+import { DATA_ROWS } from "tests/e2e/utils/ui/selectors";
export class EventsPage extends BasePage {
public readonly eventColumn: Locator;
@@ -32,7 +33,8 @@ export class EventsPage extends BasePage {
public constructor(page: Page) {
super(page);
- this.eventsPageTitle = page.getByRole("heading", { level: 2, name: "Audit
Log" });
+ // DataTable's row count is the page heading, so this reads "950 Events"
rather than "Audit Log"
+ this.eventsPageTitle = page.getByRole("heading", { name: /events/i });
this.eventsTable = page.getByTestId("table-list");
this.eventColumn = this.eventsTable.getByRole("columnheader").filter({
hasText: "Event" });
this.extraColumn = this.eventsTable.getByRole("columnheader").filter({
hasText: "Extra" });
@@ -41,7 +43,7 @@ export class EventsPage extends BasePage {
.filter({ has: page.getByTestId("add-filter-button") })
.first();
this.ownerColumn = this.eventsTable.getByRole("columnheader").filter({
hasText: "User" });
- this.tableRows = this.eventsTable.locator("tbody").getByRole("row");
+ this.tableRows = this.eventsTable.locator(DATA_ROWS);
this.whenColumn = this.eventsTable.getByRole("columnheader").filter({
hasText: "When" });
}
diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/PluginsPage.ts
b/airflow-core/src/airflow/ui/tests/e2e/pages/PluginsPage.ts
index 806e07c88ac..a5652a7e1e1 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/pages/PluginsPage.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/pages/PluginsPage.ts
@@ -18,6 +18,7 @@
*/
import { expect } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
+import { DATA_ROWS } from "tests/e2e/utils/ui/selectors";
import { BasePage } from "./BasePage";
@@ -33,7 +34,7 @@ export class PluginsPage extends BasePage {
this.heading = page.getByRole("heading", { name: /plugins/i });
this.table = page.getByTestId("table-list");
- this.rows = this.table.locator("tbody tr").filter({ has:
page.locator("td") });
+ this.rows = this.table.locator(DATA_ROWS).filter({ has: page.locator("td")
});
this.nameColumn = this.rows.getByTestId("table-cell-name");
this.sourceColumn = this.rows.getByTestId("table-cell-source");
}
diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/ProvidersPage.ts
b/airflow-core/src/airflow/ui/tests/e2e/pages/ProvidersPage.ts
index a25e1940acf..3b69641eb7b 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/pages/ProvidersPage.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/pages/ProvidersPage.ts
@@ -18,6 +18,7 @@
*/
import { expect } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
+import { DATA_ROWS } from "tests/e2e/utils/ui/selectors";
import { BasePage } from "./BasePage";
@@ -32,10 +33,11 @@ export class ProvidersPage extends BasePage {
super(page);
this.adminMenuButton = page.getByRole("button", { name: /^admin$/i });
- this.heading = page.getByRole("heading", { name: /^providers$/i });
+ // DataTable's row count is the page heading, so this reads "90 Providers"
rather than "Providers"
+ this.heading = page.getByRole("heading", { name: /providers/i });
this.providersMenuItem = page.getByRole("menuitem", { name: /^providers$/i
});
this.table = page.getByTestId("table-list");
- this.rows = this.table.locator("tbody tr").filter({
+ this.rows = this.table.locator(DATA_ROWS).filter({
has: page.locator("td"),
});
}
diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/TaskInstancesPage.ts
b/airflow-core/src/airflow/ui/tests/e2e/pages/TaskInstancesPage.ts
index bc21a1287df..5bb7a25f906 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/pages/TaskInstancesPage.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/pages/TaskInstancesPage.ts
@@ -18,6 +18,7 @@
*/
import { expect, type Locator, type Page } from "@playwright/test";
import { BasePage } from "tests/e2e/pages/BasePage";
+import { DATA_ROWS } from "tests/e2e/utils/ui/selectors";
export class TaskInstancesPage extends BasePage {
public static get taskInstancesUrl(): string {
@@ -66,10 +67,7 @@ export class TaskInstancesPage extends BasePage {
await expect(dataLink).toBeVisible({ timeout: 30_000 });
await expect(this.taskInstancesTable).toBeVisible();
- // getByRole("row") returns all rows including the header; filter to data
rows only.
- const rowsAfterFilter = this.taskInstancesTable
- .getByRole("row")
- .filter({ hasNot: this.taskInstancesTable.getByRole("columnheader") });
+ const rowsAfterFilter = this.taskInstancesTable.locator(DATA_ROWS);
const noDataMessage = this.page.getByText(/no .* found|no .* results/i);
const stateBadges = this.taskInstancesTable.locator('[class*="badge"],
[class*="Badge"]');
@@ -98,7 +96,7 @@ export class TaskInstancesPage extends BasePage {
* Verify that task instance details are displayed correctly
*/
public async verifyTaskDetailsDisplay(): Promise<void> {
- const firstRow = this.taskInstancesTable.getByRole("row").nth(1);
+ const firstRow = this.taskInstancesTable.locator(DATA_ROWS).first();
const dagIdLink = firstRow.getByRole("link").first();
@@ -147,10 +145,7 @@ export class TaskInstancesPage extends BasePage {
* Verify that task instances exist in the table
*/
public async verifyTaskInstancesExist(): Promise<void> {
- // Skip the header row (index 0); all subsequent rows are data rows.
- const rows = this.taskInstancesTable
- .getByRole("row")
- .filter({ hasNot: this.taskInstancesTable.getByRole("columnheader") });
+ const rows = this.taskInstancesTable.locator(DATA_ROWS);
expect(await rows.count()).toBeGreaterThan(0);
}
diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/VariablePage.ts
b/airflow-core/src/airflow/ui/tests/e2e/pages/VariablePage.ts
index 6f08d5f0038..dca00d0f172 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/pages/VariablePage.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/pages/VariablePage.ts
@@ -17,6 +17,7 @@
* under the License.
*/
import { expect, type Locator, type Page } from "@playwright/test";
+import { DATA_ROWS } from "tests/e2e/utils/ui/selectors";
import { BasePage } from "./BasePage";
@@ -35,7 +36,7 @@ export class VariablePage extends BasePage {
this.addButton = page.getByRole("button", { name: /add/i });
this.importButton = page.getByRole("button", { name: "Import Variables" });
this.table = page.getByTestId("table-list");
- this.tableRows = this.table.locator("tbody tr");
+ this.tableRows = this.table.locator(DATA_ROWS);
this.selectAllCheckbox = page.locator("thead input[type='checkbox']");
}
diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/XComsPage.ts
b/airflow-core/src/airflow/ui/tests/e2e/pages/XComsPage.ts
index 578cf945a0e..54fdd83cd41 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/pages/XComsPage.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/pages/XComsPage.ts
@@ -18,6 +18,7 @@
*/
import { expect, type Locator, type Page } from "@playwright/test";
import { BasePage } from "tests/e2e/pages/BasePage";
+import { DATA_ROWS } from "tests/e2e/utils/ui/selectors";
export class XComsPage extends BasePage {
public static get xcomsUrl(): string {
@@ -36,7 +37,7 @@ export class XComsPage extends BasePage {
this.collapseAllButton = page.getByTestId("collapse-all-button");
this.expandAllButton = page.getByTestId("expand-all-button");
this.xcomsTable = page.getByTestId("table-list");
- this.tableRows = this.xcomsTable.locator("tbody tr");
+ this.tableRows = this.xcomsTable.locator(DATA_ROWS);
}
public async applyFilter(filterName: string, value: string): Promise<void> {
@@ -82,7 +83,7 @@ export class XComsPage extends BasePage {
await expect(firstLink).toContainText(dagDisplayNamePattern, {
ignoreCase: true });
}).toPass({ timeout: 30_000 });
- const rows = this.xcomsTable.locator("tbody tr");
+ const rows = this.xcomsTable.locator(DATA_ROWS);
await expect(rows).not.toHaveCount(0);
@@ -116,7 +117,7 @@ export class XComsPage extends BasePage {
await expect(firstKeyCell).toContainText(keyPattern, { ignoreCase: true
});
}).toPass({ timeout: 30_000 });
- const rows = this.xcomsTable.locator("tbody tr");
+ const rows = this.xcomsTable.locator(DATA_ROWS);
await expect(rows).not.toHaveCount(0);
diff --git
a/airflow-core/src/airflow/ui/src/components/DataTable/ToggleTableDisplay.tsx
b/airflow-core/src/airflow/ui/tests/e2e/utils/ui/selectors.ts
similarity index 51%
copy from
airflow-core/src/airflow/ui/src/components/DataTable/ToggleTableDisplay.tsx
copy to airflow-core/src/airflow/ui/tests/e2e/utils/ui/selectors.ts
index 01c7314e1d1..f261b86a1c9 100644
---
a/airflow-core/src/airflow/ui/src/components/DataTable/ToggleTableDisplay.tsx
+++ b/airflow-core/src/airflow/ui/tests/e2e/utils/ui/selectors.ts
@@ -16,31 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { useTranslation } from "react-i18next";
-import { FiAlignJustify, FiGrid } from "react-icons/fi";
-import { ButtonGroupToggle } from "src/components/ui/ButtonGroupToggle";
-
-type Display = "card" | "table";
-
-type Props = {
- readonly display: Display;
- readonly setDisplay: (display: Display) => void;
-};
-
-export const ToggleTableDisplay = ({ display, setDisplay }: Props) => {
- const { t: translate } = useTranslation("components");
-
- return (
- <ButtonGroupToggle
- isIcon
- onChange={setDisplay}
- options={[
- { label: <FiGrid />, title: translate("toggleCardView"), value: "card"
},
- { label: <FiAlignJustify />, title: translate("toggleTableView"),
value: "table" },
- ]}
- pb={2}
- value={display}
- />
- );
-};
+/**
+ * Data rows of a DataTable, excluding the "no items found" row.
+ *
+ * DataTable renders its empty state as a single `<tr>` with a cell spanning
every column, so a
+ * plain `tbody tr` locator counts it as a data row. That turns "at least one
row exists"
+ * assertions into false passes on an empty table. Always select data rows
through this.
+ */
+export const DATA_ROWS = 'tbody tr:not([data-testid="table-no-rows"])';