diff options
author | Revanshu Paliwal <revanshu.paliwal@sonarsource.com> | 2024-01-23 11:01:29 +0100 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2024-01-24 20:03:33 +0000 |
commit | c7d0bb440b8e6bf4c625463b05ec390f1a7a27ab (patch) | |
tree | d63fb66278d829d8a3d182e618193a4b0ed7f19a | |
parent | b0c0ca16a0112de6bfcdb5841c26268d551fbe03 (diff) | |
download | sonarqube-c7d0bb440b8e6bf4c625463b05ec390f1a7a27ab.tar.gz sonarqube-c7d0bb440b8e6bf4c625463b05ec390f1a7a27ab.zip |
SONAR-21422 Migrating projects management page to adopt new UI
19 files changed, 364 insertions, 797 deletions
diff --git a/server/sonar-web/design-system/src/components/input/InputSearch.tsx b/server/sonar-web/design-system/src/components/input/InputSearch.tsx index c5b47c07fde..e3288bbc497 100644 --- a/server/sonar-web/design-system/src/components/input/InputSearch.tsx +++ b/server/sonar-web/design-system/src/components/input/InputSearch.tsx @@ -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], ); diff --git a/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx b/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx index a28ac860a19..e7e08747003 100644 --- a/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx +++ b/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx @@ -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() { diff --git a/server/sonar-web/src/main/js/app/index.ts b/server/sonar-web/src/main/js/app/index.ts index c2f82a83d4e..9f8575a64ef 100644 --- a/server/sonar-web/src/main/js/app/index.ts +++ b/server/sonar-web/src/main/js/app/index.ts @@ -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'; diff --git a/server/sonar-web/src/main/js/apps/permissions/project/components/ApplyTemplate.tsx b/server/sonar-web/src/main/js/apps/permissions/project/components/ApplyTemplate.tsx index 2181a978e13..78b18822c40 100644 --- a/server/sonar-web/src/main/js/apps/permissions/project/components/ApplyTemplate.tsx +++ b/server/sonar-web/src/main/js/apps/permissions/project/components/ApplyTemplate.tsx @@ -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> diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/BulkApplyTemplateModal.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/BulkApplyTemplateModal.tsx index f174dcdcb74..bf45c9e1962 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/BulkApplyTemplateModal.tsx +++ b/server/sonar-web/src/main/js/apps/projectsManagement/BulkApplyTemplateModal.tsx @@ -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')} + /> ); } } diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/ChangeDefaultVisibilityForm.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/ChangeDefaultVisibilityForm.tsx index 5ee867fb7db..31d075ba7a1 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/ChangeDefaultVisibilityForm.tsx +++ b/server/sonar-web/src/main/js/apps/projectsManagement/ChangeDefaultVisibilityForm.tsx @@ -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')} + /> ); } diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/DeleteModal.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/DeleteModal.tsx index c33dfa87bf5..81fb8629f6f 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/DeleteModal.tsx +++ b/server/sonar-web/src/main/js/apps/projectsManagement/DeleteModal.tsx @@ -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')} + /> ); } } diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/Header.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/Header.tsx index b9076134e04..18673aae3f6 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/Header.tsx +++ b/server/sonar-web/src/main/js/apps/projectsManagement/Header.tsx @@ -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 diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/ProjectManagementApp.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/ProjectManagementApp.tsx index baf0a3bccda..e75ef5ff0a2 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/ProjectManagementApp.tsx +++ b/server/sonar-web/src/main/js/apps/projectsManagement/ProjectManagementApp.tsx @@ -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> ); } } diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/ProjectRow.css b/server/sonar-web/src/main/js/apps/projectsManagement/ProjectRow.css deleted file mode 100644 index 439591f4eef..00000000000 --- a/server/sonar-web/src/main/js/apps/projectsManagement/ProjectRow.css +++ /dev/null @@ -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; -} diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/ProjectRow.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/ProjectRow.tsx index 262f7401ae5..3c1e3d0fea2 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/ProjectRow.tsx +++ b/server/sonar-web/src/main/js/apps/projectsManagement/ProjectRow.tsx @@ -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> ); } diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/ProjectRowActions.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/ProjectRowActions.tsx index 0d7d7405ca6..aedbd1db09a 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/ProjectRowActions.tsx +++ b/server/sonar-web/src/main/js/apps/projectsManagement/ProjectRowActions.tsx @@ -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> diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/Projects.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/Projects.tsx index 07a0e0e4a8c..bc6b4336469 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/Projects.tsx +++ b/server/sonar-web/src/main/js/apps/projectsManagement/Projects.tsx @@ -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> ); } diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/RestoreAccessModal.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/RestoreAccessModal.tsx index faef80ced63..b491fd281cd 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/RestoreAccessModal.tsx +++ b/server/sonar-web/src/main/js/apps/projectsManagement/RestoreAccessModal.tsx @@ -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')} + /> ); } } diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/Search.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/Search.tsx index ebdca8c5394..7648facfb78 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/Search.tsx +++ b/server/sonar-web/src/main/js/apps/projectsManagement/Search.tsx @@ -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> diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/ProjectManagementApp-it.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/ProjectManagementApp-it.tsx index 16a2141c423..d4cb21c7b72 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/ProjectManagementApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/ProjectManagementApp-it.tsx @@ -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()); diff --git a/server/sonar-web/src/main/js/components/controls/DateInput.css b/server/sonar-web/src/main/js/components/controls/DateInput.css deleted file mode 100644 index 545e6612e82..00000000000 --- a/server/sonar-web/src/main/js/components/controls/DateInput.css +++ /dev/null @@ -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; -} diff --git a/server/sonar-web/src/main/js/components/controls/DateInput.tsx b/server/sonar-web/src/main/js/components/controls/DateInput.tsx deleted file mode 100644 index c2cf55e754a..00000000000 --- a/server/sonar-web/src/main/js/components/controls/DateInput.tsx +++ /dev/null @@ -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 || ''} />; -}); diff --git a/server/sonar-web/src/main/js/components/controls/DateRangeInput.tsx b/server/sonar-web/src/main/js/components/controls/DateRangeInput.tsx deleted file mode 100644 index fe25a826793..00000000000 --- a/server/sonar-web/src/main/js/components/controls/DateRangeInput.tsx +++ /dev/null @@ -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> - ); - } -} |