From 04cc1fd5abedc85c449d159d9fa6ea48e2e4a2f7 Mon Sep 17 00:00:00 2001 From: Casey Holzer Date: Thu, 17 Mar 2022 08:24:15 -0600 Subject: [PATCH] Fix ajax search and maxOptions. Add initial Cypress tests (#2710) * fix ajax search. remove load method. support more argument * swap source.load with source.data * reset scrollTop when performing source.search * Add source.pageSize option. Wait until user has started scrolling before loading next page. Pass correct searchTerm. Trigger fetched event. Change first page to 1. * const -> var * linting * initial Cypress tests * cypress workflow * cypress config file * fix maxOptions. add support for maxOptions when using source.data * add tests for maxOptions --- .github/workflows/cypress.yml | 14 + cypress.json | 1 + cypress/integration/basic.spec.js | 137 + cypress/integration/max-options.spec.js | 82 + cypress/plugins/index.js | 22 + cypress/support/commands.js | 31 + cypress/support/index.js | 20 + js/bootstrap-select.js | 249 +- package-lock.json | 6008 +++++------------------ package.json | 7 +- tests/index.html | 26 + 11 files changed, 1767 insertions(+), 4830 deletions(-) create mode 100644 .github/workflows/cypress.yml create mode 100644 cypress.json create mode 100644 cypress/integration/basic.spec.js create mode 100644 cypress/integration/max-options.spec.js create mode 100644 cypress/plugins/index.js create mode 100644 cypress/support/commands.js create mode 100644 cypress/support/index.js create mode 100644 tests/index.html diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml new file mode 100644 index 000000000..70df2296f --- /dev/null +++ b/.github/workflows/cypress.yml @@ -0,0 +1,14 @@ +name: Cypress Tests +on: [push, pull_request] +jobs: + cypress-run: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + # Install NPM dependencies, cache them correctly + # and run all Cypress tests + - name: Cypress run + uses: cypress-io/github-action@v2 + with: + build: grunt build diff --git a/cypress.json b/cypress.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/cypress.json @@ -0,0 +1 @@ +{} diff --git a/cypress/integration/basic.spec.js b/cypress/integration/basic.spec.js new file mode 100644 index 000000000..bb60799af --- /dev/null +++ b/cypress/integration/basic.spec.js @@ -0,0 +1,137 @@ +const testArr = new Array(1000).fill(0).map((x, i) => { + return { + text: `Option ${i}`, + } +}); + +function buildPages(data) { + const pageSize = 40; + const pageCount = data.length / pageSize; + const pages = []; + + for (let i = 0; i < pageCount; i++) { + const pageStart = i * pageSize; + pages.push(data.slice(pageStart, pageStart + pageSize)); + } + + return pages; +} + +const selectpickerConfigs = [{ + title: 'built via HTML', + config: { + html: ` + + ` + } +}, { + title: 'built via source with source.search', + config: { + options: { + liveSearch: true, + source: { + data: function (callback, page) { + const pages = buildPages(testArr); + callback(pages[page - 1], pages.length > page); + }, + search: function (callback, page, searchTerm) { + if (searchTerm) { + const searchResults = testArr.filter((obj) => { + return (obj.text && obj.text.toLowerCase().includes(searchTerm.toLowerCase())) + }); + const pages = buildPages(searchResults); + + callback(pages[page - 1], pages.length > page); + } + } + } + } + } +}, { + title: 'built via source', + config: { + options: { + liveSearch: true, + source: { + data: function (callback) { + callback(testArr); + }, + } + } + } +}, { + title: 'built via source (limit 30) with source.search', + config: { + options: { + liveSearch: true, + source: { + data: function (callback, page) { + const pages = buildPages(testArr.slice(0, 30)); + callback(pages[page - 1], pages.length > page); + }, + search: function (callback, page, searchTerm) { + if (searchTerm) { + const searchResults = testArr.slice(0, 30).filter((obj) => { + return (obj.text && obj.text.toLowerCase().includes(searchTerm.toLowerCase())) + }); + const pages = buildPages(searchResults); + + callback(pages[page - 1], pages.length > page); + } + } + } + } + } +}, { + title: 'built via source (limit 30)', + config: { + options: { + liveSearch: true, + source: { + data: function (callback) { + callback(testArr.slice(0, 30)); + }, + } + } + } +}]; + +describe('Single selects with search', () => { + beforeEach(() => { + cy.visit('/tests/index.html'); + }); + + selectpickerConfigs.forEach(({config, title}) => { + it(title + ' can search for and select options', () => { + cy.selectpicker(config).then(($select) => { + const button = `[data-id="${$select[0].id}"]`; + cy.get(button).click(); + $select.on('fetched.bs.select', cy.stub().as('fetched')); + + cy.get('input').type('option 4'); + + if (config?.source?.search) { + cy.get('@fetched').its('callCount').should('equal', 8); + } + cy.get('.dropdown-menu').find('li').first().should(($el) => { + expect($el).to.have.class('active') + expect($el).to.contain('Option 4') + }); + + cy.get('.dropdown-menu').find('li').first().click(); + cy.get(button).contains('Option 4').click(); + + cy.get('li').contains('Option 4').should('have.class', 'active'); + + cy.get('li').contains('Option 9').click(); + cy.get(button).contains('Option 9').click(); + cy.get('li').contains('Option 9').should('have.class', 'active'); + cy.get('li').contains('Option 4').should('not.have.class', 'active'); + }); + }); + }); +}); diff --git a/cypress/integration/max-options.spec.js b/cypress/integration/max-options.spec.js new file mode 100644 index 000000000..ca4d8ecec --- /dev/null +++ b/cypress/integration/max-options.spec.js @@ -0,0 +1,82 @@ +const optgroupTestArr = new Array(3).fill(0).map((x, i) => { + return { + text: `Optgroup ${i}`, + maxOptions: i + 1, + children: new Array(4).fill(0).map((x, j) => { + return { + text: `Option ${i}-${j}`, + } + }) + } +}); + +describe('Multi-selects with maxOptions', () => { + beforeEach(() => { + cy.visit('/tests/index.html'); + }); + + [{ + title: 'built via HTML', + config: { + html: ` + + ` + } + }, { + title: 'built via source', + config: { + attrs: { + multiple: true, + 'data-max-options': 4 + }, + options: { + liveSearch: true, + source: { + data: function (callback) { + callback(optgroupTestArr); + }, + } + } + } + }].forEach(({config, title}) => { + it(title + ' selection is limited by maxOptions', () => { + cy.selectpicker(config).then(($select) => { + const button = `[data-id="${$select[0].id}"]`; + cy.get(button).click(); + // $select.on('fetched.bs.select', cy.stub().as('fetched')); + + // maxOptions 1 + cy.get('li').contains('Option 0-0').click(); + cy.get('li').contains('Option 0-1').click(); + cy.get('li').contains('Option 0-0').should('not.have.class', 'selected'); + cy.get('li').contains('Option 0-1').should('have.class', 'selected'); + + // maxOptions 2 + cy.get('li').contains('Option 1-0').click(); + cy.get('li').contains('Option 1-1').click(); + cy.get('li').contains('Option 1-2').click(); + cy.get('.notify').should('be.visible'); + cy.get('li').contains('Option 1-0').should('have.class', 'selected'); + cy.get('li').contains('Option 1-1').should('have.class', 'selected'); + cy.get('li').contains('Option 1-2').should('not.have.class', 'selected'); + + // maxOptions 4 (on select) + cy.get('li').contains('Option 2-0').click(); + cy.get('li').contains('Option 2-1').click(); + cy.get('.notify').should('be.visible'); + cy.get('li').contains('Option 2-0').should('have.class', 'selected'); + cy.get('li').contains('Option 2-1').should('not.have.class', 'selected'); + + cy.get(`#${$select[0].id}`) + .invoke('val') + .should('deep.equal', ['Option 0-1', 'Option 1-0', 'Option 1-1', 'Option 2-0']); + }); + }); + }); +}); diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js new file mode 100644 index 000000000..59b2bab6e --- /dev/null +++ b/cypress/plugins/index.js @@ -0,0 +1,22 @@ +/// +// *********************************************************** +// This example plugins/index.js can be used to load plugins +// +// You can change the location of this file or turn off loading +// the plugins file with the 'pluginsFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/plugins-guide +// *********************************************************** + +// This function is called when a project is opened or re-opened (e.g. due to +// the project's config changing) + +/** + * @type {Cypress.PluginConfig} + */ +// eslint-disable-next-line no-unused-vars +module.exports = (on, config) => { + // `on` is used to hook into various events Cypress emits + // `config` is the resolved Cypress config +} diff --git a/cypress/support/commands.js b/cypress/support/commands.js new file mode 100644 index 000000000..0012248d6 --- /dev/null +++ b/cypress/support/commands.js @@ -0,0 +1,31 @@ +import {v4 as uuidv4} from 'uuid'; + +Cypress.Commands.add('selectpicker', ({options, attrs, selector, html}) => { + let id; + if (!selector) { + id = uuidv4(); + selector = `#${id}`; + } + + cy.window().then(window => { + const $ = window.$; + + if (html) { + const $select = $(html); + + if (id && !$select[0].id) { + $select.attr('id', id); + } + $('body').append($select); + } else if (id) { + $('body').append(`