Merge pull request #12851 from overleaf/jk-dashboard-filter-visibility

[web] Improve filter visibility on project dashboard

GitOrigin-RevId: de7a9f999d6d0164ab3c18c58e305c7c628f946c
This commit is contained in:
June Kelly 2023-05-16 09:17:36 +01:00 committed by Copybot
parent d1bbbc1bf1
commit ff4ac0a803
10 changed files with 306 additions and 26 deletions

View file

@ -742,6 +742,11 @@
"search_bib_files": "", "search_bib_files": "",
"search_command_find": "", "search_command_find": "",
"search_command_replace": "", "search_command_replace": "",
"search_in_all_projects": "",
"search_in_archived_projects": "",
"search_in_shared_projects": "",
"search_in_trashed_projects": "",
"search_in_your_projects": "",
"search_match_case": "", "search_match_case": "",
"search_next": "", "search_next": "",
"search_previous": "", "search_previous": "",

View file

@ -19,6 +19,7 @@ import SearchForm from './search-form'
import ProjectsDropdown from './dropdown/projects-dropdown' import ProjectsDropdown from './dropdown/projects-dropdown'
import SortByDropdown from './dropdown/sort-by-dropdown' import SortByDropdown from './dropdown/sort-by-dropdown'
import ProjectTools from './table/project-tools/project-tools' import ProjectTools from './table/project-tools/project-tools'
import ProjectListTitle from './title/project-list-title'
import Sidebar from './sidebar/sidebar' import Sidebar from './sidebar/sidebar'
import LoadMore from './load-more' import LoadMore from './load-more'
import { useEffect } from 'react' import { useEffect } from 'react'
@ -44,8 +45,13 @@ function ProjectListPageContent() {
searchText, searchText,
setSearchText, setSearchText,
selectedProjects, selectedProjects,
filter,
tags,
selectedTagId,
} = useProjectListContext() } = useProjectListContext()
const selectedTag = tags.find(tag => tag._id === selectedTagId)
useEffect(() => { useEffect(() => {
eventTracking.sendMB('loads_v2_dash', {}) eventTracking.sendMB('loads_v2_dash', {})
}, []) }, [])
@ -68,14 +74,12 @@ function ProjectListPageContent() {
<UserNotifications /> <UserNotifications />
</Col> </Col>
</Row> </Row>
<Row> <div className="project-list-header-row">
<Col md={7} className="hidden-xs"> <ProjectListTitle
<SearchForm filter={filter}
inputValue={searchText} selectedTag={selectedTag}
setInputValue={setSearchText} className="hidden-xs text-truncate"
/> />
</Col>
<Col md={5}>
<div className="project-tools"> <div className="project-tools">
<div className="hidden-xs"> <div className="hidden-xs">
{selectedProjects.length === 0 ? ( {selectedProjects.length === 0 ? (
@ -88,6 +92,15 @@ function ProjectListPageContent() {
<CurrentPlanWidget /> <CurrentPlanWidget />
</div> </div>
</div> </div>
</div>
<Row className="hidden-xs">
<Col md={7}>
<SearchForm
inputValue={searchText}
setInputValue={setSearchText}
filter={filter}
selectedTag={selectedTag}
/>
</Col> </Col>
</Row> </Row>
<div className="project-list-sidebar-survey-wrapper visible-xs"> <div className="project-list-sidebar-survey-wrapper visible-xs">
@ -112,6 +125,8 @@ function ProjectListPageContent() {
<SearchForm <SearchForm
inputValue={searchText} inputValue={searchText}
setInputValue={setSearchText} setInputValue={setSearchText}
filter={filter}
selectedTag={selectedTag}
className="overflow-hidden" className="overflow-hidden"
formGroupProps={{ className: 'mb-0' }} formGroupProps={{ className: 'mb-0' }}
/> />

View file

@ -9,10 +9,14 @@ import {
import Icon from '../../../shared/components/icon' import Icon from '../../../shared/components/icon'
import * as eventTracking from '../../../infrastructure/event-tracking' import * as eventTracking from '../../../infrastructure/event-tracking'
import classnames from 'classnames' import classnames from 'classnames'
import { Tag } from '../../../../../app/src/Features/Tags/types'
import { Filter } from '../context/project-list-context'
type SearchFormOwnProps = { type SearchFormOwnProps = {
inputValue: string inputValue: string
setInputValue: (input: string) => void setInputValue: (input: string) => void
filter: Filter
selectedTag: Tag | undefined
formGroupProps?: FormGroupProps & formGroupProps?: FormGroupProps &
Omit<React.ComponentProps<'div'>, keyof FormGroupProps> Omit<React.ComponentProps<'div'>, keyof FormGroupProps>
} }
@ -23,11 +27,35 @@ type SearchFormProps = SearchFormOwnProps &
function SearchForm({ function SearchForm({
inputValue, inputValue,
setInputValue, setInputValue,
filter,
selectedTag,
formGroupProps, formGroupProps,
...props ...props
}: SearchFormProps) { }: SearchFormProps) {
const { t } = useTranslation() const { t } = useTranslation()
const placeholder = `${t('search_projects')}` let placeholderMessage = t('search_projects')
if (selectedTag) {
placeholderMessage = `${t('search')} ${selectedTag.name}`
} else {
switch (filter) {
case 'all':
placeholderMessage = t('search_in_all_projects')
break
case 'owned':
placeholderMessage = t('search_in_your_projects')
break
case 'shared':
placeholderMessage = t('search_in_shared_projects')
break
case 'archived':
placeholderMessage = t('search_in_archived_projects')
break
case 'trashed':
placeholderMessage = t('search_in_trashed_projects')
break
}
}
const placeholder = `${placeholderMessage}`
const { className: formGroupClassName, ...restFormGroupProps } = const { className: formGroupClassName, ...restFormGroupProps } =
formGroupProps || {} formGroupProps || {}

View file

@ -0,0 +1,45 @@
import { useTranslation } from 'react-i18next'
import classnames from 'classnames'
import { Tag } from '../../../../../../app/src/Features/Tags/types'
import { Filter } from '../../context/project-list-context'
function ProjectListTitle({
filter,
selectedTag,
className,
}: {
filter: Filter
selectedTag: Tag | undefined
className?: string
}) {
const { t } = useTranslation()
let message = t('projects')
if (selectedTag) {
message = `${selectedTag.name}`
} else {
switch (filter) {
case 'all':
message = t('all_projects')
break
case 'owned':
message = t('your_projects')
break
case 'shared':
message = t('shared_with_you')
break
case 'archived':
message = t('archived_projects')
break
case 'trashed':
message = t('trashed_projects')
break
}
}
return (
<div className={classnames('project-list-title', className)}>
<span>{message}</span>
</div>
)
}
export default ProjectListTitle

View file

@ -83,6 +83,23 @@
padding: @content-margin-vertical @grid-gutter-width / 2; padding: @content-margin-vertical @grid-gutter-width / 2;
} }
.project-list-header-row {
display: flex;
align-items: center;
margin-bottom: @margin-sm;
}
.project-list-title {
min-width: 0;
color: @content-secondary;
font-family: Lato, sans-serif;
font-size: @font-size-large;
line-height: 28px;
font-weight: bold;
overflow: hidden;
text-overflow: ellipsis;
}
ul.project-list-filters { ul.project-list-filters {
margin: @margin-sm @folders-menu-margin; margin: @margin-sm @folders-menu-margin;
@ -248,6 +265,11 @@
} }
} }
.project-tools {
flex-shrink: 0;
margin-left: auto;
}
.project-dash-table { .project-dash-table {
width: 100%; width: 100%;
table-layout: fixed; table-layout: fixed;
@ -533,6 +555,7 @@
@media (max-width: @screen-xs-max) { @media (max-width: @screen-xs-max) {
.project-tools { .project-tools {
float: left; float: left;
margin-left: initial;
} }
} }

View file

@ -945,10 +945,10 @@
@sidebar-color: @ol-blue-gray-2; @sidebar-color: @ol-blue-gray-2;
@sidebar-link-color: #fff; @sidebar-link-color: #fff;
@sidebar-active-border-radius: 0; @sidebar-active-border-radius: 0;
@sidebar-active-bg: @ol-blue-gray-6; @sidebar-active-bg: @ol-blue-gray-4;
@sidebar-active-color: #fff; @sidebar-active-color: #fff;
@sidebar-active-font-weight: 700; @sidebar-active-font-weight: 700;
@sidebar-hover-bg: @ol-blue-gray-4; @sidebar-hover-bg: @ol-blue-gray-6;
@sidebar-hover-text-decoration: none; @sidebar-hover-text-decoration: none;
@v2-dash-pane-bg: @ol-blue-gray-4; @v2-dash-pane-bg: @ol-blue-gray-4;
@v2-dash-pane-link-color: #fff; @v2-dash-pane-link-color: #fff;

View file

@ -1288,6 +1288,11 @@
"search_bib_files": "Search by author, title, year", "search_bib_files": "Search by author, title, year",
"search_command_find": "Find", "search_command_find": "Find",
"search_command_replace": "Replace", "search_command_replace": "Replace",
"search_in_all_projects": "Search in all projects",
"search_in_archived_projects": "Search in archived projects",
"search_in_shared_projects": "Search in projects shared with you",
"search_in_trashed_projects": "Search in trashed projects",
"search_in_your_projects": "Search in your projects",
"search_match_case": "Match case", "search_match_case": "Match case",
"search_next": "next", "search_next": "next",
"search_previous": "previous", "search_previous": "previous",

View file

@ -1037,7 +1037,7 @@ describe('<ProjectListRoot />', function () {
describe('search', function () { describe('search', function () {
it('shows only projects based on the input', async function () { it('shows only projects based on the input', async function () {
const input = screen.getAllByRole('textbox', { const input = screen.getAllByRole('textbox', {
name: /search projects/i, name: /search in all projects/i,
})[0] })[0]
const value = currentList[0].name const value = currentList[0].name

View file

@ -0,0 +1,64 @@
import { render, screen } from '@testing-library/react'
import { Filter } from '../../../../../frontend/js/features/project-list/context/project-list-context'
import { Tag } from '../../../../../app/src/Features/Tags/types'
import ProjectListTitle from '../../../../../frontend/js/features/project-list/components/title/project-list-title'
describe('<ProjectListTitle />', function () {
type TestCase = {
filter: Filter
selectedTag: Tag | undefined
expectedText: string
}
const testCases: Array<TestCase> = [
// Filter, without tag
{
filter: 'all',
selectedTag: undefined,
expectedText: 'all projects',
},
{
filter: 'owned',
selectedTag: undefined,
expectedText: 'your projects',
},
{
filter: 'shared',
selectedTag: undefined,
expectedText: 'shared with you',
},
{
filter: 'archived',
selectedTag: undefined,
expectedText: 'archived projects',
},
{
filter: 'trashed',
selectedTag: undefined,
expectedText: 'trashed projects',
},
// Tags
{
filter: 'all',
selectedTag: { _id: '', user_id: '', name: 'sometag' },
expectedText: 'sometag',
},
{
filter: 'shared',
selectedTag: { _id: '', user_id: '', name: 'othertag' },
expectedText: 'othertag',
},
]
for (const testCase of testCases) {
it(`renders the title text for filter: ${testCase.filter}, tag: ${testCase?.selectedTag?.name}`, function () {
render(
<ProjectListTitle
filter={testCase.filter}
selectedTag={testCase.selectedTag}
/>
)
screen.getByText(new RegExp(testCase.expectedText, 'i'))
})
}
})

View file

@ -4,6 +4,8 @@ import { expect } from 'chai'
import SearchForm from '../../../../../frontend/js/features/project-list/components/search-form' import SearchForm from '../../../../../frontend/js/features/project-list/components/search-form'
import * as eventTracking from '../../../../../frontend/js/infrastructure/event-tracking' import * as eventTracking from '../../../../../frontend/js/infrastructure/event-tracking'
import fetchMock from 'fetch-mock' import fetchMock from 'fetch-mock'
import { Filter } from '../../../../../frontend/js/features/project-list/context/project-list-context'
import { Tag } from '../../../../../app/src/Features/Tags/types'
describe('Project list search form', function () { describe('Project list search form', function () {
let sendMBSpy: sinon.SinonSpy let sendMBSpy: sinon.SinonSpy
@ -19,17 +21,35 @@ describe('Project list search form', function () {
}) })
it('renders the search form', function () { it('renders the search form', function () {
render(<SearchForm inputValue="" setInputValue={() => {}} />) const filter: Filter = 'all'
const selectedTag = undefined
render(
<SearchForm
inputValue=""
setInputValue={() => {}}
filter={filter}
selectedTag={selectedTag}
/>
)
screen.getByRole('search') screen.getByRole('search')
screen.getByRole('textbox', { name: /search projects/i }) screen.getByRole('textbox', { name: /search in all projects/i })
}) })
it('calls clear text when clear button is clicked', function () { it('calls clear text when clear button is clicked', function () {
const filter: Filter = 'all'
const selectedTag = undefined
const setInputValueMock = sinon.stub() const setInputValueMock = sinon.stub()
render(<SearchForm inputValue="abc" setInputValue={setInputValueMock} />) render(
<SearchForm
inputValue="abc"
setInputValue={setInputValueMock}
filter={filter}
selectedTag={selectedTag}
/>
)
const input = screen.getByRole<HTMLInputElement>('textbox', { const input = screen.getByRole<HTMLInputElement>('textbox', {
name: /search projects/i, name: /search in all projects/i,
}) })
expect(input.value).to.equal('abc') expect(input.value).to.equal('abc')
@ -43,8 +63,20 @@ describe('Project list search form', function () {
it('changes text', function () { it('changes text', function () {
const setInputValueMock = sinon.stub() const setInputValueMock = sinon.stub()
render(<SearchForm inputValue="" setInputValue={setInputValueMock} />) const filter: Filter = 'all'
const input = screen.getByRole('textbox', { name: /search projects/i }) const selectedTag = undefined
render(
<SearchForm
inputValue=""
setInputValue={setInputValueMock}
filter={filter}
selectedTag={selectedTag}
/>
)
const input = screen.getByRole('textbox', {
name: /search in all projects/i,
})
const value = 'abc' const value = 'abc'
fireEvent.change(input, { target: { value } }) fireEvent.change(input, { target: { value } })
@ -56,4 +88,67 @@ describe('Project list search form', function () {
}) })
expect(setInputValueMock).to.be.calledWith(value) expect(setInputValueMock).to.be.calledWith(value)
}) })
type TestCase = {
filter: Filter
selectedTag: Tag | undefined
expectedText: string
}
const placeholderTestCases: Array<TestCase> = [
// Filter, without tag
{
filter: 'all',
selectedTag: undefined,
expectedText: 'search in all projects',
},
{
filter: 'owned',
selectedTag: undefined,
expectedText: 'search in your projects',
},
{
filter: 'shared',
selectedTag: undefined,
expectedText: 'search in projects shared with you',
},
{
filter: 'archived',
selectedTag: undefined,
expectedText: 'search in archived projects',
},
{
filter: 'trashed',
selectedTag: undefined,
expectedText: 'search in trashed projects',
},
// Tags
{
filter: 'all',
selectedTag: { _id: '', user_id: '', name: 'sometag' },
expectedText: 'search sometag',
},
{
filter: 'shared',
selectedTag: { _id: '', user_id: '', name: 'othertag' },
expectedText: 'search othertag',
},
]
for (const testCase of placeholderTestCases) {
it(`renders placeholder text for filter:${testCase.filter}, tag:${testCase?.selectedTag?.name}`, function () {
render(
<SearchForm
inputValue=""
setInputValue={() => {}}
filter={testCase.filter}
selectedTag={testCase.selectedTag}
/>
)
screen.getByRole('search')
screen.getByRole('textbox', {
name: new RegExp(testCase.expectedText, 'i'),
})
})
}
}) })