Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions src/seis_lab_data/webapp/templates/macros.html
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,16 @@ <h4 class="mb-0">{{ _("link") | capitalize }} {{ loop.index0 + 1 }}</h4>
{% endmacro %}


{% macro reset_selection_js() -%}
Object.keys($selectedIds).forEach((k) => { $selectedIds[k] = false }); Object.keys($excludedIds).forEach((k) => { $excludedIds[k] = false }); $selectAllMatching = false;
{%- endmacro %}


{% macro reset_excluded_ids_js() -%}
Object.keys($excludedIds).forEach((k) => { $excludedIds[k] = false });
{%- endmacro %}


{% macro render_pagination(pagination) %}
{% if pagination.total_filtered_pages > 1 %}
<nav aria-label="project-pagination">
Expand Down Expand Up @@ -161,15 +171,16 @@ <h4 class="mb-0">{{ _("link") | capitalize }} {{ loop.index0 + 1 }}</h4>
begin_label_text,
end_label_text,
begin_initial_value="",
end_initial_value=""
end_initial_value="",
extra_on_input_js=""
) %}
<div class="mb-3">
<label for="search-temporal-extent-begin">{{ begin_label_text }}</label>
<input
id="search-temporal-extent-begin"
class="form-control" type="date"
data-bind="temporalExtentBegin"
data-on:input__debounce.200ms="@get('{{ search_url }}')"
data-on:input__debounce.200ms="{{ extra_on_input_js }}@get('{{ search_url }}')"
value="{{ begin_initial_value }}"
>
</div>
Expand All @@ -180,13 +191,13 @@ <h4 class="mb-0">{{ _("link") | capitalize }} {{ loop.index0 + 1 }}</h4>
class="form-control"
type="date"
data-bind="temporalExtentEnd"
data-on:input__debounce.200ms="@get('{{ search_url }}')"
data-on:input__debounce.200ms="{{ extra_on_input_js }}@get('{{ search_url }}')"
value="{{ end_initial_value }}"
>
</div>
{% endmacro %}

