From 7892945ee4a8464d695552269f43ed29d77ae3bd Mon Sep 17 00:00:00 2001 From: Sadiq Khoja Date: Tue, 12 Aug 2025 14:36:36 -0400 Subject: [PATCH 01/21] Bulk delete Entities --- src/alert.js | 11 +- src/assets/scss/_variables.scss | 5 + src/components/action-bar.vue | 99 ++++++++++++ src/components/entity/data-row.vue | 10 +- src/components/entity/list.vue | 157 +++++++++++++++++++- src/components/entity/metadata-row.vue | 12 +- src/components/entity/table.vue | 35 ++++- src/composables/request.js | 3 +- src/request-data/entities.js | 3 +- src/util/request.js | 4 +- test/alert.spec.js | 10 ++ test/components/entity/list.spec.js | 142 ++++++++++++++++++ test/components/entity/metadata-row.spec.js | 2 +- test/components/entity/table.spec.js | 1 + transifex/strings_en.json | 17 +++ 15 files changed, 493 insertions(+), 18 deletions(-) create mode 100644 src/components/action-bar.vue diff --git a/src/alert.js b/src/alert.js index c15a0f072..ff4720db0 100644 --- a/src/alert.js +++ b/src/alert.js @@ -32,6 +32,7 @@ class AlertData { #cta; #readonlyCta; #defaultOptions; + #onHideCb; #showChain; constructor(defaultOptions = undefined) { @@ -54,7 +55,7 @@ class AlertData { this.#cta = shallowReactive({ text: null, handler: null, pending: false }); this.#readonlyCta = readonly(this.#cta); - this.#showChain = { cta: this.#showCta.bind(this) }; + this.#showChain = { cta: this.showCta.bind(this), onHide: this.#setOnHideCb.bind(this) }; } get state() { return this.#data.state; } @@ -76,6 +77,7 @@ class AlertData { options: { ...this.#defaultOptions, ...options } }); this.#hideCta(); + this.#onHideCb = null; return this.#showChain; } @@ -89,6 +91,7 @@ class AlertData { }); Object.assign(this.#data, this.#defaultOptions); this.#hideCta(); + if (this.#onHideCb) this.#onHideCb(); } /* @@ -97,7 +100,7 @@ class AlertData { async. If the function returns `true` or resolves to `true`, the alert will be hidden. */ - #showCta(text, handler) { + showCta(text, handler) { this.#cta.text = text; // Wraps the specified handler in a function with some extra behavior. this.#cta.handler = () => { @@ -125,6 +128,10 @@ class AlertData { if (this.#cta.text == null) return; Object.assign(this.#cta, { text: null, handler: null, pending: false }); } + + #setOnHideCb(f) { + this.#onHideCb = f; + } } export const createAlert = () => new AlertData(); diff --git a/src/assets/scss/_variables.scss b/src/assets/scss/_variables.scss index fc3d05cdd..5d6918d67 100644 --- a/src/assets/scss/_variables.scss +++ b/src/assets/scss/_variables.scss @@ -85,6 +85,8 @@ $padding-bottom-table-data: 8px; $padding-left-table-data: 8px; $padding-right-table-data: 8px; $padding-top-table-data: 8px; +// Row +$color-selected-row: #E4EDF1; // Panels $box-shadow-panel-main: 0 0 24px rgba(0, 0, 0, 0.25), 0 35px 115px rgba(0, 0, 0, 0.28); @@ -131,3 +133,6 @@ $max-width-page-body: 1280px; // Popover $box-shadow-popover: 0 5px 10px rgba(0, 0, 0, 0.2); + +// Figma Variables +$m3-elevation-light-3: 0px 4px 8px 3px rgba(0, 0, 0, 0.15), 0px 1px 3px 0px rgba(0, 0, 0, 0.30); diff --git a/src/components/action-bar.vue b/src/components/action-bar.vue new file mode 100644 index 000000000..50b2aa4ab --- /dev/null +++ b/src/components/action-bar.vue @@ -0,0 +1,99 @@ + + + + + + diff --git a/src/components/entity/data-row.vue b/src/components/entity/data-row.vue index 28afac7f3..ea4272b7c 100644 --- a/src/components/entity/data-row.vue +++ b/src/components/entity/data-row.vue @@ -10,7 +10,7 @@ including this file, may be copied, modified, propagated, or distributed except according to the terms contained in the LICENSE file. --> diff --git a/src/composables/request.js b/src/composables/request.js index 8c51146e1..3d90f9d37 100644 --- a/src/composables/request.js +++ b/src/composables/request.js @@ -111,8 +111,9 @@ const _request = (container, awaitingResponse) => (config) => { // eslint-disable-next-line no-param-reassign awaitingResponse.value = false; + logAxiosError(logger, error); + if (alertOption) { - logAxiosError(logger, error); alert.danger(requestAlertMessage(i18n, error, problemToAlert)); } throw error; diff --git a/src/request-data/entities.js b/src/request-data/entities.js index 4ce0ae184..13c2b7ade 100644 --- a/src/request-data/entities.js +++ b/src/request-data/entities.js @@ -22,7 +22,8 @@ const transformValue = (data, config) => { ...entity, __system: { ...entity.__system, - rowNumber: count - skip - index + rowNumber: count - skip - index, + selected: false } })); }; diff --git a/src/util/request.js b/src/util/request.js index 549b928f3..47103c1c3 100644 --- a/src/util/request.js +++ b/src/util/request.js @@ -168,10 +168,10 @@ export const apiPaths = { datasets: projectPath('/datasets'), dataset: datasetPath(''), datasetProperties: datasetPath('/properties'), - entities: (projectId, datasetName, extension = '', query = undefined) => { + entities: (projectId, datasetName, suffix = '', query = undefined) => { const encodedName = encodeURIComponent(datasetName); const qs = queryString(query); - return `/v1/projects/${projectId}/datasets/${encodedName}/entities${extension}${qs}`; + return `/v1/projects/${projectId}/datasets/${encodedName}/entities${suffix}${qs}`; }, odataEntitiesSvc: datasetPath('.svc'), odataEntities: datasetPath('.svc/Entities'), diff --git a/test/alert.spec.js b/test/alert.spec.js index d8f8e385a..e125c81eb 100644 --- a/test/alert.spec.js +++ b/test/alert.spec.js @@ -128,6 +128,16 @@ describe('createAlert()', () => { should.not.exist(alert.message); should.not.exist(alert.cta); }); + + it('calls the onHide callback when alert is hidden', () => { + const { alert } = createAlerts(); + const onHideSpy = sinon.spy(); + + alert.info('Something happened!').onHide(onHideSpy); + alert.last.hide(); + + onHideSpy.called.should.be.true; + }); }); }); diff --git a/test/components/entity/list.spec.js b/test/components/entity/list.spec.js index 743cae748..cd7585243 100644 --- a/test/components/entity/list.spec.js +++ b/test/components/entity/list.spec.js @@ -1174,4 +1174,146 @@ describe('EntityList', () => { app.get('.search-textbox').element.value.should.be.equal('john'); })); }); + + describe('bulk delete functionality', () => { + beforeEach(() => { + createEntities(3); + }); + + it('shows action bar when entities are selected', async () => { + const component = await loadEntityList(); + const actionBar = component.findComponent({ name: 'ActionBar' }); + actionBar.props().state.should.be.false; + + const checkboxes = component.findAll('.entity-metadata-row input[type="checkbox"]'); + await checkboxes[0].setValue(true); + await checkboxes[1].setValue(true); + + actionBar.props().state.should.be.true; + actionBar.props().message.should.equal('2 Entities selected'); + }); + + it('hides action bar when all entities are deselected', async () => { + const component = await loadEntityList(); + const actionBar = component.findComponent({ name: 'ActionBar' }); + + // Click on checkboxes to select entities first + const checkboxes = component.findAll('.entity-metadata-row input[type="checkbox"]'); + await checkboxes[0].setValue(true); + await checkboxes[1].setValue(true); + + actionBar.props().state.should.be.true; + + await checkboxes[0].setValue(false); + await checkboxes[1].setValue(false); + + actionBar.props().state.should.be.false; + }); + + it('handles select all functionality', async () => { + const component = await loadEntityList(); + const actionBar = component.findComponent({ name: 'ActionBar' }); + + actionBar.props().state.should.be.false; + + // Click the header "select all" checkbox + const headerCheckbox = component.find('#entity-table input[type="checkbox"]'); + await headerCheckbox.setValue(true); + + actionBar.props().state.should.be.true; + actionBar.props().message.should.equal('3 Entities selected'); + }); + + it('handles bulk delete request successfully', async () => loadEntityList() + .complete() + .request(async component => { + const checkboxes = component.findAll('.entity-metadata-row input[type="checkbox"]'); + await checkboxes[0].setValue(true); + await checkboxes[1].setValue(true); + return component.find('.action-bar-container .btn-primary').trigger('click'); + }) + .beforeEachResponse((_, { url }) => { + url.should.equal('/v1/projects/1/datasets/trees/entities/bulk-delete'); + }) + .respondWithSuccess() + .afterResponse(component => { + const actionBar = component.findComponent({ name: 'ActionBar' }); + actionBar.props().state.should.be.false; + })); + + it('handles bulk delete failure with retry functionality', () => loadEntityList() + .complete() + .request(async component => { + const checkboxes = component.findAll('.entity-metadata-row input[type="checkbox"]'); + await checkboxes[0].setValue(true); + await checkboxes[1].setValue(true); + return component.find('.action-bar-container .btn-primary').trigger('click'); + }) + .beforeEachResponse((_, { url }) => { + url.should.equal('/v1/projects/1/datasets/trees/entities/bulk-delete'); + }) + .respondWithProblem(500.1) + .afterResponse(component => { + component.should.alert('danger'); + })); + + it('should undo the bulk delete', () => loadEntityList() + .complete() + .request(async component => { + // First perform bulk delete to have entities to restore + const checkboxes = component.findAll('.entity-metadata-row input[type="checkbox"]'); + await checkboxes[0].setValue(true); + await checkboxes[1].setValue(true); + return component.find('.action-bar-container .btn-primary').trigger('click'); + }) + .respondWithSuccess() + .afterResponse(component => { + component.should.alert('success'); + }) + .complete() + .request(component => component.vm.$container.alert.cta.handler()) + .beforeEachResponse((_, { url }) => { + url.should.equal('/v1/projects/1/datasets/trees/entities/bulk-restore'); + }) + .respondWithSuccess() + .afterResponse(component => { + component.should.alert('success', /Entities successfully restored/); + })); + + it('handles bulk restore failure with onHide callback', () => loadEntityList() + .complete() + .request(async component => { + // First perform bulk delete to have entities to restore + const checkboxes = component.findAll('.entity-metadata-row input[type="checkbox"]'); + await checkboxes[0].setValue(true); + return component.find('.action-bar-container .btn-primary').trigger('click'); + }) + .respondWithSuccess() + .complete() + .request(component => component.vm.$container.alert.cta.handler()) + .beforeEachResponse((_, { url }) => { + url.should.equal('/v1/projects/1/datasets/trees/entities/bulk-restore'); + }) + .respondWithProblem(500.1) + .afterResponse(component => { + component.should.alert('danger'); + component.vm.alert.last.hide(); + const actionBar = component.findComponent({ name: 'ActionBar' }); + actionBar.props().state.should.be.false; + })); + + it('hides alert when entities are selected', async () => { + const component = await loadEntityList(); + + // Show an alert first + component.vm.alert.info('Some alert message'); + component.vm.alert.state.should.be.true; + + // Select an entity by clicking its checkbox - this should hide the alert + const checkboxes = component.findAll('.entity-metadata-row input[type="checkbox"]'); + await checkboxes[0].setValue(true); + + component.vm.alert.state.should.be.false; + }); + }); }); diff --git a/test/components/entity/metadata-row.spec.js b/test/components/entity/metadata-row.spec.js index 04c85dee9..dfc27f1ec 100644 --- a/test/components/entity/metadata-row.spec.js +++ b/test/components/entity/metadata-row.spec.js @@ -42,7 +42,7 @@ describe('EntityMetadataRow', () => { const creator = testData.extendedUsers.first(); testData.extendedEntities.createPast(1, { creator }).last(); const row = mountComponent(); - const td = row.findAll('td')[1]; + const td = row.findAll('td')[2]; td.classes('creator-name').should.be.true; td.text().should.equal(creator.displayName); await td.get('span').should.have.textTooltip(); diff --git a/test/components/entity/table.spec.js b/test/components/entity/table.spec.js index bb6bbc36f..c043f0a4f 100644 --- a/test/components/entity/table.spec.js +++ b/test/components/entity/table.spec.js @@ -43,6 +43,7 @@ describe('EntityTable', () => { const table = component.get('.table-freeze-frozen'); headers(table).should.eql([ 'Row', + '', 'Created by', 'Created at', 'Last Updated / Actions' diff --git a/transifex/strings_en.json b/transifex/strings_en.json index 80803c745..08bc9eeaf 100644 --- a/transifex/strings_en.json +++ b/transifex/strings_en.json @@ -2168,6 +2168,12 @@ "alert": { "delete": { "string": "Entity “{label}” has been deleted." + }, + "bulkDelete": { + "string": "{count, plural, one {{count} Entity successfully deleted} other {{count} Entities successfully deleted}}" + }, + "restored": { + "string": "{count, plural, one {{count} Entity successfully restored} other {{count} Entities successfully restored}}" } }, "filterDisabledMessage": { @@ -2190,6 +2196,17 @@ "string": "All Entities on the page have been restored." } }, + "actionBar": { + "message": { + "string": "{count, plural, one {{count} Entity selected} other {{count} Entities selected}}" + }, + "cta": { + "string": "Delete" + } + }, + "undo": { + "string": "Undo" + }, "action": { "download": { "unfiltered": { From 3003c6bede04c1b6b63761db52873d5eb75af4bf Mon Sep 17 00:00:00 2001 From: Sadiq Khoja Date: Mon, 18 Aug 2025 14:31:33 -0400 Subject: [PATCH 02/21] Show bulk events in entity feed --- src/components/entity/feed-entry.vue | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/entity/feed-entry.vue b/src/components/entity/feed-entry.vue index 0189afd0a..092e3a31f 100644 --- a/src/components/entity/feed-entry.vue +++ b/src/components/entity/feed-entry.vue @@ -129,13 +129,13 @@ except according to the terms contained in the LICENSE file. - diff --git a/src/components/entity/list.vue b/src/components/entity/list.vue index 7faab0ffb..d99761520 100644 --- a/src/components/entity/list.vue +++ b/src/components/entity/list.vue @@ -76,7 +76,7 @@ except according to the terms contained in the LICENSE file. + + +{ + "en": { + "field": { + // This is the text of a form field that allows the user to filter + // Entities by a date range. + "creationDate": "At" + }, + // Text shown when in Entities date filter when Entities for all dates are shown + "allCreationDateSelected": "All time" + } +} + diff --git a/src/components/entity/filters/creator.vue b/src/components/entity/filters/creator.vue new file mode 100644 index 000000000..cdf41a6ed --- /dev/null +++ b/src/components/entity/filters/creator.vue @@ -0,0 +1,131 @@ + + + + + + + + +{ + "en": { + "field": { + "creator": "Created by", + "search": "Search creators…" + }, + "action": { + /* + This is the text of the button in dropdown menu of submitter filter, + that allows the user to select all creators. + */ + "all": "All", + /* + This is the text of the button in dropdown menu of creator filter, + that allows the user to unselect all creators. + */ + "none": "None" + }, + "unknown": "Unknown creator", + } +} + diff --git a/src/components/entity/list.vue b/src/components/entity/list.vue index 537932e50..c298a9ca3 100644 --- a/src/components/entity/list.vue +++ b/src/components/entity/list.vue @@ -14,8 +14,8 @@ except according to the terms contained in the LICENSE file.
- +
+ + + diff --git a/src/components/radio-field.vue b/src/components/radio-field.vue new file mode 100644 index 000000000..18a47fcc2 --- /dev/null +++ b/src/components/radio-field.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/src/components/submission/list.vue b/src/components/submission/list.vue index d48727c86..95b646b35 100644 --- a/src/components/submission/list.vue +++ b/src/components/submission/list.vue @@ -33,15 +33,18 @@ except according to the terms contained in the LICENSE file.
+ v-if="selectedFields != null && fields.selectable.length > 11" + v-model="selectedFields"/> + - +

{{ emptyTableMessage }}

- - - - diff --git a/src/components/submission/table-view.vue b/src/components/submission/table-view.vue new file mode 100644 index 000000000..69239dbb2 --- /dev/null +++ b/src/components/submission/table-view.vue @@ -0,0 +1,173 @@ + + + + diff --git a/src/components/submission/table.vue b/src/components/submission/table.vue index 579e11a00..eb261e3ec 100644 --- a/src/components/submission/table.vue +++ b/src/components/submission/table.vue @@ -98,6 +98,8 @@ defineExpose({ afterReview, afterDelete }); @import '../../assets/scss/mixins'; #submission-table { + table:has(tbody:empty) { display: none; } + .table-freeze-scrolling { th, td { @include text-overflow-ellipsis; diff --git a/src/locales/en.json5 b/src/locales/en.json5 index 3fac91ab4..12946d02a 100644 --- a/src/locales/en.json5 +++ b/src/locales/en.json5 @@ -416,6 +416,7 @@ "forum": "Forum", "lastUpdate": "Last update", "loading": "Loading…", + "map": "Map", "no": "No", // This is shown if a search returned no results. "noResults": "No results", @@ -434,6 +435,7 @@ "tab": { "settings": "Settings" }, + "table": "Table", "total": "Total", "totalSubmissions": "Total Submissions", // {inform} is the number of Entity Properties defined by the form. diff --git a/src/request-data/fields.js b/src/request-data/fields.js index b02f43915..d362387a2 100644 --- a/src/request-data/fields.js +++ b/src/request-data/fields.js @@ -27,8 +27,8 @@ export default () => { return data; }, /* eslint-enable no-param-reassign */ - selectable: computeIfExists(() => { - const selectable = []; + outsideRepeat: computeIfExists(() => { + const result = []; // The path of the top-level repeat group currently being traversed let repeat = null; for (const field of fields) { @@ -39,19 +39,21 @@ export default () => { // in the Widgets sample form (): // https://github.com/getodk/sample-forms/blob/e9fe5838e106b04bf69f43a8a791327093571443/Widgets.xml const { type } = field; - if (type === 'repeat') { + if (type === 'repeat') repeat = `${path}/`; - } else if (type !== 'structure' && path !== '/meta/instanceID' && - path !== '/instanceID') { - selectable.push(field); - } + else if (type !== 'structure') + result.push(field); } } - return selectable; + return result; }), + selectable: computeIfExists(() => fields.outsideRepeat.filter(({ path }) => + path !== '/meta/instanceID' && path !== '/instanceID')), binaryPaths: computeIfExists(() => fields.reduce( (acc, cur) => (cur.binary ? acc.add(cur.path) : acc), new Set() - )) + )), + hasMappable: computeIfExists(() => fields.outsideRepeat.some(({ type }) => + type === 'geopoint' || type === 'geotrace' || type === 'geoshape')) })); }; diff --git a/src/util/request.js b/src/util/request.js index f0de2d840..a6e577833 100644 --- a/src/util/request.js +++ b/src/util/request.js @@ -25,8 +25,14 @@ export const queryString = (query) => { const entries = Object.entries(query); if (entries.length === 0) return ''; const params = new URLSearchParams(); - for (const [name, value] of entries) - if (value != null) params.set(name, value.toString()); + for (const [name, value] of entries) { + if (Array.isArray(value)) { + for (const element of value) + params.append(name, element === null ? 'null' : element.toString()); + } else if (value != null) { + params.set(name, value.toString()); + } + } const qs = params.toString(); return qs !== '' ? `?${qs}` : qs; }; diff --git a/test/components/radio-field.spec.js b/test/components/radio-field.spec.js new file mode 100644 index 000000000..c1287ee77 --- /dev/null +++ b/test/components/radio-field.spec.js @@ -0,0 +1,59 @@ +import RadioField from '../../src/components/radio-field.vue'; + +import { mount } from '../util/lifecycle'; + +describe('RadioField', () => { + it('uses the options prop', () => { + const component = mount(RadioField, { + props: { + options: [ + { value: 'jan', text: 'January' }, + { value: 'feb', text: 'February' } + ], + modelValue: 'jan' + } + }); + + const values = component.findAll('input').map(input => input.attributes().value); + values.should.eql(['jan', 'feb']); + + const text = component.findAll('label').map(label => label.text()); + text.should.eql(['January', 'February']); + }); + + it('uses the modelValue prop', async () => { + const component = mount(RadioField, { + props: { + // Using numeric values, not string, in order to confirm that values are + // not converted to string in all circumstances. + options: [{ value: 1, text: 'one' }, { value: 2, text: 'two' }], + modelValue: 2 + } + }); + component.get('input:checked').attributes().value.should.equal('2'); + await component.get('input').setChecked(); + component.emitted('update:modelValue').should.eql([[1]]); + await component.setProps({ modelValue: 1 }); + component.get('input:checked').attributes().value.should.equal('1'); + }); + + it('uses the disabled prop', () => { + const component = mount(RadioField, { + props: { + options: [{ value: 1, text: 'one' }, { value: 2, text: 'two' }], + modelValue: 1, + disabled: true, + disabledMessage: 'It is disabled.' + } + }); + + for (const radio of component.findAll('.radio')) { + radio.classes('disabled').should.be.true; + const input = radio.get('input'); + input.element.disabled.should.be.true; + input.should.have.ariaDescription('It is disabled.'); + } + + component.should.have.tooltip('It is disabled.'); + }); +}); diff --git a/test/components/submission/list.spec.js b/test/components/submission/list.spec.js index 5ae542242..72b45bb9a 100644 --- a/test/components/submission/list.spec.js +++ b/test/components/submission/list.spec.js @@ -27,36 +27,27 @@ describe('SubmissionList', () => { describe('initial requests', () => { it('sends the correct requests for a form', () => { testData.extendedForms.createPast(1, { xmlFormId: 'a b' }); - let count = 0; - return loadSubmissionList() - .beforeEachResponse((_, { url }, i) => { - count += 1; - if (i === 0) - url.should.equal('/v1/projects/1/forms/a%20b/fields?odata=true'); - else if (i === 1) - url.should.startWith('/v1/projects/1/forms/a%20b.svc/Submissions?'); - else - url.should.equal('/v1/projects/1/forms/a%20b/submissions/submitters'); - }) - .afterResponses(() => { - count.should.equal(3); - }); + return loadSubmissionList().testRequests([ + { url: '/v1/projects/1/forms/a%20b/fields?odata=true' }, + { url: '/v1/projects/1/forms/a%20b/submissions/submitters' }, + { + url: ({ pathname }) => { + pathname.should.equal('/v1/projects/1/forms/a%20b.svc/Submissions'); + } + } + ]); }); it('sends the correct requests for a form draft', () => { testData.extendedForms.createPast(1, { xmlFormId: 'a b', draft: true }); - let count = 0; - return loadSubmissionList() - .beforeEachResponse((_, { url }, i) => { - count += 1; - if (i === 0) - url.should.equal('/v1/projects/1/forms/a%20b/draft/fields?odata=true'); - else - url.should.startWith('/v1/projects/1/forms/a%20b/draft.svc/Submissions?'); - }) - .afterResponses(() => { - count.should.equal(2); - }); + return loadSubmissionList().testRequests([ + { url: '/v1/projects/1/forms/a%20b/draft/fields?odata=true' }, + { + url: ({ pathname }) => { + pathname.should.equal('/v1/projects/1/forms/a%20b/draft.svc/Submissions'); + } + } + ]); }); }); @@ -77,8 +68,8 @@ describe('SubmissionList', () => { describe('after the refresh button is clicked', () => { it('completes a background refresh', () => { testData.extendedSubmissions.createPast(1); - const assertRowCount = (count, responseIndex = 0) => (component, _, i) => { - if (i === responseIndex) { + const assertRowCount = (count) => (component, _, i) => { + if (i === 0 || i == null) { component.findAllComponents(SubmissionMetadataRow).length.should.equal(count); component.findAllComponents(SubmissionDataRow).length.should.equal(count); } @@ -587,8 +578,7 @@ describe('SubmissionList', () => { const loadDeletedSubmissions = () => { testData.extendedSubmissions.createPast(1, { instanceId: 'e', deletedAt: new Date().toISOString() }); return load('/projects/1/forms/f/submissions?deleted=true', { root: false }, { - deletedSubmissionCount: false, - odata: testData.submissionDeletedOData + deletedSubmissionCount: false }); }; @@ -666,8 +656,7 @@ describe('SubmissionList', () => { it('hides the table', () => load('/projects/1/forms/f/submissions?deleted=true', { root: false, attachTo: document.body }, { - deletedSubmissionCount: false, - odata: testData.submissionDeletedOData + deletedSubmissionCount: false }) .complete() .request(restore(1)) @@ -683,8 +672,7 @@ describe('SubmissionList', () => { it('shows a message', () => load('/projects/1/forms/f/submissions?deleted=true', { root: false }, { - deletedSubmissionCount: false, - odata: testData.submissionDeletedOData + deletedSubmissionCount: false }) .complete() .request(restore(1)) @@ -703,8 +691,7 @@ describe('SubmissionList', () => { it('continues to show modal if checkbox was not checked', () => { testData.extendedSubmissions.createPast(2, { deletedAt: new Date().toISOString() }); return load('/projects/1/forms/f/submissions?deleted=true', { root: false }, { - deletedSubmissionCount: false, - odata: testData.submissionDeletedOData + deletedSubmissionCount: false }) .complete() .request(async (component) => { @@ -725,8 +712,7 @@ describe('SubmissionList', () => { .createPast(1, { instanceId: 'e1', deletedAt: new Date().toISOString() }) .createPast(1, { instanceId: 'e2', deletedAt: new Date().toISOString() }); return load('/projects/1/forms/f/submissions?deleted=true', { root: false }, { - deletedSubmissionCount: false, - odata: testData.submissionDeletedOData + deletedSubmissionCount: false }) .complete() .request(async (component) => { diff --git a/test/components/submission/map-view.spec.js b/test/components/submission/map-view.spec.js new file mode 100644 index 000000000..e84835cbc --- /dev/null +++ b/test/components/submission/map-view.spec.js @@ -0,0 +1,317 @@ +import { F } from 'ramda'; + +import GeojsonMap from '../../../src/components/geojson-map.vue'; +import RadioField from '../../../src/components/radio-field.vue'; +import SubmissionList from '../../../src/components/submission/list.vue'; +import SubmissionMapView from '../../../src/components/submission/map-view.vue'; + +import testData from '../../data'; +import { changeMultiselect } from '../../util/trigger'; +import { findTab } from '../../util/dom'; +import { load } from '../../util/http'; +import { mockLogin } from '../../util/session'; +import { setLuxon } from '../../util/date-time'; + +const findToggle = (component) => + component.getComponent(SubmissionList).findComponent(RadioField); +const toggleView = (view) => (app) => + findToggle(app).get(`input[type="radio"][value="${view}"]`).setChecked(); +const getView = (app) => { + // SubmissionTableView always renders #submission-table. + const hasTable = app.find('#submission-table').exists(); + // In contrast, SubmissionMapView doesn't always render .geojson-map. + const hasMap = app.findComponent(SubmissionMapView).exists(); + if (hasTable && hasMap) throw new Error('both views are rendered'); + if (!hasTable && !hasMap) throw new Error('neither view is rendered'); + return hasTable ? 'table' : 'map'; +}; + +const mypoint = testData.fields.geopoint('/mypoint'); + +describe('SubmissionMapView', () => { + beforeEach(mockLogin); + + describe('toggle', () => { + it('shows the toggle if the form has a geo field', async () => { + testData.extendedForms.createPast(1, { fields: [mypoint] }); + const app = await load('/projects/1/forms/f/submissions'); + findToggle(app).exists().should.be.true; + }); + + it('does not show toggle if form does not have a geo field', async () => { + testData.extendedForms.createPast(1); + const app = await load('/projects/1/forms/f/submissions'); + findToggle(app).exists().should.be.false; + }); + + it('does not show toggle if the only geo field is in a repeat group', async () => { + testData.extendedForms.createPast(1, { + fields: [ + testData.fields.repeat('/plot'), + testData.fields.geopoint('/plot/plotpoint') + ] + }); + const app = await load('/projects/1/forms/f/submissions'); + findToggle(app).exists().should.be.false; + }); + + it('does not show toggle on Edit Form page', async () => { + testData.extendedForms.createPast(1, { draft: true, fields: [mypoint] }); + const app = await load('/projects/1/forms/f/draft'); + findToggle(app).exists().should.be.false; + }); + + it('disables the toggle if there is an encrypted submission', async () => { + testData.extendedForms.createPast(1, { + fields: [mypoint], + key: testData.standardKeys.createPast(1, { managed: true }).last() + }); + const app = await load('/projects/1/forms/f/submissions'); + findToggle(app).props().disabled.should.be.true; + }); + + it('switches to map view', () => { + testData.extendedForms.createPast(1, { + xmlFormId: 'a b', + fields: [mypoint] + }); + return load('/projects/1/forms/a%20b/submissions') + .complete() + .request(toggleView('map')) + .respondWithData(testData.submissionGeojson) + .testRequests([{ url: '/v1/projects/1/forms/a%20b/submissions.geojson' }]) + .afterResponses(app => { + getView(app).should.equal('map'); + expect(app.vm.$route.query.map).to.equal('true'); + }); + }); + + it('switches back to table view', () => { + testData.extendedForms.createPast(1, { fields: [mypoint] }); + return load('/projects/1/forms/f/submissions') + .complete() + .request(toggleView('map')) + .respondWithData(testData.submissionGeojson) + .complete() + .request(toggleView('table')) + .respondWithData(testData.submissionOData) + .testRequests([{ + url: ({ pathname }) => { + pathname.should.equal('/v1/projects/1/forms/f.svc/Submissions'); + } + }]) + .afterResponses(app => { + getView(app).should.equal('table'); + should.not.exist(app.vm.$route.query.map); + }); + }); + }); + + it('shows map view immediately if ?map=true', () => { + testData.extendedForms.createPast(1, { fields: [mypoint] }); + return load('/projects/1/forms/f/submissions?map=true') + .testRequestsInclude([{ url: '/v1/projects/1/forms/f/submissions.geojson' }]) + .afterResponses(app => { + getView(app).should.equal('map'); + }); + }); + + describe('filters', () => { + beforeEach(() => { + setLuxon({ defaultZoneName: 'UTC' }); + testData.extendedForms.createPast(1, { fields: [mypoint] }); + }); + + it('passes filters through to the request', () => + load('/projects/1/forms/f/submissions?map=true&submitterId=1&submitterId=2&start=1970-01-01&end=1970-01-02&reviewState=%27approved%27&reviewState=null') + .testRequestsInclude([{ + url: ({ pathname, searchParams }) => { + pathname.should.equal('/v1/projects/1/forms/f/submissions.geojson'); + searchParams.getAll('submitterId').should.eql(['1', '2']); + searchParams.get('start__gte').should.equal('1970-01-01T00:00:00.000Z'); + searchParams.get('end__lte').should.equal('1970-01-02T23:59:59.999Z'); + searchParams.getAll('reviewState').should.eql(['approved', 'null']); + } + }])); + + it('refreshes the map after a filter changes', () => { + testData.extendedSubmissions.createPast(1, { + mypoint: 'POINT (1 2)', + reviewState: 'hasIssues' + }); + return load('/projects/1/forms/f/submissions?map=true', { attachTo: document.body }) + .afterResponses(app => { + app.find('.geojson-map').exists().should.be.true; + }) + .request(changeMultiselect('#submission-filters-review-state', [1])) + .beforeEachResponse((app, { url }) => { + url.should.equal('/v1/projects/1/forms/f/submissions.geojson?reviewState=hasIssues'); + // Not a background refresh: the map disappears during the request. + app.find('.geojson-map').exists().should.be.false; + }) + .respondWithData(testData.submissionGeojson) + .afterResponse(app => { + app.find('.geojson-map').exists().should.be.true; + }); + }); + }); + + describe('deleted submissions', () => { + beforeEach(() => { + testData.extendedForms.createPast(1, { fields: [mypoint] }); + }); + + it('shows map view for deleted submissions', () => { + testData.extendedSubmissions.createPast(1, { deletedAt: new Date().toISOString() }); + return load('/projects/1/forms/f/submissions?map=true&deleted=true') + .testRequestsInclude([{ + url: '/v1/projects/1/forms/f/submissions.geojson?deleted=true' + }]) + .afterResponses(app => { + getView(app).should.equal('map'); + }); + }); + + it('preserves map view while toggling deleted submissions', () => { + testData.extendedSubmissions.createPast(1, { deletedAt: new Date().toISOString() }); + return load('/projects/1/forms/f/submissions?map=true&reviewState=null') + .complete() + .request(app => app.get('.toggle-deleted-submissions').trigger('click')) + .respondWithData(testData.submissionGeojson) + .testRequestsInclude([{ + // Map view is preserved, but the review state filter is not. + url: '/v1/projects/1/forms/f/submissions.geojson?deleted=true' + }]) + .afterResponse(app => { + getView(app).should.equal('map'); + const { fullPath } = app.vm.$route; + fullPath.should.equal('/projects/1/forms/f/submissions?deleted=true&map=true'); + }) + .request(app => app.get('.toggle-deleted-submissions').trigger('click')) + .respondWithData(() => testData.submissionGeojson(F)) + .afterResponse(app => { + getView(app).should.equal('map'); + const { fullPath } = app.vm.$route; + fullPath.should.equal('/projects/1/forms/f/submissions?map=true'); + }); + }); + }); + + describe('after the Refresh button is clicked', () => { + beforeEach(() => { + testData.extendedForms.createPast(1, { fields: [mypoint] }); + }); + + it('sends the correct requests', () => + load('/projects/1/forms/f/submissions?map=true') + .complete() + .request(app => + app.get('#submission-list-refresh-button').trigger('click')) + .respondWithData(testData.submissionGeojson) + .respondWithData(() => testData.submissionDeletedOData(0)) + .testRequests([ + { url: '/v1/projects/1/forms/f/submissions.geojson' }, + { + // deletedSubmissionCount + url: ({ pathname, searchParams }) => { + pathname.should.equal('/v1/projects/1/forms/f.svc/Submissions'); + searchParams.get('$filter').should.equal('__system/deletedAt ne null'); + searchParams.get('$top').should.equal('0'); + } + } + ])); + + it('updates the map', () => { + testData.extendedSubmissions.createPast(1, { mypoint: 'POINT (1 2)' }); + const assertCount = (app, count) => { + const { features } = app.getComponent(GeojsonMap).props(); + features.length.should.equal(count); + }; + return load('/projects/1/forms/f/submissions?map=true') + .afterResponses(app => { + assertCount(app, 1); + }) + .request(app => + app.get('#submission-list-refresh-button').trigger('click')) + .beforeEachResponse((app, _, i) => { + if (i === 0) assertCount(app, 1); + }) + .respondWithData(() => { + testData.extendedSubmissions.createNew(); + return testData.submissionGeojson(); + }) + .respondWithData(() => testData.submissionDeletedOData(0)) + .afterResponses(app => { + assertCount(app, 2); + }); + }); + }); + + describe('empty message', () => { + beforeEach(() => { + testData.extendedForms.createPast(1, { fields: [mypoint] }); + }); + + it('shows a message if no submissions are returned', async () => { + const app = await load('/projects/1/forms/f/submissions?map=true'); + const message = app.get('.empty-table-message'); + message.should.be.visible(); + message.text().should.equal('There are no Submissions yet.'); + app.find('.geojson-map').exists().should.be.false; + }); + + it('shows a different message if a filter is applied', async () => { + const app = await load('/projects/1/forms/f/submissions?map=true&reviewState=null'); + const message = app.get('.empty-table-message'); + message.should.be.visible(); + message.text().should.equal('There are no matching Submissions.'); + app.find('.geojson-map').exists().should.be.false; + }); + + it('hides the empty message after toggling to map view', () => + load('/projects/1/forms/f/submissions') + .afterResponses(app => { + app.get('.empty-table-message').should.be.visible(); + }) + .request(toggleView('map')) + .beforeEachResponse(app => { + // The message should disappear immediately, not just after the + // GeoJSON response is received. + app.get('.empty-table-message').should.be.hidden(); + }) + .respondWithData(() => { + testData.extendedSubmissions.createNew({ mypoint: 'POINT (1 2)' }); + return testData.submissionGeojson(); + }) + .afterResponse(app => { + app.get('.empty-table-message').should.be.hidden(); + app.find('.geojson-map').exists().should.be.true; + })); + }); + + // Submissions with geo data are a subset of all submissions, so we don't show + // the count of all submissions when loading the map. + it('does not show the submission count in the loading message', () => { + testData.extendedForms.createPast(1, { fields: [mypoint] }); + testData.extendedSubmissions.createPast(1, { mypoint: 'POINT (1 2)' }); + return load('/projects/1/forms/f/submissions') + .complete() + .request(toggleView('map')) + .beforeAnyResponse(app => { + app.get('#odata-loading-message').text().should.equal('Loading Submissions…'); + }) + .respondWithData(testData.submissionGeojson); + }); + + it('does not update the tab badge', async () => { + testData.extendedForms.createPast(1, { fields: [mypoint], submissions: 2 }); + testData.extendedSubmissions + .createPast(1, { mypoint: 'POINT (1 2)' }) + .createPast(1, { mypoint: null }); + const app = await load('/projects/1/forms/f/submissions?map=true'); + app.getComponent(GeojsonMap).props().features.length.should.equal(1); + // Even though there is only one submission in the GeoJSON, that should not + // change the submission count in the tab badge. + findTab(app, 'Submissions').get('.badge').text().should.equal('2'); + }); +}); diff --git a/test/data/submissions.js b/test/data/submissions.js index 41613b7c6..85ce8cd61 100644 --- a/test/data/submissions.js +++ b/test/data/submissions.js @@ -1,6 +1,6 @@ import { faker } from '@faker-js/faker'; import { DateTime } from 'luxon'; -import { clone, comparator, hasPath, lensPath, set } from 'ramda'; +import { T, clone, comparator, hasPath, lensPath, path as getPath, set } from 'ramda'; import { dataStore, view } from './data-store'; import { extendedForms } from './forms'; @@ -68,6 +68,27 @@ const randomOData = (instanceId, versionFields, partial) => versionFields partial ); +const odataToGeojson = (odata, versionFields) => { + const field = versionFields.find(({ type }) => type === 'geopoint'); + if (field == null) return null; + const path = field.path.split('/'); + path.shift(); + const value = getPath(path, odata); + if (value == null) return null; + const match = value.match(/\d+(\.\d+)?/g); + if (match == null) return null; + const coordinates = match.map(s => Number.parseInt(s, 10)); + return { + type: 'Feature', + id: odata.__id, + geometry: { + type: 'GeometryCollection', + geometries: [{ type: 'Point', coordinates }] + }, + properties: { fieldpath: field.path } + }; +}; + // eslint-disable-next-line import/prefer-default-export export const extendedSubmissions = dataStore({ factory: ({ @@ -158,7 +179,8 @@ export const extendedSubmissions = dataStore({ // An actual submission JSON response does not have this property. We // include it here so that it is easy to match submission data and // metadata during testing. - _odata: odata + _odata: odata, + _geojson: odataToGeojson(odata, formVersion._fields) }; }, sort: comparator((submission1, submission2) => @@ -188,3 +210,11 @@ const restToOData = (filterExpression) => (top = 250, skip = 0) => { export const submissionOData = restToOData(submission => submission.deletedAt == null); export const submissionDeletedOData = restToOData(submission => submission.deletedAt != null); + +export const submissionGeojson = (filterExpression = T) => ({ + type: 'FeatureCollection', + features: extendedSubmissions.sorted() + .filter(filterExpression) + .filter(submission => submission._geojson != null) + .map(submission => submission._geojson) +}); diff --git a/test/unit/request.spec.js b/test/unit/request.spec.js index 53fac49b6..478559cc2 100644 --- a/test/unit/request.spec.js +++ b/test/unit/request.spec.js @@ -16,6 +16,14 @@ describe('util/request', () => { queryString({ x: 1, y: null }).should.eql('?x=1'); }); + it('supports arrays', () => { + queryString({ x: [1, 2] }).should.equal('?x=1&x=2'); + }); + + it('allows an array to include null', () => { + queryString({ x: [1, null] }).should.equal('?x=1&x=null'); + }); + it('returns an empty string for an empty object', () => { queryString({}).should.equal(''); }); diff --git a/test/util/http.js b/test/util/http.js index ddca8b8a8..948cea14f 100644 --- a/test/util/http.js +++ b/test/util/http.js @@ -462,7 +462,10 @@ class MockHttp { if (Array.isArray(response)) { return series.respondIf( response[0], - () => mockResponse.of((option ?? response[1])()) + (config) => { + const f = option ?? response[1]; + return mockResponse.of(f(config)); + } ); } throw new Error(`invalid response for component ${componentName}`); @@ -699,7 +702,7 @@ class MockHttp { let isOrdered = false; for (const [i, [f, ifCallback]] of this._respondIf.entries()) { if (!respondedIf.has(i) && f(config)) { - responseCallback = ifCallback; + responseCallback = () => ifCallback(config); respondedIf.add(i); break; } diff --git a/test/util/http/data.js b/test/util/http/data.js index 29f433eec..ec11525df 100644 --- a/test/util/http/data.js +++ b/test/util/http/data.js @@ -3,10 +3,11 @@ import { pick } from 'ramda'; import useForm from '../../../src/request-data/form'; import useProject from '../../../src/request-data/project'; import useDatasets from '../../../src/request-data/datasets'; +import { apiPaths } from '../../../src/util/request'; import testData from '../../data'; import { mockResponse } from '../axios'; -import { apiPaths } from '../../../src/util/request'; +import { relativeUrl } from '../request'; // The names of the following properties correspond to requestData resources. const responseDefaults = { @@ -132,14 +133,49 @@ const responsesByComponent = { }), FormSubmissions: componentResponses({ keys: () => testData.standardKeys.sorted(), - deletedSubmissionCount: () => testData.submissionDeletedOData(0), fields: () => testData.extendedForms.last()._fields, - odata: testData.submissionOData, submitters: () => testData.extendedFieldKeys .sorted() .sort((fieldKey1, fieldKey2) => fieldKey1.displayName.localeCompare(fieldKey2.displayName)) - .map(testData.toActor) + .map(testData.toActor), + deletedSubmissionCount: [ + ({ url }) => { + if (!url.includes('top=0')) return false; + return matchesApiPath( + (projectId, xmlFormId) => apiPaths.odataSubmissions(projectId, xmlFormId, false), + url + ); + }, + () => testData.submissionDeletedOData(0) + ], + odata: [ + ({ url }) => { + if (url.includes('top=0')) return false; + return matchesApiPath( + (projectId, xmlFormId) => apiPaths.odataSubmissions(projectId, xmlFormId, false), + url + ); + }, + ({ url }) => { + const filter = relativeUrl(url).searchParams.get('$filter'); + return filter.includes('__system/deletedAt eq null') + ? testData.submissionOData() + : testData.submissionDeletedOData(); + } + ], + geojson: [ + ({ url }) => matchesApiPath( + (projectId, xmlFormId) => apiPaths.submissions(projectId, xmlFormId, false, '.geojson'), + url + ), + ({ url }) => { + const filter = url.includes('deleted=true') + ? (submission) => submission.deletedAt != null + : (submission) => submission.deletedAt == null; + return testData.submissionGeojson(filter); + } + ] }), PublicLinkList: componentResponses({ publicLinks: () => testData.standardPublicLinks.sorted() @@ -173,7 +209,7 @@ const responsesByComponent = { ], odata: [ ({ url }) => matchesApiPath((projectId, xmlFormId) => apiPaths.odataSubmissions(projectId, xmlFormId, true), url), - testData.submissionOData + () => testData.submissionOData() ] }), FormSettings: [], diff --git a/test/util/submission.js b/test/util/submission.js index e991afaa0..2c8a56512 100644 --- a/test/util/submission.js +++ b/test/util/submission.js @@ -39,7 +39,6 @@ export const loadSubmissionList = (mountOptions = {}) => { return mockHttp() .mount(SubmissionList, mergedOptions) .respondWithData(() => form._fields) - .respondWithData(() => (deleted ? testData.submissionDeletedOData() : testData.submissionOData())) .modify(series => { if (form.publishedAt == null) return series; return series.respondWithData(() => testData.extendedFieldKeys @@ -47,5 +46,6 @@ export const loadSubmissionList = (mountOptions = {}) => { .sort((fieldKey1, fieldKey2) => fieldKey1.displayName.localeCompare(fieldKey2.displayName)) .map(testData.toActor)); - }); + }) + .respondWithData(() => (deleted ? testData.submissionDeletedOData() : testData.submissionOData())); }; diff --git a/transifex/strings_en.json b/transifex/strings_en.json index 37ab720ac..02648302e 100644 --- a/transifex/strings_en.json +++ b/transifex/strings_en.json @@ -907,6 +907,9 @@ "loading": { "string": "Loading…" }, + "map": { + "string": "Map" + }, "no": { "string": "No" }, @@ -937,6 +940,9 @@ "developer_comment": "This is the text of a navigation tab, which may also be shown as the page title (browser tab)." } }, + "table": { + "string": "Table" + }, "total": { "string": "Total" }, @@ -4931,6 +4937,9 @@ "filterDisabledMessage": { "string": "Filtering is unavailable for deleted Submissions" }, + "noMapEncryption": { + "string": "Map is unavailable due to Form encryption" + }, "deletedSubmission": { "emptyTable": { "string": "There are no deleted Submissions." From 0807b7008fdc2d1235cb8063397ec6210d141810 Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Tue, 9 Sep 2025 18:43:25 +0300 Subject: [PATCH 17/21] e2e-tests: use npm clean-install (#1337) From https://docs.npmjs.com/cli/v11/commands/npm-ci > This command is similar to npm install, except it's meant to be used in automated environments such as test platforms, continuous integration, and deployment -- or any situation where you want to make sure you're doing a clean install of your dependencies. --- e2e-tests/run-tests.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e-tests/run-tests.sh b/e2e-tests/run-tests.sh index f2efa0be9..10e7667f2 100755 --- a/e2e-tests/run-tests.sh +++ b/e2e-tests/run-tests.sh @@ -67,7 +67,7 @@ if [[ ${CI-} = true ]]; then fi log "Installing npm packages..." -npm install +npm ci cd e2e-tests log "Playwright: $(npx playwright --version)" From 83b3851c201d80e0a0833e727dcf4385544310ad Mon Sep 17 00:00:00 2001 From: Sadiq Khoja Date: Wed, 10 Sep 2025 10:19:53 -0400 Subject: [PATCH 18/21] PR Feedback round 3 --- src/assets/scss/_mixins.scss | 2 + src/components/action-bar.vue | 9 +-- src/components/alerts.vue | 1 - src/components/entity/list.vue | 95 +++++++++++++---------------- src/components/entity/table.vue | 19 ++---- test/components/entity/list.spec.js | 29 +++++++++ 6 files changed, 80 insertions(+), 75 deletions(-) diff --git a/src/assets/scss/_mixins.scss b/src/assets/scss/_mixins.scss index e772f8d21..4f20d86d0 100644 --- a/src/assets/scss/_mixins.scss +++ b/src/assets/scss/_mixins.scss @@ -125,4 +125,6 @@ bottom: 34px; z-index: $z-index-toast; pointer-events: none; + + & > * { pointer-events: auto; } } diff --git a/src/components/action-bar.vue b/src/components/action-bar.vue index 1fafcbf15..45e384506 100644 --- a/src/components/action-bar.vue +++ b/src/components/action-bar.vue @@ -13,7 +13,8 @@ except according to the terms contained in the LICENSE file.
@@ -219,7 +220,7 @@ export default { selectedEntities: new Set(), bulkOperationInProgress: false, - actionBarState: false + allSelected: false }; }, computed: { @@ -261,6 +262,9 @@ export default { } return this.deleted ? this.$t('deletedEntity.emptyTable') : (this.odataFilter ? this.$t('noMatching') : this.$t('noEntities')); + }, + actionBarState() { + return this.selectedEntities.size > 0 && !this.alert.state && !this.container.openModal.state; } }, watch: { @@ -287,7 +291,6 @@ export default { }, 'selectedEntities.size': { handler(size) { - this.actionBarState = size > 0; if (size > 0) { this.alert.last.hide(); } @@ -296,27 +299,20 @@ export default { // Hide the action bar if any alert is raised. 'alert.state': { handler(state) { - if (state) { - this.actionBarState = false; - } else { + if (!state) { // since alert is hidden, we no longer need to keep bulkDeletedEntities list as there is // no longer a way to undo bulk delete this.bulkDeletedEntities.length = 0; - if (this.selectedEntities.size > 0) { - this.actionBarState = true; - } } } }, - 'container.openModal.state': { - handler(state) { - if (state) { - this.actionBarState = false; - } else if (this.selectedEntities.size > 0) { - this.actionBarState = true; - } + actionBarState(state) { + // only if all rows are unselected, this doesn't happen when a modal is shown + if (!state && this.selectedEntities.size === 0) { + this.allSelected = false; } } + }, created() { this.fetchChunk(true); @@ -574,6 +570,7 @@ export default { clearSelectedEntities() { this.selectedEntities.clear(); this.odataEntities.value?.forEach(e => { e.__system.selected = false; }); + this.allSelected = false; }, requestBulkDelete() { const uuids = Array.from(this.selectedEntities).map(e => e.__id); @@ -588,6 +585,8 @@ export default { data: { ids: uuids } + }).finally(() => { + this.bulkOperationInProgress = false; }); }; @@ -607,33 +606,29 @@ export default { .then(onSuccess) .catch((error) => { const { cta } = this.alert.danger(requestAlertMessage(this.$i18n, error)); - cta(this.$t('action.tryAgain'), () => { - bulkDelete() - .then(() => { - onSuccess(); - return true; - }) - .catch(noop) - .finally(() => { - this.bulkOperationInProgress = false; - }); - }); - }) - .finally(() => { - this.bulkOperationInProgress = false; + cta(this.$t('action.tryAgain'), () => bulkDelete() + .then(() => { + onSuccess(); + return true; + }) + .catch(noop)); }); }, requestBulkRestore() { - this.bulkOperationInProgress = true; const uuids = this.bulkDeletedEntities.map(e => e.__id); - const bulkRestore = () => this.request({ - method: 'POST', - url: apiPaths.entities(this.projectId, this.datasetName, '/bulk-restore'), - alert: false, - data: { - ids: uuids - } - }); + const bulkRestore = () => { + this.bulkOperationInProgress = true; + return this.request({ + method: 'POST', + url: apiPaths.entities(this.projectId, this.datasetName, '/bulk-restore'), + alert: false, + data: { + ids: uuids + } + }).finally(() => { + this.bulkOperationInProgress = false; + }); + }; const onSuccess = () => { this.bulkDeletedEntities.forEach(e => { @@ -657,20 +652,12 @@ export default { .then(onSuccess) .catch((error) => { const { cta } = this.alert.danger(requestAlertMessage(this.$i18n, error)); - cta(this.$t('action.tryAgain'), () => { - bulkRestore() - .then(() => { - onSuccess(); - return true; - }) - .catch(noop) - .finally(() => { - this.bulkOperationInProgress = false; - }); - }); - }) - .finally(() => { - this.bulkOperationInProgress = false; + cta(this.$t('action.tryAgain'), () => bulkRestore() + .then(() => { + onSuccess(); + return true; + }) + .catch(noop)); }); }, handleSelectionChange(entity, selected) { diff --git a/src/components/entity/table.vue b/src/components/entity/table.vue index 6393a0087..f2643a0ad 100644 --- a/src/components/entity/table.vue +++ b/src/components/entity/table.vue @@ -47,7 +47,7 @@ except according to the terms contained in the LICENSE file.