diff --git a/client/branded/src/search-ui/input/__snapshots__/SearchContextMenu.test.tsx.snap b/client/branded/src/search-ui/input/__snapshots__/SearchContextMenu.test.tsx.snap index 74cdf3c0a93f..bb10f48709e7 100644 --- a/client/branded/src/search-ui/input/__snapshots__/SearchContextMenu.test.tsx.snap +++ b/client/branded/src/search-ui/input/__snapshots__/SearchContextMenu.test.tsx.snap @@ -4,7 +4,7 @@ exports[`SearchContextMenu should filter list by spec when searching 1`] = ` Array [
  • import('./setup-wizard'), 'SetupWizard') + export interface LegacyLayoutProps extends SettingsCascadeProps, PlatformContextProps, @@ -152,7 +154,17 @@ export const Layout: React.FC = props => { } if (isSetupWizardPage) { - return + return ( + + + + } + > + + + ) } return ( diff --git a/client/web/src/LegacyLayout.tsx b/client/web/src/LegacyLayout.tsx index a6d93a0013de..70168e82a6c2 100644 --- a/client/web/src/LegacyLayout.tsx +++ b/client/web/src/LegacyLayout.tsx @@ -15,6 +15,7 @@ import { Settings } from '@sourcegraph/shared/src/schema/settings.schema' import { SearchContextProps } from '@sourcegraph/shared/src/search' import { SettingsCascadeProps, SettingsSubjectCommonFields } from '@sourcegraph/shared/src/settings/settings' import { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService' +import { lazyComponent } from '@sourcegraph/shared/src/util/lazyComponent' import { parseQueryAndHash } from '@sourcegraph/shared/src/util/url' import { FeedbackPrompt, LoadingSpinner, Panel } from '@sourcegraph/wildcard' @@ -50,7 +51,6 @@ import type { LegacyLayoutRouteComponentProps, LayoutRouteProps } from './routes import { EnterprisePageRoutes, PageRoutes } from './routes.constants' import { parseSearchURLQuery, SearchAggregationProps, SearchStreamingProps } from './search' import { NotepadContainer } from './search/Notepad' -import { SetupWizard } from './setup-wizard' import type { SiteAdminAreaRoute } from './site-admin/SiteAdminArea' import type { SiteAdminSideBarGroups } from './site-admin/SiteAdminSidebar' import { useExperimentalFeatures } from './stores' @@ -64,6 +64,8 @@ import { parseBrowserRepoURL } from './util/url' import styles from './Layout.module.scss' +const LazySetupWizard = lazyComponent(() => import('./setup-wizard'), 'SetupWizard') + export interface LegacyLayoutProps extends SettingsCascadeProps, PlatformContextProps, @@ -196,7 +198,17 @@ export const LegacyLayout: React.FunctionComponent if (isSetupWizardPage) { - return + return ( + + + + } + > + + + ) } return ( diff --git a/client/web/src/components/externalServices/externalServices.tsx b/client/web/src/components/externalServices/externalServices.tsx index 4ab9542d8fc9..75833dba54d1 100644 --- a/client/web/src/components/externalServices/externalServices.tsx +++ b/client/web/src/components/externalServices/externalServices.tsx @@ -15,7 +15,7 @@ import NpmIcon from 'mdi-react/NpmIcon' import { hasProperty } from '@sourcegraph/common' import { PerforceIcon, PhabricatorIcon } from '@sourcegraph/shared/src/components/icons' -import { Link, Code, Text } from '@sourcegraph/wildcard' +import { Link, Code, Text, setLinkComponent, RouterLink } from '@sourcegraph/wildcard' import awsCodeCommitSchemaJSON from '../../../../../schema/aws_codecommit.schema.json' import azureDevOpsSchemaJSON from '../../../../../schema/azuredevops.schema.json' @@ -45,6 +45,8 @@ import { EditorAction } from '../../settings/EditorActionsGroup' import { GerritIcon } from './GerritIcon' +setLinkComponent(RouterLink) + /** * Metadata associated with adding a given external service. */ diff --git a/client/web/src/enterprise/codeintel/configuration/components/RepositoryPatternList.module.scss b/client/web/src/enterprise/codeintel/configuration/components/RepositoryPatternList.module.scss index d06318b476e5..dbb677014ec3 100644 --- a/client/web/src/enterprise/codeintel/configuration/components/RepositoryPatternList.module.scss +++ b/client/web/src/enterprise/codeintel/configuration/components/RepositoryPatternList.module.scss @@ -24,7 +24,7 @@ .input-actions { width: 10%; display: flex; - justify-content: start; + justify-content: flex-start; align-self: stretch; } diff --git a/client/web/src/settings/DynamicallyImportedMonacoSettingsEditor.tsx b/client/web/src/settings/DynamicallyImportedMonacoSettingsEditor.tsx index 2488b3a924b7..38db5c2f12a1 100644 --- a/client/web/src/settings/DynamicallyImportedMonacoSettingsEditor.tsx +++ b/client/web/src/settings/DynamicallyImportedMonacoSettingsEditor.tsx @@ -100,7 +100,7 @@ export class DynamicallyImportedMonacoSettingsEditor exte } private get effectiveValue(): string { - return this.state.value === undefined ? this.props.value : this.state.value + return this.props.value } private get isDirty(): boolean { @@ -130,14 +130,16 @@ export class DynamicallyImportedMonacoSettingsEditor exte ) } + const { className, ...otherProps } = this.props + return ( -
    +
    {this.props.actions && ( )} }> {actions.map(({ id, label }) => ( - ))} diff --git a/client/web/src/setup-wizard/SetupWizard.tsx b/client/web/src/setup-wizard/SetupWizard.tsx index 9381284a2b67..2e1cabea0c53 100644 --- a/client/web/src/setup-wizard/SetupWizard.tsx +++ b/client/web/src/setup-wizard/SetupWizard.tsx @@ -1,11 +1,12 @@ -import { FC } from 'react' +import { FC, ReactElement, useCallback } from 'react' import { useTemporarySetting } from '@sourcegraph/shared/src/settings/temporary' -import { Container, H1, H2 } from '@sourcegraph/wildcard' +import { H1, H2 } from '@sourcegraph/wildcard' import { BrandLogo } from '../components/branding/BrandLogo' -import { SetupStepsRoot, CustomNextButton, StepConfiguration } from './components/setup-steps' +import { RemoteRepositoriesStep } from './components/remote-repositories-step' +import { SetupStepsRoot, StepConfiguration } from './components/setup-steps' import styles from './Setup.module.scss' @@ -14,38 +15,36 @@ const SETUP_STEPS: StepConfiguration[] = [ id: '001', name: 'Add local repositories', path: '/setup/local-repositories', - render: () =>

    Hello local repositories step

    , + component: LocalRepositoriesStep, }, { id: '002', name: 'Add remote repositories', path: '/setup/remote-repositories', - render: () => ( - -

    Hello remote repositories step

    - -
    - ), + component: RemoteRepositoriesStep, }, { id: '003', name: 'Sync repositories', path: '/setup/sync-repositories', - render: () =>

    Hello sync repositories step

    , + component: () =>

    Hello sync repositories step

    , }, ] -export const SetupWizard: FC = props => { +export const SetupWizard: FC = () => { const [activeStepId, setStepId, status] = useTemporarySetting('setup.activeStepId') + const handleStepChange = useCallback( + (step: StepConfiguration): void => { + setStepId(step.id) + }, + [setStepId] + ) + if (status !== 'loaded') { return null } - const handleStepChange = (step: StepConfiguration): void => { - setStepId(step.id) - } - return (
    @@ -60,3 +59,7 @@ export const SetupWizard: FC = props => {
    ) } + +function LocalRepositoriesStep(props: any): ReactElement { + return

    Hello local repositories step

    +} diff --git a/client/web/src/setup-wizard/components/remote-repositories-step/RemoteRepositoriesStep.module.scss b/client/web/src/setup-wizard/components/remote-repositories-step/RemoteRepositoriesStep.module.scss new file mode 100644 index 000000000000..44941bf70528 --- /dev/null +++ b/client/web/src/setup-wizard/components/remote-repositories-step/RemoteRepositoriesStep.module.scss @@ -0,0 +1,59 @@ +.root { + display: flex; + flex-direction: column; +} + +.content { + display: flex; + gap: 0.25rem; + min-height: 30rem; + + &-navigation { + flex-basis: 35%; + flex-grow: 0; + flex-shrink: 0; + padding: 1rem; + } + + &-main { + flex-grow: 1; + padding: 1rem; + } +} + +.navigation { + // Top margin to compensate main and left content first line texts + margin-top: 0.2rem; +} + +.code-host-picker { + &-header { + display: flex; + flex-direction: column; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--border-color); + } + + &-list { + width: 100%; + list-style: none; + padding: 0; + margin: 1rem 0 0 0; + display: flex; + flex-direction: column; + gap: 0.25rem; + justify-content: stretch; + } + + &-item { + display: flex; + width: 100%; + justify-content: flex-start; + align-items: center; + gap: 0.25rem; + + &:hover { + background-color: var(--btn-light-color-variant) !important; + } + } +} diff --git a/client/web/src/setup-wizard/components/remote-repositories-step/RemoteRepositoriesStep.tsx b/client/web/src/setup-wizard/components/remote-repositories-step/RemoteRepositoriesStep.tsx new file mode 100644 index 000000000000..647e502c47a7 --- /dev/null +++ b/client/web/src/setup-wizard/components/remote-repositories-step/RemoteRepositoriesStep.tsx @@ -0,0 +1,52 @@ +import { FC, HTMLAttributes } from 'react' + +import classNames from 'classnames' +import { Routes, Route, matchPath, useLocation } from 'react-router-dom-v5-compat' + +import { Container, Text } from '@sourcegraph/wildcard' + +import { CustomNextButton } from '../setup-steps' + +import { CodeHostsPicker } from './components/code-host-picker' +import { CodeHostCreation, CodeHostEdit } from './components/code-hosts' +import { CodeHostsNavigation } from './components/navigation' + +import styles from './RemoteRepositoriesStep.module.scss' + +interface RemoteRepositoriesStepProps extends HTMLAttributes {} + +export const RemoteRepositoriesStep: FC = props => { + const { className, ...attributes } = props + + const location = useLocation() + const editConnectionRouteMatch = matchPath('/setup/remote-repositories/:codehostId/edit', location.pathname) + const newConnectionRouteMatch = matchPath('/setup/remote-repositories/:codeHostType/create', location.pathname) + + return ( +
    + + Connect remote code hosts where your source code lives. + + +
    + + + + + + + } /> + } /> + } /> + + +
    + + +
    + ) +} diff --git a/client/web/src/setup-wizard/components/remote-repositories-step/components/code-host-picker/CodeHostsPicker.module.scss b/client/web/src/setup-wizard/components/remote-repositories-step/components/code-host-picker/CodeHostsPicker.module.scss new file mode 100644 index 000000000000..caa3e9dbaed7 --- /dev/null +++ b/client/web/src/setup-wizard/components/remote-repositories-step/components/code-host-picker/CodeHostsPicker.module.scss @@ -0,0 +1,29 @@ +.header { + display: flex; + flex-direction: column; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--border-color); +} + +.list { + width: 100%; + list-style: none; + padding: 0; + margin: 1rem 0 0 0; + display: flex; + flex-direction: column; + gap: 0.25rem; + justify-content: stretch; +} + +.item { + display: flex; + width: 100%; + justify-content: flex-start; + align-items: center; + gap: 0.25rem; + + &:hover { + background-color: var(--btn-light-color-variant) !important; + } +} diff --git a/client/web/src/setup-wizard/components/remote-repositories-step/components/code-host-picker/CodeHostsPicker.tsx b/client/web/src/setup-wizard/components/remote-repositories-step/components/code-host-picker/CodeHostsPicker.tsx new file mode 100644 index 000000000000..c53b82d4c835 --- /dev/null +++ b/client/web/src/setup-wizard/components/remote-repositories-step/components/code-host-picker/CodeHostsPicker.tsx @@ -0,0 +1,40 @@ +import { FC } from 'react' + +import { ExternalServiceKind } from '@sourcegraph/shared/src/graphql-operations' +import { Button, Icon, Link } from '@sourcegraph/wildcard' + +import { getCodeHostIcon, getCodeHostName, getCodeHostURLParam } from '../../helpers' + +import styles from './CodeHostsPicker.module.scss' + +const SUPPORTED_CODE_HOSTS = [ + ExternalServiceKind.GITHUB, + ExternalServiceKind.GITLAB, + ExternalServiceKind.BITBUCKETCLOUD, +] + +export const CodeHostsPicker: FC = () => ( +
    +
    + Add another remote code host + Choose a provider from the list below +
    + +
      + {SUPPORTED_CODE_HOSTS.map(codeHostType => ( +
    • + +
    • + ))} +
    +
    +) diff --git a/client/web/src/setup-wizard/components/remote-repositories-step/components/code-host-picker/index.ts b/client/web/src/setup-wizard/components/remote-repositories-step/components/code-host-picker/index.ts new file mode 100644 index 000000000000..679f91935a1a --- /dev/null +++ b/client/web/src/setup-wizard/components/remote-repositories-step/components/code-host-picker/index.ts @@ -0,0 +1 @@ +export { CodeHostsPicker } from './CodeHostsPicker' diff --git a/client/web/src/setup-wizard/components/remote-repositories-step/components/code-hosts/CodeHostCreation.module.scss b/client/web/src/setup-wizard/components/remote-repositories-step/components/code-hosts/CodeHostCreation.module.scss new file mode 100644 index 000000000000..cf08e5e97b48 --- /dev/null +++ b/client/web/src/setup-wizard/components/remote-repositories-step/components/code-hosts/CodeHostCreation.module.scss @@ -0,0 +1,21 @@ +.loading-state { + display: flex; + align-items: center; + gap: 0.25rem; + color: var(--text-muted); +} + +.footer { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + position: sticky; + // Calculated value since we already have sticky footer element for + // the core wizard UI. + bottom: 51px; + margin-top: auto; + padding: 0.5rem 1rem; + background-color: var(--color-bg-1); + border-top: 1px solid var(--border-color); +} diff --git a/client/web/src/setup-wizard/components/remote-repositories-step/components/code-hosts/CodeHostCreation.tsx b/client/web/src/setup-wizard/components/remote-repositories-step/components/code-hosts/CodeHostCreation.tsx new file mode 100644 index 000000000000..9af4e857a92d --- /dev/null +++ b/client/web/src/setup-wizard/components/remote-repositories-step/components/code-hosts/CodeHostCreation.tsx @@ -0,0 +1,78 @@ +import { FC, ReactNode } from 'react' + +import { useParams } from 'react-router-dom-v5-compat' + +import { ExternalServiceKind } from '@sourcegraph/shared/src/graphql-operations' +import { Alert, H4, Link, Button } from '@sourcegraph/wildcard' + +import { defaultExternalServices } from '../../../../../components/externalServices/externalServices' +import { getCodeHostKindFromURLParam } from '../../helpers' + +import { CodeHostJSONForm, CodeHostJSONFormState } from './common' +import { GithubConnectView } from './github/GithubConnectView' + +import styles from './CodeHostCreation.module.scss' + +/** + * Renders creation UI for any supported code hosts (Github, Gitlab) based on + * "codeHostType" URL param see root component routing logic. + */ +export const CodeHostCreation: FC = () => { + const { codeHostType } = useParams() + const codeHostKind = getCodeHostKindFromURLParam(codeHostType!) + + if (codeHostKind === null) { + return ( + +

    We either couldn't find "{codeHostType}" code host option or we do not support this

    + Pick one of supported code host option here +
    + ) + } + + // We render content inside react fragment because this view is rendered + // within Container UI (avoid unnecessary DOM nesting) + return ( + + {state => ( +
    + + +
    + )} +
    + ) +} + +interface CodeHostCreationFormProps { + codeHostKind: ExternalServiceKind + children: (state: CodeHostJSONFormState) => ReactNode +} + +/** + * Renders specific creation UI form for particular code host type. Most of + * the code hosts have similar UI but some of them (like GitHub) have special enhanced + * UI with pickers and other form UI. + */ +const CodeHostCreationView: FC = props => { + const { codeHostKind, children } = props + + const handleSubmit = async (): Promise => { + // TODO connect API handler + await Promise.resolve() + } + + if (codeHostKind === ExternalServiceKind.GITHUB) { + return {children} + } + + return ( + + {children} + + ) +} diff --git a/client/web/src/setup-wizard/components/remote-repositories-step/components/code-hosts/CodeHostEdit.tsx b/client/web/src/setup-wizard/components/remote-repositories-step/components/code-hosts/CodeHostEdit.tsx new file mode 100644 index 000000000000..d95bc41066cd --- /dev/null +++ b/client/web/src/setup-wizard/components/remote-repositories-step/components/code-hosts/CodeHostEdit.tsx @@ -0,0 +1,139 @@ +import { FC, ReactNode } from 'react' + +import { gql, useQuery } from '@apollo/client' +import { useParams } from 'react-router-dom-v5-compat' + +import { ExternalServiceKind } from '@sourcegraph/shared/src/graphql-operations' +import { Alert, Button, ErrorAlert, H4, Link, LoadingSpinner } from '@sourcegraph/wildcard' + +import { defaultExternalServices } from '../../../../../components/externalServices/externalServices' +import { GetExternalServiceByIdResult, GetExternalServiceByIdVariables } from '../../../../../graphql-operations' + +import { CodeHostConnectFormFields, CodeHostJSONForm, CodeHostJSONFormState } from './common' +import { GithubConnectView } from './github/GithubConnectView' + +import styles from './CodeHostCreation.module.scss' + +const GET_EXTERNAL_SERVICE_BY_ID = gql` + query GetExternalServiceById($id: ID!) { + node(id: $id) { + ... on ExternalService { + id + __typename + kind + displayName + config + } + } + } +` + +interface CodeHostEditProps {} + +/** + * Renders edit UI for any supported code host type. (Github, Gitlab, ...) + * Also performs edit, delete actions over opened code host connection + */ +export const CodeHostEdit: FC = () => { + const { codehostId } = useParams() + + const { data, loading, error, refetch } = useQuery( + GET_EXTERNAL_SERVICE_BY_ID, + { + fetchPolicy: 'cache-and-network', + variables: { id: codehostId! }, + } + ) + + if (error && !loading) { + return ( +
    + + +
    + ) + } + + if (!data || (!data && loading)) { + return ( + + Fetching connected code host... + + ) + } + + if (data.node?.__typename !== 'ExternalService') { + return ( + +

    We either couldn't find code host

    + Try to connect new code host instead here +
    + ) + } + + return ( + + {state => ( +
    + + + + + +
    + )} +
    + ) +} + +interface CodeHostEditViewProps { + codeHostKind: ExternalServiceKind + displayName: string + configuration: string + children: (state: CodeHostJSONFormState) => ReactNode +} + +const CodeHostEditView: FC = props => { + const { codeHostKind, displayName, configuration, children } = props + + const handleSubmit = async (): Promise => { + // TODO Connect edit API + await Promise.resolve() + } + + const initialValues: CodeHostConnectFormFields = { + displayName, + configuration, + } + + if (codeHostKind === ExternalServiceKind.GITHUB) { + return ( + + {children} + + ) + } + + return ( + + {children} + + ) +} diff --git a/client/web/src/setup-wizard/components/remote-repositories-step/components/code-hosts/common/CodeHostConnection.module.scss b/client/web/src/setup-wizard/components/remote-repositories-step/components/code-hosts/common/CodeHostConnection.module.scss new file mode 100644 index 000000000000..a576d0333d3f --- /dev/null +++ b/client/web/src/setup-wizard/components/remote-repositories-step/components/code-hosts/common/CodeHostConnection.module.scss @@ -0,0 +1,36 @@ +.form-view { + display: flex; + flex-direction: column; + gap: 0.5rem; + height: 100%; +} + +.footer { + margin: auto -1rem -1rem; + position: sticky; + // Calculated value since we already have sticky footer element for + // the core wizard UI. + bottom: 51px; +} + +.configuration-group { + &-label { + font-weight: 500; + } + + &-instruction-button { + margin-left: -0.5rem; + } + + &-instructions { + padding-left: 1.5rem; + margin-top: 0.25rem; + font-size: 0.8rem; + color: var(--body-color); + margin-left: -1.5rem; + } + + &-editor { + margin-top: -0.5rem; + } +} diff --git a/client/web/src/setup-wizard/components/remote-repositories-step/components/code-hosts/common/CodeHostConnection.tsx b/client/web/src/setup-wizard/components/remote-repositories-step/components/code-hosts/common/CodeHostConnection.tsx new file mode 100644 index 000000000000..6a2357b8f454 --- /dev/null +++ b/client/web/src/setup-wizard/components/remote-repositories-step/components/code-hosts/common/CodeHostConnection.tsx @@ -0,0 +1,157 @@ +import { FC, ReactElement, ReactNode, useState } from 'react' + +import { mdiChevronDown, mdiChevronUp } from '@mdi/js' + +import { NOOP_TELEMETRY_SERVICE } from '@sourcegraph/shared/src/telemetry/telemetryService' +import { + Button, + Collapse, + CollapseHeader, + CollapsePanel, + Icon, + Input, + Text, + useLocalStorage, + FormGroup, + getDefaultInputProps, + SubmissionErrors, + useField, + useFieldAPI, + useForm, +} from '@sourcegraph/wildcard' + +import { AddExternalServiceOptions } from '../../../../../../components/externalServices/externalServices' +import { DynamicallyImportedMonacoSettingsEditor } from '../../../../../../settings/DynamicallyImportedMonacoSettingsEditor' + +import styles from './CodeHostConnection.module.scss' + +export interface CodeHostConnectFormFields { + displayName: string + configuration: string +} + +export interface CodeHostJSONFormState { + submitting: boolean + submitErrors: SubmissionErrors +} + +interface CodeHostJSONFormProps { + externalServiceOptions: AddExternalServiceOptions + initialValues?: CodeHostConnectFormFields + children: (state: CodeHostJSONFormState) => ReactNode + onSubmit: (values: CodeHostConnectFormFields) => Promise +} + +export function CodeHostJSONForm(props: CodeHostJSONFormProps): ReactElement { + const { externalServiceOptions, initialValues, onSubmit, children } = props + + const [localValues, setLocalValues] = useLocalStorage( + `${externalServiceOptions.kind}-connect-form`, + { + displayName: externalServiceOptions.defaultDisplayName, + configuration: externalServiceOptions.defaultConfig, + } + ) + + const form = useForm({ + initialValues: initialValues ?? localValues, + onSubmit, + onChange: event => setLocalValues(event.values), + }) + + const displayName = useField({ + formApi: form.formAPI, + name: 'displayName', + required: true, + }) + + const configuration = useField({ + formApi: form.formAPI, + name: 'configuration', + }) + + return ( + // eslint-disable-next-line react/forbid-elements +
    + + +
    {children(form.formAPI)}
    + + ) +} + +interface CodeHostJSONFormContentProps { + displayNameField: useFieldAPI + configurationField: useFieldAPI + externalServiceOptions: AddExternalServiceOptions +} + +export function CodeHostJSONFormContent(props: CodeHostJSONFormContentProps): ReactElement { + const { displayNameField, configurationField, externalServiceOptions } = props + + // Fragment to avoid nesting since it's rendered within TabPanel fieldset + return ( + <> + + + } + labelClassName={styles.configurationGroupLabel} + > + + Use Ctrl+Space for completion, and hover over JSON properties for documentation. + + } + /> + + + ) +} + +interface CodeHostInstructionsProps { + instructions: ReactNode +} + +const CodeHostInstructions: FC = props => { + const { instructions } = props + const [isInstructionOpen, setInstructionOpen] = useState(false) + + return ( + + + See instructions how to fill out JSONC configuration{' '} + + + {instructions} + + ) +} diff --git a/client/web/src/setup-wizard/components/remote-repositories-step/components/code-hosts/common/index.ts b/client/web/src/setup-wizard/components/remote-repositories-step/components/code-hosts/common/index.ts new file mode 100644 index 000000000000..c1a009699aea --- /dev/null +++ b/client/web/src/setup-wizard/components/remote-repositories-step/components/code-hosts/common/index.ts @@ -0,0 +1,5 @@ +export { RadioGroupSection } from './radio-group-section/RadioGroupSection' +export { CodeHostJSONForm, CodeHostJSONFormContent } from './CodeHostConnection' + +// Types export +export type { CodeHostJSONFormState, CodeHostConnectFormFields } from './CodeHostConnection' diff --git a/client/web/src/setup-wizard/components/remote-repositories-step/components/code-hosts/common/radio-group-section/RadioGroupSection.module.scss b/client/web/src/setup-wizard/components/remote-repositories-step/components/code-hosts/common/radio-group-section/RadioGroupSection.module.scss new file mode 100644 index 000000000000..9dfa145f43b4 --- /dev/null +++ b/client/web/src/setup-wizard/components/remote-repositories-step/components/code-hosts/common/radio-group-section/RadioGroupSection.module.scss @@ -0,0 +1,28 @@ +.radio-group { + display: grid; + column-gap: 0.5rem; + grid-template-rows: min-content auto; + grid-template-columns: min-content auto; + grid-template-areas: + 'input label' + 'input content'; + + &-input { + grid-area: input; + align-self: center; + grid-row-start: 1; + grid-row-end: 2; + margin: 0; + } + + &-label { + grid-area: label; + display: flex; + margin: 0; + } + + &-content { + grid-area: content; + margin-top: 0.5rem; + } +} diff --git a/client/web/src/setup-wizard/components/remote-repositories-step/components/code-hosts/common/radio-group-section/RadioGroupSection.tsx b/client/web/src/setup-wizard/components/remote-repositories-step/components/code-hosts/common/radio-group-section/RadioGroupSection.tsx new file mode 100644 index 000000000000..86807bd2b79e --- /dev/null +++ b/client/web/src/setup-wizard/components/remote-repositories-step/components/code-hosts/common/radio-group-section/RadioGroupSection.tsx @@ -0,0 +1,44 @@ +import { ChangeEvent, PropsWithChildren, ReactElement } from 'react' + +import { Label } from '@sourcegraph/wildcard' + +import styles from './RadioGroupSection.module.scss' + +interface RadioGroupSectionProps { + name: string + label: string + value: string + checked: boolean + labelId: string + className?: string + onChange: (event: ChangeEvent) => void +} + +export function RadioGroupSection(props: PropsWithChildren): ReactElement { + const { name, label, value, checked, labelId, children, onChange } = props + + return ( +
    + {/* + Standard wildcard input doesn't provide a simple layout for the radio element, + in order to have custom layout in the repo control we have to use native input + with custom styles around spacing and layout + */} + {/* eslint-disable-next-line react/forbid-elements */} + + + + {checked &&
    {children}
    } +
    + ) +} diff --git a/client/web/src/setup-wizard/components/remote-repositories-step/components/code-hosts/github/GithubConnectView.module.scss b/client/web/src/setup-wizard/components/remote-repositories-step/components/code-hosts/github/GithubConnectView.module.scss new file mode 100644 index 000000000000..a3dfd992da68 --- /dev/null +++ b/client/web/src/setup-wizard/components/remote-repositories-step/components/code-hosts/github/GithubConnectView.module.scss @@ -0,0 +1,61 @@ +.form { + // Since this form is rendered within container with padding + // but we want to render some element without this padding we stretch + // the form and control padding on form level instead. + margin: -1rem; + display: flex; + flex-direction: column; + height: calc(100% + 2rem); + background-color: inherit; +} + +.tab-list { + padding: 0.25rem 0.5rem 0 1rem; +} + +.tab { + padding: 0.2rem 0.5rem; +} + +.tab-panels { + padding: 1rem; +} + +.form-view { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.repositories-fields { + margin-top: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; + + &--disabled { + filter: grayscale(100%) opacity(0.4); + pointer-events: none; + } +} + +.configuration-group { + &-label { + font-weight: 500; + } + + &-instruction-button { + margin-left: -0.5rem; + } + + &-instructions { + padding-left: 1.5rem; + margin-top: 0.25rem; + font-size: 0.8rem; + color: var(--body-color); + } + + &-editor { + margin-top: -0.5rem; + } +} diff --git a/client/web/src/setup-wizard/components/remote-repositories-step/components/code-hosts/github/GithubConnectView.tsx b/client/web/src/setup-wizard/components/remote-repositories-step/components/code-hosts/github/GithubConnectView.tsx new file mode 100644 index 000000000000..97344b11e7da --- /dev/null +++ b/client/web/src/setup-wizard/components/remote-repositories-step/components/code-hosts/github/GithubConnectView.tsx @@ -0,0 +1,340 @@ +import { FC, ReactNode, ReactElement, useCallback, useState, useMemo, ChangeEvent } from 'react' + +import classNames from 'classnames' +import { parse as parseJSONC } from 'jsonc-parser' +import { noop } from 'lodash' + +import { modify } from '@sourcegraph/common' +import { + Tabs, + Tab, + TabList, + TabPanel, + TabPanels, + Input, + Checkbox, + useLocalStorage, + useField, + useForm, + FormInstance, + getDefaultInputProps, + useFieldAPI, + useControlledField, +} from '@sourcegraph/wildcard' + +import { codeHostExternalServices } from '../../../../../../components/externalServices/externalServices' +import { AddExternalServiceInput, ExternalServiceKind } from '../../../../../../graphql-operations' +import { CodeHostJSONFormContent, RadioGroupSection, CodeHostConnectFormFields, CodeHostJSONFormState } from '../common' + +import { GithubOrganizationsPicker, GithubRepositoriesPicker } from './GithubEntityPickers' + +import styles from './GithubConnectView.module.scss' + +const DEFAULT_FORM_VALUES: CodeHostConnectFormFields = { + displayName: codeHostExternalServices.github.defaultDisplayName, + configuration: ` +{ + "url": "https://github.com", + "token": "" +} +`.trim(), +} + +interface GithubConnectViewProps { + initialValues?: CodeHostConnectFormFields + + /** + * Render props that is connected to form state, usually is used to render + * form actions UI, like save, cancel, clear fields. Action layout is the same + * for all variations of this form (create, edit UI) but content is different + */ + children: (state: CodeHostJSONFormState) => ReactNode + onSubmit: (input: AddExternalServiceInput) => Promise +} + +/** + * GitHub's creation UI panel, it renders GitHub connection form UI and also handles + * form values logic, like saving work-in-progress form values in local + * storage + */ +export const GithubConnectView: FC = props => { + const { initialValues, children, onSubmit } = props + const [localValues, setInitialValues] = useLocalStorage('github-connection-form', DEFAULT_FORM_VALUES) + + const handleSubmit = useCallback( + async (values: CodeHostConnectFormFields): Promise => { + // Perform public API code host connection create action + await onSubmit({ + kind: ExternalServiceKind.GITHUB, + displayName: values.displayName, + config: values.configuration, + }) + + // Reset initial values after successful connect action + setInitialValues(DEFAULT_FORM_VALUES) + }, + [setInitialValues, onSubmit] + ) + + return ( + + {children} + + ) +} + +enum GithubConnectFormTab { + Form, + JSONC, +} + +interface GithubConnectFormProps { + initialValues: CodeHostConnectFormFields + children: (state: CodeHostJSONFormState) => ReactNode + onChange: (values: CodeHostConnectFormFields) => void + onSubmit: (values: CodeHostConnectFormFields) => void +} + +/** + * It renders custom GitHub connect form that provides form UI and plain JSONC + * configuration UI. + */ +export const GithubConnectForm: FC = props => { + const { initialValues, children, onChange, onSubmit } = props + + const [activeTab, setActiveTab] = useState(GithubConnectFormTab.Form) + const form = useForm({ + initialValues, + onSubmit, + onChange: event => onChange(event.values), + }) + + const displayName = useField({ + formApi: form.formAPI, + name: 'displayName', + required: true, + }) + + const configuration = useField({ + formApi: form.formAPI, + name: 'configuration', + }) + + return ( + + + + Settings + + + JSONC editor + + + + + + + + + + + + {children(form.formAPI)} + + ) +} + +interface GithubFormViewProps { + form: FormInstance + displayNameField: useFieldAPI + configurationField: useFieldAPI + isTabActive: boolean +} + +function GithubFormView(props: GithubFormViewProps): ReactElement { + const { form, displayNameField, configurationField } = props + + const accessTokenField = useControlledField({ + value: getAccessTokenValue(configurationField.input.value), + name: 'accessToken', + submitted: form.formAPI.submitted, + formTouched: form.formAPI.touched, + validators: { sync: syncAccessTokenValidator, async: asyncAccessTokenValidator }, + onChange: value => configurationField.input.onChange(modify(configurationField.input.value, ['token'], value)), + }) + + const { isAffiliatedRepositories, isOrgsRepositories, isSetRepositories, organizations, repositories } = useMemo( + () => getRepositoriesSettings(configurationField.input.value), + [configurationField.input.value] + ) + + const handleAffiliatedModeChange = (event: ChangeEvent): void => { + const parsedConfiguration = parseJSONC(configurationField.input.value) as Record + const reposQuery: string[] = + typeof parsedConfiguration === 'object' ? [...(parsedConfiguration.reposQuery ?? [])] : [] + + const nextReposQuery = event.target.checked + ? [...reposQuery, 'affiliated'] + : reposQuery.filter(token => token !== 'affiliated') + + configurationField.input.onChange(modify(configurationField.input.value, ['repositoryQuery'], nextReposQuery)) + } + + const handleOrganizationsModeChange = (event: ChangeEvent): void => { + const nextConfiguration = event.target.checked + ? modify(configurationField.input.value, ['orgs'], []) + : modify(configurationField.input.value, ['orgs'], undefined) + + configurationField.input.onChange(nextConfiguration) + } + + const handleRepositoriesModeChange = (event: ChangeEvent): void => { + const nextConfiguration = event.target.checked + ? modify(configurationField.input.value, ['repos'], []) + : modify(configurationField.input.value, ['repos'], undefined) + + configurationField.input.onChange(nextConfiguration) + } + + const handleOrganizationsChange = (organizations: string[]): void => { + configurationField.input.onChange(modify(configurationField.input.value, ['orgs'], organizations)) + } + + const handleRepositoriesChange = (repositories: string[]): void => { + configurationField.input.onChange(modify(configurationField.input.value, ['repos'], repositories)) + } + + // Fragment to avoid nesting since it's rendered within TabPanel fieldset + return ( + <> + + + + +
    + + + + + + + + + +
    + + ) +} + +function syncAccessTokenValidator(value: string | undefined): string | undefined { + if (!value || value.length === 0) { + return 'Access token is a required field' + } + + return +} + +async function asyncAccessTokenValidator(value: string | undefined): Promise { + if (!value) { + return + } + + await new Promise(res => setTimeout(res, 1000)) + + return +} + +function getAccessTokenValue(configuration: string): string { + const parsedConfiguration = parseJSONC(configuration) as Record + + if (typeof parsedConfiguration === 'object') { + return parsedConfiguration.token ?? '' + } + + return '' +} + +interface GithubFormConfiguration { + isAffiliatedRepositories: boolean + isOrgsRepositories: boolean + isSetRepositories: boolean + repositories: string[] + organizations: string[] +} + +function getRepositoriesSettings(configuration: string): GithubFormConfiguration { + const parsedConfiguration = parseJSONC(configuration) as Record + + if (typeof parsedConfiguration === 'object') { + const repositoryQuery: string[] = parsedConfiguration.repositoryQuery ?? [] + + return { + isAffiliatedRepositories: repositoryQuery.includes('affiliated'), + isOrgsRepositories: Array.isArray(parsedConfiguration.orgs), + organizations: parsedConfiguration.orgs ?? [], + isSetRepositories: Array.isArray(parsedConfiguration.repos), + repositories: parsedConfiguration.repos ?? [], + } + } + + return { + isAffiliatedRepositories: false, + isOrgsRepositories: false, + organizations: [], + isSetRepositories: false, + repositories: [], + } +} diff --git a/client/web/src/setup-wizard/components/remote-repositories-step/components/code-hosts/github/GithubEntityPickers.tsx b/client/web/src/setup-wizard/components/remote-repositories-step/components/code-hosts/github/GithubEntityPickers.tsx new file mode 100644 index 000000000000..5c390289ede1 --- /dev/null +++ b/client/web/src/setup-wizard/components/remote-repositories-step/components/code-hosts/github/GithubEntityPickers.tsx @@ -0,0 +1,108 @@ +import { FC, useState } from 'react' + +import { mdiGithub } from '@mdi/js' +import { identity } from 'lodash' + +import { + Icon, + MultiCombobox, + MultiComboboxInput, + MultiComboboxList, + MultiComboboxOption, + MultiComboboxOptionText, +} from '@sourcegraph/wildcard' + +const DEMO_ORGS_SUGGESTIONS = [ + 'Sourcegraph org', + 'My personal organization', + 'Golang working group', + 'University labs', + 'React working group', +] + +interface GithubOrganizationsPickerProps { + organizations: string[] + onChange: (orginaziations: string[]) => void +} + +export const GithubOrganizationsPicker: FC = props => { + const { organizations, onChange } = props + const [searchTerm, setSearchTerm] = useState('') + + const suggestions = DEMO_ORGS_SUGGESTIONS.filter(item => !organizations.find(selectedItem => selectedItem === item)) + + return ( + + setSearchTerm(event.target.value)} + /> + + Pick at least one organization and we clone all repositories that this organzization has + + + + {items => + items.map((item, index) => ( + + + + )) + } + + + ) +} + +const DEMO_REPOS_SUGGESTIONS = [ + 'sourcegraph/sourcegraph', + 'sourcegraph/about', + 'personal/my-project', + 'peraonal/university-labs', + 'facebook/react', +] + +interface GithubRepositoriesPickerProps { + repositories: string[] + onChange: (repositories: string[]) => void +} + +export const GithubRepositoriesPicker: FC = props => { + const { repositories, onChange } = props + + const [searchTerm, setSearchTerm] = useState('') + + const suggestions = DEMO_REPOS_SUGGESTIONS.filter(item => !repositories.find(selectedItem => selectedItem === item)) + + return ( + + setSearchTerm(event.target.value)} + /> + Pick at least one repository + + + {items => + items.map((item, index) => ( + + + + )) + } + + + ) +} diff --git a/client/web/src/setup-wizard/components/remote-repositories-step/components/code-hosts/index.ts b/client/web/src/setup-wizard/components/remote-repositories-step/components/code-hosts/index.ts new file mode 100644 index 000000000000..6673e24f010d --- /dev/null +++ b/client/web/src/setup-wizard/components/remote-repositories-step/components/code-hosts/index.ts @@ -0,0 +1,2 @@ +export { CodeHostCreation } from './CodeHostCreation' +export { CodeHostEdit } from './CodeHostEdit' diff --git a/client/web/src/setup-wizard/components/remote-repositories-step/components/navigation/CodeHostsNavigation.module.scss b/client/web/src/setup-wizard/components/remote-repositories-step/components/navigation/CodeHostsNavigation.module.scss new file mode 100644 index 000000000000..4d8c987f3354 --- /dev/null +++ b/client/web/src/setup-wizard/components/remote-repositories-step/components/navigation/CodeHostsNavigation.module.scss @@ -0,0 +1,130 @@ +.loading-state { + display: flex; + align-items: center; + gap: 0.25rem; + color: var(--text-muted); +} + +.empty-state { + // Top margin to compensate main and left content first line texts + margin-top: 0.2rem; + + display: flex; + gap: 0.25rem; + align-items: baseline; + color: var(--text-muted); + + &-icon { + flex-shrink: 0; + } +} + +.list { + list-style: none; + padding: 0; + margin: -0.25rem 0 0 0; + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.delete-button { + color: var(--text-muted); + border-top-left-radius: 0; + border-bottom-left-radius: 0; + + &:hover, + &:focus { + color: var(--danger); + background-color: var(--danger-4); + } + + :global(.theme-dark) & { + &:hover, + &:focus { + color: var(--light-text); + background-color: var(--danger-2); + } + } +} + +.item { + display: flex; + margin: 0 -0.5rem; + border-radius: 3px; + + &:hover { + background-color: var(--secondary-2); + } + + &--with-more-link { + border-top: 1px solid var(--border-color); + position: sticky; + bottom: 51px; + background-color: var(--light-text); + margin-top: 0.5rem; + margin-bottom: -1rem; + margin-left: -1rem; + margin-right: -1rem; + padding: 0 0.5rem; + } + + &--creation { + display: flex; + justify-content: flex-start; + align-items: flex-start; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + } + + &--active { + color: var(--light-text) !important; + background-color: var(--primary); + + .item-button, + .item-description-status { + color: var(--light-text); + } + + .delete-button { + color: var(--light-text); + + &:hover, + &:focus { + color: var(--light-text) !important; + background-color: var(--primary-3) !important; + } + } + + &:hover { + background-color: var(--primary); + } + } +} + +.more-link { + padding: 0.5rem; + display: flex; + align-items: center; + gap: 0.25rem; +} + +.item-button { + display: flex; + justify-content: flex-start; + align-items: flex-start; + gap: 0.25rem; + text-align: start; + font-weight: normal; + padding: 0.25rem 0 0.25rem 0.5rem; + flex-grow: 1; +} + +.item-description { + display: flex; + flex-direction: column; + + &-status { + color: var(--text-muted); + } +} diff --git a/client/web/src/setup-wizard/components/remote-repositories-step/components/navigation/CodeHostsNavigation.tsx b/client/web/src/setup-wizard/components/remote-repositories-step/components/navigation/CodeHostsNavigation.tsx new file mode 100644 index 000000000000..02ffa4c1dd79 --- /dev/null +++ b/client/web/src/setup-wizard/components/remote-repositories-step/components/navigation/CodeHostsNavigation.tsx @@ -0,0 +1,151 @@ +import { FC, ReactElement } from 'react' + +import { gql, useQuery } from '@apollo/client' +import { mdiInformationOutline, mdiDelete, mdiPlus } from '@mdi/js' +import classNames from 'classnames' + +import { ErrorAlert, Icon, LoadingSpinner, Button, Tooltip, Link } from '@sourcegraph/wildcard' + +import { GetCodeHostsResult } from '../../../../../graphql-operations' +import { getCodeHostIcon, getCodeHostKindFromURLParam, getCodeHostName } from '../../helpers' + +import styles from './CodeHostsNavigation.module.scss' + +const GET_CODE_HOSTS = gql` + query GetCodeHosts { + externalServices { + nodes { + __typename + id + kind + repoCount + displayName + lastSyncAt + nextSyncAt + } + } + } +` + +interface CodeHostsNavigationProps { + activeConnectionId: string | undefined + createConnectionType: string | undefined + className?: string +} + +export const CodeHostsNavigation: FC = props => { + const { activeConnectionId, createConnectionType, className } = props + + const { data, loading, error, refetch } = useQuery(GET_CODE_HOSTS, { + fetchPolicy: 'cache-and-network', + }) + + if (error && !loading) { + return ( +
    + + +
    + ) + } + + if (!data || (!data && loading)) { + return ( + + Fetching connected code host... + + ) + } + + if (data.externalServices.nodes.length === 0) { + return ( + + + + + Choose at least one of the code host providers from the list on the right. + + ) + } + + return ( +
      + {createConnectionType && } + {data.externalServices.nodes.map(codeHost => ( +
    • + + + + + +
    • + ))} +
    • + + Add more code hosts + +
    • +
    + ) +} + +interface CreateCodeHostConnectionCardProps { + codeHostType: string +} + +function CreateCodeHostConnectionCard(props: CreateCodeHostConnectionCardProps): ReactElement { + const { codeHostType } = props + const codeHostKind = getCodeHostKindFromURLParam(codeHostType) + + return ( +
  • + + + + + + Connect {' '} + {getCodeHostName(codeHostKind)} + + + New code host will appear in the list as soon as you connect it + + +
  • + ) +} diff --git a/client/web/src/setup-wizard/components/remote-repositories-step/components/navigation/index.ts b/client/web/src/setup-wizard/components/remote-repositories-step/components/navigation/index.ts new file mode 100644 index 000000000000..6656de0c1fac --- /dev/null +++ b/client/web/src/setup-wizard/components/remote-repositories-step/components/navigation/index.ts @@ -0,0 +1 @@ +export { CodeHostsNavigation } from './CodeHostsNavigation' diff --git a/client/web/src/setup-wizard/components/remote-repositories-step/helpers.ts b/client/web/src/setup-wizard/components/remote-repositories-step/helpers.ts new file mode 100644 index 000000000000..6fe6b07cddaf --- /dev/null +++ b/client/web/src/setup-wizard/components/remote-repositories-step/helpers.ts @@ -0,0 +1,45 @@ +import { mdiBitbucket, mdiGithub, mdiGitlab, mdiAws } from '@mdi/js' + +import { ExternalServiceKind } from '@sourcegraph/shared/src/graphql-operations' + +export const getCodeHostIcon = (codeHostType: ExternalServiceKind | null): string => { + switch (codeHostType) { + case ExternalServiceKind.GITHUB: + return mdiGithub + case ExternalServiceKind.GITLAB: + return mdiGitlab + case ExternalServiceKind.BITBUCKETCLOUD: + return mdiBitbucket + case ExternalServiceKind.AWSCODECOMMIT: + return mdiAws + + default: + // TODO: Add support for other code host + return '' + } +} + +export const getCodeHostName = (codeHostType: ExternalServiceKind | null): string => { + switch (codeHostType) { + case ExternalServiceKind.GITHUB: + return 'GitHub' + case ExternalServiceKind.GITLAB: + return 'GitLab' + case ExternalServiceKind.BITBUCKETCLOUD: + return 'BitBucket.org' + case ExternalServiceKind.AWSCODECOMMIT: + return 'AWS Code Commit' + + default: + // TODO: Add support for other code host + return 'Unknown' + } +} + +export const getCodeHostURLParam = (codeHostType: ExternalServiceKind): string => codeHostType.toString().toLowerCase() + +export const getCodeHostKindFromURLParam = (possibleCodeHostType: string): ExternalServiceKind | null => { + const possibleKind = ExternalServiceKind[possibleCodeHostType.toUpperCase() as ExternalServiceKind] + + return possibleKind ?? null +} diff --git a/client/web/src/setup-wizard/components/remote-repositories-step/index.ts b/client/web/src/setup-wizard/components/remote-repositories-step/index.ts new file mode 100644 index 000000000000..eb6df0dad149 --- /dev/null +++ b/client/web/src/setup-wizard/components/remote-repositories-step/index.ts @@ -0,0 +1 @@ +export { RemoteRepositoriesStep } from './RemoteRepositoriesStep' diff --git a/client/web/src/setup-wizard/components/setup-steps/SetupSteps.module.scss b/client/web/src/setup-wizard/components/setup-steps/SetupSteps.module.scss index f9f3079b6ea2..ae2bdbffb3b4 100644 --- a/client/web/src/setup-wizard/components/setup-steps/SetupSteps.module.scss +++ b/client/web/src/setup-wizard/components/setup-steps/SetupSteps.module.scss @@ -1,16 +1,16 @@ .root { display: flex; flex-direction: column; - gap: 2rem; + gap: 1rem; flex-grow: 1; justify-content: center; } .header { display: flex; - justify-content: space-between; gap: 2rem; - margin: -1.5rem auto 0; + justify-content: space-between; + margin: -1.5rem auto 0.5rem; &-step { display: flex; @@ -63,12 +63,30 @@ &-step-label { font-weight: normal; + + // Font normal and font bold have different text size, in order to + // avoid text layout jumps we reserve element width based on bold version + // of label. + &::before { + content: attr(data-label-text); + display: block; + font-weight: bold; + height: 0; + visibility: hidden; + } + + &--active { + font-weight: bold; + } } } .content { + width: 100%; flex-grow: 1; margin: auto; + max-width: 55rem; + padding: 0.5rem; } .actions { @@ -77,7 +95,8 @@ .navigation { display: flex; - padding: 0.5rem; + position: sticky; + bottom: 0; background-color: var(--gray-02); border-top: 1px solid var(--border-color); @@ -89,8 +108,10 @@ display: flex; flex-grow: 1; gap: 0.5rem; - max-width: 40rem; + width: 100%; + max-width: 55rem; margin: auto; + padding: 0.5rem; } // Hide default next button if the custom button is rendered diff --git a/client/web/src/setup-wizard/components/setup-steps/SetupSteps.tsx b/client/web/src/setup-wizard/components/setup-steps/SetupSteps.tsx index eedf80384454..7200e7039dfe 100644 --- a/client/web/src/setup-wizard/components/setup-steps/SetupSteps.tsx +++ b/client/web/src/setup-wizard/components/setup-steps/SetupSteps.tsx @@ -1,14 +1,14 @@ import { - useRef, Ref, createContext, + ComponentType, FC, - ReactNode, HTMLAttributes, useMemo, useContext, useCallback, useEffect, + useState, } from 'react' import { mdiChevronLeft, mdiChevronRight } from '@mdi/js' @@ -24,7 +24,7 @@ export interface StepConfiguration { id: string path: string name: string - render: () => ReactNode + component: ComponentType<{ className?: string }> } interface SetupStepsContextData { @@ -55,7 +55,7 @@ export const SetupStepsRoot: FC = props => { const navigate = useNavigate() const location = useLocation() - const nextButtonPortalRef = useRef(null) + const [nextButtonPortal, setNextButtonPortal] = useState(null) // Resolve current setup step and its index by URL matches const { currentStep, activeStepIndex } = useMemo(() => { @@ -114,28 +114,30 @@ export const SetupStepsRoot: FC = props => { const cachedContext = useMemo( () => ({ steps, - nextButtonPortalElement: nextButtonPortalRef.current, + nextButtonPortalElement: nextButtonPortal, onNextStep: handleGoToNextStep, }), - [handleGoToNextStep, steps] + [handleGoToNextStep, steps, nextButtonPortal] ) return (
    -
    - - {steps.map(step => ( - - ))} - } /> - -
    + + {steps.map(({ path, component: Component }) => ( + } + /> + ))} + } /> + @@ -164,7 +166,14 @@ export const SetupStepsHeader: FC = props => { > {index + 1} - {step.name} + + {step.name} +
    ))} diff --git a/client/wildcard/src/components/Combobox/Combobox.module.scss b/client/wildcard/src/components/Combobox/Combobox.module.scss index e883e43dfd28..8bdb6fc5fa8c 100644 --- a/client/wildcard/src/components/Combobox/Combobox.module.scss +++ b/client/wildcard/src/components/Combobox/Combobox.module.scss @@ -45,6 +45,8 @@ } .item { + border-radius: 3px; + &--disabled { cursor: not-allowed; } diff --git a/client/wildcard/src/components/Combobox/Combobox.tsx b/client/wildcard/src/components/Combobox/Combobox.tsx index 956cf051742d..27bdf5b161c5 100644 --- a/client/wildcard/src/components/Combobox/Combobox.tsx +++ b/client/wildcard/src/components/Combobox/Combobox.tsx @@ -240,7 +240,7 @@ export const ComboboxOption = forwardRef((props, ref) => {
  • {typeof children === 'function' ? children(context) : children ?? value} @@ -249,7 +249,12 @@ export const ComboboxOption = forwardRef((props, ref) => { } return ( - + {children} ) diff --git a/client/wildcard/src/components/Container/Container.tsx b/client/wildcard/src/components/Container/Container.tsx index 527421e18457..ad7a6d808595 100644 --- a/client/wildcard/src/components/Container/Container.tsx +++ b/client/wildcard/src/components/Container/Container.tsx @@ -1,20 +1,14 @@ -import React from 'react' +import { forwardRef } from 'react' import classNames from 'classnames' -import styles from './Container.module.scss' +import { ForwardReferenceComponent } from '../../types' -interface Props extends React.HTMLAttributes { - className?: string -} +import styles from './Container.module.scss' /** A container wrapper. Used for grouping content together. */ -export const Container: React.FunctionComponent> = ({ - children, - className, - ...props -}) => ( -
    - {children} -
    -) +export const Container = forwardRef((props, ref) => { + const { as: Comp = 'div', className, ...attributes } = props + + return +}) as ForwardReferenceComponent<'div'> diff --git a/client/wildcard/src/components/Tabs/useShouldPanelRender.ts b/client/wildcard/src/components/Tabs/useShouldPanelRender.ts index 83b55bbecf86..823d8c6527b1 100644 --- a/client/wildcard/src/components/Tabs/useShouldPanelRender.ts +++ b/client/wildcard/src/components/Tabs/useShouldPanelRender.ts @@ -1,10 +1,10 @@ -import { useLayoutEffect, useState } from 'react' +import { useLayoutEffect, useState, ReactNode } from 'react' import { useTabsContext as useReachTabsContext } from '@reach/tabs' import { useTablePanelIndex, useTabsState } from './context' -export function useShouldPanelRender(children: React.ReactNode): boolean { +export function useShouldPanelRender(children: ReactNode): boolean { const { selectedIndex } = useReachTabsContext() const index = useTablePanelIndex() const { diff --git a/client/wildcard/src/components/Tooltip/Tooltip.tsx b/client/wildcard/src/components/Tooltip/Tooltip.tsx index 2836c0eb720d..6b8318457d18 100644 --- a/client/wildcard/src/components/Tooltip/Tooltip.tsx +++ b/client/wildcard/src/components/Tooltip/Tooltip.tsx @@ -44,6 +44,8 @@ export interface TooltipProps { /** The open state of the tooltip when it is initially rendered. Defaults to `false`. */ defaultOpen?: boolean + debounce?: number + /** * The preferred side of the trigger to render against when open. Will be reversed if * a collision is detected. Defaults to `bottom`. @@ -76,7 +78,15 @@ export interface TooltipProps { * it must have an `aria-label`. */ export const Tooltip: FC = props => { - const { children, content, open, defaultOpen = false, placement = 'bottom', onOpenChange = noop } = props + const { + children, + content, + open, + defaultOpen = false, + placement = 'bottom', + debounce = 100, + onOpenChange = noop, + } = props const [target, setTarget] = useState(null) const [tail, setTail] = useState(null) @@ -152,7 +162,7 @@ export const Tooltip: FC = props => { } const tooltipId = `tooltip-${useId()}` - const isOpenDebounced = useDebounce(isOpen, 100) + const isOpenDebounced = useDebounce(isOpen, debounce) return ( <> diff --git a/client/wildcard/src/hooks/useDebounce.ts b/client/wildcard/src/hooks/useDebounce.ts index 2717f8e386ec..ec0ca679276a 100644 --- a/client/wildcard/src/hooks/useDebounce.ts +++ b/client/wildcard/src/hooks/useDebounce.ts @@ -15,5 +15,5 @@ export const useDebounce = (value: T, delay: number): T => { return () => clearTimeout(handler) }, [delay, value]) - return debouncedValue + return delay === 0 ? value : debouncedValue }