{% macro render_search_component(search_url, label_text, initial_value="", set_autofocus=False) %}
{% macro render_search_component(search_url, label_text, initial_value="", set_autofocus=False, extra_on_input_js="") %}
<div class="row m-lg-5 justify-content-center" data-signals:search="'{{ initial_value }}'">
<div class="col-6">
<search
Expand All @@ -199,7 +210,7 @@ <h4 class="mb-0">{{ _("link") | capitalize }} {{ loop.index0 + 1 }}</h4>
id="search"
placeholder="search"
data-bind:search
data-on:input__debounce.200ms="@get('{{ search_url }}')"
data-on:input__debounce.200ms="{{ extra_on_input_js }}@get('{{ search_url }}')"
data-indicator:searching
{% if set_autofocus %}autofocus{% endif %}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,18 @@ <h6>
<div
class="row mt-4"
data-signals:listing-version="0"
data-signals:selected-ids="{}"
data-signals:excluded-ids="{}"
data-signals:select-all-matching="false"
data-effect="$listingVersion > 0 && @get('{{ url_for('survey_missions:get_mission_records_list_component', survey_mission_id=survey_mission.id) }}')"
>
<div class="col">
<h2>{{ _("survey-related-records") | capitalize }}</h2>
{{ macros.render_search_component(
url_for("survey_missions:get_mission_records_list_component", survey_mission_id=survey_mission.id),
_("search survey-related records"),
initial_value=search_initial_value | default("", true)
initial_value=search_initial_value | default("", true),
extra_on_input_js=macros.reset_selection_js()
) }}
<div id="survey-mission-records">
{% with items = survey_related_records %}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,47 @@
>
<div class="col">
{% if items | length > 0 %}
<div
class="row align-items-center mb-3"
aria-label="selection-toolbar"
data-signals:page-item-ids="[{% for item in items %}'{{ item.id }}'{% if not loop.last %},{% endif %}{% endfor %}]"
data-computed:all-page-items-selected="$pageItemIds.length > 0 && $pageItemIds.every(id => $selectAllMatching ? !$excludedIds[id] : !!$selectedIds[id])"
data-computed:selected-count="$selectAllMatching ? ({{ pagination.total_filtered_items }} - Object.values($excludedIds).filter(Boolean).length) : Object.values($selectedIds).filter(Boolean).length"
>
<div class="col-auto form-check">
<input
type="checkbox"
class="form-check-input"
id="select-all-on-page"
aria-label="select-all-on-page"
data-effect="el.checked = $allPageItemsSelected"
data-on:change="evt.target.checked ? ($selectAllMatching ? $pageItemIds.forEach(id => $excludedIds[id] = false) : $pageItemIds.forEach(id => $selectedIds[id] = true)) : ($selectAllMatching ? $pageItemIds.forEach(id => $excludedIds[id] = true) : $pageItemIds.forEach(id => $selectedIds[id] = false))"
>
<label class="form-check-label" for="select-all-on-page">{{ _("select all on this page") | capitalize }}</label>
</div>
<div class="col-auto" aria-label="selected-count" data-attr:hidden="$selectedCount === 0">
<small class="text-body-secondary"><span data-text="$selectedCount"></span> {{ _("selected") }}</small>
</div>
<div
class="col-auto"
data-attr:hidden="!($allPageItemsSelected && !$selectAllMatching && {{ pagination.total_filtered_items }} > $pageItemIds.length)"
>
<button
type="button"
class="btn btn-link btn-sm p-0"
aria-label="select-all-matching"
data-on:click="$selectAllMatching=true; {{ macros.reset_excluded_ids_js() }}"
>{{ _("Select all %(total)d records that match your search") | format(total=pagination.total_filtered_items) }}</button>
</div>
<div class="col-auto" data-attr:hidden="$selectedCount === 0">
<button
type="button"
class="btn btn-link btn-sm p-0"
aria-label="clear-selection"
data-on:click="{{ macros.reset_selection_js() }}"
>{{ _("clear selection") | capitalize }}</button>
</div>
</div>
<ul class="list-inline text-center">
<li class="list-inline-item">
<small>{{ _("Total pages: %(total_pages)d") | format(total_pages=pagination.total_filtered_pages) }}</small>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,16 @@
class="col"
data-signals:is-valid-id{{ id_ }}="{{ 'true' if item.validation_result.is_valid else 'false' }}"
>
<div class="card mb-3">
<div class="card mb-3 position-relative">
<div class="form-check position-absolute top-0 start-0 m-2">
<input
type="checkbox"
class="form-check-input"
aria-label="select-item"
data-effect="el.checked = ($selectAllMatching ? !$excludedIds['{{ item.id }}'] : !!$selectedIds['{{ item.id }}'])"
data-on:change="$selectAllMatching ? ($excludedIds['{{ item.id }}'] = !evt.target.checked) : ($selectedIds['{{ item.id }}'] = evt.target.checked)"
>
</div>
<div class="row g-0">
<div class="col-md-4">
<div class="placeholder-rectangle bg-secondary"></div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@
data-signals:advanced-search-panel-open="false"
data-signals:searching="false"
data-signals:listing-version="0"
data-signals:selected-ids="{}"
data-signals:excluded-ids="{}"
data-signals:select-all-matching="false"
data-effect="$listingVersion > 0 && @get('{{ url_for('survey_related_records:get_list_component') }}')"
>
<div class="col-8">
Expand All @@ -42,7 +45,7 @@
id="search"
placeholder="Search"
data-bind:search
data-on:input__debounce.200ms="@get('{{ url_for("survey_related_records:get_list_component") }}')"
data-on:input__debounce.200ms="{{ macros.reset_selection_js() }}@get('{{ url_for("survey_related_records:get_list_component") }}')"
data-indicator:searching
/>
<label class="form-label" for="search">
Expand Down Expand Up @@ -86,15 +89,16 @@
_("Temporal extent begin"),
_("Temporal extent end"),
"{{ current_temporal_extent.begin }}",
"{{ current_temporal_extent.end }}"
"{{ current_temporal_extent.end }}",
extra_on_input_js=macros.reset_selection_js()
) }}
<div class="mb-3">
<label for="path-fragment">{{ _("Path") }}</label>
<input
id="path-fragment"
class="form-control"
data-bind="assetPathFragment"
data-on:input__debounce.200ms="@get('{{ url_for("survey_related_records:get_list_component") }}')"
data-on:input__debounce.200ms="{{ macros.reset_selection_js() }}@get('{{ url_for("survey_related_records:get_list_component") }}')"
value=""
>
</div>
Expand All @@ -121,7 +125,7 @@
data-initial-max-lon="{{ map_bounds.max_lon }}"
data-initial-max-lat="{{ map_bounds.max_lat }}"
style="display: block; width: 100%; height: 30vh"
data-on:map-moveend="document.querySelector('base-map').checkVisibility() ? $currentBbox=evt.detail.map.getBounds().toArray() : $currentBbox=[[-180, -90],[180, 90]]; @get('{{ url_for('survey_related_records:get_list_component') }}')"
data-on:map-moveend="document.querySelector('base-map').checkVisibility() ? $currentBbox=evt.detail.map.getBounds().toArray() : $currentBbox=[[-180, -90],[180, 90]]; {{ macros.reset_selection_js() }}@get('{{ url_for('survey_related_records:get_list_component') }}')"
></base-map>
</div>
</div>
Expand Down
23 changes: 14 additions & 9 deletions tests/e2e/test_e2e_projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,23 +81,28 @@ def test_project_lifecycle(authenticated_page: Page):
re.compile(r"/projects/[0-9a-f-]{36}$"), timeout=10_000
)

