@@ -75,7 +75,6 @@ export function InputSearch(props: PropsWithChildren<Props>) { | |||
value: parentValue, | |||
searchInputAriaLabel, | |||
} = props; | |||
const intl = useIntl(); | |||
const input = useRef<null | HTMLElement>(null); | |||
const [value, setValue] = useState(parentValue ?? ''); | |||
@@ -84,6 +83,7 @@ export function InputSearch(props: PropsWithChildren<Props>) { | |||
() => | |||
debounce((val: string) => { | |||
onChange(val); | |||
setDirty(false); | |||
}, DEBOUNCE_DELAY), | |||
[onChange], | |||
); |
@@ -85,6 +85,7 @@ const TEMP_PAGELIST_WITH_NEW_BACKGROUND_WHITE = [ | |||
'/admin/settings/encryption', | |||
'/admin/extension/license/support', | |||
'/admin/audit', | |||
'/admin/projects_management', | |||
]; | |||
export default function GlobalContainer() { |
@@ -23,6 +23,7 @@ | |||
import 'core-js/stable'; | |||
/* */ | |||
import axios from 'axios'; | |||
import 'react-day-picker/dist/style.css'; | |||
import { getAvailableFeatures } from '../api/features'; | |||
import { getGlobalNavigation } from '../api/navigation'; | |||
import { getCurrentUser } from '../api/users'; |
@@ -27,7 +27,6 @@ import { | |||
} from 'design-system'; | |||
import * as React from 'react'; | |||
import { applyTemplateToProject, getPermissionTemplates } from '../../../../api/permissions'; | |||
import MandatoryFieldsExplanation from '../../../../components/ui/MandatoryFieldsExplanation'; | |||
import { translate, translateWithParameters } from '../../../../helpers/l10n'; | |||
import { PermissionTemplate } from '../../../../types/types'; | |||
@@ -136,25 +135,22 @@ export default class ApplyTemplate extends React.PureComponent<Props, State> { | |||
)} | |||
{!this.state.done && !this.state.loading && ( | |||
<> | |||
<MandatoryFieldsExplanation className="sw-mb-4" /> | |||
<FormField | |||
label={translate('template')} | |||
required | |||
htmlFor="project-permissions-template-input" | |||
> | |||
{this.state.permissionTemplates && ( | |||
<InputSelect | |||
size="full" | |||
id="project-permissions-template" | |||
inputId="project-permissions-template-input" | |||
onChange={this.handlePermissionTemplateChange} | |||
options={options} | |||
value={options.filter((o) => o.value === this.state.permissionTemplate)} | |||
/> | |||
)} | |||
</FormField> | |||
</> | |||
<FormField | |||
label={translate('template')} | |||
required | |||
htmlFor="project-permissions-template-input" | |||
> | |||
{this.state.permissionTemplates && ( | |||
<InputSelect | |||
size="full" | |||
id="project-permissions-template" | |||
inputId="project-permissions-template-input" | |||
onChange={this.handlePermissionTemplateChange} | |||
options={options} | |||
value={options.filter((o) => o.value === this.state.permissionTemplate)} | |||
/> | |||
)} | |||
</FormField> | |||
)} | |||
</div> | |||
</form> |
@@ -17,14 +17,18 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { | |||
ButtonPrimary, | |||
FlagMessage, | |||
FormField, | |||
InputSelect, | |||
LabelValueSelectOption, | |||
Modal, | |||
Spinner, | |||
} from 'design-system'; | |||
import * as React from 'react'; | |||
import { bulkApplyTemplate, getPermissionTemplates } from '../../api/permissions'; | |||
import { Project } from '../../api/project-management'; | |||
import Modal from '../../components/controls/Modal'; | |||
import Select from '../../components/controls/Select'; | |||
import { ResetButtonLink, SubmitButton } from '../../components/controls/buttons'; | |||
import { Alert } from '../../components/ui/Alert'; | |||
import MandatoryFieldMarker from '../../components/ui/MandatoryFieldMarker'; | |||
import MandatoryFieldsExplanation from '../../components/ui/MandatoryFieldsExplanation'; | |||
import { toISO8601WithOffsetString } from '../../helpers/dates'; | |||
import { addGlobalErrorMessageFromAPI } from '../../helpers/globalMessages'; | |||
@@ -49,6 +53,8 @@ interface State { | |||
submitting: boolean; | |||
} | |||
const FORM_ID = 'bulk-apply-template-form'; | |||
export default class BulkApplyTemplateModal extends React.PureComponent<Props, State> { | |||
mounted = false; | |||
state: State = { done: false, loading: true, submitting: false }; | |||
@@ -83,7 +89,8 @@ export default class BulkApplyTemplateModal extends React.PureComponent<Props, S | |||
); | |||
} | |||
handleConfirmClick = () => { | |||
handleConfirmClick = (event: React.FormEvent<HTMLFormElement>) => { | |||
event.preventDefault(); | |||
const { analyzedBefore } = this.props; | |||
const { permissionTemplate } = this.state; | |||
if (permissionTemplate) { | |||
@@ -118,7 +125,7 @@ export default class BulkApplyTemplateModal extends React.PureComponent<Props, S | |||
} | |||
}; | |||
handlePermissionTemplateChange = ({ value }: { value: string }) => { | |||
handlePermissionTemplateChange = ({ value }: LabelValueSelectOption<string>) => { | |||
this.setState({ permissionTemplate: value }); | |||
}; | |||
@@ -132,15 +139,15 @@ export default class BulkApplyTemplateModal extends React.PureComponent<Props, S | |||
if (isSelectionOnlyManaged) { | |||
return ( | |||
<Alert variant="error"> | |||
<FlagMessage variant="error" className="sw-my-2"> | |||
{translate( | |||
'permission_templates.bulk_apply_permission_template.apply_to_only_github_projects', | |||
)} | |||
</Alert> | |||
</FlagMessage> | |||
); | |||
} else if (isSelectionOnlyLocal) { | |||
return ( | |||
<Alert variant="warning"> | |||
<FlagMessage variant="warning" className="sw-my-2"> | |||
{this.props.selection.length | |||
? translateWithParameters( | |||
'permission_templates.bulk_apply_permission_template.apply_to_selected', | |||
@@ -150,11 +157,11 @@ export default class BulkApplyTemplateModal extends React.PureComponent<Props, S | |||
'permission_templates.bulk_apply_permission_template.apply_to_all', | |||
this.props.total, | |||
)} | |||
</Alert> | |||
</FlagMessage> | |||
); | |||
} | |||
return ( | |||
<Alert variant="warning"> | |||
<FlagMessage variant="warning" className="sw-my-2"> | |||
{translateWithParameters( | |||
'permission_templates.bulk_apply_permission_template.apply_to_selected', | |||
localProjects.length, | |||
@@ -164,7 +171,7 @@ export default class BulkApplyTemplateModal extends React.PureComponent<Props, S | |||
'permission_templates.bulk_apply_permission_template.apply_to_github_projects', | |||
managedProjects.length, | |||
)} | |||
</Alert> | |||
</FlagMessage> | |||
); | |||
}; | |||
@@ -174,20 +181,17 @@ export default class BulkApplyTemplateModal extends React.PureComponent<Props, S | |||
? this.state.permissionTemplates.map((t) => ({ label: t.name, value: t.id })) | |||
: []; | |||
return ( | |||
<div className="modal-field"> | |||
<label htmlFor="bulk-apply-template-input"> | |||
{translate('template')} | |||
<MandatoryFieldMarker /> | |||
</label> | |||
<Select | |||
<FormField htmlFor="bulk-apply-template-input" label={translate('template')} required> | |||
<InputSelect | |||
id="bulk-apply-template" | |||
inputId="bulk-apply-template-input" | |||
isDisabled={this.state.submitting || isSelectionOnlyManaged} | |||
onChange={this.handlePermissionTemplateChange} | |||
options={options} | |||
value={options.find((option) => option.value === this.state.permissionTemplate)} | |||
size="auto" | |||
/> | |||
</div> | |||
</FormField> | |||
); | |||
}; | |||
@@ -196,44 +200,49 @@ export default class BulkApplyTemplateModal extends React.PureComponent<Props, S | |||
const header = translate('permission_templates.bulk_apply_permission_template'); | |||
const isSelectionOnlyManaged = this.props.selection.every((s) => s.managed === true); | |||
const body = ( | |||
<form id={FORM_ID} onSubmit={this.handleConfirmClick}> | |||
{done && ( | |||
<FlagMessage variant="success"> | |||
{translate('projects_role.apply_template.success')} | |||
</FlagMessage> | |||
)} | |||
return ( | |||
<Modal contentLabel={header} onRequestClose={this.props.onClose} size="small"> | |||
<header className="modal-head"> | |||
<h2>{header}</h2> | |||
</header> | |||
<div className="modal-body"> | |||
{done && ( | |||
<Alert variant="success">{translate('projects_role.apply_template.success')}</Alert> | |||
)} | |||
{loading && <i className="spinner" />} | |||
{!loading && !done && permissionTemplates && ( | |||
<> | |||
<MandatoryFieldsExplanation className="spacer-bottom" /> | |||
{this.renderWarning()} | |||
{this.renderSelect(isSelectionOnlyManaged)} | |||
</> | |||
)} | |||
</div> | |||
<Spinner loading={loading} /> | |||
<footer className="modal-foot"> | |||
{submitting && <i className="spinner spacer-right" />} | |||
{!loading && !done && permissionTemplates && ( | |||
<SubmitButton | |||
{!loading && !done && permissionTemplates && ( | |||
<> | |||
<MandatoryFieldsExplanation className="sw-mb-2" /> | |||
{this.renderWarning()} | |||
{this.renderSelect(isSelectionOnlyManaged)} | |||
</> | |||
)} | |||
</form> | |||
); | |||
return ( | |||
<Modal | |||
isScrollable={false} | |||
isOverflowVisible | |||
headerTitle={header} | |||
onClose={this.props.onClose} | |||
loading={submitting} | |||
body={body} | |||
primaryButton={ | |||
!loading && | |||
!done && | |||
permissionTemplates && ( | |||
<ButtonPrimary | |||
autoFocus | |||
disabled={submitting || isSelectionOnlyManaged} | |||
onClick={this.handleConfirmClick} | |||
form={FORM_ID} | |||
type="submit" | |||
> | |||
{translate('apply')} | |||
</SubmitButton> | |||
)} | |||
<ResetButtonLink onClick={this.props.onClose}> | |||
{done ? translate('close') : translate('cancel')} | |||
</ResetButtonLink> | |||
</footer> | |||
</Modal> | |||
</ButtonPrimary> | |||
) | |||
} | |||
secondaryButtonLabel={done ? translate('close') : translate('cancel')} | |||
/> | |||
); | |||
} | |||
} |
@@ -17,11 +17,8 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { ButtonPrimary, FlagMessage, Modal, RadioButton, TextSubdued } from 'design-system'; | |||
import React, { useState } from 'react'; | |||
import Modal from '../../components/controls/Modal'; | |||
import Radio from '../../components/controls/Radio'; | |||
import { Button, ResetButtonLink } from '../../components/controls/buttons'; | |||
import { Alert } from '../../components/ui/Alert'; | |||
import { translate } from '../../helpers/l10n'; | |||
import { useGithubProvisioningEnabledQuery } from '../../queries/identity-provider/github'; | |||
import { Visibility } from '../../types/component'; | |||
@@ -32,11 +29,14 @@ export interface Props { | |||
onConfirm: (visiblity: Visibility) => void; | |||
} | |||
const FORM_ID = 'change-default-visibility-form'; | |||
export default function ChangeDefaultVisibilityForm(props: Props) { | |||
const [visibility, setVisibility] = useState(props.defaultVisibility); | |||
const { data: githubProbivisioningEnabled } = useGithubProvisioningEnabledQuery(); | |||
const handleConfirmClick = () => { | |||
const handleConfirmClick = (event: React.FormEvent<HTMLFormElement>) => { | |||
event.preventDefault(); | |||
props.onConfirm(visibility); | |||
props.onClose(); | |||
}; | |||
@@ -47,47 +47,47 @@ export default function ChangeDefaultVisibilityForm(props: Props) { | |||
const header = translate('settings.projects.change_visibility_form.header'); | |||
return ( | |||
<Modal contentLabel={header} onRequestClose={props.onClose}> | |||
<header className="modal-head"> | |||
<h2>{header}</h2> | |||
</header> | |||
<div className="modal-body"> | |||
{Object.values(Visibility).map((visibilityValue) => ( | |||
<div className="big-spacer-bottom" key={visibilityValue}> | |||
<Radio | |||
value={visibilityValue} | |||
checked={visibility === visibilityValue} | |||
onCheck={handleVisibilityChange} | |||
> | |||
<div> | |||
{translate('visibility', visibilityValue)} | |||
<p className="text-muted spacer-top"> | |||
{translate('visibility', visibilityValue, 'description.short')} | |||
</p> | |||
</div> | |||
</Radio> | |||
</div> | |||
))} | |||
<Alert variant="warning"> | |||
{translate( | |||
`settings.projects.change_visibility_form.warning${ | |||
githubProbivisioningEnabled ? '.github' : '' | |||
}`, | |||
)} | |||
</Alert> | |||
</div> | |||
const body = ( | |||
<form id={FORM_ID} onSubmit={handleConfirmClick}> | |||
{Object.values(Visibility).map((visibilityValue) => ( | |||
<div className="sw-mb-4" key={visibilityValue}> | |||
<RadioButton | |||
value={visibilityValue} | |||
checked={visibility === visibilityValue} | |||
onCheck={handleVisibilityChange} | |||
> | |||
<div> | |||
{translate('visibility', visibilityValue)} | |||
<TextSubdued as="p" className="sw-mt-2"> | |||
{translate('visibility', visibilityValue, 'description.short')} | |||
</TextSubdued> | |||
</div> | |||
</RadioButton> | |||
</div> | |||
))} | |||
<FlagMessage variant="warning"> | |||
{translate( | |||
`settings.projects.change_visibility_form.warning${ | |||
githubProbivisioningEnabled ? '.github' : '' | |||
}`, | |||
)} | |||
</FlagMessage> | |||
</form> | |||
); | |||
<footer className="modal-foot"> | |||
<Button className="js-confirm" type="submit" onClick={handleConfirmClick}> | |||
return ( | |||
<Modal | |||
isScrollable={false} | |||
isOverflowVisible | |||
headerTitle={header} | |||
onClose={props.onClose} | |||
body={body} | |||
primaryButton={ | |||
<ButtonPrimary form={FORM_ID} autoFocus type="submit"> | |||
{translate('settings.projects.change_visibility_form.submit')} | |||
</Button> | |||
<ResetButtonLink className="js-modal-close" onClick={props.onClose}> | |||
{translate('cancel')} | |||
</ResetButtonLink> | |||
</footer> | |||
</Modal> | |||
</ButtonPrimary> | |||
} | |||
secondaryButtonLabel={translate('cancel')} | |||
/> | |||
); | |||
} |
@@ -17,11 +17,9 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { DangerButtonPrimary, FlagMessage, Modal } from 'design-system'; | |||
import * as React from 'react'; | |||
import { Project, bulkDeleteProjects } from '../../api/project-management'; | |||
import Modal from '../../components/controls/Modal'; | |||
import { ResetButtonLink, SubmitButton } from '../../components/controls/buttons'; | |||
import { Alert } from '../../components/ui/Alert'; | |||
import { toISO8601WithOffsetString } from '../../helpers/dates'; | |||
import { translate, translateWithParameters } from '../../helpers/l10n'; | |||
@@ -80,42 +78,43 @@ export default class DeleteModal extends React.PureComponent<Props, State> { | |||
}; | |||
renderWarning = () => ( | |||
<Alert variant="warning"> | |||
<FlagMessage variant="warning"> | |||
{this.props.selection.length | |||
? translateWithParameters( | |||
'projects_management.delete_selected_warning', | |||
this.props.selection.length, | |||
) | |||
: translateWithParameters('projects_management.delete_all_warning', this.props.total)} | |||
</Alert> | |||
</FlagMessage> | |||
); | |||
render() { | |||
const header = translate('qualifiers.delete', this.props.qualifier); | |||
return ( | |||
<Modal contentLabel={header} onRequestClose={this.props.onClose}> | |||
<header className="modal-head"> | |||
<h2>{header}</h2> | |||
</header> | |||
<div className="modal-body"> | |||
{this.renderWarning()} | |||
{translate('qualifiers.delete_confirm', this.props.qualifier)} | |||
</div> | |||
<footer className="modal-foot"> | |||
{this.state.loading && <i className="spinner spacer-right" />} | |||
<SubmitButton | |||
className="button-red" | |||
<Modal | |||
headerTitle={header} | |||
onClose={this.props.onClose} | |||
body={ | |||
<> | |||
{this.renderWarning()} | |||
<p className="sw-mt-2"> | |||
{translate('qualifiers.delete_confirm', this.props.qualifier)} | |||
</p> | |||
</> | |||
} | |||
primaryButton={ | |||
<DangerButtonPrimary | |||
autoFocus | |||
disabled={this.state.loading} | |||
onClick={this.handleConfirmClick} | |||
type="submit" | |||
> | |||
{translate('delete')} | |||
</SubmitButton> | |||
<ResetButtonLink onClick={this.props.onClose}>{translate('cancel')}</ResetButtonLink> | |||
</footer> | |||
</Modal> | |||
</DangerButtonPrimary> | |||
} | |||
secondaryButtonLabel={translate('cancel')} | |||
/> | |||
); | |||
} | |||
} |
@@ -17,10 +17,10 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { ButtonPrimary, InteractiveIcon, PencilIcon, Title } from 'design-system'; | |||
import * as React from 'react'; | |||
import { useState } from 'react'; | |||
import { useLocation, useNavigate } from 'react-router-dom'; | |||
import { Button, EditButton } from '../../components/controls/buttons'; | |||
import { translate } from '../../helpers/l10n'; | |||
import { Visibility } from '../../types/component'; | |||
import ChangeDefaultVisibilityForm from './ChangeDefaultVisibilityForm'; | |||
@@ -39,39 +39,41 @@ export default function Header(props: Readonly<Props>) { | |||
const { defaultProjectVisibility, hasProvisionPermission } = props; | |||
return ( | |||
<header className="page-header"> | |||
<h1 className="page-title">{translate('projects_management')}</h1> | |||
<header className="sw-mb-5"> | |||
<div className="sw-flex sw-items-center sw-justify-between"> | |||
<Title className="sw-m-0">{translate('projects_management')}</Title> | |||
<div className="sw-flex sw-items-center it__page-actions"> | |||
<div className="sw-mr-2"> | |||
<span className="sw-mr-1"> | |||
{translate('settings.projects.default_visibility_of_new_projects')}{' '} | |||
<strong className="sw-body-sm-highlight"> | |||
{defaultProjectVisibility ? translate('visibility', defaultProjectVisibility) : '—'} | |||
</strong> | |||
</span> | |||
<InteractiveIcon | |||
className="it__change-visibility" | |||
Icon={PencilIcon} | |||
onClick={() => setVisibilityForm(true)} | |||
aria-label={translate('settings.projects.change_visibility_form.label')} | |||
/> | |||
</div> | |||
<div className="page-actions"> | |||
<span className="big-spacer-right"> | |||
<span className="text-middle"> | |||
{translate('settings.projects.default_visibility_of_new_projects')}{' '} | |||
<strong> | |||
{defaultProjectVisibility ? translate('visibility', defaultProjectVisibility) : '—'} | |||
</strong> | |||
</span> | |||
<EditButton | |||
className="js-change-visibility spacer-left button-small" | |||
onClick={() => setVisibilityForm(true)} | |||
aria-label={translate('settings.projects.change_visibility_form.label')} | |||
/> | |||
</span> | |||
{hasProvisionPermission && ( | |||
<Button | |||
id="create-project" | |||
onClick={() => | |||
navigate('/projects/create?mode=manual', { | |||
state: { from: location.pathname }, | |||
}) | |||
} | |||
> | |||
{translate('qualifiers.create.TRK')} | |||
</Button> | |||
)} | |||
{hasProvisionPermission && ( | |||
<ButtonPrimary | |||
id="create-project" | |||
onClick={() => | |||
navigate('/projects/create?mode=manual', { | |||
state: { from: location.pathname }, | |||
}) | |||
} | |||
> | |||
{translate('qualifiers.create.TRK')} | |||
</ButtonPrimary> | |||
)} | |||
</div> | |||
</div> | |||
<p className="page-description">{translate('projects_management.page.description')}</p> | |||
<p className="sw-mt-4">{translate('projects_management.page.description')}</p> | |||
{visibilityForm && ( | |||
<ChangeDefaultVisibilityForm |
@@ -17,6 +17,7 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { LargeCenteredLayout, PageContentFontWrapper } from 'design-system'; | |||
import { debounce, uniq } from 'lodash'; | |||
import * as React from 'react'; | |||
import { Helmet } from 'react-helmet-async'; | |||
@@ -202,52 +203,55 @@ class ProjectManagementApp extends React.PureComponent<Props, State> { | |||
const { currentUser } = this.props; | |||
const { defaultProjectVisibility } = this.state; | |||
return ( | |||
<main className="page page-limited" id="projects-management-page"> | |||
<Suggestions suggestions="projects_management" /> | |||
<Helmet defer={false} title={translate('projects_management')} /> | |||
<Header | |||
defaultProjectVisibility={defaultProjectVisibility} | |||
hasProvisionPermission={hasGlobalPermission(currentUser, Permissions.ProjectCreation)} | |||
onChangeDefaultProjectVisibility={this.handleDefaultProjectVisibilityChange} | |||
/> | |||
<Search | |||
analyzedBefore={this.state.analyzedBefore} | |||
onAllDeselected={this.onAllDeselected} | |||
onAllSelected={this.onAllSelected} | |||
onDateChanged={this.handleDateChanged} | |||
onDeleteProjects={this.requestProjects} | |||
onProvisionedChanged={this.onProvisionedChanged} | |||
onQualifierChanged={this.onQualifierChanged} | |||
onSearch={this.onSearch} | |||
onVisibilityChanged={this.onVisibilityChanged} | |||
projects={this.state.projects} | |||
provisioned={this.state.provisioned} | |||
qualifiers={this.state.qualifiers} | |||
query={this.state.query} | |||
ready={this.state.ready} | |||
selection={this.state.selection} | |||
total={this.state.total} | |||
visibility={this.state.visibility} | |||
/> | |||
<Projects | |||
currentUser={this.props.currentUser} | |||
onProjectDeselected={this.onProjectDeselected} | |||
onProjectSelected={this.onProjectSelected} | |||
projects={this.state.projects} | |||
ready={this.state.ready} | |||
selection={this.state.selection} | |||
/> | |||
<ListFooter | |||
count={this.state.projects.length} | |||
loadMore={this.loadMore} | |||
ready={this.state.ready} | |||
total={this.state.total} | |||
/> | |||
</main> | |||
<LargeCenteredLayout as="main" id="projects-management-page"> | |||
<PageContentFontWrapper className="sw-body-sm sw-my-8"> | |||
<Suggestions suggestions="projects_management" /> | |||
<Helmet defer={false} title={translate('projects_management')} /> | |||
<Header | |||
defaultProjectVisibility={defaultProjectVisibility} | |||
hasProvisionPermission={hasGlobalPermission(currentUser, Permissions.ProjectCreation)} | |||
onChangeDefaultProjectVisibility={this.handleDefaultProjectVisibilityChange} | |||
/> | |||
<Search | |||
analyzedBefore={this.state.analyzedBefore} | |||
onAllDeselected={this.onAllDeselected} | |||
onAllSelected={this.onAllSelected} | |||
onDateChanged={this.handleDateChanged} | |||
onDeleteProjects={this.requestProjects} | |||
onProvisionedChanged={this.onProvisionedChanged} | |||
onQualifierChanged={this.onQualifierChanged} | |||
onSearch={this.onSearch} | |||
onVisibilityChanged={this.onVisibilityChanged} | |||
projects={this.state.projects} | |||
provisioned={this.state.provisioned} | |||
qualifiers={this.state.qualifiers} | |||
query={this.state.query} | |||
ready={this.state.ready} | |||
selection={this.state.selection} | |||
total={this.state.total} | |||
visibility={this.state.visibility} | |||
/> | |||
<Projects | |||
currentUser={this.props.currentUser} | |||
onProjectDeselected={this.onProjectDeselected} | |||
onProjectSelected={this.onProjectSelected} | |||
projects={this.state.projects} | |||
ready={this.state.ready} | |||
selection={this.state.selection} | |||
/> | |||
<ListFooter | |||
count={this.state.projects.length} | |||
loadMore={this.loadMore} | |||
ready={this.state.ready} | |||
total={this.state.total} | |||
useMIUIButtons | |||
/> | |||
</PageContentFontWrapper> | |||
</LargeCenteredLayout> | |||
); | |||
} | |||
} |
@@ -1,43 +0,0 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2024 SonarSource SA | |||
* mailto:info AT sonarsource DOT com | |||
* | |||
* This program is free software; you can redistribute it and/or | |||
* modify it under the terms of the GNU Lesser General Public | |||
* License as published by the Free Software Foundation; either | |||
* version 3 of the License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||
* Lesser General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Lesser General Public License | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
.project-row-text-cell { | |||
max-width: 20em; | |||
} | |||
.projects-management-search { | |||
display: flex; | |||
gap: 20px; | |||
padding: 8px 10px; | |||
flex-wrap: wrap; | |||
} | |||
.projects-management-search > * { | |||
display: flex; | |||
white-space: nowrap; | |||
} | |||
.projects-management-search > *:not(.bulk-actions) { | |||
flex-direction: column; | |||
justify-content: center; | |||
} | |||
.projects-management-search > .bulk-actions { | |||
justify-content: end; | |||
} |
@@ -17,20 +17,17 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { ActionCell, Badge, Checkbox, ContentCell, HoverLink, Note, TableRow } from 'design-system'; | |||
import * as React from 'react'; | |||
import { Project } from '../../api/project-management'; | |||
import Link from '../../components/common/Link'; | |||
import PrivacyBadgeContainer from '../../components/common/PrivacyBadgeContainer'; | |||
import Checkbox from '../../components/controls/Checkbox'; | |||
import Tooltip from '../../components/controls/Tooltip'; | |||
import QualifierIcon from '../../components/icons/QualifierIcon'; | |||
import DateFormatter from '../../components/intl/DateFormatter'; | |||
import { translate, translateWithParameters } from '../../helpers/l10n'; | |||
import { getComponentOverviewUrl } from '../../helpers/urls'; | |||
import { useGithubProvisioningEnabledQuery } from '../../queries/identity-provider/github'; | |||
import { ComponentQualifier } from '../../types/component'; | |||
import { LoggedInUser } from '../../types/users'; | |||
import './ProjectRow.css'; | |||
import ProjectRowActions from './ProjectRowActions'; | |||
interface Props { | |||
@@ -49,52 +46,42 @@ export default function ProjectRow(props: Props) { | |||
}; | |||
return ( | |||
<tr data-project-key={project.key}> | |||
<td className="thin"> | |||
<TableRow data-project-key={project.key}> | |||
<ContentCell> | |||
<Checkbox | |||
label={translateWithParameters('projects_management.select_project', project.name)} | |||
checked={selected} | |||
onCheck={handleProjectCheck} | |||
/> | |||
</td> | |||
<td className="nowrap hide-overflow project-row-text-cell"> | |||
<Link | |||
className="link-no-underline" | |||
to={getComponentOverviewUrl(project.key, project.qualifier)} | |||
> | |||
<QualifierIcon className="little-spacer-right" qualifier={project.qualifier} /> | |||
</ContentCell> | |||
<ContentCell className="it__project-row-text-cell"> | |||
<HoverLink to={getComponentOverviewUrl(project.key, project.qualifier)}> | |||
<Tooltip overlay={project.name} placement="left"> | |||
<span>{project.name}</span> | |||
</Tooltip> | |||
</Link> | |||
</HoverLink> | |||
{project.qualifier === ComponentQualifier.Project && | |||
githubProvisioningEnabled && | |||
!project.managed && <span className="badge sw-ml-1">{translate('local')}</span>} | |||
</td> | |||
<td className="thin nowrap"> | |||
!project.managed && <Badge className="sw-ml-1">{translate('local')}</Badge>} | |||
</ContentCell> | |||
<ContentCell> | |||
<PrivacyBadgeContainer qualifier={project.qualifier} visibility={project.visibility} /> | |||
</td> | |||
<td className="nowrap hide-overflow project-row-text-cell"> | |||
</ContentCell> | |||
<ContentCell className="it__project-row-text-cell"> | |||
<Tooltip overlay={project.key} placement="left"> | |||
<span className="note">{project.key}</span> | |||
<Note>{project.key}</Note> | |||
</Tooltip> | |||
</td> | |||
<td className="thin nowrap text-right"> | |||
</ContentCell> | |||
<ContentCell> | |||
{project.lastAnalysisDate ? ( | |||
<DateFormatter date={project.lastAnalysisDate} /> | |||
) : ( | |||
<span className="note">—</span> | |||
<Note>—</Note> | |||
)} | |||
</td> | |||
<td className="thin nowrap"> | |||
</ContentCell> | |||
<ActionCell> | |||
<ProjectRowActions currentUser={currentUser} project={project} /> | |||
</td> | |||
</tr> | |||
</ActionCell> | |||
</TableRow> | |||
); | |||
} |
@@ -17,11 +17,10 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { ActionsDropdown, ItemButton, ItemLink, PopupZLevel, Spinner } from 'design-system'; | |||
import React, { useState } from 'react'; | |||
import { getComponentNavigation } from '../../api/navigation'; | |||
import { Project } from '../../api/project-management'; | |||
import ActionsDropdown, { ActionsDropdownItem } from '../../components/controls/ActionsDropdown'; | |||
import Spinner from '../../components/ui/Spinner'; | |||
import { throwGlobalError } from '../../helpers/error'; | |||
import { translate, translateWithParameters } from '../../helpers/l10n'; | |||
import { getComponentPermissionsUrl } from '../../helpers/urls'; | |||
@@ -71,43 +70,37 @@ export default function ProjectRowActions({ currentUser, project }: Props) { | |||
return ( | |||
<> | |||
<ActionsDropdown | |||
label={translateWithParameters('projects_management.show_actions_for_x', project.name)} | |||
id="project-management-action-dropdown" | |||
toggleClassName="it__user-actions-toggle" | |||
onOpen={handleDropdownOpen} | |||
allowResizing | |||
ariaLabel={translateWithParameters('projects_management.show_actions_for_x', project.name)} | |||
zLevel={PopupZLevel.Global} | |||
> | |||
{loading ? ( | |||
<ActionsDropdownItem> | |||
<Spinner /> | |||
</ActionsDropdownItem> | |||
) : ( | |||
<Spinner loading={loading} className="sw-flex sw-ml-3"> | |||
<> | |||
{hasAccess === true && ( | |||
<ActionsDropdownItem | |||
className="js-edit-permissions" | |||
to={getComponentPermissionsUrl(project.key)} | |||
> | |||
<ItemLink to={getComponentPermissionsUrl(project.key)}> | |||
{translate(project.managed ? 'show_permissions' : 'edit_permissions')} | |||
</ActionsDropdownItem> | |||
</ItemLink> | |||
)} | |||
{hasAccess === false && | |||
(!project.managed || currentUser.local || !githubProvisioningEnabled) && ( | |||
<ActionsDropdownItem | |||
className="js-restore-access" | |||
<ItemButton | |||
className="it__restore-access" | |||
onClick={() => setRestoreAccessModal(true)} | |||
> | |||
{translate('global_permissions.restore_access')} | |||
</ActionsDropdownItem> | |||
</ItemButton> | |||
)} | |||
</> | |||
)} | |||
</Spinner> | |||
{!project.managed && ( | |||
<ActionsDropdownItem | |||
className="js-apply-template" | |||
onClick={() => setApplyTemplateModal(true)} | |||
> | |||
<ItemButton className="it__apply-template" onClick={() => setApplyTemplateModal(true)}> | |||
{translate('projects_role.apply_template')} | |||
</ActionsDropdownItem> | |||
</ItemButton> | |||
)} | |||
</ActionsDropdown> | |||
@@ -17,7 +17,9 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import classNames from 'classnames'; | |||
import { ActionCell, ContentCell, Table, TableRow } from 'design-system'; | |||
import * as React from 'react'; | |||
import { Project } from '../../api/project-management'; | |||
import { translate } from '../../helpers/l10n'; | |||
@@ -44,34 +46,33 @@ export default function Projects(props: Readonly<Props>) { | |||
} | |||
}; | |||
const header = ( | |||
<TableRow> | |||
<ContentCell> </ContentCell> | |||
<ContentCell>{translate('name')}</ContentCell> | |||
<ContentCell>{translate('visibility')}</ContentCell> | |||
<ContentCell>{translate('key')}</ContentCell> | |||
<ContentCell>{translate('last_analysis')}</ContentCell> | |||
<ActionCell>{translate('actions')}</ActionCell> | |||
</TableRow> | |||
); | |||
return ( | |||
<div className="boxed-group boxed-group-inner"> | |||
<table | |||
className={classNames('data', 'zebra', { 'new-loading': !ready })} | |||
id="projects-management-page-projects" | |||
> | |||
<thead> | |||
<tr> | |||
<th /> | |||
<th>{translate('name')}</th> | |||
<th /> | |||
<th>{translate('key')}</th> | |||
<th className="thin nowrap text-right">{translate('last_analysis')}</th> | |||
<th /> | |||
</tr> | |||
</thead> | |||
<tbody> | |||
{projects.map((project) => ( | |||
<ProjectRow | |||
currentUser={currentUser} | |||
key={project.key} | |||
onProjectCheck={onProjectCheck} | |||
project={project} | |||
selected={selection.some((s) => s.key === project.key)} | |||
/> | |||
))} | |||
</tbody> | |||
</table> | |||
</div> | |||
<Table | |||
columnCount={6} | |||
header={header} | |||
id="projects-management-page-projects" | |||
className={classNames({ 'sw-opacity-50 sw-transition sw-duration-75 sw-ease-in': !ready })} | |||
> | |||
{projects.map((project) => ( | |||
<ProjectRow | |||
currentUser={currentUser} | |||
key={project.key} | |||
onProjectCheck={onProjectCheck} | |||
project={project} | |||
selected={selection.some((s) => s.key === project.key)} | |||
/> | |||
))} | |||
</Table> | |||
); | |||
} |
@@ -17,12 +17,11 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { ButtonPrimary, Modal } from 'design-system'; | |||
import * as React from 'react'; | |||
import { FormattedMessage } from 'react-intl'; | |||
import { grantPermissionToUser } from '../../api/permissions'; | |||
import { Project } from '../../api/project-management'; | |||
import Modal from '../../components/controls/Modal'; | |||
import { ResetButtonLink, SubmitButton } from '../../components/controls/buttons'; | |||
import { translate } from '../../helpers/l10n'; | |||
import { LoggedInUser } from '../../types/users'; | |||
@@ -37,6 +36,8 @@ interface State { | |||
loading: boolean; | |||
} | |||
const FORM_ID = 'restore-access-form'; | |||
export default class RestoreAccessModal extends React.PureComponent<Props, State> { | |||
mounted = false; | |||
state: State = { loading: false }; | |||
@@ -70,16 +71,16 @@ export default class RestoreAccessModal extends React.PureComponent<Props, State | |||
}); | |||
render() { | |||
const { loading } = this.state; | |||
const header = translate('global_permissions.restore_access'); | |||
return ( | |||
<Modal contentLabel={header} onRequestClose={this.props.onClose}> | |||
<form onSubmit={this.handleFormSubmit}> | |||
<header className="modal-head"> | |||
<h2>{header}</h2> | |||
</header> | |||
<div className="modal-body"> | |||
<Modal | |||
headerTitle={header} | |||
onClose={this.props.onClose} | |||
loading={loading} | |||
body={ | |||
<form id={FORM_ID} onSubmit={this.handleFormSubmit}> | |||
<FormattedMessage | |||
defaultMessage={translate('global_permissions.restore_access.message')} | |||
id="global_permissions.restore_access.message" | |||
@@ -88,15 +89,15 @@ export default class RestoreAccessModal extends React.PureComponent<Props, State | |||
administer: <strong>{translate('projects_role.admin')}</strong>, | |||
}} | |||
/> | |||
</div> | |||
<footer className="modal-foot"> | |||
{this.state.loading && <i className="spinner spacer-right" />} | |||
<SubmitButton disabled={this.state.loading}>{translate('restore')}</SubmitButton> | |||
<ResetButtonLink onClick={this.props.onClose}>{translate('cancel')}</ResetButtonLink> | |||
</footer> | |||
</form> | |||
</Modal> | |||
</form> | |||
} | |||
primaryButton={ | |||
<ButtonPrimary autoFocus disabled={loading} form={FORM_ID} type="submit"> | |||
{translate('restore')} | |||
</ButtonPrimary> | |||
} | |||
secondaryButtonLabel={translate('cancel')} | |||
/> | |||
); | |||
} | |||
} |
@@ -17,18 +17,24 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { | |||
ButtonSecondary, | |||
Checkbox, | |||
DangerButtonPrimary, | |||
DatePicker, | |||
HelperHintIcon, | |||
InputSearch, | |||
InputSelect, | |||
Spinner, | |||
} from 'design-system'; | |||
import { sortBy } from 'lodash'; | |||
import * as React from 'react'; | |||
import { components, OptionProps, SingleValueProps } from 'react-select'; | |||
import { OptionProps, SingleValueProps, components } from 'react-select'; | |||
import { Project } from '../../api/project-management'; | |||
import withAppStateContext from '../../app/components/app-state/withAppStateContext'; | |||
import { Button } from '../../components/controls/buttons'; | |||
import Checkbox from '../../components/controls/Checkbox'; | |||
import DateInput from '../../components/controls/DateInput'; | |||
import HelpTooltip from '../../components/controls/HelpTooltip'; | |||
import SearchBox from '../../components/controls/SearchBox'; | |||
import Select, { LabelValueSelectOption } from '../../components/controls/Select'; | |||
import QualifierIcon from '../../components/icons/QualifierIcon'; | |||
import { LabelValueSelectOption } from '../../components/controls/Select'; | |||
import { translate } from '../../helpers/l10n'; | |||
import { AppState } from '../../types/appstate'; | |||
import { Visibility } from '../../types/component'; | |||
@@ -129,6 +135,7 @@ class Search extends React.PureComponent<Props, State> { | |||
const checked = isAllChecked || thirdState; | |||
return ( | |||
<Checkbox | |||
className="it__projects-selection" | |||
checked={checked} | |||
id="projects-selection" | |||
onCheck={this.onCheck} | |||
@@ -138,12 +145,7 @@ class Search extends React.PureComponent<Props, State> { | |||
); | |||
}; | |||
renderQualifierOption = (option: LabelValueSelectOption) => ( | |||
<div className="display-flex-center"> | |||
<QualifierIcon className="little-spacer-right" qualifier={option.value} /> | |||
{option.label} | |||
</div> | |||
); | |||
renderQualifierOption = (option: LabelValueSelectOption) => <div>{option.label}</div>; | |||
renderQualifierFilter = () => { | |||
const options = this.getQualifierOptions(); | |||
@@ -151,8 +153,8 @@ class Search extends React.PureComponent<Props, State> { | |||
return null; | |||
} | |||
return ( | |||
<Select | |||
className="input-medium it__project-qualifier-select" | |||
<InputSelect | |||
className="it__project-qualifier-select" | |||
isDisabled={!this.props.ready} | |||
name="projects-qualifier" | |||
onChange={this.handleQualifierChange} | |||
@@ -175,8 +177,7 @@ class Search extends React.PureComponent<Props, State> { | |||
{ value: Visibility.Private, label: translate('visibility.private') }, | |||
]; | |||
return ( | |||
<Select | |||
className="input-small" | |||
<InputSelect | |||
isDisabled={!this.props.ready} | |||
name="projects-visibility" | |||
onChange={this.handleVisibilityChange} | |||
@@ -190,64 +191,69 @@ class Search extends React.PureComponent<Props, State> { | |||
renderTypeFilter = () => | |||
this.props.qualifiers === 'TRK' ? ( | |||
<div> | |||
<div className="sw-flex sw-items-center"> | |||
<Checkbox | |||
checked={this.props.provisioned} | |||
className="link-checkbox-control" | |||
id="projects-provisioned" | |||
onCheck={this.props.onProvisionedChanged} | |||
> | |||
<span className="text-middle little-spacer-left"> | |||
{translate('provisioning.only_provisioned')} | |||
</span> | |||
<span className="sw-ml-1">{translate('provisioning.only_provisioned')}</span> | |||
<HelpTooltip | |||
className="spacer-left" | |||
className="sw-ml-2" | |||
overlay={translate('provisioning.only_provisioned.tooltip')} | |||
/> | |||
> | |||
<HelperHintIcon /> | |||
</HelpTooltip> | |||
</Checkbox> | |||
</div> | |||
) : null; | |||
renderDateFilter = () => { | |||
return ( | |||
<DateInput | |||
inputClassName="input-medium" | |||
<DatePicker | |||
clearButtonLabel={translate('clear')} | |||
name="analyzed-before" | |||
onChange={this.props.onDateChanged} | |||
placeholder={translate('last_analysis_before')} | |||
value={this.props.analyzedBefore} | |||
showClearButton | |||
alignRight | |||
size="auto" | |||
/> | |||
); | |||
}; | |||
render() { | |||
return ( | |||
<div className="big-spacer-bottom"> | |||
<div className="projects-management-search"> | |||
<div>{this.props.ready ? this.renderCheckbox() : <i className="spinner" />}</div> | |||
<div className="sw-mb-4"> | |||
<div className="sw-flex sw-justify-start sw-items-center sw-flex-wrap sw-gap-2 sw-p-2"> | |||
<Spinner loading={!this.props.ready} className="sw-ml-2"> | |||
{this.renderCheckbox()} | |||
</Spinner> | |||
{this.renderQualifierFilter()} | |||
{this.renderDateFilter()} | |||
{this.renderVisibilityFilter()} | |||
{this.renderTypeFilter()} | |||
<div className="flex-grow"> | |||
<SearchBox | |||
<div className="sw-flex-grow"> | |||
<InputSearch | |||
minLength={3} | |||
onChange={this.props.onSearch} | |||
placeholder={translate('search.search_by_name_or_key')} | |||
value={this.props.query} | |||
size="auto" | |||
/> | |||
</div> | |||
<div className="bulk-actions"> | |||
<Button | |||
className="js-bulk-apply-permission-template" | |||
<div> | |||
<ButtonSecondary | |||
className="it__bulk-apply-permission-template" | |||
disabled={this.props.selection.length === 0} | |||
onClick={this.handleBulkApplyTemplateClick} | |||
> | |||
{translate('permission_templates.bulk_apply_permission_template')} | |||
</Button> | |||
</ButtonSecondary> | |||
{this.props.qualifiers === 'TRK' && ( | |||
<Button | |||
className="js-delete spacer-left button-red" | |||
<DangerButtonPrimary | |||
className="sw-ml-2" | |||
disabled={this.props.selection.length === 0} | |||
onClick={this.handleDeleteClick} | |||
title={ | |||
@@ -257,7 +263,7 @@ class Search extends React.PureComponent<Props, State> { | |||
} | |||
> | |||
{translate('delete')} | |||
</Button> | |||
</DangerButtonPrimary> | |||
)} | |||
</div> | |||
</div> |
@@ -75,10 +75,10 @@ const ui = { | |||
}), | |||
projectActions: (projectName: string) => | |||
byRole('button', { name: `projects_management.show_actions_for_x.${projectName}` }), | |||
editPermissions: byRole('link', { name: 'edit_permissions' }), | |||
showPermissions: byRole('link', { name: 'show_permissions' }), | |||
applyPermissionTemplate: byRole('button', { name: 'projects_role.apply_template' }), | |||
restoreAccess: byRole('button', { name: 'global_permissions.restore_access' }), | |||
editPermissions: byRole('menuitem', { name: 'edit_permissions' }), | |||
showPermissions: byRole('menuitem', { name: 'show_permissions' }), | |||
applyPermissionTemplate: byRole('menuitem', { name: 'projects_role.apply_template' }), | |||
restoreAccess: byRole('menuitem', { name: 'global_permissions.restore_access' }), | |||
editPermissionsPage: byText('/project_roles?id=project1'), | |||
apply: byRole('button', { name: 'apply' }), | |||
@@ -114,7 +114,7 @@ const ui = { | |||
qualifierFilter: byRole('combobox', { name: 'projects_management.filter_by_component' }), | |||
analysisDateFilter: byPlaceholderText('last_analysis_before'), | |||
provisionedFilter: byRole('checkbox', { | |||
name: 'provisioning.only_provisioned help', | |||
name: 'provisioning.only_provisioned', | |||
}), | |||
searchFilter: byRole('searchbox', { name: 'search.search_by_name_or_key' }), | |||
@@ -291,7 +291,7 @@ describe('Bulk permission templates', () => { | |||
.get(), | |||
).toBeInTheDocument(); | |||
await selectEvent.select( | |||
ui.bulkApplyDialog.by(ui.selectTemplate('field_required')).get(), | |||
ui.bulkApplyDialog.by(ui.selectTemplate('required')).get(), | |||
'Permission Template 2', | |||
); | |||
await user.click(ui.bulkApplyDialog.by(ui.apply).get()); |
@@ -1,87 +0,0 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2024 SonarSource SA | |||
* mailto:info AT sonarsource DOT com | |||
* | |||
* This program is free software; you can redistribute it and/or | |||
* modify it under the terms of the GNU Lesser General Public | |||
* License as published by the Free Software Foundation; either | |||
* version 3 of the License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||
* Lesser General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Lesser General Public License | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
.rdp { | |||
--rdp-cell-size: 30px; | |||
--rdp-caption-font-size: 13px; | |||
/* Ensures the month/year dropdowns do not move on click, but rdp outline is not shown */ | |||
--rdp-outline: 2px solid transparent; | |||
--rdp-outline-selected: 2px solid transparent; | |||
} | |||
.rdp-day_selected { | |||
background-color: var(--blue); | |||
} | |||
.rdp-day_selected:hover { | |||
background-color: var(--blue); | |||
} | |||
.date-input-control { | |||
position: relative; | |||
display: inline-block; | |||
cursor: pointer; | |||
} | |||
.date-input-control-input { | |||
width: 130px; | |||
padding-left: var(--controlHeight) !important; | |||
cursor: pointer; | |||
} | |||
.date-input-control-input.is-filled { | |||
padding-right: 16px !important; | |||
} | |||
.date-input-control-icon { | |||
position: absolute; | |||
top: 4px; | |||
left: 4px; | |||
} | |||
.date-input-control-icon path { | |||
fill: var(--neutral600); | |||
opacity: 0.9; | |||
} | |||
.date-input-control-input:focus + .date-input-control-icon path { | |||
fill: var(--info500); | |||
} | |||
.date-input-control-reset { | |||
position: absolute; | |||
top: 4px; | |||
right: 4px; | |||
border: none; | |||
} | |||
.date-input-calendar { | |||
position: absolute; | |||
z-index: var(--dropdownMenuZIndex); | |||
top: 100%; | |||
left: 0; | |||
border: 1px solid var(--barBorderColor); | |||
background-color: #fff; | |||
box-shadow: var(--defaultShadow); | |||
} | |||
.date-input-calendar.align-right { | |||
left: initial; | |||
right: 0; | |||
} |
@@ -1,195 +0,0 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2024 SonarSource SA | |||
* mailto:info AT sonarsource DOT com | |||
* | |||
* This program is free software; you can redistribute it and/or | |||
* modify it under the terms of the GNU Lesser General Public | |||
* License as published by the Free Software Foundation; either | |||
* version 3 of the License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||
* Lesser General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Lesser General Public License | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import classNames from 'classnames'; | |||
import * as React from 'react'; | |||
import { ActiveModifiers, DayPicker, Matcher } from 'react-day-picker'; | |||
import 'react-day-picker/dist/style.css'; | |||
import { injectIntl, WrappedComponentProps } from 'react-intl'; | |||
import { ClearButton } from '../../components/controls/buttons'; | |||
import OutsideClickHandler from '../../components/controls/OutsideClickHandler'; | |||
import CalendarIcon from '../../components/icons/CalendarIcon'; | |||
import { getShortWeekDayName, translate } from '../../helpers/l10n'; | |||
import './DateInput.css'; | |||
import EscKeydownHandler from './EscKeydownHandler'; | |||
import FocusOutHandler from './FocusOutHandler'; | |||
// When no minDate is given, year dropdown will show year options up to PAST_MAX_YEARS in the past | |||
const YEARS_TO_DISPLAY = 10; | |||
interface Props { | |||
alignRight?: boolean; | |||
className?: string; | |||
currentMonth?: Date; | |||
highlightFrom?: Date; | |||
highlightTo?: Date; | |||
inputClassName?: string; | |||
maxDate?: Date; | |||
minDate?: Date; | |||
name?: string; | |||
id?: string; | |||
onChange: (date: Date | undefined) => void; | |||
placeholder: string; | |||
value?: Date; | |||
} | |||
interface State { | |||
currentMonth: Date; | |||
open: boolean; | |||
lastHovered?: Date; | |||
} | |||
export default class DateInput extends React.PureComponent<Props, State> { | |||
input?: HTMLInputElement | null; | |||
constructor(props: Props) { | |||
super(props); | |||
this.state = { currentMonth: props.value || props.currentMonth || new Date(), open: false }; | |||
} | |||
focus = () => { | |||
if (this.input) { | |||
this.input.focus(); | |||
} | |||
this.openCalendar(); | |||
}; | |||
handleResetClick = () => { | |||
this.closeCalendar(); | |||
this.props.onChange(undefined); | |||
}; | |||
openCalendar = () => { | |||
this.setState({ | |||
currentMonth: this.props.value || this.props.currentMonth || new Date(), | |||
lastHovered: undefined, | |||
open: true, | |||
}); | |||
}; | |||
closeCalendar = () => { | |||
this.setState({ open: false }); | |||
}; | |||
handleDayClick = (day: Date, modifiers: ActiveModifiers) => { | |||
if (!modifiers.disabled) { | |||
this.closeCalendar(); | |||
this.props.onChange(day); | |||
} | |||
}; | |||
handleDayMouseEnter = (day: Date, modifiers: ActiveModifiers) => { | |||
this.setState({ lastHovered: modifiers.disabled ? undefined : day }); | |||
}; | |||
render() { | |||
const { | |||
alignRight, | |||
highlightFrom, | |||
highlightTo, | |||
minDate, | |||
maxDate = new Date(), | |||
value: selectedDay, | |||
name, | |||
className, | |||
inputClassName, | |||
id, | |||
placeholder, | |||
} = this.props; | |||
const { lastHovered, currentMonth, open } = this.state; | |||
// Infer start and end dropdown year from min/max dates, if set | |||
const fromYear = minDate ? minDate.getFullYear() : new Date().getFullYear() - YEARS_TO_DISPLAY; | |||
const toYear = maxDate ? maxDate.getFullYear() : new Date().getFullYear() + 1; | |||
let highlighted: Matcher = false; | |||
const lastHoveredOrValue = lastHovered || selectedDay; | |||
if (highlightFrom && lastHoveredOrValue) { | |||
highlighted = { from: highlightFrom, to: lastHoveredOrValue }; | |||
} | |||
if (highlightTo && lastHoveredOrValue) { | |||
highlighted = { from: lastHoveredOrValue, to: highlightTo }; | |||
} | |||
return ( | |||
<FocusOutHandler onFocusOut={this.closeCalendar}> | |||
<OutsideClickHandler onClickOutside={this.closeCalendar}> | |||
<EscKeydownHandler onKeydown={this.closeCalendar}> | |||
<span className={classNames('date-input-control', className)}> | |||
<InputWrapper | |||
className={classNames('date-input-control-input', inputClassName, { | |||
'is-filled': selectedDay !== undefined, | |||
})} | |||
id={id} | |||
innerRef={(node: HTMLInputElement | null) => (this.input = node)} | |||
name={name} | |||
onFocus={this.openCalendar} | |||
placeholder={placeholder} | |||
readOnly | |||
type="text" | |||
value={selectedDay} | |||
/> | |||
<CalendarIcon className="date-input-control-icon" fill="" /> | |||
{selectedDay !== undefined && ( | |||
<ClearButton | |||
aria-label={translate('reset_date')} | |||
className="button-tiny date-input-control-reset" | |||
iconProps={{ size: 12 }} | |||
onClick={this.handleResetClick} | |||
/> | |||
)} | |||
{open && ( | |||
<div className={classNames('date-input-calendar', { 'align-right': alignRight })}> | |||
<DayPicker | |||
mode="default" | |||
captionLayout="dropdown-buttons" | |||
fromYear={fromYear} | |||
toYear={toYear} | |||
disabled={{ after: maxDate, before: minDate }} | |||
weekStartsOn={1} | |||
formatters={{ | |||
formatWeekdayName: (date) => getShortWeekDayName(date.getDay()), | |||
}} | |||
modifiers={{ highlighted }} | |||
modifiersClassNames={{ highlighted: 'highlighted' }} | |||
month={currentMonth} | |||
onMonthChange={(currentMonth) => this.setState({ currentMonth })} | |||
selected={selectedDay} | |||
onDayClick={this.handleDayClick} | |||
onDayMouseEnter={this.handleDayMouseEnter} | |||
/> | |||
</div> | |||
)} | |||
</span> | |||
</EscKeydownHandler> | |||
</OutsideClickHandler> | |||
</FocusOutHandler> | |||
); | |||
} | |||
} | |||
type InputWrapperProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'value'> & | |||
WrappedComponentProps & { innerRef: React.Ref<HTMLInputElement>; value: Date | undefined }; | |||
const InputWrapper = injectIntl(({ innerRef, intl, value, ...other }: InputWrapperProps) => { | |||
const formattedValue = | |||
value && intl.formatDate(value, { year: 'numeric', month: 'short', day: 'numeric' }); | |||
return <input {...other} ref={innerRef} value={formattedValue || ''} />; | |||
}); |
@@ -1,108 +0,0 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2024 SonarSource SA | |||
* mailto:info AT sonarsource DOT com | |||
* | |||
* This program is free software; you can redistribute it and/or | |||
* modify it under the terms of the GNU Lesser General Public | |||
* License as published by the Free Software Foundation; either | |||
* version 3 of the License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||
* Lesser General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Lesser General Public License | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import classNames from 'classnames'; | |||
import { max, min } from 'date-fns'; | |||
import * as React from 'react'; | |||
import { translate } from '../../helpers/l10n'; | |||
import DateInput from './DateInput'; | |||
type DateRange = { from?: Date; to?: Date }; | |||
interface Props { | |||
className?: string; | |||
maxDate?: Date; | |||
minDate?: Date; | |||
onChange: (date: DateRange) => void; | |||
value?: DateRange; | |||
alignEndDateCalandarRight?: boolean; | |||
} | |||
export default class DateRangeInput extends React.PureComponent<Props> { | |||
toDateInput?: DateInput | null; | |||
get from() { | |||
return this.props.value && this.props.value.from; | |||
} | |||
get to() { | |||
return this.props.value && this.props.value.to; | |||
} | |||
handleFromChange = (from: Date | undefined) => { | |||
this.props.onChange({ from, to: this.to }); | |||
// use `setTimeout` to work around the immediate closing of the `toDateInput` | |||
setTimeout(() => { | |||
if (from && !this.to && this.toDateInput) { | |||
this.toDateInput.focus(); | |||
} | |||
}); | |||
}; | |||
handleToChange = (to: Date | undefined) => { | |||
this.props.onChange({ from: this.from, to }); | |||
}; | |||
render() { | |||
const { alignEndDateCalandarRight, minDate, maxDate } = this.props; | |||
return ( | |||
<div className={classNames('display-flex-end', this.props.className)}> | |||
<div className="display-flex-column"> | |||
<label className="text-bold little-spacer-bottom" htmlFor="date-from"> | |||
{translate('start_date')} | |||
</label> | |||
<DateInput | |||
currentMonth={this.to} | |||
data-test="from" | |||
id="date-from" | |||
highlightTo={this.to} | |||
minDate={minDate} | |||
maxDate={maxDate && this.to ? min([maxDate, this.to]) : maxDate || this.to} | |||
onChange={this.handleFromChange} | |||
placeholder={translate('start_date')} | |||
value={this.from} | |||
/> | |||
</div> | |||
<span className="note little-spacer-left little-spacer-right little-spacer-bottom"> | |||
{translate('to_')} | |||
</span> | |||
<div className="display-flex-column"> | |||
<label className="text-bold little-spacer-bottom" htmlFor="date-to"> | |||
{translate('end_date')} | |||
</label> | |||
<DateInput | |||
alignRight={alignEndDateCalandarRight} | |||
currentMonth={this.from} | |||
data-test="to" | |||
id="date-to" | |||
highlightFrom={this.from} | |||
minDate={minDate && this.from ? max([minDate, this.from]) : minDate || this.from} | |||
maxDate={maxDate} | |||
onChange={this.handleToChange} | |||
placeholder={translate('end_date')} | |||
ref={(element) => (this.toDateInput = element)} | |||
value={this.to} | |||
/> | |||
</div> | |||
</div> | |||
); | |||
} | |||
} |