Skip to content

Commit 3285e14

Browse files
authored
400 Be able to select which columns are included in the CSV file (#401)
* initial prototype * use a table for columns * improve styles * hook up columns to API * make sure the primary key is in the list of columns * make column table hideable * add code comments * add a warning if the number of rows being downloaded is high * fix playwright test * fix `for` attributes
1 parent aa0c795 commit 3285e14

File tree

2 files changed

+222
-20
lines changed

2 files changed

+222
-20
lines changed

admin_ui/src/components/CSVModal.vue

Lines changed: 216 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,63 @@
99
<option value=";">{{ $t("Semicolon") }}</option>
1010
</select>
1111

12+
<p id="column_header">
13+
<a href="#" @click.prevent="showColumnTable = !showColumnTable">
14+
<font-awesome-icon
15+
:icon="showColumnTable ? 'angle-down' : 'angle-right'"
16+
/>
17+
Columns</a
18+
><a
19+
id="toggle_all"
20+
href="#"
21+
@click.prevent="toggleAll()"
22+
v-show="showColumnTable"
23+
>Toggle all</a
24+
>
25+
</p>
26+
27+
<table class="column_list" v-show="showColumnTable">
28+
<tbody>
29+
<tr v-for="columnName in allColumnNames">
30+
<td>
31+
<label :for="'csv_' + columnName">{{
32+
columnName
33+
}}</label>
34+
35+
<input
36+
type="checkbox"
37+
:id="'csv_' + columnName"
38+
:checked="
39+
selectedColumns.indexOf(columnName) != -1
40+
"
41+
@change="
42+
toggleValue(
43+
($event.target as HTMLInputElement)
44+
.checked,
45+
columnName
46+
)
47+
"
48+
/>
49+
</td>
50+
</tr>
51+
</tbody>
52+
53+
<tfoot>
54+
<tr>
55+
<td>
56+
<label for="include_readable"
57+
>Include readable</label
58+
>
59+
<input
60+
type="checkbox"
61+
id="include_readable"
62+
v-model="includeReadable"
63+
/>
64+
</td>
65+
</tr>
66+
</tfoot>
67+
</table>
68+
1269
<button
1370
data-uitest="download_csv_button"
1471
:disabled="buttonDisabled"
@@ -25,10 +82,10 @@
2582

2683
<script setup lang="ts">
2784
import axios from "axios"
28-
import { ref, inject } from "vue"
85+
import { ref, inject, computed, onMounted, watch } from "vue"
2986
import type { I18n } from "vue-i18n"
3087
31-
import type { RowCountAPIResponse } from "../interfaces"
88+
import type { RowCountAPIResponse, Schema } from "../interfaces"
3289
import { getOrderByString } from "@/utils"
3390
import Modal from "./Modal.vue"
3491
import { useStore } from "vuex"
@@ -44,6 +101,58 @@ const i18n = inject<I18n>("i18n")
44101
45102
/*****************************************************************************/
46103
104+
const schema = computed((): Schema => {
105+
return store.state.schema
106+
})
107+
108+
/*****************************************************************************/
109+
110+
const showColumnTable = ref<boolean>(false)
111+
112+
/*****************************************************************************/
113+
114+
const selectedColumns = ref<string[]>([])
115+
const includeReadable = ref<boolean>(true)
116+
117+
const toggleAll = () => {
118+
if (selectedColumns.value.length == 0) {
119+
selectedColumns.value = allColumnNames.value
120+
} else {
121+
selectedColumns.value = []
122+
}
123+
}
124+
125+
const toggleValue = (checked: boolean, columnName: string) => {
126+
if (checked) {
127+
selectedColumns.value.push(columnName)
128+
} else {
129+
selectedColumns.value = selectedColumns.value.filter(
130+
(i) => i != columnName
131+
)
132+
}
133+
}
134+
135+
const allColumnNames = computed(() => {
136+
let columnNames = Object.keys(schema.value.properties)
137+
const primaryKeyName = schema.value.extra.primary_key_name
138+
139+
if (columnNames.indexOf(primaryKeyName) == -1) {
140+
columnNames = [primaryKeyName, ...columnNames]
141+
}
142+
143+
return columnNames
144+
})
145+
146+
const setupInitialColumns = () => {
147+
selectedColumns.value = schema.value.extra.visible_column_names
148+
}
149+
150+
watch(schema, setupInitialColumns)
151+
152+
onMounted(setupInitialColumns)
153+
154+
/*****************************************************************************/
155+
47156
// Just in case `replaceAll` isn't supported by the browser, provide a
48157
// fallback.
49158
const replaceAll = (input: string, value: string, newValue: string): string => {
@@ -64,38 +173,84 @@ const translate = (term: string): string => {
64173
65174
/*****************************************************************************/
66175
176+
// We allow the user to download more than this number of rows, but we ask them
177+
// to confirm first.
178+
const softRowLimit = 10000
179+
67180
const fetchExportedRows = async () => {
68181
buttonDisabled.value = true
69182
70183
const params = store.state.filterParams
71-
const orderBy = store.state.orderBy
72184
const tableName = store.state.currentTableName
73185
74-
if (orderBy && orderBy.length > 0) {
75-
params["__order"] = getOrderByString(orderBy)
76-
}
77-
// Get the row counts:
186+
/*************************************************************************/
187+
// Get the row count
188+
78189
const response = await axios.get(`api/tables/${tableName}/count/`, {
79190
params
80191
})
81192
const data = response.data as RowCountAPIResponse
193+
const rowCount = data.count
194+
195+
if (
196+
rowCount > softRowLimit &&
197+
!confirm(
198+
`There are more than ${softRowLimit}, are you sure you want to continue?`
199+
)
200+
) {
201+
return
202+
}
203+
204+
/*************************************************************************/
205+
// Work out how many requests we need to make (based on the row count).
206+
// If there are lots of rows, we need to make multiple requests.
207+
82208
const localParams = { ...params }
83209
84-
localParams["__page"] = data.count
85210
// Set higher __page_size param to have fewer requests to the API:
86211
localParams["__page_size"] = 1000
87-
const pages = Math.ceil(data.count / localParams["__page_size"])
212+
213+
const pages = Math.ceil(rowCount / localParams["__page_size"])
214+
215+
/*************************************************************************/
216+
// Make sure orderBy is included in the query, so it matches how the
217+
// results are currently displayed.
218+
219+
const orderBy = store.state.orderBy
220+
221+
if (orderBy && orderBy.length > 0) {
222+
localParams["__order"] = getOrderByString(orderBy)
223+
}
224+
225+
/*************************************************************************/
226+
// Work out which columns to fetch
227+
228+
if (selectedColumns.value.length == 0) {
229+
alert("Please select at least one column.")
230+
return
231+
}
232+
233+
if (selectedColumns.value.length != allColumnNames.value.length) {
234+
// If only some columns are selected, we need to filter which are
235+
// returned.
236+
localParams["__visible_fields"] = selectedColumns.value.join(",")
237+
}
238+
239+
/*************************************************************************/
240+
// Add readable if required
241+
242+
localParams["__readable"] = true
243+
244+
/*************************************************************************/
245+
88246
const exportedRows = []
89247
90248
try {
91249
for (let i = 1; i < pages + 1; i++) {
92250
localParams["__page"] = i
93-
const response = await axios.get(
94-
`api/tables/${tableName}/?__readable=true`,
95-
{
96-
params: localParams
97-
}
98-
)
251+
const response = await axios.get(`api/tables/${tableName}/`, {
252+
params: localParams
253+
})
99254
exportedRows.push(...response.data.rows)
100255
}
101256
let data: string = ""
@@ -138,4 +293,50 @@ const fetchExportedRows = async () => {
138293
p.note {
139294
font-size: 0.85em;
140295
}
296+
297+
p#column_header {
298+
margin-bottom: 0;
299+
300+
a {
301+
text-decoration: none;
302+
303+
&#toggle_all {
304+
text-decoration: none;
305+
font-size: 0.8em;
306+
float: right;
307+
}
308+
}
309+
}
310+
311+
table.column_list {
312+
width: 100%;
313+
314+
tr {
315+
td {
316+
box-sizing: border-box;
317+
padding: 0.5rem;
318+
display: flex;
319+
flex-direction: row;
320+
321+
label {
322+
flex-grow: 1;
323+
padding: 0;
324+
}
325+
326+
input {
327+
flex-grow: 0;
328+
}
329+
330+
a {
331+
text-decoration: none;
332+
}
333+
}
334+
}
335+
336+
tfoot {
337+
td {
338+
margin-top: 1rem;
339+
}
340+
}
341+
}
141342
</style>

e2e/test_codegen.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,13 @@ def test_row_listing_filter(playwright: Playwright, dev_server) -> None:
2020
page.locator('input[name="password"]').press("Enter")
2121
page.get_by_role("link", name="director").click()
2222
page.get_by_role("link", name="Show filters").click()
23-
page.locator('input[name="name"]').click()
24-
page.locator('input[name="name"]').fill("Howard")
25-
page.locator('input[name="name"]').press("Enter")
23+
name_input = page.locator('.filter_wrapper input[name="name"]')
24+
name_input.click()
25+
name_input.fill("Howard")
26+
name_input.press("Enter")
2627
page.get_by_role("button", name="Clear filters").click()
27-
page.locator('input[name="name"]').click()
28-
page.locator('input[name="name"]').fill("ron")
28+
name_input.click()
29+
name_input.fill("ron")
2930
page.get_by_role("button", name="Apply").click()
3031
page.get_by_role("button", name="Clear filters").click()
3132
page.get_by_text("Close").click()

0 commit comments

Comments
 (0)