diff --git a/wp1-frontend/cypress/e2e/combinator.cy.js b/wp1-frontend/cypress/e2e/combinator.cy.js new file mode 100644 index 00000000..9cba2545 --- /dev/null +++ b/wp1-frontend/cypress/e2e/combinator.cy.js @@ -0,0 +1,291 @@ +/// + +describe('the combinator builder page', () => { + describe('when the user is logged in', () => { + beforeEach(() => { + cy.intercept('v1/sites/', { fixture: 'sites.json' }).as('sites'); + cy.intercept('v1/oauth/identify', { fixture: 'identity.json' }).as( + 'identity' + ); + cy.intercept('v1/selection/simple/lists', { + fixture: 'combinator_lists.json', + }).as('lists'); + }); + + it('loads create form with eligible builders only', () => { + cy.visit('/#/selections/combinator'); + cy.wait('@lists'); + + cy.get('#include-builder-select').should('contain', 'Simple Ready'); + cy.get('#include-builder-select').should('contain', 'SPARQL Ready'); + cy.get('#include-builder-select').should('not.contain', 'German Simple'); + cy.get('#include-builder-select').should( + 'not.contain', + 'Existing Combinator' + ); + cy.get('#exclude-builder-select').should('contain', 'Simple Ready'); + cy.get('#exclude-builder-select').should('contain', 'SPARQL Ready'); + cy.get('#exclude-builder-select').should('not.contain', 'German Simple'); + cy.get('#exclude-builder-select').should( + 'not.contain', + 'Existing Combinator' + ); + }); + + it('displays builder loading errors', () => { + cy.intercept('v1/selection/simple/lists', { + statusCode: 500, + body: {}, + }).as('listsFailure'); + + cy.visit('/#/selections/combinator'); + cy.wait('@listsFailure'); + + cy.get('.alert-danger') + .should('be.visible') + .and('contain', 'Unable to load builders. Please try again later.'); + cy.contains('No builders are available').should('not.exist'); + }); + + it('displays a separate message when no eligible builders are available', () => { + cy.intercept('v1/selection/simple/lists', { + body: { + builders: [ + { + id: 'german-simple', + name: 'German Simple', + project: 'de.wikipedia.org', + model: 'wp1.selection.models.simple', + }, + { + id: 'combo-1', + name: 'Existing Combinator', + project: 'en.wikipedia.org', + model: 'wp1.selection.models.combinator', + }, + ], + }, + }).as('listsNoEligible'); + + cy.visit('/#/selections/combinator'); + cy.wait('@listsNoEligible'); + + cy.get('#include-items .alert-warning') + .should('be.visible') + .and( + 'contain', + 'No eligible builders are available for en.wikipedia.org.' + ); + cy.get('#include-items .alert-danger').should('not.exist'); + cy.contains('Unable to load builders. Please try again later.').should( + 'not.exist' + ); + }); + + it('validates that at least one include builder is selected', () => { + cy.visit('/#/selections/combinator'); + cy.wait('@lists'); + + cy.get('#listName > .form-control').click().type('Combined List'); + cy.get('#saveListButton').click(); + + cy.get('#include-items .invalid-feedback').should('be.visible'); + }); + + it('validates selected builders belong to the selected project', () => { + cy.visit('/#/selections/combinator'); + cy.wait('@lists'); + + cy.get('#listName > .form-control').click().type('Combined List'); + cy.get('#include-builder-select').select('simple-ready'); + cy.get('#add-include-builder').click(); + cy.get('#project > select').select('en.wiktionary.org'); + cy.get('#saveListButton').click(); + + cy.get('#include-items .invalid-feedback') + .should('be.visible') + .and('contain', 'Simple Ready (en.wikipedia.org)'); + }); + + it('displays backend validation errors', () => { + cy.visit('/#/selections/combinator'); + cy.wait('@lists'); + + cy.get('#listName > .form-control').click().type('Combined List'); + cy.get('#include-builder-select').select('simple-ready'); + cy.get('#add-include-builder').click(); + cy.intercept('POST', 'v1/builders/', { + fixture: 'save_combinator_failure.json', + }).as('createBuilderFailure'); + cy.get('#saveListButton').click(); + + cy.wait('@createBuilderFailure'); + cy.get('.errors').contains( + "Builder 'missing-builder' no longer exists. Please remove it from this combinator." + ); + }); + + it('sends correct create payload to the API', () => { + cy.visit('/#/selections/combinator'); + cy.wait('@lists'); + + cy.get('#listName > .form-control').click().type('Combined List'); + cy.get('#include-operation').select('intersection'); + cy.get('#include-builder-select').select('simple-ready'); + cy.get('#add-include-builder').click(); + cy.get('#exclude-operation').select('union'); + cy.get('#exclude-builder-select').select('sparql-ready'); + cy.get('#add-exclude-builder').click(); + + cy.intercept('POST', 'v1/builders/', { + fixture: 'save_list_success.json', + }).as('createBuilderSuccess'); + cy.get('#saveListButton').click(); + + cy.wait('@createBuilderSuccess').then((interception) => { + expect(interception.request.body.model).to.equal( + 'wp1.selection.models.combinator' + ); + expect(interception.request.body.params.include).to.deep.equal({ + builders: ['simple-ready'], + operation: 'intersection', + }); + expect(interception.request.body.params.exclude).to.deep.equal({ + builders: ['sparql-ready'], + operation: 'union', + }); + }); + }); + + it('loads and updates an existing combinator', () => { + cy.intercept('GET', 'v1/builders/combo-1', { + fixture: 'combinator_builder.json', + }).as('builder'); + cy.visit('/#/selections/combinator/combo-1'); + cy.wait('@builder'); + cy.wait('@lists'); + + cy.get('#listName > .form-control').should( + 'have.value', + 'Combinator Builder' + ); + cy.get('#include-builders').should('contain', 'Simple Ready'); + cy.get('#exclude-builders').should('contain', 'SPARQL Ready'); + cy.get('#include-operation').should('have.value', 'intersection'); + cy.get('#exclude-operation').should('have.value', 'union'); + cy.get('#include-builder-select').should( + 'not.contain', + 'Existing Combinator' + ); + + cy.intercept('POST', 'v1/builders/combo-1', { + fixture: 'save_list_success.json', + }).as('updateBuilderSuccess'); + cy.get('#updateListButton').click(); + + cy.wait('@updateBuilderSuccess').then((interception) => { + expect(interception.request.body.params.include).to.deep.equal({ + builders: ['simple-ready'], + operation: 'intersection', + }); + expect(interception.request.body.params.exclude).to.deep.equal({ + builders: ['sparql-ready'], + operation: 'union', + }); + }); + }); + + describe('when save button clicked', () => { + beforeEach(() => { + cy.visit('/#/selections/combinator'); + cy.wait('@lists'); + cy.get('#listName > .form-control').click().type('Combined List'); + cy.get('#include-builder-select').select('simple-ready'); + cy.get('#add-include-builder').click(); + cy.intercept('POST', 'v1/builders/', { + delay: 4000, + fixture: 'save_list_success.json', + }).as('createBuilderSuccess'); + }); + + it('shows spinner', () => { + cy.get('#saveListButton').click(); + cy.get('#saveLoader').should('be.visible'); + }); + + it('disables save button', () => { + cy.get('#saveListButton').click(); + cy.get('#saveListButton').should('have.attr', 'disabled'); + }); + }); + + describe('when the builder has fatal errors', () => { + beforeEach(() => { + cy.intercept('GET', 'v1/builders/combo-1', { + fixture: 'combinator_builder_fatal_error.json', + }).as('builder'); + cy.visit('/#/selections/combinator/combo-1'); + cy.wait('@builder'); + }); + + it('displays the error div', () => { + cy.get('.materialize-error').should('be.visible'); + }); + + it('disables the retry button', () => { + cy.get('.materialize-error .btn').should('have.attr', 'disabled'); + }); + }); + + describe('when the builder has retryable errors', () => { + beforeEach(() => { + cy.intercept('GET', 'v1/builders/combo-1', { + fixture: 'combinator_builder_retryable_error.json', + }).as('builder'); + cy.visit('/#/selections/combinator/combo-1'); + cy.wait('@builder'); + }); + + it('displays the error div', () => { + cy.get('.materialize-error').should('be.visible'); + }); + + it('enables the retry button', () => { + cy.get('.materialize-error .btn').should('not.have.attr', 'disabled'); + }); + }); + + describe('when the builder is not found', () => { + beforeEach(() => { + cy.intercept('GET', 'v1/builders/missing-combo', { + statusCode: 404, + body: '404 NOT FOUND', + }).as('builder'); + cy.visit('/#/selections/combinator/missing-combo'); + cy.wait('@builder'); + }); + + it('displays the 404 text', () => { + cy.get('#404').should('be.visible'); + }); + }); + }); + + describe('when the user is not logged in', () => { + beforeEach(() => { + cy.intercept('v1/sites/', { fixture: 'sites.json' }); + }); + + it('opens login page for create', () => { + cy.visit('/#/selections/combinator'); + cy.contains('Please Log In To Continue'); + cy.get('.pt-2 > .btn'); + }); + + it('opens login page for edit', () => { + cy.visit('/#/selections/combinator/combo-1'); + cy.contains('Please Log In To Continue'); + cy.get('.pt-2 > .btn'); + }); + }); +}); diff --git a/wp1-frontend/cypress/e2e/myLists.cy.js b/wp1-frontend/cypress/e2e/myLists.cy.js index bc1d55e0..e0285d6c 100644 --- a/wp1-frontend/cypress/e2e/myLists.cy.js +++ b/wp1-frontend/cypress/e2e/myLists.cy.js @@ -17,7 +17,7 @@ describe('the user selection list page', () => { it('successfully loads', () => {}); it('displays the datatables view', () => { - cy.get('.dataTables_info').contains('Showing 1 to 13 of 13 entries'); + cy.get('.dataTables_info').contains('Showing 1 to 14 of 14 entries'); }); it('displays list and its contents', () => { @@ -85,6 +85,20 @@ describe('the user selection list page', () => { cy.url().should('eq', 'http://localhost:5173/#/selections/sparql/2'); }); + it('takes the user to the combinator edit screen when combinator edit is clicked', () => { + cy.intercept('GET', 'v1/builders/combo-list', { + fixture: 'combinator_builder.json', + }); + cy.contains('td', 'combinator list') + .siblings() + .contains('.btn-primary', 'Edit') + .click(); + cy.url().should( + 'eq', + 'http://localhost:5173/#/selections/combinator/combo-list' + ); + }); + it('displays a failed link for selection with failed ZIM', () => { cy.contains('td', 'zim failed') .parent('tr') diff --git a/wp1-frontend/cypress/fixtures/combinator_builder.json b/wp1-frontend/cypress/fixtures/combinator_builder.json new file mode 100644 index 00000000..50ed07ec --- /dev/null +++ b/wp1-frontend/cypress/fixtures/combinator_builder.json @@ -0,0 +1,16 @@ +{ + "params": { + "include": { + "builders": ["simple-ready"], + "operation": "intersection" + }, + "exclude": { + "builders": ["sparql-ready"], + "operation": "union" + } + }, + "name": "Combinator Builder", + "model": "wp1.selection.models.combinator", + "project": "en.wikipedia.org", + "selection_errors": [] +} diff --git a/wp1-frontend/cypress/fixtures/combinator_builder_fatal_error.json b/wp1-frontend/cypress/fixtures/combinator_builder_fatal_error.json new file mode 100644 index 00000000..82452aa8 --- /dev/null +++ b/wp1-frontend/cypress/fixtures/combinator_builder_fatal_error.json @@ -0,0 +1,24 @@ +{ + "params": { + "include": { + "builders": ["simple-ready"], + "operation": "union" + }, + "exclude": { + "builders": [], + "operation": "union" + } + }, + "name": "Combinator Fatal Error", + "model": "wp1.selection.models.combinator", + "project": "en.wikipedia.org", + "selection_errors": [ + { + "error_messages": [ + "Referenced builder Failed Builder (failed-builder) latest selection failed" + ], + "ext": "tsv", + "status": "FAILED" + } + ] +} diff --git a/wp1-frontend/cypress/fixtures/combinator_builder_retryable_error.json b/wp1-frontend/cypress/fixtures/combinator_builder_retryable_error.json new file mode 100644 index 00000000..4e3cb3be --- /dev/null +++ b/wp1-frontend/cypress/fixtures/combinator_builder_retryable_error.json @@ -0,0 +1,24 @@ +{ + "params": { + "include": { + "builders": ["simple-ready"], + "operation": "union" + }, + "exclude": { + "builders": [], + "operation": "union" + } + }, + "name": "Combinator Retryable Error", + "model": "wp1.selection.models.combinator", + "project": "en.wikipedia.org", + "selection_errors": [ + { + "error_messages": [ + "Referenced builder Retry Builder (retry-builder) latest selection is not ready" + ], + "ext": "tsv", + "status": "CAN_RETRY" + } + ] +} diff --git a/wp1-frontend/cypress/fixtures/combinator_lists.json b/wp1-frontend/cypress/fixtures/combinator_lists.json new file mode 100644 index 00000000..c45630d8 --- /dev/null +++ b/wp1-frontend/cypress/fixtures/combinator_lists.json @@ -0,0 +1,44 @@ +{ + "builders": [ + { + "id": "simple-ready", + "name": "Simple Ready", + "project": "en.wikipedia.org", + "model": "wp1.selection.models.simple", + "updated_at": 1685646150, + "s_updated_at": 1685646500, + "s_status": "OK", + "s_url": "https://www.example.fake/simple-ready.tsv" + }, + { + "id": "sparql-ready", + "name": "SPARQL Ready", + "project": "en.wikipedia.org", + "model": "wp1.selection.models.sparql", + "updated_at": 1685646150, + "s_updated_at": 1685646500, + "s_status": "OK", + "s_url": "https://www.example.fake/sparql-ready.tsv" + }, + { + "id": "german-simple", + "name": "German Simple", + "project": "de.wikipedia.org", + "model": "wp1.selection.models.simple", + "updated_at": 1685646150, + "s_updated_at": 1685646500, + "s_status": "OK", + "s_url": "https://www.example.fake/german-simple.tsv" + }, + { + "id": "combo-1", + "name": "Existing Combinator", + "project": "en.wikipedia.org", + "model": "wp1.selection.models.combinator", + "updated_at": 1685646150, + "s_updated_at": 1685646500, + "s_status": "OK", + "s_url": "https://www.example.fake/combo-1.tsv" + } + ] +} diff --git a/wp1-frontend/cypress/fixtures/list_data.json b/wp1-frontend/cypress/fixtures/list_data.json index 9b0b5fc9..efe8d390 100644 --- a/wp1-frontend/cypress/fixtures/list_data.json +++ b/wp1-frontend/cypress/fixtures/list_data.json @@ -237,6 +237,24 @@ "interval_months": 3, "next_generation_date": "2026-06-01" } + }, + { + "created_at": 1685646000, + "id": "combo-list", + "model": "wp1.selection.models.combinator", + "name": "combinator list", + "project": "en.wikipedia.org", + "s_content_type": "text/tab-separated-values", + "s_extension": "tsv", + "s_id": "combo-selection-001", + "s_status": "OK", + "s_updated_at": 1685646500, + "s_url": "https://www.example.fake/combo-selection-001", + "updated_at": 1685646150, + "z_updated_at": null, + "z_url": null, + "z_status": "NOT_REQUESTED", + "z_is_deleted": null } ] } diff --git a/wp1-frontend/cypress/fixtures/save_combinator_failure.json b/wp1-frontend/cypress/fixtures/save_combinator_failure.json new file mode 100644 index 00000000..35b4d629 --- /dev/null +++ b/wp1-frontend/cypress/fixtures/save_combinator_failure.json @@ -0,0 +1,10 @@ +{ + "success": false, + "items": { + "valid": [], + "invalid": [], + "errors": [ + "Builder 'missing-builder' no longer exists. Please remove it from this combinator." + ] + } +} diff --git a/wp1-frontend/src/components/BaseBuilder.vue b/wp1-frontend/src/components/BaseBuilder.vue index cde52b66..25e28c96 100644 --- a/wp1-frontend/src/components/BaseBuilder.vue +++ b/wp1-frontend/src/components/BaseBuilder.vue @@ -115,7 +115,11 @@ Please provide a valid list name - +
+ + + + + + + + + + diff --git a/wp1-frontend/src/main.js b/wp1-frontend/src/main.js index e9818fe0..29701a57 100644 --- a/wp1-frontend/src/main.js +++ b/wp1-frontend/src/main.js @@ -12,6 +12,7 @@ import VueRouter from 'vue-router'; import App from './App.vue'; import ArticlePage from './components/ArticlePage.vue'; import BookBuilder from './components/BookBuilder.vue'; +import CombinatorBuilder from './components/CombinatorBuilder.vue'; import PetscanBuilder from './components/PetscanBuilder.vue'; import SimpleBuilder from './components/SimpleBuilder.vue'; import SparqlBuilder from './components/SparqlBuilder.vue'; @@ -134,6 +135,13 @@ const routes = [ title: () => BASE_TITLE + ' - Edit WikiProject Selection', }, }, + { + path: '/selections/combinator', + component: CombinatorBuilder, + meta: { + title: () => BASE_TITLE + ' - Create Combinator Selection', + }, + }, { path: '/selections/simple/:builder_id', component: SimpleBuilder, @@ -169,6 +177,13 @@ const routes = [ title: () => BASE_TITLE + ' - Edit WikiProject Selection', }, }, + { + path: '/selections/combinator/:builder_id', + component: CombinatorBuilder, + meta: { + title: () => BASE_TITLE + ' - Edit Combinator Selection', + }, + }, { path: '/selections/:builder_id/zim', component: ZimFile,