Member index: Filter by joining date (#17975)

* add the date picker into view

* allow date picker to overflow modal

* allow aria labels to be passed to inputs

* filter records

* add missing comment

* set earliest date

* specs and a small fix

* cypress spec

* fix typo

* tidying up

* fetch date_format

* commit code review suggestion to params

* Revert "commit code review suggestion to params"

This reverts commit 84fb0e0d3acc13257a362eb41c17b2bb8089606a.

* show added filter pill

* add cypress specs for applied filters

* woops! fix unintended change to filter code
This commit is contained in:
Suzanne Aitchison 2022-07-14 14:52:42 +01:00 committed by GitHub
parent d9e79d6e70
commit 76c7932b09
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 285 additions and 25 deletions

View file

@ -10,6 +10,7 @@
var(--modal-padding);
--modal-alignment: unset;
--modal-header-alignment: space-between;
--modal-overflow: hidden;
@media (min-width: $breakpoint-s) {
--modal-padding: var(--su-4);
@ -80,7 +81,7 @@
max-height: 100%;
width: 100%;
height: 100%;
overflow: hidden;
overflow: var(--modal-overflow);
position: relative;
pointer-events: auto;
border-radius: var(--radius-large-auto);
@ -149,6 +150,10 @@
}
}
&--overflow-visible {
--modal-overflow: visible;
}
&--prompt {
@media (min-width: $breakpoint-s) {
--modal-max-width: 480px;

View file

@ -51,11 +51,15 @@ module Admin
search: params[:search],
role: params[:role],
roles: params[:roles],
joining_start: params[:joining_start],
joining_end: params[:joining_end],
date_format: params[:date_format],
organizations: params[:organizations],
).page(params[:page]).per(50)
@organization_limit = 3
@organizations = Organization.order(name: :desc)
@earliest_join_date = User.first.registered_at.to_s
end
def edit

View file

@ -18,6 +18,7 @@ export const Modal = ({
showHeader = true,
sheetAlign = 'center',
backdropDismissible = false,
allowOverflow = false,
onClose = () => {},
focusTrapSelector = '.crayons-modal__box',
document = window.document,
@ -29,6 +30,7 @@ export const Modal = ({
'crayons-modal--prompt': prompt,
'crayons-modal--centered': centered && prompt,
'crayons-modal--bg-dismissible': !noBackdrop && backdropDismissible,
'crayons-modal--overflow-visible': allowOverflow,
[className]: className,
});

View file

@ -251,6 +251,8 @@ const useDateRangeValidation = ({
* @param {Date} props.minStartDate The oldest date that may be selected
* @param {Function} props.onDatesChanged Callback function for when dates are selected. Receives an object with startDate and endDate values.
* @param {[string]} props.presetRanges Quick-select preset date ranges to offer in the calendar. These will only be shown if they fall within the min and max dates.
* @param {string} props.startDateAriaLabel Custom aria-label for start date input
* @param {string} props.endDateAriaLabel Custom aria-label for end date input
* @param {Date} props.todaysDate Optional param to pass in today's Date, primarily for testing purposes
*/
export const DateRangePicker = ({
@ -262,6 +264,8 @@ export const DateRangePicker = ({
minStartDate = new Date(),
onDatesChanged,
presetRanges = [],
startDateAriaLabel,
endDateAriaLabel,
todaysDate = new Date(),
}) => {
const [focusedInput, setFocusedInput] = useState(START_DATE);
@ -312,10 +316,12 @@ export const DateRangePicker = ({
<ReactDateRangePicker
startDateId={startDateId}
startDate={startMoment}
startDateAriaLabel={`Start date (${dateFormat})`}
startDateAriaLabel={`${
startDateAriaLabel ?? 'Start date'
} (${dateFormat})`}
endDate={endMoment}
endDateId={endDateId}
endDateAriaLabel={`End date (${dateFormat})`}
endDateAriaLabel={`${endDateAriaLabel ?? 'End date'} (${dateFormat})`}
startDatePlaceholderText={dateFormat}
endDatePlaceholderText={dateFormat}
displayFormat={dateFormat}
@ -381,6 +387,8 @@ export const DateRangePicker = ({
/>
)}
/>
{/* This hidden input provides information that may be needed by implementing forms to properly parse dates */}
<input type="hidden" name="date_format" value={dateFormat} />
<div
className="c-date-picker__errors crayons-field__description"
aria-live="assertive"

View file

@ -19,6 +19,12 @@ export default {
endDateId: {
description: 'A unique identifier for the end date input (required)',
},
startDateAriaLabel: {
description: 'A custom aria-label for the start date input (optional)',
},
endDateAriaLabel: {
description: 'A custom aria-label for the end date input (optional)',
},
defaultStartDate: {
description: 'A default value for the start date of the range (optional)',
control: 'date',
@ -69,6 +75,8 @@ Default.args = {
minStartDate: new Date(2020, 0, 1),
maxEndDate: new Date(),
presetRanges: [],
startDateAriaLabel: undefined,
endDateAriaLabel: undefined,
};
export const DateRangePickerWithPresetRanges = (args) => {
@ -82,6 +90,8 @@ DateRangePickerWithPresetRanges.args = {
defaultEndDate: undefined,
minStartDate: new Date(2020, 0, 1),
maxEndDate: new Date(),
startDateAriaLabel: undefined,
endDateAriaLabel: undefined,
presetRanges: [
MONTH_UNTIL_TODAY,
LAST_FULL_MONTH,

View file

@ -5,6 +5,15 @@ export const LAST_FULL_MONTH = 'LAST_FULL_MONTH';
export const LAST_FULL_QUARTER = 'LAST_FULL_QUARTER';
export const LAST_FULL_YEAR = 'LAST_FULL_YEAR';
export const ALL_PRESET_RANGES = [
MONTH_UNTIL_TODAY,
LAST_FULL_MONTH,
QUARTER_UNTIL_TODAY,
LAST_FULL_QUARTER,
YEAR_UNTIL_TODAY,
LAST_FULL_YEAR,
];
export const RANGE_LABELS = {
MONTH_UNTIL_TODAY: 'This month',
QUARTER_UNTIL_TODAY: 'This quarter',

View file

@ -4,6 +4,67 @@ import {
WINDOW_MODAL_ID,
} from '@utilities/showModal';
const updateDateRangeFilterIndicator = ({ startDate, endDate, indicator }) => {
const hasValues = startDate || endDate;
if (hasValues) {
indicator.classList.remove('hidden');
} else {
indicator.classList.add('hidden');
}
};
/**
* Some sections require the Preact DateRangePicker.
* This function imports the required packages and adds the pickers to the modal.
*/
const initializeDateRangePickers = async () => {
const joiningRangeContainer = document.querySelector(
`#${WINDOW_MODAL_ID} .js-joining-date-range`,
);
const joiningDateIndicator = document.querySelector(
`#${WINDOW_MODAL_ID} .js-filtered-indicator-joining-date`,
);
const [
{ render, h },
{ DateRangePicker, ALL_PRESET_RANGES },
{ default: moment },
] = await Promise.all([
import('preact'),
import('@crayons'),
import('moment'),
]);
const { defaultStart, defaultEnd, defaultDatesFormat, earliestDate } =
joiningRangeContainer.dataset;
render(
<DateRangePicker
startDateId="joining_start"
endDateId="joining_end"
startDateAriaLabel="Joined after"
endDateAriaLabel="Joined before"
onDatesChanged={(dates) =>
updateDateRangeFilterIndicator({
...dates,
indicator: joiningDateIndicator,
})
}
defaultStartDate={
defaultStart && moment(defaultStart, defaultDatesFormat).toDate()
}
defaultEndDate={
defaultEnd && moment(defaultEnd, defaultDatesFormat).toDate()
}
minStartDate={new Date(earliestDate)}
maxEndDate={new Date()}
presetRanges={ALL_PRESET_RANGES}
/>,
joiningRangeContainer,
);
};
/**
* Details panels will automatically expand on click when required.
* We want to make sure only _one_ panel is expanded at any given time,
@ -145,11 +206,13 @@ export const initializeFiltersModal = () => {
sheet: true,
sheetAlign: 'right',
size: 'small',
allowOverflow: true,
onOpen: () => {
initializeModalCloseButton();
initializeFilterDetailsToggles();
initializeShowHideRoles();
initializeFilterClearButtons();
initializeDateRangePickers();
},
});
});

View file

@ -34,16 +34,27 @@ const initializeFilterPills = () => {
} = relevantElement;
const urlParams = new URLSearchParams(document.location.search);
const allFiltersMatchingKey = urlParams.getAll(filterKey);
// Remove the clicked filter name from the array
allFiltersMatchingKey.splice(allFiltersMatchingKey.indexOf(filterValue), 1);
// Delete _all_ filters with given key
urlParams.delete(filterKey);
// Re-attach any filters with given key that we want to keep
allFiltersMatchingKey.forEach((filterVal) =>
urlParams.append(filterKey, filterVal),
);
if (filterKey === 'joining_date') {
urlParams.delete('joining_start');
urlParams.delete('joining_end');
} else {
// In all other cases, we may have filters with a matching key that we want to keep
const allFiltersMatchingKey = urlParams.getAll(filterKey);
// Remove the clicked filter name from the array
allFiltersMatchingKey.splice(
allFiltersMatchingKey.indexOf(filterValue),
1,
);
// Delete _all_ filters with given key
urlParams.delete(filterKey);
// Re-attach any filters with given key that we want to keep
allFiltersMatchingKey.forEach((filterVal) =>
urlParams.append(filterKey, filterVal),
);
}
// Redirect the page
document.location.search = urlParams;
});

View file

@ -10,7 +10,17 @@ module Admin
# @param search [String, nil]
# @param roles [Array<String>, nil]
# @param organizations [Array<String>, nil]
def self.call(relation: User.registered, role: nil, search: nil, roles: [], organizations: [])
# @param joining_start [String, nil]
# @param joining_end [String, nil]
# @param date_format [String]
def self.call(relation: User.registered,
role: nil,
search: nil,
roles: [],
organizations: [],
joining_start: nil,
joining_end: nil,
date_format: "DD/MM/YYYY")
# We are at an interstitial moment where we are exposing both the role and roles param. We
# need to favor one or the other.
if role.presence
@ -23,6 +33,11 @@ module Admin
relation = filter_organization_memberships(relation: relation, organizations: organizations)
end
if joining_start.presence || joining_end.presence
relation = filter_joining_date(relation: relation, joining_start: joining_start, joining_end: joining_end,
date_format: date_format)
end
relation = search_relation(relation, search) if search.presence
relation.distinct.order(created_at: :desc)
end
@ -31,6 +46,21 @@ module Admin
relation.where(QUERY_CLAUSE, search: "%#{search.strip}%")
end
def self.filter_joining_date(relation:, joining_start:, joining_end:, date_format:)
ui_formats_to_parse_format = {
"DD/MM/YYYY" => "%d/%m/%Y",
"MM/DD/YYYY" => "%m/%d/%Y"
}
parse_format = ui_formats_to_parse_format.fetch(date_format)
if joining_start.presence
relation = relation.where("registered_at >= ?", DateTime.strptime(joining_start, parse_format).beginning_of_day)
end
return relation unless joining_end.presence
relation.where("registered_at <= ?", DateTime.strptime(joining_end, parse_format).end_of_day)
end
def self.filter_organization_memberships(relation:, organizations:)
sub_query = OrganizationMembership.select(:user_id).where(organization_id: organizations)
relation.where(id: sub_query)

View file

@ -23,7 +23,19 @@
<%= crayons_icon_tag("x.svg", class: "c-pill__action-icon", aria_hidden: true) %>
</button>
<% end %>
<% if params[:roles] || params[:organizations] %>
<% if params[:joining_start]&.present? || params[:joining_end]&.present? %>
<button
data-filter-key="joining_date"
aria-label="Remove filter: Joining date"
class="c-pill c-pill--action-icon c-pill--action-icon--destructive"
type="button">
<% start_date = params[:joining_start]&.blank? ? "Community creation" : params[:joining_start] %>
<% end_date = params[:joining_end]&.blank? ? "Today" : params[:joining_end] %>
<%= start_date %> - <%= end_date %>
<%= crayons_icon_tag("x.svg", class: "c-pill__action-icon", aria_hidden: true) %>
</button>
<% end %>
<% if params[:roles] || params[:organizations] || params[:joining_start]&.present? || params[:joining_end]&.present? %>
<button class="js-clear-filters-btn c-btn" aria-label="Clear all filters">Clear all</button>
<% end %>
</div>

View file

@ -43,8 +43,18 @@
Status options
</details>
<details data-section="joining-date" class="admin-details">
<summary class="py-4 flex justify-between text-uppercase color-base-60 fs-s">Joining date<%= crayons_icon_tag("chevron-down", aria_hidden: true, class: "summary-icon") %></summary>
Joining date options
<summary class="py-4 flex justify-between text-uppercase color-base-60 fs-s">
<span class="flex">
Joining date
<span class="js-filtered-indicator-joining-date relative ml-2 <%= params[:joining_start].blank? && params[:joining_end].blank? ? "hidden" : "" %>">
<span class="block absolute c-indicator c-indicator--info"></span>
</span>
</span>
<%= crayons_icon_tag("chevron-down", aria_hidden: true, class: "summary-icon") %>
</summary>
<div class="js-joining-date-range mb-4 absolute" data-earliest-date="<%= @earliest_join_date %>" data-default-start="<%= params[:joining_start] %>" data-default-end="<%= params[:joining_end] %>" data-default-dates-format="<%= params[:date_format] %>"></div>
<%# This hidden field is only here to reserve the correct amount of vertical space for where we later add the Preact inputs %>
<input class="crayons-textfield p-4 mb-4 invisible">
</details>
<details data-section="organizations" class="admin-details">
<summary class="py-4 flex justify-between text-uppercase color-base-60 fs-s">

View file

@ -170,6 +170,58 @@ describe('Filter user index', () => {
});
});
describe('Joining date', () => {
it('filters by joining date', () => {
cy.findByRole('button', { name: 'Filter' }).click();
cy.getModal().within(() => {
cy.findAllByText('Joining date').first().click();
// We need to use a partial name match here, because we can't force the Cypress browser locale to e.g. en-us, and we
// want to void flake caused by DD/MM/YYYY format vs MM/DD/YYYY
cy.findByRole('textbox', { name: /Joined after/ }).type('01/01/2020');
cy.findByRole('textbox', { name: /Joined before/ }).type('01/01/2020');
cy.findByRole('button', { name: 'Apply filters' }).click();
});
// Admin user is deliberately seeded with very early registered date, and should be the only result
cy.findAllByRole('row').should('have.length', 2);
cy.findByRole('button', {
name: 'Remove filter: Joining date',
}).should('contain', '01/01/2020 - 01/01/2020');
});
it('shows community creation in filter pill if no start date selected', () => {
cy.findByRole('button', { name: 'Filter' }).click();
cy.getModal().within(() => {
cy.findAllByText('Joining date').first().click();
// We need to use a partial name match here, because we can't force the Cypress browser locale to e.g. en-us, and we
// want to void flake caused by DD/MM/YYYY format vs MM/DD/YYYY
cy.findByRole('textbox', { name: /Joined before/ }).type('01/01/2020');
cy.findByRole('button', { name: 'Apply filters' }).click();
});
cy.findByRole('button', {
name: 'Remove filter: Joining date',
}).should('contain', 'Community creation - 01/01/2020');
});
it('shows today in filter pill if no end date selected', () => {
cy.findByRole('button', { name: 'Filter' }).click();
cy.getModal().within(() => {
cy.findAllByText('Joining date').first().click();
// We need to use a partial name match here, because we can't force the Cypress browser locale to e.g. en-us, and we
// want to void flake caused by DD/MM/YYYY format vs MM/DD/YYYY
cy.findByRole('textbox', { name: /Joined after/ }).type('01/01/2020');
cy.findByRole('button', { name: 'Apply filters' }).click();
});
cy.findByRole('button', { name: 'Remove filter: Joining date' }).should(
'contain',
'01/01/2020 - Today',
);
});
});
describe('Multiple filters', () => {
it('filters by multiple criteria', () => {
cy.findAllByRole('button', { name: 'Filter' }).last().click();

View file

@ -1,23 +1,33 @@
require "rails_helper"
RSpec.describe Admin::UsersQuery, type: :query do
subject { described_class.call(search: search, role: role, roles: roles, organizations: organizations) }
subject do
described_class.call(search: search, role: role, roles: roles, organizations: organizations,
joining_start: joining_start, joining_end: joining_end, date_format: date_format)
end
let(:role) { nil }
let(:roles) { [] }
let(:organizations) { [] }
let(:search) { [] }
let(:joining_start) { nil }
let(:joining_end) { nil }
let(:date_format) { "DD/MM/YYYY" }
let!(:org1) { create(:organization, name: "Org1") }
let!(:org2) { create(:organization, name: "Org2") }
let!(:user) { create(:user, :trusted, name: "Greg") }
let!(:user2) { create(:user, :trusted, name: "Gregory") }
let!(:user3) { create(:user, :tag_moderator, name: "Paul") }
let!(:user4) { create(:user, :admin, name: "Susi") }
let!(:user5) { create(:user, :trusted, :admin, name: "Beth") }
let!(:user6) { create(:user, :super_admin, name: "Jean") }
let!(:user7) { create(:user).tap { |u| u.add_role(:single_resource_admin, DataUpdateScript) } }
let!(:user) { create(:user, :trusted, name: "Greg", registered_at: "2020-05-06T13:09:47+0000") }
let!(:user2) { create(:user, :trusted, name: "Gregory", registered_at: "2020-05-08T13:09:47+0000") }
let!(:user3) { create(:user, :tag_moderator, name: "Paul", registered_at: "2020-05-10T13:09:47+0000") }
let!(:user4) { create(:user, :admin, name: "Susi", registered_at: "2020-10-05T13:09:47+0000") }
let!(:user5) { create(:user, :trusted, :admin, name: "Beth", registered_at: "2020-10-07T13:09:47+0000") }
let!(:user6) { create(:user, :super_admin, name: "Jean", registered_at: "2020-10-08T13:09:47+0000") }
let!(:user7) do
create(:user, registered_at: "2020-12-05T13:09:47+0000").tap do |u|
u.add_role(:single_resource_admin, DataUpdateScript)
end
end
describe ".call" do
context "when no arguments are given" do
@ -91,5 +101,38 @@ RSpec.describe Admin::UsersQuery, type: :query do
it { is_expected.to eq([user4]) }
end
context "when filtering by joining_start and default DD/MM/YYYY date format" do
let(:joining_start) { "01/12/2020" }
it { is_expected.to eq([user7]) }
end
context "when filtering by joining_start and alternative MM/DD/YYYY date format" do
let(:joining_start) { "12/01/2020" }
let(:date_format) { "MM/DD/YYYY" }
it { is_expected.to eq([user7]) }
end
context "when filtering by joining_end and default DD/MM/YYYY date format" do
let(:joining_end) { "07/05/2020" }
it { is_expected.to eq([user]) }
end
context "when filtering by joining_end and alternative MM/DD/YYYY date format" do
let(:joining_end) { "05/07/2020" }
let(:date_format) { "MM/DD/YYYY" }
it { is_expected.to eq([user]) }
end
context "when filtering by both joining_start and joining_end" do
let(:joining_start) { "01/05/2020" }
let(:joining_end) { "31/05/2020" }
it { is_expected.to eq([user3, user2, user]) }
end
end
end

View file

@ -42,7 +42,8 @@ seeder.create_if_doesnt_exist(User, "email", "admin@forem.local") do
username: "Admin_McAdmin",
profile_image: File.open(Rails.root.join("app/assets/images/#{rand(1..40)}.png")),
confirmed_at: Time.current,
registered_at: Time.current,
registered_at: "2020-01-01T13:09:47+0000",
created_at: "2020-01-01T13:09:47+0000",
password: "password",
password_confirmation: "password",
saw_onboarding: true,