# now try to modify the project
# try to modify the project, but this time hit the cancel button. This is done
# before the real modification below because submitting a real update puts the
# project into the `under_validation` status for a few seconds, during which the
# `update-item` link is disabled - the status signal is only ever set once, when
# the detail page is rendered, so clicking `update-item` again right after a real
# update is racy (see issue tracking the live status/validation signal updates).
authenticated_page.get_by_role("link", name="update-item").click()
authenticated_page.get_by_role("textbox", name="field-name-en").fill(
f"The modified name {project_name_id}"
)
authenticated_page.get_by_role("button", name="submit-update-form").click()
authenticated_page.get_by_role("link", name="cancel-update").click()

# expect to be redirector the project detail page upon successful modification
# expect to be redirector the project detail page upon cancelling the modification
expect(authenticated_page).to_have_url(
re.compile(r"/projects/[0-9a-f-]{36}$"), timeout=10_000
)

# now try to modify the project again, but this time hit the cancel button
# now actually modify the project
authenticated_page.get_by_role("link", name="update-item").click()
authenticated_page.get_by_role("link", name="cancel-update").click()
authenticated_page.get_by_role("textbox", name="field-name-en").fill(
f"The modified name {project_name_id}"
)
authenticated_page.get_by_role("button", name="submit-update-form").click()

# expect to be redirector the project detail page upon cancelling the modification
# expect to be redirector the project detail page upon successful modification
expect(authenticated_page).to_have_url(
re.compile(r"/projects/[0-9a-f-]{36}$"), timeout=10_000
)
Expand Down
94 changes: 94 additions & 0 deletions tests/e2e/test_e2e_survey_related_records.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,100 @@ def test_survey_related_record_lifecycle(authenticated_page: Page):
expect(authenticated_page).to_have_url(re.compile(r"/projects/?$"))


@pytest.mark.e2e
def test_survey_related_record_bulk_selection(authenticated_page: Page):
# NOTE: this only exercises the selection primitives (checkboxes, count,
# clear, auto-clear-on-filter-change) on the mission-scoped listing, where
# the record count is fully controlled by the test. The top-level listing
# pools records system-wide, so a deterministic "select all N matching"
# assertion there would depend on how much other data exists in the DB.

