@@ -25,7 +25,7 @@ import Search from './Search'; | |||
import Projects from './Projects'; | |||
import CreateProjectForm from './CreateProjectForm'; | |||
import ListFooter from '../../components/controls/ListFooter'; | |||
import { PAGE_SIZE, Type, Project } from './utils'; | |||
import { PAGE_SIZE, Project } from './utils'; | |||
import { getComponents, getProvisioned } from '../../api/components'; | |||
import { Organization } from '../../app/types'; | |||
import { translate } from '../../helpers/l10n'; | |||
@@ -41,12 +41,12 @@ interface State { | |||
createProjectForm: boolean; | |||
page: number; | |||
projects: Project[]; | |||
provisioned: boolean; | |||
qualifiers: string; | |||
query: string; | |||
ready: boolean; | |||
selection: string[]; | |||
total: number; | |||
type: Type; | |||
} | |||
export default class App extends React.PureComponent<Props, State> { | |||
@@ -58,11 +58,11 @@ export default class App extends React.PureComponent<Props, State> { | |||
createProjectForm: false, | |||
ready: false, | |||
projects: [], | |||
provisioned: false, | |||
total: 0, | |||
page: 1, | |||
query: '', | |||
qualifiers: 'TRK', | |||
type: Type.All, | |||
selection: [] | |||
}; | |||
this.requestProjects = debounce(this.requestProjects, 250); | |||
@@ -84,16 +84,8 @@ export default class App extends React.PureComponent<Props, State> { | |||
q: this.state.query ? this.state.query : undefined | |||
}); | |||
requestProjects = () => { | |||
switch (this.state.type) { | |||
case Type.All: | |||
this.requestAllProjects(); | |||
break; | |||
case Type.Provisioned: | |||
this.requestProvisioned(); | |||
break; | |||
} | |||
}; | |||
requestProjects = () => | |||
this.state.provisioned ? this.requestProvisioned() : this.requestAllProjects(); | |||
requestProvisioned = () => { | |||
const data = this.getFilters(); | |||
@@ -134,16 +126,23 @@ export default class App extends React.PureComponent<Props, State> { | |||
this.setState({ ready: false, page: 1, query, selection: [] }, this.requestProjects); | |||
}; | |||
onTypeChanged = (newType: Type) => { | |||
onProvisionedChanged = (provisioned: boolean) => { | |||
this.setState( | |||
{ ready: false, page: 1, query: '', type: newType, qualifiers: 'TRK', selection: [] }, | |||
{ ready: false, page: 1, query: '', provisioned, qualifiers: 'TRK', selection: [] }, | |||
this.requestProjects | |||
); | |||
}; | |||
onQualifierChanged = (newQualifier: string) => { | |||
this.setState( | |||
{ ready: false, page: 1, query: '', type: Type.All, qualifiers: newQualifier, selection: [] }, | |||
{ | |||
ready: false, | |||
page: 1, | |||
provisioned: false, | |||
query: '', | |||
qualifiers: newQualifier, | |||
selection: [] | |||
}, | |||
this.requestProjects | |||
); | |||
}; | |||
@@ -188,14 +187,21 @@ export default class App extends React.PureComponent<Props, State> { | |||
/> | |||
<Search | |||
{...this.props} | |||
{...this.state} | |||
onAllSelected={this.onAllSelected} | |||
onAllDeselected={this.onAllDeselected} | |||
onDeleteProjects={this.requestProjects} | |||
onProvisionedChanged={this.onProvisionedChanged} | |||
onQualifierChanged={this.onQualifierChanged} | |||
onSearch={this.onSearch} | |||
onTypeChanged={this.onTypeChanged} | |||
organization={this.props.organization} | |||
projects={this.state.projects} | |||
provisioned={this.state.provisioned} | |||
qualifiers={this.state.qualifiers} | |||
query={this.state.query} | |||
ready={this.state.ready} | |||
selection={this.state.selection} | |||
topLevelQualifiers={this.props.topLevelQualifiers} | |||
total={this.state.total} | |||
/> | |||
<Projects |
@@ -20,7 +20,6 @@ | |||
import * as React from 'react'; | |||
import Modal from 'react-modal'; | |||
import * as Select from 'react-select'; | |||
import { Type } from './utils'; | |||
import { | |||
getPermissionTemplates, | |||
PermissionTemplate, | |||
@@ -32,11 +31,11 @@ import { translate, translateWithParameters } from '../../helpers/l10n'; | |||
export interface Props { | |||
onClose: () => void; | |||
organization: string; | |||
provisioned: boolean; | |||
qualifier: string; | |||
query: string; | |||
selection: string[]; | |||
total: number; | |||
type: Type; | |||
} | |||
interface State { |
@@ -18,32 +18,34 @@ | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import * as React from 'react'; | |||
import * as Select from 'react-select'; | |||
import { sortBy } from 'lodash'; | |||
import BulkApplyTemplateModal from './BulkApplyTemplateModal'; | |||
import DeleteModal from './DeleteModal'; | |||
import { Type, QUALIFIERS_ORDER } from './utils'; | |||
import { QUALIFIERS_ORDER } from './utils'; | |||
import { Project } from './utils'; | |||
import { Organization } from '../../app/types'; | |||
import RadioToggle from '../../components/controls/RadioToggle'; | |||
import Checkbox from '../../components/controls/Checkbox'; | |||
import { translate } from '../../helpers/l10n'; | |||
import QualifierIcon from '../../components/shared/QualifierIcon'; | |||
import Tooltip from '../../components/controls/Tooltip'; | |||
export interface Props { | |||
onAllDeselected: () => void; | |||
onAllSelected: () => void; | |||
onDeleteProjects: () => void; | |||
onProvisionedChanged: (provisioned: boolean) => void; | |||
onQualifierChanged: (qualifier: string) => void; | |||
onSearch: (query: string) => void; | |||
onTypeChanged: (type: Type) => void; | |||
organization: Organization; | |||
projects: Project[]; | |||
provisioned: boolean; | |||
qualifiers: string; | |||
query: string; | |||
ready: boolean; | |||
selection: any[]; | |||
topLevelQualifiers: string[]; | |||
total: number; | |||
type: Type; | |||
} | |||
interface State { | |||
@@ -66,15 +68,11 @@ export default class Search extends React.PureComponent<Props, State> { | |||
this.props.onSearch(q); | |||
}; | |||
getTypeOptions = () => [ | |||
{ value: Type.All, label: 'All' }, | |||
{ value: Type.Provisioned, label: 'Provisioned' } | |||
]; | |||
getQualifierOptions = () => { | |||
const options = this.props.topLevelQualifiers.map(q => { | |||
return { value: q, label: translate('qualifiers', q) }; | |||
}); | |||
const options = this.props.topLevelQualifiers.map(q => ({ | |||
label: translate('qualifiers', q), | |||
value: q | |||
})); | |||
return sortBy(options, option => QUALIFIERS_ORDER.indexOf(option.value)); | |||
}; | |||
@@ -111,6 +109,8 @@ export default class Search extends React.PureComponent<Props, State> { | |||
this.setState({ bulkApplyTemplateModal: false }); | |||
}; | |||
handleQualifierChange = ({ value }: { value: string }) => this.props.onQualifierChanged(value); | |||
renderCheckbox = () => { | |||
const isAllChecked = | |||
this.props.projects.length > 0 && this.props.selection.length === this.props.projects.length; | |||
@@ -119,9 +119,22 @@ export default class Search extends React.PureComponent<Props, State> { | |||
this.props.selection.length > 0 && | |||
this.props.selection.length < this.props.projects.length; | |||
const checked = isAllChecked || thirdState; | |||
return <Checkbox checked={checked} thirdState={thirdState} onCheck={this.onCheck} />; | |||
return ( | |||
<Checkbox | |||
checked={checked} | |||
id="projects-selection" | |||
thirdState={thirdState} | |||
onCheck={this.onCheck} | |||
/> | |||
); | |||
}; | |||
renderQualifierOption = (option: { label: string; value: string }) => | |||
<span> | |||
<QualifierIcon className="little-spacer-right" qualifier={option.value} /> | |||
{option.label} | |||
</span>; | |||
renderQualifierFilter = () => { | |||
const options = this.getQualifierOptions(); | |||
if (options.length < 2) { | |||
@@ -129,16 +142,40 @@ export default class Search extends React.PureComponent<Props, State> { | |||
} | |||
return ( | |||
<td className="thin nowrap text-middle"> | |||
<RadioToggle | |||
<Select | |||
className="input-medium" | |||
clearable={false} | |||
disabled={!this.props.ready} | |||
optionRenderer={this.renderQualifierOption} | |||
options={this.getQualifierOptions()} | |||
value={this.props.qualifiers} | |||
valueRenderer={this.renderQualifierOption} | |||
name="projects-qualifier" | |||
onCheck={this.props.onQualifierChanged} | |||
onChange={this.handleQualifierChange} | |||
searchable={false} | |||
/> | |||
</td> | |||
); | |||
}; | |||
renderTypeFilter = () => | |||
this.props.qualifiers === 'TRK' | |||
? <td className="thin nowrap text-middle"> | |||
<Checkbox | |||
className="link-checkbox-control" | |||
checked={this.props.provisioned} | |||
id="projects-provisioned" | |||
onCheck={this.props.onProvisionedChanged}> | |||
<span className="little-spacer-left"> | |||
{translate('provisioning.only_provisioned')} | |||
<Tooltip overlay={translate('provisioning.only_provisioned.tooltip')}> | |||
<i className="spacer-left icon-help" /> | |||
</Tooltip> | |||
</span> | |||
</Checkbox> | |||
</td> | |||
: null; | |||
render() { | |||
const isSomethingSelected = this.props.projects.length > 0 && this.props.selection.length > 0; | |||
return ( | |||
@@ -150,14 +187,7 @@ export default class Search extends React.PureComponent<Props, State> { | |||
{this.props.ready ? this.renderCheckbox() : <i className="spinner" />} | |||
</td> | |||
{this.renderQualifierFilter()} | |||
<td className="thin nowrap text-middle"> | |||
<RadioToggle | |||
options={this.getTypeOptions()} | |||
value={this.props.type} | |||
name="projects-type" | |||
onCheck={this.props.onTypeChanged} | |||
/> | |||
</td> | |||
{this.renderTypeFilter()} | |||
<td className="text-middle"> | |||
<form onSubmit={this.onSubmit} className="search-box"> | |||
<button className="search-box-submit button-clean"> | |||
@@ -194,11 +224,11 @@ export default class Search extends React.PureComponent<Props, State> { | |||
<BulkApplyTemplateModal | |||
onClose={this.closeBulkApplyTemplateModal} | |||
organization={this.props.organization.key} | |||
provisioned={this.props.provisioned} | |||
qualifier={this.props.qualifiers} | |||
query={this.props.query} | |||
selection={this.props.selection} | |||
total={this.props.total} | |||
type={this.props.type} | |||
/>} | |||
{this.state.deleteModal && |
@@ -23,6 +23,13 @@ jest.mock('lodash', () => { | |||
return lodash; | |||
}); | |||
// actual version breaks `mount` | |||
jest.mock('rc-tooltip', () => ({ | |||
default: function Tooltip() { | |||
return null; | |||
} | |||
})); | |||
jest.mock('../../../api/components', () => ({ | |||
getComponents: jest.fn(), | |||
getProvisioned: jest.fn(() => Promise.resolve({ paging: { total: 0 }, projects: [] })) | |||
@@ -31,7 +38,6 @@ jest.mock('../../../api/components', () => ({ | |||
import * as React from 'react'; | |||
import { mount } from 'enzyme'; | |||
import App, { Props } from '../App'; | |||
import { Type } from '../utils'; | |||
const getComponents = require('../../../api/components').getComponents as jest.Mock<any>; | |||
const getProvisioned = require('../../../api/components').getProvisioned as jest.Mock<any>; | |||
@@ -57,15 +63,15 @@ it('fetches all projects on mount', () => { | |||
expect(getComponents).lastCalledWith({ ...defaultSearchParameters, qualifiers: 'TRK' }); | |||
}); | |||
it('changes type', () => { | |||
it('selects provisioned', () => { | |||
const wrapper = mountRender(); | |||
wrapper.find('Search').prop<Function>('onTypeChanged')(Type.Provisioned); | |||
wrapper.find('Search').prop<Function>('onProvisionedChanged')(true); | |||
expect(getProvisioned).lastCalledWith(defaultSearchParameters); | |||
}); | |||
it('changes qualifier and resets type', () => { | |||
it('changes qualifier and resets provisioned', () => { | |||
const wrapper = mountRender(); | |||
wrapper.setState({ type: Type.Provisioned }); | |||
wrapper.setState({ provisioned: true }); | |||
wrapper.find('Search').prop<Function>('onQualifierChanged')('VW'); | |||
expect(getComponents).lastCalledWith({ ...defaultSearchParameters, qualifiers: 'VW' }); | |||
}); |
@@ -26,7 +26,6 @@ jest.mock('../../../api/permissions', () => ({ | |||
import * as React from 'react'; | |||
import { mount, shallow } from 'enzyme'; | |||
import BulkApplyTemplateModal, { Props } from '../BulkApplyTemplateModal'; | |||
import { Type } from '../utils'; | |||
import { click } from '../../../helpers/testUtils'; | |||
const applyTemplateToProject = require('../../../api/permissions') | |||
@@ -117,11 +116,11 @@ function render(props?: { [P in keyof Props]?: Props[P] }) { | |||
<BulkApplyTemplateModal | |||
onClose={jest.fn()} | |||
organization="org" | |||
provisioned={true} | |||
qualifier="TRK" | |||
query="bla" | |||
selection={[]} | |||
total={17} | |||
type={Type.All} | |||
{...props} | |||
/> | |||
); |
@@ -20,7 +20,6 @@ | |||
import * as React from 'react'; | |||
import { shallow } from 'enzyme'; | |||
import Search, { Props } from '../Search'; | |||
import { Type } from '../utils'; | |||
import { change, click } from '../../../helpers/testUtils'; | |||
const organization = { key: 'org', name: 'org', projectVisibility: 'public' }; | |||
@@ -36,15 +35,22 @@ it('render qualifiers filter', () => { | |||
it('updates qualifier', () => { | |||
const onQualifierChanged = jest.fn(); | |||
const wrapper = shallowRender({ onQualifierChanged, topLevelQualifiers: ['TRK', 'VW', 'APP'] }); | |||
wrapper.find('RadioToggle[name="projects-qualifier"]').prop<Function>('onCheck')('VW'); | |||
wrapper.find('Select[name="projects-qualifier"]').prop<Function>('onChange')({ value: 'VW' }); | |||
expect(onQualifierChanged).toBeCalledWith('VW'); | |||
}); | |||
it('updates type', () => { | |||
const onTypeChanged = jest.fn(); | |||
const wrapper = shallowRender({ onTypeChanged }); | |||
wrapper.find('RadioToggle[name="projects-type"]').prop<Function>('onCheck')(Type.Provisioned); | |||
expect(onTypeChanged).toBeCalledWith(Type.Provisioned); | |||
it('selects provisioned', () => { | |||
const onProvisionedChanged = jest.fn(); | |||
const wrapper = shallowRender({ onProvisionedChanged }); | |||
wrapper.find('Checkbox[id="projects-provisioned"]').prop<Function>('onCheck')(true); | |||
expect(onProvisionedChanged).toBeCalledWith(true); | |||
}); | |||
it('does not render provisioned filter for portfolios', () => { | |||
const wrapper = shallowRender(); | |||
expect(wrapper.find('Checkbox[id="projects-provisioned"]').exists()).toBeTruthy(); | |||
wrapper.setProps({ qualifiers: 'VW' }); | |||
expect(wrapper.find('Checkbox[id="projects-provisioned"]').exists()).toBeFalsy(); | |||
}); | |||
it('searches', () => { | |||
@@ -59,10 +65,10 @@ it('checks all or none projects', () => { | |||
const onAllSelected = jest.fn(); | |||
const wrapper = shallowRender({ onAllDeselected, onAllSelected }); | |||
wrapper.find('Checkbox').prop<Function>('onCheck')(true); | |||
wrapper.find('Checkbox[id="projects-selection"]').prop<Function>('onCheck')(true); | |||
expect(onAllSelected).toBeCalled(); | |||
wrapper.find('Checkbox').prop<Function>('onCheck')(false); | |||
wrapper.find('Checkbox[id="projects-selection"]').prop<Function>('onCheck')(false); | |||
expect(onAllDeselected).toBeCalled(); | |||
}); | |||
@@ -89,18 +95,18 @@ function shallowRender(props?: { [P in keyof Props]?: Props[P] }) { | |||
onAllDeselected={jest.fn()} | |||
onAllSelected={jest.fn()} | |||
onDeleteProjects={jest.fn()} | |||
onProvisionedChanged={jest.fn()} | |||
onQualifierChanged={jest.fn()} | |||
onSearch={jest.fn()} | |||
onTypeChanged={jest.fn()} | |||
organization={organization} | |||
projects={[]} | |||
provisioned={false} | |||
qualifiers="TRK" | |||
query="" | |||
ready={true} | |||
selection={[]} | |||
topLevelQualifiers={['TRK']} | |||
total={0} | |||
type={Type.All} | |||
{...props} | |||
/> | |||
); |
@@ -4,11 +4,11 @@ exports[`bulk applies permission template 1`] = ` | |||
<BulkApplyTemplateModal | |||
onClose={[Function]} | |||
organization="org" | |||
provisioned={false} | |||
qualifier="TRK" | |||
query="" | |||
selection={Array []} | |||
total={0} | |||
type="ALL" | |||
/> | |||
`; | |||
@@ -41,6 +41,7 @@ exports[`render qualifiers filter 1`] = ` | |||
> | |||
<Checkbox | |||
checked={false} | |||
id="projects-selection" | |||
onCheck={[Function]} | |||
thirdState={false} | |||
/> | |||
@@ -48,10 +49,40 @@ exports[`render qualifiers filter 1`] = ` | |||
<td | |||
className="thin nowrap text-middle" | |||
> | |||
<RadioToggle | |||
<Select | |||
addLabelText="Add \\"{label}\\"?" | |||
arrowRenderer={[Function]} | |||
autosize={true} | |||
backspaceRemoves={true} | |||
backspaceToRemoveMessage="Press backspace to remove {label}" | |||
className="input-medium" | |||
clearAllText="Clear all" | |||
clearRenderer={[Function]} | |||
clearValueText="Clear value" | |||
clearable={false} | |||
deleteRemoves={true} | |||
delimiter="," | |||
disabled={false} | |||
escapeClearsValue={true} | |||
filterOptions={[Function]} | |||
ignoreAccents={true} | |||
ignoreCase={true} | |||
inputProps={Object {}} | |||
isLoading={false} | |||
joinValues={false} | |||
labelKey="label" | |||
matchPos="any" | |||
matchProp="any" | |||
menuBuffer={0} | |||
menuRenderer={[Function]} | |||
multi={false} | |||
name="projects-qualifier" | |||
onCheck={[Function]} | |||
noResultsText="No results found" | |||
onBlurResetsInput={true} | |||
onChange={[Function]} | |||
onCloseResetsInput={true} | |||
optionComponent={[Function]} | |||
optionRenderer={[Function]} | |||
options={ | |||
Array [ | |||
Object { | |||
@@ -68,30 +99,43 @@ exports[`render qualifiers filter 1`] = ` | |||
}, | |||
] | |||
} | |||
pageSize={5} | |||
placeholder="Select..." | |||
required={false} | |||
scrollMenuIntoView={true} | |||
searchable={false} | |||
simpleValue={false} | |||
tabSelectsValue={true} | |||
value="TRK" | |||
valueComponent={[Function]} | |||
valueKey="value" | |||
valueRenderer={[Function]} | |||
/> | |||
</td> | |||
<td | |||
className="thin nowrap text-middle" | |||
> | |||
<RadioToggle | |||
disabled={false} | |||
name="projects-type" | |||
<Checkbox | |||
checked={false} | |||
className="link-checkbox-control" | |||
id="projects-provisioned" | |||
onCheck={[Function]} | |||
options={ | |||
Array [ | |||
Object { | |||
"label": "All", | |||
"value": "ALL", | |||
}, | |||
Object { | |||
"label": "Provisioned", | |||
"value": "PROVISIONED", | |||
}, | |||
] | |||
} | |||
value="ALL" | |||
/> | |||
thirdState={false} | |||
> | |||
<span | |||
className="little-spacer-left" | |||
> | |||
provisioning.only_provisioned | |||
<Tooltip | |||
overlay="provisioning.only_provisioned.tooltip" | |||
placement="bottom" | |||
> | |||
<i | |||
className="spacer-left icon-help" | |||
/> | |||
</Tooltip> | |||
</span> | |||
</Checkbox> | |||
</td> | |||
<td | |||
className="text-middle" | |||
@@ -153,6 +197,7 @@ exports[`renders 1`] = ` | |||
> | |||
<Checkbox | |||
checked={false} | |||
id="projects-selection" | |||
onCheck={[Function]} | |||
thirdState={false} | |||
/> | |||
@@ -160,24 +205,27 @@ exports[`renders 1`] = ` | |||
<td | |||
className="thin nowrap text-middle" | |||
> | |||
<RadioToggle | |||
disabled={false} | |||
name="projects-type" | |||
<Checkbox | |||
checked={false} | |||
className="link-checkbox-control" | |||
id="projects-provisioned" | |||
onCheck={[Function]} | |||
options={ | |||
Array [ | |||
Object { | |||
"label": "All", | |||
"value": "ALL", | |||
}, | |||
Object { | |||
"label": "Provisioned", | |||
"value": "PROVISIONED", | |||
}, | |||
] | |||
} | |||
value="ALL" | |||
/> | |||
thirdState={false} | |||
> | |||
<span | |||
className="little-spacer-left" | |||
> | |||
provisioning.only_provisioned | |||
<Tooltip | |||
overlay="provisioning.only_provisioned.tooltip" | |||
placement="bottom" | |||
> | |||
<i | |||
className="spacer-left icon-help" | |||
/> | |||
</Tooltip> | |||
</span> | |||
</Checkbox> | |||
</td> | |||
<td | |||
className="text-middle" |
@@ -19,12 +19,7 @@ | |||
*/ | |||
export const PAGE_SIZE = 50; | |||
export const QUALIFIERS_ORDER = ['TRK', 'VW', 'APP', 'DEV']; | |||
export enum Type { | |||
All = 'ALL', | |||
Provisioned = 'PROVISIONED' | |||
} | |||
export const QUALIFIERS_ORDER = ['TRK', 'VW', 'APP']; | |||
export interface Project { | |||
key: string; |
@@ -103,7 +103,7 @@ | |||
.Select-value svg, | |||
.Select-value [class^="icon-"] { | |||
padding-top: 4px; | |||
padding-top: 5px; | |||
} | |||
.Select-value img { |
@@ -83,6 +83,12 @@ a { | |||
} | |||
} | |||
.link-checkbox-control { | |||
display: inline-block; | |||
padding: 4px 0 5px; | |||
line-height: 16px; | |||
} | |||
a.active-link, | |||
.link-active { | |||
.link-no-underline; |
@@ -1997,6 +1997,8 @@ provisioning.missing.name=Name is missing | |||
provisioning.no_analysis=No analysis has been performed since creation. The only available section is the configuration. | |||
provisioning.no_analysis.delete=Either you should retry to analyze the project, or simply {0}. | |||
provisioning.no_analysis.delete_it=delete it | |||
provisioning.only_provisioned=Only Provisioned | |||
provisioning.only_provisioned.tooltip=Provisioned projects are projects that have been created, but have not been analyzed yet. | |||
#------------------------------------------------------------------------------ |