diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js
index e1d6e089d..f2c77e945 100644
--- a/app/assets/config/manifest.js
+++ b/app/assets/config/manifest.js
@@ -3,3 +3,5 @@
//= link legacy_layout.css
//= link application.js
//= link legacy_layout.js
+
+//= link components/_option-select.css
diff --git a/app/assets/images/option-select/input-icon.svg b/app/assets/images/option-select/input-icon.svg
new file mode 100644
index 000000000..ad3de76f1
--- /dev/null
+++ b/app/assets/images/option-select/input-icon.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js
index 5bd8106c0..7de4925fa 100644
--- a/app/assets/javascripts/application.js
+++ b/app/assets/javascripts/application.js
@@ -1,7 +1,5 @@
//= require govuk_publishing_components/dependencies
//= require govuk_publishing_components/all_components
//= require_tree ./modules
-//= require rails-ujs
-
-// Components from this application
//= require_tree ./components
+//= require rails-ujs
diff --git a/app/assets/javascripts/components/option-select.js b/app/assets/javascripts/components/option-select.js
new file mode 100644
index 000000000..7fd99fbc0
--- /dev/null
+++ b/app/assets/javascripts/components/option-select.js
@@ -0,0 +1,304 @@
+window.GOVUK = window.GOVUK || {}
+window.GOVUK.Modules = window.GOVUK.Modules || {};
+
+(function (Modules) {
+ /* This JavaScript provides two functional enhancements to option-select components:
+ 1) A count that shows how many results have been checked in the option-container
+ 2) Open/closing of the list of checkboxes
+ */
+ function OptionSelect ($module) {
+ this.$optionSelect = $module
+ this.$options = this.$optionSelect.querySelectorAll("input[type='checkbox']")
+ this.$optionsContainer = this.$optionSelect.querySelector('.js-options-container')
+ this.$optionList = this.$optionsContainer.querySelector('.js-auto-height-inner')
+ this.$allCheckboxes = this.$optionsContainer.querySelectorAll('.govuk-checkboxes__item')
+ this.hasFilter = this.$optionSelect.getAttribute('data-filter-element') || ''
+
+ this.checkedCheckboxes = []
+ }
+
+ OptionSelect.prototype.init = function () {
+ if (this.hasFilter.length) {
+ var filterEl = document.createElement('div')
+ filterEl.innerHTML = this.hasFilter
+
+ var optionSelectFilter = document.createElement('div')
+ optionSelectFilter.classList.add('app-c-option-select__filter')
+ optionSelectFilter.innerHTML = filterEl.childNodes[0].nodeValue
+
+ this.$optionsContainer.parentNode.insertBefore(optionSelectFilter, this.$optionsContainer)
+
+ this.$filter = this.$optionSelect.querySelector('input[name="option-select-filter"]')
+ this.$filterCount = document.getElementById(this.$filter.getAttribute('aria-describedby'))
+ this.filterTextSingle = ' ' + this.$filterCount.getAttribute('data-single')
+ this.filterTextMultiple = ' ' + this.$filterCount.getAttribute('data-multiple')
+ this.filterTextSelected = ' ' + this.$filterCount.getAttribute('data-selected')
+ this.checkboxLabels = []
+ this.filterTimeout = 0
+
+ this.getAllCheckedCheckboxes()
+ for (var i = 0; i < this.$allCheckboxes.length; i++) {
+ this.checkboxLabels.push(this.cleanString(this.$allCheckboxes[i].textContent))
+ }
+
+ this.$filter.addEventListener('keyup', this.typeFilterText.bind(this))
+ }
+
+ // Attach listener to update checked count
+ this.$optionsContainer.querySelector('.gem-c-checkboxes__list').addEventListener('change', this.updateCheckedCount.bind(this))
+
+ // Replace div.container-head with a button
+ this.replaceHeadingSpanWithButton()
+
+ // Add js-collapsible class to parent for CSS
+ this.$optionSelect.classList.add('js-collapsible')
+
+ // Add open/close listeners
+ var button = this.$optionSelect.querySelector('.js-container-button')
+ button.addEventListener('click', this.toggleOptionSelect.bind(this))
+
+ var closedOnLoad = this.$optionSelect.getAttribute('data-closed-on-load')
+ var closedOnLoadMobile = this.$optionSelect.getAttribute('data-closed-on-load-mobile')
+
+ // By default the .filter-content container is hidden on mobile
+ // By checking if .filter-content is hidden, we are in mobile view given the current implementation
+ var isFacetsContentHidden = this.isFacetsContainerHidden()
+
+ // Check if the option select should be closed for mobile screen sizes
+ var closedForMobile = closedOnLoadMobile === 'true' && isFacetsContentHidden
+
+ // Always set the contain height to 200px for mobile screen sizes
+ if (closedForMobile) {
+ this.setContainerHeight(200)
+ }
+
+ if (closedOnLoad === 'true' || closedForMobile) {
+ this.close()
+ } else {
+ this.setupHeight()
+ }
+
+ var checkedString = this.checkedString()
+ if (checkedString) {
+ this.attachCheckedCounter(checkedString)
+ }
+ }
+
+ OptionSelect.prototype.typeFilterText = function (event) {
+ event.stopPropagation()
+ var ENTER_KEY = 13
+
+ if (event.keyCode !== ENTER_KEY) {
+ clearTimeout(this.filterTimeout)
+ this.filterTimeout = setTimeout(
+ function () { this.doFilter(this) }.bind(this),
+ 300
+ )
+ } else {
+ event.preventDefault() // prevents finder forms from being submitted when user presses ENTER
+ }
+ }
+
+ OptionSelect.prototype.cleanString = function cleanString (text) {
+ text = text.replace(/&/g, 'and')
+ text = text.replace(/[’',:–-]/g, '') // remove punctuation characters
+ text = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // escape special characters
+ return text.trim().replace(/\s\s+/g, ' ').toLowerCase() // replace multiple spaces with one
+ }
+
+ OptionSelect.prototype.getAllCheckedCheckboxes = function getAllCheckedCheckboxes () {
+ this.checkedCheckboxes = []
+
+ for (var i = 0; i < this.$options.length; i++) {
+ if (this.$options[i].checked) {
+ this.checkedCheckboxes.push(i)
+ }
+ }
+ }
+
+ OptionSelect.prototype.doFilter = function doFilter (obj) {
+ var filterBy = obj.cleanString(obj.$filter.value)
+ var showCheckboxes = obj.checkedCheckboxes.slice()
+ var i = 0
+
+ for (i = 0; i < obj.$allCheckboxes.length; i++) {
+ if (showCheckboxes.indexOf(i) === -1 && obj.checkboxLabels[i].search(filterBy) !== -1) {
+ showCheckboxes.push(i)
+ }
+ }
+
+ for (i = 0; i < obj.$allCheckboxes.length; i++) {
+ obj.$allCheckboxes[i].style.display = 'none'
+ }
+
+ for (i = 0; i < showCheckboxes.length; i++) {
+ obj.$allCheckboxes[showCheckboxes[i]].style.display = 'block'
+ }
+
+ var lenChecked = obj.$optionsContainer.querySelectorAll('.govuk-checkboxes__input:checked').length
+ var len = showCheckboxes.length + lenChecked
+ var html = len + (len === 1 ? obj.filterTextSingle : obj.filterTextMultiple) + ', ' + lenChecked + obj.filterTextSelected
+ obj.$filterCount.innerHTML = html
+ }
+
+ OptionSelect.prototype.replaceHeadingSpanWithButton = function replaceHeadingSpanWithButton () {
+ /* Replace the span within the heading with a button element. This is based on feedback from Léonie Watson.
+ * The button has all of the accessibility hooks that are used by screen readers and etc.
+ * We do this in the JavaScript because if the JavaScript is not active then the button shouldn't
+ * be there as there is no JS to handle the click event.
+ */
+ var containerHead = this.$optionSelect.querySelector('.js-container-button')
+ var jsContainerHeadHTML = containerHead.innerHTML
+
+ // Create button and replace the preexisting html with the button.
+ var button = document.createElement('button')
+ button.setAttribute('class', 'js-container-button app-c-option-select__title app-c-option-select__button')
+ // Add type button to override default type submit when this component is used within a form
+ button.setAttribute('type', 'button')
+ button.setAttribute('aria-expanded', true)
+ button.setAttribute('id', containerHead.getAttribute('id'))
+ button.setAttribute('aria-controls', this.$optionsContainer.getAttribute('id'))
+ button.innerHTML = jsContainerHeadHTML
+ containerHead.parentNode.replaceChild(button, containerHead)
+
+ // GA4 Accordion tracking. Relies on the ga4-finder-tracker setting the index first, so we wrap this in a custom event.
+ window.addEventListener('ga4-filter-indexes-added', function () {
+ if (window.GOVUK.analyticsGa4) {
+ if (window.GOVUK.analyticsGa4.Ga4FinderTracker) {
+ window.GOVUK.analyticsGa4.Ga4FinderTracker.addFilterButtonTracking(button, button.innerHTML)
+ }
+ }
+ })
+ }
+
+ OptionSelect.prototype.attachCheckedCounter = function attachCheckedCounter (checkedString) {
+ var element = document.createElement('div')
+ element.setAttribute('class', 'app-c-option-select__selected-counter js-selected-counter')
+ element.innerHTML = checkedString
+ this.$optionSelect.querySelector('.js-container-button').insertAdjacentElement('afterend', element)
+ }
+
+ OptionSelect.prototype.updateCheckedCount = function updateCheckedCount () {
+ var checkedString = this.checkedString()
+ var checkedStringElement = this.$optionSelect.querySelector('.js-selected-counter')
+
+ if (checkedString) {
+ if (checkedStringElement === null) {
+ this.attachCheckedCounter(checkedString)
+ } else {
+ checkedStringElement.textContent = checkedString
+ }
+ } else if (checkedStringElement) {
+ checkedStringElement.parentNode.removeChild(checkedStringElement)
+ }
+ }
+
+ OptionSelect.prototype.checkedString = function checkedString () {
+ this.getAllCheckedCheckboxes()
+ var count = this.checkedCheckboxes.length
+ var checkedString = false
+ if (count > 0) {
+ checkedString = count + ' selected'
+ }
+
+ return checkedString
+ }
+
+ OptionSelect.prototype.toggleOptionSelect = function toggleOptionSelect (e) {
+ if (this.isClosed()) {
+ this.open()
+ } else {
+ this.close()
+ }
+ e.preventDefault()
+ }
+
+ OptionSelect.prototype.open = function open () {
+ if (this.isClosed()) {
+ this.$optionSelect.querySelector('.js-container-button').setAttribute('aria-expanded', true)
+ this.$optionSelect.classList.remove('js-closed')
+ this.$optionSelect.classList.add('js-opened')
+ if (!this.$optionsContainer.style.height) {
+ this.setupHeight()
+ }
+ }
+ }
+
+ OptionSelect.prototype.close = function close () {
+ this.$optionSelect.classList.remove('js-opened')
+ this.$optionSelect.classList.add('js-closed')
+ this.$optionSelect.querySelector('.js-container-button').setAttribute('aria-expanded', false)
+ }
+
+ OptionSelect.prototype.isClosed = function isClosed () {
+ return this.$optionSelect.classList.contains('js-closed')
+ }
+
+ OptionSelect.prototype.setContainerHeight = function setContainerHeight (height) {
+ this.$optionsContainer.style.height = height + 'px'
+ }
+
+ OptionSelect.prototype.isCheckboxVisible = function isCheckboxVisible (option) {
+ var initialOptionContainerHeight = this.$optionsContainer.clientHeight
+ var optionListOffsetTop = this.$optionList.getBoundingClientRect().top
+ var distanceFromTopOfContainer = option.getBoundingClientRect().top - optionListOffsetTop
+ return distanceFromTopOfContainer < initialOptionContainerHeight
+ }
+
+ OptionSelect.prototype.getVisibleCheckboxes = function getVisibleCheckboxes () {
+ var visibleCheckboxes = []
+ for (var i = 0; i < this.$options.length; i++) {
+ if (this.isCheckboxVisible(this.$options[i])) {
+ visibleCheckboxes.push(this.$options[i])
+ }
+ }
+
+ // add an extra checkbox, if the label of the first is too long it collapses onto itself
+ if (this.$options[visibleCheckboxes.length]) {
+ visibleCheckboxes.push(this.$options[visibleCheckboxes.length])
+ }
+ return visibleCheckboxes
+ }
+
+ OptionSelect.prototype.isFacetsContainerHidden = function isFacetsContainerHidden () {
+ var facetsContent = this.$optionSelect.parentElement
+ var isFacetsContentHidden = false
+ // check whether this is hidden by progressive disclosure,
+ // because height calculations won't work
+ // would use offsetParent === null but for IE10+
+ if (facetsContent) {
+ isFacetsContentHidden = !(facetsContent.offsetWidth || facetsContent.offsetHeight || facetsContent.getClientRects().length)
+ }
+
+ return isFacetsContentHidden
+ }
+
+ OptionSelect.prototype.setupHeight = function setupHeight () {
+ var initialOptionContainerHeight = this.$optionsContainer.clientHeight
+ var height = this.$optionList.offsetHeight
+
+ var isFacetsContainerHidden = this.isFacetsContainerHidden()
+
+ if (isFacetsContainerHidden) {
+ initialOptionContainerHeight = 200
+ height = 200
+ }
+
+ // Resize if the list is only slightly bigger than its container
+ // If isFacetsContainerHidden is true, then 200 < 250
+ // And the container height is always set to 201px
+ if (height < initialOptionContainerHeight + 50) {
+ this.setContainerHeight(height + 1)
+ return
+ }
+
+ // Resize to cut last item cleanly in half
+ var visibleCheckboxes = this.getVisibleCheckboxes()
+
+ var lastVisibleCheckbox = visibleCheckboxes[visibleCheckboxes.length - 1]
+ var position = lastVisibleCheckbox.parentNode.offsetTop // parent element is relative
+ this.setContainerHeight(position + (lastVisibleCheckbox.clientHeight / 1.5))
+ }
+
+ Modules.OptionSelect = OptionSelect
+})(window.GOVUK.Modules)
diff --git a/app/assets/javascripts/legacy/modules/dropdown_filter.js b/app/assets/javascripts/legacy/modules/dropdown_filter.js
deleted file mode 100644
index 79572bbb4..000000000
--- a/app/assets/javascripts/legacy/modules/dropdown_filter.js
+++ /dev/null
@@ -1,49 +0,0 @@
-(function (Modules) {
- 'use strict'
- Modules.DropdownFilter = function () {
- var that = this
- that.start = function (element) {
- var list = element.find('.js-filter-list')
- var listItems = list.find('li:not(:first)')
- var listInput = element.find('.js-filter-list-input')
-
- // Prevent dropdowns with text inputs from closing when
- // interacting with them
- element.on('click', '.js-filter-list-input', function (event) { event.stopPropagation() })
-
- element.on('shown.bs.dropdown', focusInput)
- element.on('keyup change', '.js-filter-list-input', filterListBasedOnInput)
- element.on('submit', 'form', openFirstVisibleLink)
-
- // Set explicit width inline, so filtering doesn't change dropdown size
- list.width(list.width())
-
- function filterListBasedOnInput (event) {
- var searchString = $.trim(listInput.val())
- var regExp = new RegExp(searchString, 'i')
-
- listItems.each(function () {
- var item = $(this)
- if (item.text().search(regExp) > -1) {
- item.show()
- } else {
- item.hide()
- }
- })
- }
-
- function openFirstVisibleLink (evt) {
- evt.preventDefault()
- var link = list.find('a:visible').first()
- GOVUKAdmin.redirect(link.attr('href'))
- }
-
- function focusInput (event) {
- var container = $(event.target)
- setTimeout(function () {
- container.find('input[type="text"]').focus()
- }, 50)
- }
- }
- }
-})(window.GOVUKAdmin.Modules)
diff --git a/app/assets/javascripts/modules/auto-submit-form.js b/app/assets/javascripts/modules/auto-submit-form.js
new file mode 100644
index 000000000..a400db3d2
--- /dev/null
+++ b/app/assets/javascripts/modules/auto-submit-form.js
@@ -0,0 +1,21 @@
+window.GOVUK = window.GOVUK || {}
+window.GOVUK.Modules = window.GOVUK.Modules || {};
+
+(function (Modules) {
+ 'use strict'
+
+ function AutoSubmitForm (module) {
+ this.module = module
+ this.module.ignore = this.module.getAttribute('data-auto-submit-ignore').split(',')
+ }
+
+ AutoSubmitForm.prototype.init = function () {
+ this.module.addEventListener('change', function (e) {
+ if (!this.module.ignore.includes(e.target.getAttribute('name'))) {
+ this.module.submit()
+ }
+ }.bind(this))
+ }
+
+ Modules.AutoSubmitForm = AutoSubmitForm
+})(window.GOVUK.Modules)
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index 58486e4f8..a3d8c3f3d 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -1,3 +1,5 @@
+$govuk-page-width: 1140px;
+
@import "govuk_publishing_components/all_components";
@import "user_research_recruitment_banner";
@@ -42,6 +44,10 @@
padding: 7px 10px;
}
+.app-bottom-separator {
+ border-bottom: 1px solid $govuk-border-colour;
+}
+
.govuk-error-message p {
margin: 0;
}
@@ -73,3 +79,9 @@
.govuk-table__cell--line-through {
text-decoration: line-through;
}
+
+.users-index-button-group {
+ @include govuk-media-query($from: tablet) {
+ float: right;
+ }
+}
diff --git a/app/assets/stylesheets/components/_option-select.scss b/app/assets/stylesheets/components/_option-select.scss
new file mode 100644
index 000000000..2470abbdb
--- /dev/null
+++ b/app/assets/stylesheets/components/_option-select.scss
@@ -0,0 +1,168 @@
+@import "govuk_publishing_components/individual_component_support";
+
+.app-c-option-select {
+ position: relative;
+ padding: 0 0 govuk-spacing(2);
+ margin-bottom: govuk-spacing(2);
+ border-bottom: 1px solid $govuk-border-colour;
+
+ @include govuk-media-query($from: desktop) {
+ // Redefine scrollbars on desktop where these lists are scrollable
+ // so they are always visible in option lists
+ ::-webkit-scrollbar {
+ -webkit-appearance: none;
+ width: 7px;
+ }
+
+ ::-webkit-scrollbar-thumb {
+ border-radius: 4px;
+
+ background-color: rgba(0, 0, 0, .5);
+ -webkit-box-shadow: 0 0 1px rgba(255, 255, 255, .87);
+ }
+ }
+
+ .gem-c-checkboxes {
+ margin: 0;
+ }
+}
+
+.app-c-option-select__title {
+ @include govuk-font(19);
+ margin: 0;
+}
+
+.app-c-option-select__button {
+ z-index: 1;
+ background: none;
+ border: 0;
+ text-align: left;
+ padding: 0;
+ cursor: pointer;
+ color: $govuk-link-colour;
+
+ &:hover {
+ text-decoration: underline;
+ text-underline-offset: .1em;
+ @include govuk-link-hover-decoration;
+ }
+
+ &::-moz-focus-inner {
+ border: 0;
+ }
+
+ &:focus {
+ outline: none;
+ text-decoration: none;
+ @include govuk-focused-text;
+ }
+
+ &[disabled] {
+ background-image: none;
+ color: inherit;
+ }
+
+ // Extend the touch area of the button to span the heading
+ &::after {
+ content: "";
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ z-index: 2;
+ }
+}
+
+.app-c-option-select__icon {
+ display: none;
+ position: absolute;
+ top: 0;
+ left: 9px;
+ width: 30px;
+ height: 40px;
+ fill: govuk-colour("black");
+}
+
+.app-c-option-select__container {
+ position: relative;
+ max-height: 200px;
+ overflow-y: auto;
+ overflow-x: hidden;
+ background-color: govuk-colour("white");
+
+ &:focus {
+ outline: 0;
+ }
+}
+
+.app-c-option-select__container--large {
+ max-height: 600px;
+}
+
+.app-c-option-select__container-inner {
+ padding: govuk-spacing(1) 13px;
+}
+
+.app-c-option-select__filter {
+ position: relative;
+ background: govuk-colour("white");
+ padding: 13px 13px govuk-spacing(2) 13px;
+}
+
+.app-c-option-select__filter-input {
+ @include govuk-font(19);
+ padding-left: 33px;
+ background: image-url("option-select/input-icon.svg") govuk-colour("white") no-repeat -5px -3px;
+
+ @include govuk-media-query($from: tablet) {
+ @include govuk-font(16);
+ }
+}
+
+.js-enabled {
+ .app-c-option-select__heading {
+ position: relative;
+ padding: 10px 8px 5px 43px;
+ }
+
+ [aria-expanded="true"] ~ .app-c-option-select__icon--up {
+ display: block;
+ }
+
+ [aria-expanded="false"] ~ .app-c-option-select__icon--down {
+ display: block;
+ }
+
+ .app-c-option-select__container {
+ height: 200px;
+ }
+
+ .app-c-option-select__container--large {
+ height: 600px;
+ }
+
+ [data-closed-on-load="true"] .app-c-option-select__container {
+ display: none;
+ }
+}
+
+.app-c-option-select__selected-counter {
+ @include govuk-font($size: 14);
+ color: $govuk-text-colour;
+ margin-top: 3px;
+}
+
+.app-c-option-select.js-closed {
+ .app-c-option-select__filter,
+ .app-c-option-select__container {
+ display: none;
+ }
+}
+
+.app-c-option-select.js-opened {
+ .app-c-option-select__filter,
+ .app-c-option-select__container {
+ display: block;
+ }
+}
diff --git a/app/assets/stylesheets/components/_table.scss b/app/assets/stylesheets/components/_table.scss
index 3390b5345..b44c60574 100644
--- a/app/assets/stylesheets/components/_table.scss
+++ b/app/assets/stylesheets/components/_table.scss
@@ -6,6 +6,7 @@ $table-border-colour: govuk-colour("mid-grey", $legacy: "grey-2");
$table-header-border-width: 2px;
$table-header-background-colour: govuk-colour("light-grey", $legacy: "grey-3");
$vertical-row-bottom-border-width: 3px;
+$vertical-on-smallscreen-breakpoint: 940px;
$sort-link-active-colour: govuk-colour("white");
$sort-link-arrow-size: 14px;
$sort-link-arrow-size-small: 8px;
@@ -157,7 +158,7 @@ $table-row-even-background-colour: govuk-colour("light-grey", $legacy: "grey-4")
text-align: right;
}
- @include govuk-media-query($until: tablet) {
+ @include govuk-media-query($until: $vertical-on-smallscreen-breakpoint) {
.govuk-table__cell {
padding-right: 0;
}
@@ -178,7 +179,7 @@ $table-row-even-background-colour: govuk-colour("light-grey", $legacy: "grey-4")
word-break: initial;
}
- @include govuk-media-query($from: tablet) {
+ @include govuk-media-query($from: $vertical-on-smallscreen-breakpoint) {
.govuk-table__head {
clip: auto;
-webkit-clip-path: none;
diff --git a/app/assets/stylesheets/legacy/_filters.scss b/app/assets/stylesheets/legacy/_filters.scss
deleted file mode 100644
index ef4e4b290..000000000
--- a/app/assets/stylesheets/legacy/_filters.scss
+++ /dev/null
@@ -1,102 +0,0 @@
-// stylelint-disable selector-no-qualifying-type -- There are a large number
-// of violations of this rule in this file that should be fixed.
-
-.filters .panel-heading {
- background: transparent;
-}
-
-a.filter-option,
-a.filter-option:visited,
-.filter-option {
- color: inherit !important; // stylelint-disable-line declaration-no-important
-
- .glyphicon {
- @extend %glyphicon-smaller-than-text;
- }
-
- &.filter-selected {
- font-weight: bold;
- background: #eeeeee;
-
- &:hover,
- &:focus,
- &:active {
- background: #dddddd;
- }
- }
-
- .selected-and-available & {
- float: left;
-
- &:first-child {
- border-top-right-radius: 0;
- border-bottom-right-radius: 0;
- }
-
- &:first-child + .filter-option {
- border-top-left-radius: 0;
- border-bottom-left-radius: 0;
- border-left: 1px solid #dddddd;
- }
- }
-}
-
-.remove-filters {
- float: right;
- margin: 5px;
-}
-
-.filter-by-name-field {
- width: 290px;
-
- &::placeholder {
- color: #757575;
- }
-}
-
-// Nav compact
-
-.nav-compact > li > a {
- padding: 5px 10px;
-}
-
-// Nav pills
-
-.nav-pill-text {
- position: relative;
- display: block;
- padding: 10px 15px;
-}
-
-.nav-compact .nav-pill-text {
- padding: 5px 10px;
-}
-
-.nav-compact .nav-pill-text:first-child {
- padding-left: 0;
-}
-
-// Dropdown filter for Organisation & Permission
-
-.filter-by-organisation-menu,
-.filter-by-permission-menu {
- .dropdown-menu {
- max-height: 300px;
- overflow-y: scroll;
- }
-
- abbr {
- cursor: pointer;
- }
-}
-
-// List filter
-
-.list-filter {
- background: #eeeeee;
- padding: 10px $default-horizontal-margin;
- margin-top: -5px;
- margin-bottom: $default-vertical-margin / 2;
- border-radius: $border-radius-base $border-radius-base 0 0;
- border-bottom: 1px solid $dropdown-border;
-}
diff --git a/app/assets/stylesheets/legacy_layout.scss b/app/assets/stylesheets/legacy_layout.scss
index 9ec297016..8c34c62b8 100644
--- a/app/assets/stylesheets/legacy_layout.scss
+++ b/app/assets/stylesheets/legacy_layout.scss
@@ -2,6 +2,5 @@
@import "chosen";
@import "legacy/bootstrap_chosen";
-@import "legacy/filters";
@import "legacy/admin";
@import "legacy/thin_form";
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 9e8cdba12..a81e219e7 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -3,12 +3,13 @@
class UsersController < ApplicationController
include UserPermissionsControllerMethods
- layout "admin_layout", only: %w[edit_email_or_password event_logs require_2sv]
+ layout "admin_layout", only: %w[index edit_email_or_password event_logs require_2sv]
before_action :authenticate_user!, except: :show
before_action :load_and_authorize_user, except: %i[index show]
before_action :allow_no_application_access, only: [:update]
- helper_method :applications_and_permissions, :any_filter?
+ before_action :redirect_legacy_filters, only: [:index]
+ helper_method :applications_and_permissions, :filter_params
respond_to :html
before_action :doorkeeper_authorize!, only: :show
@@ -28,13 +29,14 @@ def show
def index
authorize User
- @users = policy_scope(User).includes(:organisation).order(:name)
- filter_users if any_filter?
+ @filter = UsersFilter.new(policy_scope(User), current_user, filter_params)
+
respond_to do |format|
format.html do
- paginate_users
+ @users = @filter.paginated_users
end
format.csv do
+ @users = @filter.users
headers["Content-Disposition"] = 'attachment; filename="signon_users.csv"'
render plain: export, content_type: "text/csv"
end
@@ -133,37 +135,10 @@ def load_and_authorize_user
authorize @user
end
- def filter_users
- @users = @users.filter_by_name(params[:filter]) if params[:filter].present?
- @users = @users.with_role(params[:role]) if can_filter_role?
- @users = @users.with_permission(params[:permission]) if params[:permission].present?
- @users = @users.with_organisation(params[:organisation]) if params[:organisation].present?
- @users = @users.with_status(params[:status]) if params[:status].present?
- @users = @users.with_2sv_enabled(params[:two_step_status]) if params[:two_step_status].present?
- end
-
- def can_filter_role?
- params[:role].present? &&
- current_user.manageable_roles.include?(params[:role])
- end
-
def should_include_permissions?
params[:format] == "csv"
end
- def paginate_users
- @users = @users.page(params[:page]).per(25)
- end
-
- def any_filter?
- params[:filter].present? ||
- params[:role].present? ||
- params[:permission].present? ||
- params[:status].present? ||
- params[:organisation].present? ||
- params[:two_step_status].present?
- end
-
def validate_token_matches_client_id
# FIXME: Once gds-sso is updated everywhere, this should always validate
# the client_id param. It should 401 if no client_id is given.
@@ -177,7 +152,7 @@ def export
CSV.generate do |csv|
presenter = UserExportPresenter.new(applications)
csv << presenter.header_row
- @users.includes(:organisation).find_each do |user|
+ @users.find_each do |user|
csv << presenter.row(user)
end
end
@@ -212,4 +187,19 @@ def password_params
:password_confirmation,
)
end
+
+ def filter_params
+ params.permit(
+ :filter, :page, :format, :"option-select-filter",
+ *LegacyUsersFilter::PARAM_KEYS,
+ **UsersFilter::PERMITTED_CHECKBOX_FILTER_PARAMS
+ ).except(:"option-select-filter")
+ end
+
+ def redirect_legacy_filters
+ filter = LegacyUsersFilter.new(filter_params)
+ if filter.redirect?
+ redirect_to users_path(filter.options)
+ end
+ end
end
diff --git a/app/helpers/user_filter_helper.rb b/app/helpers/user_filter_helper.rb
deleted file mode 100644
index 9ad1777bc..000000000
--- a/app/helpers/user_filter_helper.rb
+++ /dev/null
@@ -1,114 +0,0 @@
-module UserFilterHelper
- def current_path_with_filter(filter_type, filter_value)
- query_parameters = (request.query_parameters.clone || {})
- filter_value.nil? ? query_parameters.delete(filter_type) : query_parameters[filter_type] = filter_value
- query_string = query_parameters.map { |k, v| "#{k}=#{v}" }.join("&")
- "#{request.path_info}?#{query_string}"
- end
-
- def user_role_text
- "#{params[:role]} users".strip.humanize.capitalize
- end
-
- def two_step_abbr_tag
- tag.abbr("2SV", title: "Two step verification")
- end
-
- def title_from(filter_type)
- if filter_type == :two_step_status
- safe_join([two_step_abbr_tag, "Status"], " ")
- else
- filter_type.to_s.humanize.capitalize
- end
- end
-
- def user_filter_list_items(filter_type)
- items = case filter_type
- when :role
- filtered_user_roles
- when :permission
- Doorkeeper::Application
- .joins(:supported_permissions)
- .order("oauth_applications.name", "supported_permissions.name")
- .pluck("supported_permissions.id", "oauth_applications.name", "supported_permissions.name")
- .map { |e| [e[0], "#{e[1]} #{e[2]}"] }
- when :status
- User::USER_STATUSES
- when :organisation
- if current_user.super_organisation_admin?
- current_user.organisation.subtree.order(:name).joins(:users).uniq.map { |org| [org.id, org.name_with_abbreviation] }
- else
- Organisation.order(:name).joins(:users).uniq.map { |org| [org.id, org.name_with_abbreviation] }
- end
- when :two_step_status
- [["true", "Enabled"], ["false", "Not set up"], ["exempt", "Exempted"]]
- end
-
- list_items = items.map do |item|
- if item.is_a? String
- item_id = item
- item_name = item.humanize
- else
- item_id = item[0].to_s
- item_name = item[1]
- end
- tag.li(
- link_to(item_name, current_path_with_filter(filter_type, item_id)),
- class: params[filter_type] == item_id ? "active" : "",
- )
- end
-
- list_items << tag.li(
- link_to(
- "All #{title_from(filter_type).pluralize}".html_safe,
- current_path_with_filter(filter_type, nil),
- ),
- )
-
- list_items.join("\n").html_safe
- end
-
- def filtered_user_roles
- current_user.manageable_roles
- end
-
- def value_from(filter_type)
- value = params[filter_type]
- return nil if value.blank?
-
- case filter_type
- when :organisation
- org = Organisation.find(value)
- if org.abbreviation.presence
- tag.abbr(org.abbreviation, title: org.name)
- else
- org.name
- end
- when :permission
- Doorkeeper::Application
- .joins(:supported_permissions)
- .where("supported_permissions.id = ?", value)
- .pick("oauth_applications.name", "supported_permissions.name")
- .join(" ")
- when :two_step_status
- case value
- when "true"
- "Enabled"
- when "exempt"
- "Exempted"
- else
- "Not set up"
- end
- else
- value.humanize.capitalize
- end
- end
-
- def any_filter?
- params[:filter].present? ||
- params[:role].present? ||
- params[:permission].present? ||
- params[:status].present? ||
- params[:organisation].present?
- end
-end
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
index 2623f963f..fd5f5c2f0 100644
--- a/app/helpers/users_helper.rb
+++ b/app/helpers/users_helper.rb
@@ -1,12 +1,10 @@
module UsersHelper
+ def two_step_abbr_tag
+ tag.abbr("2SV", title: "Two step verification")
+ end
+
def two_step_status(user)
- if user.has_2sv?
- "Enabled"
- elsif user.exempt_from_2sv?
- "Exempted"
- else
- "Not set up"
- end
+ user.two_step_status.humanize.capitalize
end
def organisation_options(form_builder)
@@ -41,12 +39,25 @@ def sync_needed?(permissions)
max_updated_at.present? && max_last_synced_at.present? ? max_updated_at > max_last_synced_at : false
end
- def link_to_users_csv(text, params, options = {})
- merged_params = params.permit(:filter, :role, :permission, :status, :organisation, :two_step_status).merge(format: "csv")
- link_to text, merged_params, options
- end
-
def formatted_number_of_users(users)
pluralize(number_with_delimiter(users.total_count), "user")
end
+
+ def filtered_users_heading(users)
+ count = formatted_number_of_users(users)
+ if current_user.manageable_organisations.one?
+ "#{count} in #{current_user.manageable_organisations.first.name}"
+ else
+ count
+ end
+ end
+
+ def assignable_user_roles
+ current_user.manageable_roles
+ end
+
+ def user_name(user)
+ anchor_tag = link_to(user.name, edit_user_path(user), class: "govuk-link")
+ user.suspended? ? content_tag(:del, anchor_tag) : anchor_tag
+ end
end
diff --git a/app/models/legacy_users_filter.rb b/app/models/legacy_users_filter.rb
new file mode 100644
index 000000000..257855f35
--- /dev/null
+++ b/app/models/legacy_users_filter.rb
@@ -0,0 +1,32 @@
+class LegacyUsersFilter
+ PARAM_KEYS = %i[status two_step_status role permission organisation].freeze
+ LEGACY_TWO_STEP_STATUS_VS_TWO_STEP_STATUS = {
+ "true" => User::TWO_STEP_STATUS_ENABLED,
+ "false" => User::TWO_STEP_STATUS_NOT_SET_UP,
+ "exempt" => User::TWO_STEP_STATUS_EXEMPTED,
+ }.freeze
+
+ def initialize(options = {})
+ @options = options
+ end
+
+ def redirect?
+ !@options.slice(*PARAM_KEYS).empty?
+ end
+
+ def options
+ @options.except(*PARAM_KEYS).tap do |o|
+ o[:statuses] = [@options[:status]] if @options[:status].present?
+ o[:two_step_statuses] = [two_step_status_from(@options[:two_step_status])] if @options[:two_step_status].present?
+ o[:roles] = [@options[:role]] if @options[:role].present?
+ o[:organisations] = [@options[:organisation]] if @options[:organisation].present?
+ o[:permissions] = [@options[:permission]] if @options[:permission].present?
+ end
+ end
+
+private
+
+ def two_step_status_from(legacy_two_step_status)
+ LEGACY_TWO_STEP_STATUS_VS_TWO_STEP_STATUS[legacy_two_step_status]
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 063bbcec9..3937790bf 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -20,6 +20,16 @@ class User < ApplicationRecord
USER_STATUS_LOCKED,
USER_STATUS_ACTIVE].freeze
+ TWO_STEP_STATUS_ENABLED = "enabled".freeze
+ TWO_STEP_STATUS_NOT_SET_UP = "not_set_up".freeze
+ TWO_STEP_STATUS_EXEMPTED = "exempted".freeze
+
+ TWO_STEP_STATUSES_VS_NAMED_SCOPES = {
+ TWO_STEP_STATUS_ENABLED => "has_2sv",
+ TWO_STEP_STATUS_NOT_SET_UP => "not_setup_2sv",
+ TWO_STEP_STATUS_EXEMPTED => "exempt_from_2sv",
+ }.freeze
+
devise :database_authenticatable,
:recoverable,
:trackable,
@@ -33,6 +43,8 @@ class User < ApplicationRecord
:confirmable,
:password_archivable # in signon/lib/devise/models/password_archivable.rb
+ delegate :manageable_roles, to: :role_class
+
encrypts :otp_secret_key
validates :name, presence: true
@@ -60,11 +72,28 @@ class User < ApplicationRecord
before_save :strip_whitespace_from_name
scope :web_users, -> { where(api_user: false) }
+
+ scope :suspended, -> { where.not(suspended_at: nil) }
scope :not_suspended, -> { where(suspended_at: nil) }
- scope :with_role, ->(role_name) { where(role: role_name) }
- scope :with_permission, ->(permission_id) { joins(:supported_permissions).where("supported_permissions.id = ?", permission_id) }
- scope :with_organisation, ->(org_id) { where(organisation_id: org_id) }
- scope :filter_by_name, ->(filter_param) { where("users.email like ? OR users.name like ?", "%#{filter_param.strip}%", "%#{filter_param.strip}%") }
+ scope :invited, -> { where.not(invitation_sent_at: nil).where(invitation_accepted_at: nil) }
+ scope :not_invited, -> { where(invitation_sent_at: nil).or(where.not(invitation_accepted_at: nil)) }
+ scope :locked, -> { where.not(locked_at: nil) }
+ scope :not_locked, -> { where(locked_at: nil) }
+ scope :active, -> { not_suspended.not_invited.not_locked }
+
+ scope :exempt_from_2sv, -> { where.not(reason_for_2sv_exemption: nil) }
+ scope :not_exempt_from_2sv, -> { where(reason_for_2sv_exemption: nil) }
+ scope :has_2sv, -> { where.not(otp_secret_key: nil) }
+ scope :does_not_have_2sv, -> { where(otp_secret_key: nil) }
+ scope :not_setup_2sv, -> { not_exempt_from_2sv.does_not_have_2sv }
+
+ scope :with_role, ->(role) { where(role:) }
+ scope :with_permission, ->(permission) { joins(:supported_permissions).merge(SupportedPermission.where(id: permission)) }
+ scope :with_organisation, ->(organisation) { where(organisation:) }
+ scope :with_partially_matching_name, ->(name) { where(arel_table[:name].matches("%#{name}%")) }
+ scope :with_partially_matching_email, ->(email) { where(arel_table[:email].matches("%#{email}%")) }
+ scope :with_partially_matching_name_or_email, ->(value) { with_partially_matching_name(value).or(with_partially_matching_email(value)) }
+
scope :last_signed_in_on, ->(date) { web_users.not_suspended.where("date(current_sign_in_at) = date(?)", date) }
scope :last_signed_in_before, ->(date) { web_users.not_suspended.where("date(current_sign_in_at) < date(?)", date) }
scope :last_signed_in_after, ->(date) { web_users.not_suspended.where("date(current_sign_in_at) >= date(?)", date) }
@@ -72,35 +101,26 @@ class User < ApplicationRecord
scope :expired_never_signed_in, -> { never_signed_in.where("invitation_sent_at < ?", NEVER_SIGNED_IN_EXPIRY_PERIOD.ago) }
scope :not_recently_unsuspended, -> { where(["unsuspended_at IS NULL OR unsuspended_at < ?", UNSUSPENSION_GRACE_PERIOD.ago]) }
scope :with_access_to_application, ->(application) { UsersWithAccess.new(self, application).users }
- scope :with_2sv_enabled,
- lambda { |enabled|
- case enabled
- when "exempt"
- where("reason_for_2sv_exemption IS NOT NULL")
- when "true"
- where("otp_secret_key IS NOT NULL")
- else
- where("otp_secret_key IS NULL AND reason_for_2sv_exemption IS NULL")
- end
- }
-
- scope :with_status,
- lambda { |status|
- case status
- when USER_STATUS_SUSPENDED
- where.not(suspended_at: nil)
- when USER_STATUS_INVITED
- where.not(invitation_sent_at: nil).where(invitation_accepted_at: nil)
- when USER_STATUS_LOCKED
- where.not(locked_at: nil)
- when USER_STATUS_ACTIVE
- where(suspended_at: nil, locked_at: nil)
- .where(arel_table[:invitation_sent_at].eq(nil)
- .or(arel_table[:invitation_accepted_at].not_eq(nil)))
- else
- raise NotImplementedError, "Filtering by status '#{status}' not implemented."
- end
- }
+
+ def self.with_statuses(statuses)
+ permitted_statuses = statuses.intersection(USER_STATUSES)
+ relations = permitted_statuses.map { |s| public_send(s) }
+ relation = relations.pop || all
+ while (next_relation = relations.pop)
+ relation = relation.or(next_relation)
+ end
+ relation
+ end
+
+ def self.with_2sv_statuses(scope_names)
+ permitted_scopes = scope_names.intersection(TWO_STEP_STATUSES_VS_NAMED_SCOPES.values)
+ relations = permitted_scopes.map { |s| public_send(s) }
+ relation = relations.pop || all
+ while (next_relation = relations.pop)
+ relation = relation.or(next_relation)
+ end
+ relation
+ end
def require_2sv?
return require_2sv unless organisation
@@ -259,8 +279,26 @@ def status
USER_STATUS_ACTIVE
end
- def manageable_roles
- "Roles::#{role.camelize}".constantize.manageable_roles
+ def two_step_status
+ if has_2sv?
+ TWO_STEP_STATUS_ENABLED
+ elsif exempt_from_2sv?
+ TWO_STEP_STATUS_EXEMPTED
+ else
+ TWO_STEP_STATUS_NOT_SET_UP
+ end
+ end
+
+ def role_class
+ Roles.const_get(role.classify)
+ end
+
+ def can_manage?(other_user)
+ manageable_roles.include?(other_user.role)
+ end
+
+ def manageable_organisations
+ role_class.manageable_organisations_for(self).order(:name)
end
# Make devise send all emails using ActiveJob
diff --git a/app/models/users_filter.rb b/app/models/users_filter.rb
new file mode 100644
index 000000000..299a17a15
--- /dev/null
+++ b/app/models/users_filter.rb
@@ -0,0 +1,98 @@
+class UsersFilter
+ CHECKBOX_FILTER_KEYS = %i[statuses two_step_statuses roles permissions organisations].freeze
+ PERMITTED_CHECKBOX_FILTER_PARAMS = CHECKBOX_FILTER_KEYS.each.with_object({}) { |k, h| h[k] = [] }.freeze
+
+ attr_reader :options
+
+ def self.with_checked_at_top(options)
+ options.sort_by { |o| o[:checked] ? 0 : 1 }
+ end
+
+ def initialize(users, current_user, options = {})
+ @users = users
+ @current_user = current_user
+ @options = options
+ @options[:per_page] ||= 25
+ end
+
+ def users
+ filtered_users = @users
+ filtered_users = filtered_users.with_partially_matching_name_or_email(options[:filter].strip) if options[:filter]
+ filtered_users = filtered_users.with_statuses(options[:statuses]) if options[:statuses]
+ filtered_users = filtered_users.with_2sv_statuses(options[:two_step_statuses]) if options[:two_step_statuses]
+ filtered_users = filtered_users.with_role(options[:roles]) if options[:roles]
+ filtered_users = filtered_users.with_permission(options[:permissions]) if options[:permissions]
+ filtered_users = filtered_users.with_organisation(options[:organisations]) if options[:organisations]
+ filtered_users.includes(:organisation).order(:name)
+ end
+
+ def paginated_users
+ users.page(options[:page]).per(options[:per_page])
+ end
+
+ def status_option_select_options(aria_controls_id: nil)
+ User::USER_STATUSES.map do |status|
+ {
+ label: status.humanize.capitalize,
+ controls: aria_controls_id,
+ value: status,
+ checked: Array(options[:statuses]).include?(status),
+ }.compact
+ end
+ end
+
+ def two_step_status_option_select_options(aria_controls_id: nil)
+ User::TWO_STEP_STATUSES_VS_NAMED_SCOPES.map do |status, scope_name|
+ {
+ label: status.humanize.capitalize,
+ controls: aria_controls_id,
+ value: scope_name,
+ checked: Array(options[:two_step_statuses]).include?(scope_name),
+ }.compact
+ end
+ end
+
+ def role_option_select_options(aria_controls_id: nil)
+ @current_user.manageable_roles.map do |role|
+ {
+ label: role.humanize.capitalize,
+ controls: aria_controls_id,
+ value: role,
+ checked: Array(options[:roles]).include?(role),
+ }.compact
+ end
+ end
+
+ def permission_option_select_options(aria_controls_id: nil)
+ Doorkeeper::Application.includes(:supported_permissions).flat_map do |application|
+ application.supported_permissions.map do |permission|
+ {
+ label: "#{application.name} #{permission.name}",
+ controls: aria_controls_id,
+ value: permission.to_param,
+ checked: Array(options[:permissions]).include?(permission.to_param),
+ }.compact
+ end
+ end
+ end
+
+ def organisation_option_select_options(aria_controls_id: nil)
+ scope = @current_user.manageable_organisations
+ scope.order(:name).joins(:users).uniq.map do |organisation|
+ {
+ label: organisation.name_with_abbreviation,
+ controls: aria_controls_id,
+ value: organisation.to_param,
+ checked: Array(options[:organisations]).include?(organisation.to_param),
+ }.compact
+ end
+ end
+
+ def no_options_selected_for?(key)
+ Array(options[key]).none?
+ end
+
+ def any_options_selected?
+ options.slice(*CHECKBOX_FILTER_KEYS).values.any?
+ end
+end
diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb
index 2cac400f9..6d26412b7 100644
--- a/app/policies/user_policy.rb
+++ b/app/policies/user_policy.rb
@@ -71,7 +71,7 @@ def allow_self_only
end
def can_manage?
- Roles.const_get(current_user.role.classify).can_manage?(record.role)
+ current_user.can_manage?(record)
end
class Scope < ::BasePolicy::Scope
diff --git a/app/views/components/_option_select.html.erb b/app/views/components/_option_select.html.erb
new file mode 100644
index 000000000..144b3a9e3
--- /dev/null
+++ b/app/views/components/_option_select.html.erb
@@ -0,0 +1,69 @@
+<% add_app_component_stylesheet("option-select") %>
+<%
+ title_id = "option-select-title-#{title.parameterize}"
+ checkboxes_id = "checkboxes-#{SecureRandom.hex(4)}"
+ checkboxes_count_id = checkboxes_id + "-count"
+ show_filter ||= false
+ large ||= false
+
+ classes = %w[app-c-option-select__container js-options-container]
+ classes << "app-c-option-select__container--large" if large
+%>
+
+<% if show_filter %>
+ <%
+ filter_id ||= "input-#{SecureRandom.hex(4)}"
+ %>
+ <% filter = capture do %>
+ <%= tag.label for: filter_id, class: "govuk-label govuk-visually-hidden" do %>
+ Filter <%= title %>
+ <% end %>
+
+ <%= tag.input name: "option-select-filter",
+ id: filter_id,
+ class: "app-c-option-select__filter-input govuk-input",
+ type: "text",
+ aria: {
+ describedby: checkboxes_count_id,
+ controls: checkboxes_id
+ }
+ %>
+ <% end %>
+ <% filter_element = CGI::escapeHTML(filter) %>
+<% end %>
+
+
data-closed-on-load="true"<% end %>
+ <% if local_assigns.include?(:closed_on_load_mobile) && closed_on_load_mobile %>data-closed-on-load-mobile="true"<% end %>
+ <% if local_assigns.include?(:aria_controls_id) %>data-input-aria-controls="<%= aria_controls_id %>"<% end %>
+ <% if show_filter %>data-filter-element="<%= filter_element %>"<% end %>
+>
+