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
-
+
+
+
+
+ Use this tool to combine existing selections. Choose builders to
+ include, optionally choose builders to exclude, and select whether each
+ group uses all articles or only articles shared by every builder in that
+ group.
+
+
+
+
+
+
+
+
+
+
+
+
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,