Replace root component props with input fields

Signed-off-by: Erik Engervall <erik.engervall@gmail.com>
This commit is contained in:
Erik Engervall
2021-04-14 23:20:42 +02:00
parent 191cb7f893
commit 6691570ec1
15 changed files with 526 additions and 69 deletions
@@ -29,6 +29,7 @@
"@octokit/rest": "^18.0.12",
"luxon": "^1.26.0",
"react-dom": "^16.13.1",
"react-hook-form": "^6.6.0",
"react-router": "6.0.0-beta.0",
"react-use": "^15.3.3",
"react": "^16.13.1"
@@ -15,10 +15,11 @@
*/
import { Alert } from '@material-ui/lab';
import { CircularProgress, makeStyles } from '@material-ui/core';
import { makeStyles } from '@material-ui/core';
import { useAsync } from 'react-use';
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { useApi, ContentHeader, ErrorBoundary } from '@backstage/core';
import { useForm } from 'react-hook-form';
import { CreateRc } from './cards/createRc/CreateRc';
import { getGitHubBatchInfo } from './sideEffects/getGitHubBatchInfo';
@@ -35,11 +36,11 @@ import {
PluginApiClientContext,
usePluginApiClientContext,
} from './contexts/PluginApiClientContext';
import {
ProjectContext,
useProjectContext,
Project,
} from './contexts/ProjectContext';
import { ProjectContext, Project } from './contexts/ProjectContext';
import { isProjectValid } from './cards/projectForm/isProjectValid';
import { InfoCardPlus } from './components/InfoCardPlus';
import { RepoDetailsForm } from './cards/projectForm/RepoDetailsForm';
import { CenteredCircularProgress } from './components/CenteredCircularProgress';
interface GitHubReleaseManagerProps {
components?: {
@@ -62,30 +63,54 @@ export function GitHubReleaseManager({
}: GitHubReleaseManagerProps) {
const pluginApiClient = useApi(githubReleaseManagerApiRef);
const classes = useStyles();
const usernameResponse = useAsync(() => pluginApiClient.getUsername());
const { control, watch } = useForm();
const project: Project = watch('repo-details-form');
const project: Project = {
owner: 'erikengervall',
repo: 'playground',
versioningStrategy: 'semver',
};
if (usernameResponse.error) {
return <Alert severity="error">{usernameResponse.error.message}</Alert>;
}
if (usernameResponse.loading) {
return <CenteredCircularProgress />;
}
if (!usernameResponse.value?.username) {
return <Alert severity="error">Unable to retrieve username</Alert>;
}
return (
<ProjectContext.Provider value={project}>
{/* @ts-ignore-error TODO: Update interface for PluginApiClient */}
<PluginApiClientContext.Provider value={pluginApiClient}>
<div className={classes.root}>
<ContentHeader title="GitHub Release Manager" />
<PluginApiClientContext.Provider
value={
pluginApiClient as any // TODO: Fix type errors
}
>
<div className={classes.root}>
<ContentHeader title="GitHub Release Manager" />
<Cards components={components} />
</div>
</PluginApiClientContext.Provider>
</ProjectContext.Provider>
<InfoCardPlus>
<RepoDetailsForm
control={control}
username={usernameResponse.value.username}
/>
</InfoCardPlus>
{isProjectValid(project) && (
<Cards components={components} project={project} />
)}
</div>
</PluginApiClientContext.Provider>
);
}
function Cards({ components }: GitHubReleaseManagerProps) {
function Cards({
components,
project,
}: {
components: GitHubReleaseManagerProps['components'];
project: Project;
}) {
const pluginApiClient = usePluginApiClientContext();
const project = useProjectContext();
const [refetch, setRefetch] = useState(0);
const gitHubBatchInfo = useAsync(
getGitHubBatchInfo({ project, pluginApiClient }),
@@ -97,11 +122,7 @@ function Cards({ components }: GitHubReleaseManagerProps) {
}
if (gitHubBatchInfo.loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center' }}>
<CircularProgress />
</div>
);
return <CenteredCircularProgress />;
}
if (gitHubBatchInfo.value === undefined) {
@@ -120,38 +141,40 @@ function Cards({ components }: GitHubReleaseManagerProps) {
}
return (
<ErrorBoundary>
<Info
latestRelease={gitHubBatchInfo.value.latestRelease}
releaseBranch={gitHubBatchInfo.value.releaseBranch}
/>
{components?.default?.createRc?.omit !== true && (
<CreateRc
<ProjectContext.Provider value={project}>
<ErrorBoundary>
<Info
latestRelease={gitHubBatchInfo.value.latestRelease}
releaseBranch={gitHubBatchInfo.value.releaseBranch}
defaultBranch={gitHubBatchInfo.value.repository.defaultBranch}
setRefetch={setRefetch}
successCb={components?.default?.createRc?.successCb}
/>
)}
{components?.default?.promoteRc?.omit !== true && (
<PromoteRc
latestRelease={gitHubBatchInfo.value.latestRelease}
setRefetch={setRefetch}
successCb={components?.default?.promoteRc?.successCb}
/>
)}
{components?.default?.createRc?.omit !== true && (
<CreateRc
latestRelease={gitHubBatchInfo.value.latestRelease}
releaseBranch={gitHubBatchInfo.value.releaseBranch}
defaultBranch={gitHubBatchInfo.value.repository.defaultBranch}
setRefetch={setRefetch}
successCb={components?.default?.createRc?.successCb}
/>
)}
{components?.default?.patch?.omit !== true && (
<Patch
latestRelease={gitHubBatchInfo.value.latestRelease}
releaseBranch={gitHubBatchInfo.value.releaseBranch}
setRefetch={setRefetch}
successCb={components?.default?.patch?.successCb}
/>
)}
</ErrorBoundary>
{components?.default?.promoteRc?.omit !== true && (
<PromoteRc
latestRelease={gitHubBatchInfo.value.latestRelease}
setRefetch={setRefetch}
successCb={components?.default?.promoteRc?.successCb}
/>
)}
{components?.default?.patch?.omit !== true && (
<Patch
latestRelease={gitHubBatchInfo.value.latestRelease}
releaseBranch={gitHubBatchInfo.value.releaseBranch}
setRefetch={setRefetch}
successCb={components?.default?.patch?.successCb}
/>
)}
</ErrorBoundary>
</ProjectContext.Provider>
);
}
@@ -157,10 +157,13 @@ export interface IPluginApiClient {
} & PartialProject,
) => Promise<Todo>;
};
getOrganizations: (args: { ownerIsUser: boolean }) => Promise<Todo>;
getUsername: () => Promise<{ username: string }>;
getRepositories: (args: { owner: string; username: string }) => Promise<Todo>;
}
export class PluginApiClient implements IPluginApiClient {
// private readonly getAccessToken: any;
private readonly githubAuthApi: OAuthApi;
private readonly baseUrl: string;
readonly host: string;
@@ -174,8 +177,6 @@ export class PluginApiClient implements IPluginApiClient {
}) {
this.githubAuthApi = githubAuthApi;
// this.getAccessToken = () => this.githubAuthApi.getAccessToken();
const githubIntegrationConfig = this.getGithubIntegrationConfig({
configApi,
});
@@ -190,11 +191,13 @@ export class PluginApiClient implements IPluginApiClient {
configApi.getOptionalConfigArray('integrations.github') ?? [],
);
const githubIntegrationConfig = configs.find(
v => v.host === 'github.com' || v.host.startsWith('ghe.'),
const githubIntegrationEnterpriseConfig = configs.find(v =>
v.host.startsWith('ghe.'),
);
const githubIntegrationConfig = configs.find(v => v.host === 'github.com');
return githubIntegrationConfig;
// Prioritize enterprise configs if available
return githubIntegrationEnterpriseConfig ?? githubIntegrationConfig;
}
private async getOctokit() {
@@ -216,6 +219,40 @@ export class PluginApiClient implements IPluginApiClient {
return `${owner}/${repo}`;
}
async getOrganizations() {
const { octokit } = await this.getOctokit();
const { data: orgs } = await octokit.orgs.listForAuthenticatedUser();
return { orgs };
}
async getRepositories({
owner,
username,
}: {
owner: string;
username: string;
}) {
const { octokit } = await this.getOctokit();
if (owner === username) {
const { data: repos } = await octokit.repos.listForUser({ username });
return { repos };
}
const { data: repos } = await octokit.repos.listForOrg({ org: owner });
return { repos };
}
async getUsername() {
const { octokit } = await this.getOctokit();
const { data: user } = await octokit.users.getAuthenticated();
return { username: user.login };
}
async getRecentCommits({
owner,
repo,
@@ -20,7 +20,6 @@ import { useAsync, useAsyncFn } from 'react-use';
import {
Button,
Checkbox,
CircularProgress,
IconButton,
Link,
List,
@@ -50,6 +49,7 @@ import { useStyles } from '../../styles/styles';
import { TEST_IDS } from '../../test-helpers/test-ids';
import { patch } from './sideEffects/patch';
import { useProjectContext } from '../../contexts/ProjectContext';
import { CenteredCircularProgress } from '../../components/CenteredCircularProgress';
interface PatchBodyProps {
bumpedTag: string;
@@ -124,7 +124,7 @@ export const PatchBody = ({
return <Alert severity="error">{patchReleaseResponse.error.message}</Alert>;
}
if (githubDataResponse.loading) {
return <CircularProgress data-testid={TEST_IDS.patch.loading} />;
return <CenteredCircularProgress data-testid={TEST_IDS.patch.loading} />;
}
function Description() {
@@ -0,0 +1,85 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { useAsync } from 'react-use';
import { ControllerRenderProps } from 'react-hook-form';
import { Alert } from '@material-ui/lab';
import { FormControl, InputLabel, MenuItem, Select } from '@material-ui/core';
import { usePluginApiClientContext } from '../../contexts/PluginApiClientContext';
import { useFormClasses } from './styles';
import { CenteredCircularProgress } from '../../components/CenteredCircularProgress';
import { Project } from '../../contexts/ProjectContext';
export function Owner({
controllerRenderProps,
username,
}: {
controllerRenderProps: ControllerRenderProps;
username: string;
}) {
const pluginApiClient = usePluginApiClientContext();
const formClasses = useFormClasses();
const project: Project = controllerRenderProps.value;
const { loading, error, value } = useAsync(() =>
pluginApiClient.getOrganizations(),
);
if (error) {
return <Alert severity="error">{error.message}</Alert>;
}
if (loading) {
return <CenteredCircularProgress />;
}
if (!value?.orgs) {
return <Alert severity="error">Could not fetch organizations</Alert>;
}
return (
<FormControl className={formClasses.formControl}>
<InputLabel id="owner-select-label">Organizations</InputLabel>
<Select
labelId="owner-select-label"
id="owner-select"
value={project.owner}
onChange={event => {
controllerRenderProps.onChange({
...project,
owner: event.target.value,
repo: '',
} as Project);
}}
className={formClasses.selectEmpty}
>
<MenuItem value="">
<em>None</em>
</MenuItem>
<MenuItem value={username}>
<strong>{username}</strong>
</MenuItem>
{value.orgs.map((org, index) => (
<MenuItem key={`organization-${index}`} value={org.login}>
{org.login}
</MenuItem>
))}
</Select>
</FormControl>
);
}
@@ -0,0 +1,90 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { useAsync } from 'react-use';
import { FormControl, InputLabel, Select, MenuItem } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import { ControllerRenderProps, useForm } from 'react-hook-form';
import { usePluginApiClientContext } from '../../contexts/PluginApiClientContext';
import { useFormClasses } from './styles';
import { CenteredCircularProgress } from '../../components/CenteredCircularProgress';
import { Project } from '../../contexts/ProjectContext';
export function Repo({
username,
controllerRenderProps,
}: {
username: string;
controllerRenderProps: ControllerRenderProps;
}) {
const pluginApiClient = usePluginApiClientContext();
const formClasses = useFormClasses();
const project: Project = controllerRenderProps.value;
const { loading, error, value } = useAsync(
() =>
pluginApiClient.getRepositories({
owner: project.owner,
username,
}),
[project.owner],
);
if (error) {
return <Alert severity="error">{error.message}</Alert>;
}
if (loading) {
return <CenteredCircularProgress />;
}
if (!value?.repos) {
return (
<Alert severity="error">
Could not fetch repositories for "{project.owner}"
</Alert>
);
}
return (
<FormControl className={formClasses.formControl}>
<InputLabel id="repo-select-label">Repositories</InputLabel>
<Select
labelId="repo-select-label"
id="repo-select"
value={project.repo}
onChange={event => {
controllerRenderProps.onChange({
...project,
repo: event.target.value,
} as Project);
}}
className={formClasses.selectEmpty}
>
<MenuItem value="">
<em>None</em>
</MenuItem>
{value.repos.map((repository, index) => (
<MenuItem key={`repository-${index}`} value={repository.name}>
{repository.name}
</MenuItem>
))}
</Select>
</FormControl>
);
}
@@ -0,0 +1,66 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { Controller, useForm } from 'react-hook-form';
import { Project } from '../../contexts/ProjectContext';
import { VersioningStrategy } from './VersioningStrategy';
import { Owner } from './Owner';
import { Repo } from './Repo';
export function RepoDetailsForm({
control,
username,
}: {
control: ReturnType<typeof useForm>['control'];
username: string;
}) {
return (
<Controller
render={controllerRenderProps => {
const project: Project = controllerRenderProps.value;
return (
<>
<VersioningStrategy controllerRenderProps={controllerRenderProps} />
<Owner
controllerRenderProps={controllerRenderProps}
username={username}
/>
{project.owner.length > 0 && (
<Repo
controllerRenderProps={controllerRenderProps}
username={username}
/>
)}
</>
);
}}
control={control}
name="repo-details-form"
defaultValue={
{
owner: '',
repo: '',
versioningStrategy: 'semver',
} as Project
}
/>
);
}
@@ -0,0 +1,62 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
FormControl,
FormControlLabel,
FormLabel,
Radio,
RadioGroup,
} from '@material-ui/core';
import React from 'react';
import { ControllerRenderProps } from 'react-hook-form';
import { Project } from '../../contexts/ProjectContext';
export function VersioningStrategy({
controllerRenderProps,
}: {
controllerRenderProps: ControllerRenderProps;
}) {
const project: Project = controllerRenderProps.value;
return (
<FormControl component="fieldset">
<FormLabel component="legend">Calendar strategy</FormLabel>
<RadioGroup
aria-label="calendar-strategy"
name="calendar-strategy"
value={project.versioningStrategy}
onChange={event => {
controllerRenderProps.onChange({
...project,
versioningStrategy: event.target.value,
} as Project);
}}
>
<FormControlLabel
value="semver"
control={<Radio />}
label="Semantic versioning"
/>
<FormControlLabel
value="calver"
control={<Radio />}
label="Calendar versioning"
/>
</RadioGroup>
</FormControl>
);
}
@@ -0,0 +1,25 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Project } from '../../contexts/ProjectContext';
export function isProjectValid(project: any): project is Project {
return (
project?.owner?.length > 0 &&
project?.repo?.length > 0 &&
project?.versioningStrategy?.length > 0
);
}
@@ -0,0 +1,29 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createStyles, makeStyles, Theme } from '@material-ui/core';
export const useFormClasses = makeStyles((theme: Theme) =>
createStyles({
formControl: {
margin: theme.spacing(1),
minWidth: 120,
},
selectEmpty: {
marginTop: theme.spacing(2),
},
}),
);
@@ -0,0 +1,26 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { CircularProgress } from '@material-ui/core';
export const CenteredCircularProgress = () => {
return (
<div style={{ display: 'flex', justifyContent: 'center' }}>
<CircularProgress />
</div>
);
};
@@ -17,7 +17,6 @@
import React, { PropsWithChildren } from 'react';
import {
List,
CircularProgress,
Button,
Dialog,
DialogActions,
@@ -28,6 +27,7 @@ import {
import { ResponseStep, SetRefetch } from '../../types/types';
import { TEST_IDS } from '../../test-helpers/test-ids';
import { ResponseStepListItem } from './ResponseStepListItem';
import { CenteredCircularProgress } from '../CenteredCircularProgress';
interface ResponseStepListProps {
responseSteps?: ResponseStep[];
@@ -68,7 +68,7 @@ export const ResponseStepList = ({
{loading || !responseSteps ? (
<div style={{ margin: 10, textAlign: 'center' }}>
<CircularProgress
<CenteredCircularProgress
data-testid={TEST_IDS.components.circularProgress}
/>
</div>
@@ -22,10 +22,10 @@ export type CalverTagParts = {
patch: number;
};
export const calverRegexp = /(rc|version)-([0-9]{4}\.[0-9]{2}\.[0-9]{2})_([0-9]+)/;
export function getCalverTagParts(tag: string) {
const result = tag.match(
/(rc|version)-([0-9]{4}\.[0-9]{2}\.[0-9]{2})_([0-9]+)/,
);
const result = tag.match(calverRegexp);
if (result === null || result.length < 4) {
throw new GitHubReleaseManagerError('Invalid calver tag');
@@ -15,6 +15,7 @@
*/
import { GitHubReleaseManagerError } from '../../errors/GitHubReleaseManagerError';
import { calverRegexp } from './getCalverTagParts';
export type SemverTagParts = {
prefix: string;
@@ -30,6 +31,10 @@ export function getSemverTagParts(tag: string) {
throw new GitHubReleaseManagerError('Invalid semver tag');
}
if (tag.match(calverRegexp)) {
throw new GitHubReleaseManagerError('Invalid semver tag, found calver');
}
const tagParts: SemverTagParts = {
prefix: result[1],
major: parseInt(result[2], 10),
@@ -109,5 +109,13 @@ describe('getTagParts', () => {
getTagParts({ project: mockSemverProject, tag: 'rc-1.2' }),
).toThrowErrorMatchingInlineSnapshot(`"Invalid semver tag"`);
});
it('should throw for invalid semver (founds calver)', () => {
expect(() =>
getTagParts({ project: mockSemverProject, tag: 'rc-1337.01.01_1' }),
).toThrowErrorMatchingInlineSnapshot(
`"Invalid semver tag, found calver"`,
);
});
});
});