mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-29 06:23:38 -05:00
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:
parent
d1bbbc1bf1
commit
ff4ac0a803
10 changed files with 306 additions and 26 deletions
|
@ -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": "",
|
||||||
|
|
|
@ -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' }}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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 || {}
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
|
@ -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'),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
Loading…
Reference in a new issue