authenticated_page.goto("/")
authenticated_page.get_by_role("link", name="list-projects").click()
authenticated_page.get_by_role("link", name="new-project").click()

project_name = f"e2e test project {uuid.uuid4().hex[:8]}"
_fill_project_form_minimal(authenticated_page, project_name)
authenticated_page.get_by_role("button", name="submit-create-form").click()
expect(authenticated_page).to_have_url(
re.compile(r"/projects/[0-9a-f-]{36}$"), timeout=10_000
)

authenticated_page.get_by_role("link", name="new-item").click()
mission_name = f"e2e test survey mission {uuid.uuid4().hex[:8]}"
_fill_survey_mission_form_minimal(authenticated_page, mission_name)
authenticated_page.get_by_role("button", name="submit-create-form").click()
expect(authenticated_page).to_have_url(
re.compile(r"/survey-missions/[0-9a-f-]{36}$"), timeout=10_000
)
mission_detail_url = authenticated_page.url

record_urls = []
for i in range(2):
authenticated_page.get_by_role("link", name="new-item").click()
record_name = f"e2e bulk selection record {i} {uuid.uuid4().hex[:8]}"
_fill_survey_related_record_form(authenticated_page, record_name)
authenticated_page.get_by_role("button", name="submit-create-form").click()
expect(authenticated_page).to_have_url(
re.compile(r"/survey-related-records/[0-9a-f-]{36}$"), timeout=10_000
)
record_urls.append(authenticated_page.url)
authenticated_page.goto(mission_detail_url)

checkboxes = authenticated_page.get_by_role("checkbox", name="select-item")
expect(checkboxes).to_have_count(2)
selected_count = authenticated_page.locator("[aria-label='selected-count']")
clear_selection_button = authenticated_page.get_by_role(
"button", name="clear-selection"
)

checkboxes.nth(0).check()
expect(selected_count).to_contain_text("1 selected")
expect(clear_selection_button).to_be_visible()

checkboxes.nth(1).check()
expect(selected_count).to_contain_text("2 selected")

clear_selection_button.click()
expect(selected_count).to_be_hidden()
expect(clear_selection_button).to_be_hidden()

# selecting again, then changing the search filter should clear it back out. NOTE:
# we assert on the checkboxes disappearing rather than on the translated
# "no records found" message, since the e2e suite runs against whatever the
# default locale is (currently portuguese) and that message text is not stable
# across locales.
checkboxes.nth(0).check()
expect(selected_count).to_contain_text("1 selected")
authenticated_page.get_by_placeholder("search").fill("something not matching")
expect(checkboxes).to_have_count(0)
authenticated_page.get_by_placeholder("search").fill("")
expect(selected_count).to_be_hidden()

# clean up: delete both records, then mission, then project
for record_url in record_urls:
authenticated_page.goto(record_url)
authenticated_page.get_by_role(
"button", name="show-delete-confirmation-modal"
).click()
authenticated_page.get_by_role("button", name="delete-item").click()
expect(authenticated_page.get_by_role("link", name="new-item")).to_be_visible()

authenticated_page.get_by_role(
"button", name="show-delete-confirmation-modal"
).click()
authenticated_page.get_by_role("button", name="delete-item").click()
expect(authenticated_page).to_have_url(
re.compile(r"/projects/[0-9a-f-]{36}$"), timeout=15_000
)

authenticated_page.get_by_role(
"button", name="show-delete-confirmation-modal"
).click()
authenticated_page.get_by_role("button", name="delete-item").click()
expect(authenticated_page).to_have_url(re.compile(r"/projects/?$"))


@pytest.mark.e2e
def test_survey_related_record_creation_rejects_duplicate_english_name(
authenticated_page: Page,
Expand Down
Loading