made link to entity optional

Signed-off-by: Lykke Axlin <lykkeaxlin@hotmail.com>

Co-authored-by: klaraab <klarabroman@live.se>
This commit is contained in:
Lykke Axlin
2021-12-13 15:45:52 +01:00
parent 86db169be1
commit ad86729f5d
45 changed files with 1973 additions and 1050 deletions
+52 -38
View File
@@ -14,13 +14,11 @@
* limitations under the License.
*/
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import {
createApiRef,
DiscoveryApi,
IdentityApi,
} from '@backstage/core-plugin-api';
import { BazaarProject } from './types';
export const bazaarApiRef = createApiRef<BazaarApi>({
id: 'bazaar',
@@ -28,19 +26,23 @@ export const bazaarApiRef = createApiRef<BazaarApi>({
});
export interface BazaarApi {
updateMetadata(bazaarProject: BazaarProject): Promise<any>;
updateProject(bazaarProject: any): Promise<any>;
getMetadata(entity: Entity): Promise<any>;
addProject(bazaarProject: any): Promise<any>;
getMembers(entity: Entity): Promise<any>;
getProjectById(id: number): Promise<any>;
deleteMember(entity: Entity): Promise<void>;
getProjectByRef(entityRef: string): Promise<any>;
addMember(entity: Entity): Promise<void>;
getMembers(id: number): Promise<any>;
getEntities(): Promise<any>;
deleteMember(id: number, userId: string): Promise<void>;
deleteEntity(bazaarProject: BazaarProject): Promise<void>;
addMember(id: number, userId: string): Promise<void>;
getProjects(): Promise<any>;
deleteProject(id: number): Promise<void>;
}
export class BazaarClient implements BazaarApi {
@@ -55,10 +57,10 @@ export class BazaarClient implements BazaarApi {
this.discoveryApi = options.discoveryApi;
}
async updateMetadata(bazaarProject: BazaarProject): Promise<any> {
async updateProject(bazaarProject: any): Promise<any> {
const baseUrl = await this.discoveryApi.getBaseUrl('bazaar');
return await fetch(`${baseUrl}/metadata`, {
return await fetch(`${baseUrl}/projects`, {
method: 'PUT',
headers: {
Accept: 'application/json',
@@ -68,11 +70,34 @@ export class BazaarClient implements BazaarApi {
}).then(resp => resp.json());
}
async getMetadata(entity: Entity): Promise<any> {
async addProject(bazaarProject: any): Promise<any> {
const baseUrl = await this.discoveryApi.getBaseUrl('bazaar');
return await fetch(`${baseUrl}/projects`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(bazaarProject),
}).then(resp => resp.json());
}
async getProjectById(id: number): Promise<any> {
const baseUrl = await this.discoveryApi.getBaseUrl('bazaar');
const response = await fetch(`${baseUrl}/projects/id/${id}`, {
method: 'GET',
});
return response.ok ? response : null;
}
async getProjectByRef(entityRef: string): Promise<any> {
const baseUrl = await this.discoveryApi.getBaseUrl('bazaar');
const response = await fetch(
`${baseUrl}/metadata/${encodeURIComponent(stringifyEntityRef(entity))}`,
`${baseUrl}/projects/ref/${encodeURIComponent(entityRef)}`,
{
method: 'GET',
},
@@ -81,60 +106,49 @@ export class BazaarClient implements BazaarApi {
return response.ok ? response : null;
}
async getMembers(entity: Entity): Promise<any> {
async getMembers(id: number): Promise<any> {
const baseUrl = await this.discoveryApi.getBaseUrl('bazaar');
return await fetch(
`${baseUrl}/members/${encodeURIComponent(stringifyEntityRef(entity))}`,
{
method: 'GET',
},
).then(resp => resp.json());
return await fetch(`${baseUrl}/projects/${id}/members`, {
method: 'GET',
}).then(resp => resp.json());
}
async addMember(entity: Entity): Promise<void> {
async addMember(id: number, userId: string): Promise<void> {
const baseUrl = await this.discoveryApi.getBaseUrl('bazaar');
await fetch(`${baseUrl}/member`, {
await fetch(`${baseUrl}/projects/${id}/member/${userId}`, {
method: 'PUT',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
entity_ref: stringifyEntityRef(entity),
user_id: this.identityApi.getUserId(),
picture: this.identityApi.getProfile()?.picture,
}),
});
}
async deleteMember(entity: Entity): Promise<void> {
async deleteMember(id: number, userId: string): Promise<void> {
const baseUrl = await this.discoveryApi.getBaseUrl('bazaar');
await fetch(
`${baseUrl}/member/${encodeURIComponent(
stringifyEntityRef(entity),
)}/${this.identityApi.getUserId()}`,
{
method: 'DELETE',
},
);
await fetch(`${baseUrl}/projects/${id}/member/${userId}`, {
method: 'DELETE',
});
}
async getEntities(): Promise<any> {
async getProjects(): Promise<any> {
const baseUrl = await this.discoveryApi.getBaseUrl('bazaar');
return await fetch(`${baseUrl}/entities`, {
return await fetch(`${baseUrl}/projects`, {
method: 'GET',
}).then(resp => resp.json());
}
async deleteEntity(bazaarProject: BazaarProject): Promise<void> {
async deleteProject(id: number): Promise<void> {
const baseUrl = await this.discoveryApi.getBaseUrl('bazaar');
const entityRef = bazaarProject.entityRef as string;
await fetch(`${baseUrl}/metadata/${encodeURIComponent(entityRef)}`, {
await fetch(`${baseUrl}/projects/${id}`, {
method: 'DELETE',
});
}
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React, { useState, useEffect } from 'react';
import React, { useState } from 'react';
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import { UseFormReset, UseFormGetValues } from 'react-hook-form';
import { useApi } from '@backstage/core-plugin-api';
@@ -39,18 +39,13 @@ export const AddProjectDialog = ({
fetchCatalogEntities,
}: Props) => {
const bazaarApi = useApi(bazaarApiRef);
const [selectedEntity, setSelectedEntity] = useState(
catalogEntities ? catalogEntities[0] : null,
);
useEffect(() => {
setSelectedEntity(catalogEntities ? catalogEntities[0] : null);
}, [catalogEntities]);
const [selectedEntity, setSelectedEntity] = useState<Entity | null>(null);
const defaultValues = {
name: '',
title: 'Add project',
community: '',
announcement: '',
description: '',
status: 'proposed' as Status,
size: 'medium' as Size,
responsible: '',
@@ -58,59 +53,48 @@ export const AddProjectDialog = ({
endDate: null,
};
const handleListItemClick = (entity: Entity) => {
const handleEntityClick = (entity: Entity) => {
setSelectedEntity(entity);
};
const handleCloseDialog = () => {
setSelectedEntity(catalogEntities ? catalogEntities[0] : null);
handleClose();
};
const handleSave: any = async (
const handleSubmit: any = async (
getValues: UseFormGetValues<FormValues>,
reset: UseFormReset<FormValues>,
) => {
const formValues = getValues();
const response = await bazaarApi.addProject({
...formValues,
entityRef: selectedEntity ? stringifyEntityRef(selectedEntity) : null,
startDate: formValues.startDate ?? null,
endDate: formValues.endDate ?? null,
} as BazaarProject);
if (selectedEntity) {
await bazaarApi.updateMetadata({
name: selectedEntity.metadata.name,
entityRef: stringifyEntityRef(selectedEntity),
announcement: formValues.announcement,
status: formValues.status,
community: formValues.community,
membersCount: 0,
size: formValues.size,
startDate: formValues.startDate ?? null,
endDate: formValues.endDate ?? null,
responsible: formValues.responsible,
} as BazaarProject);
if (response.status === 'ok') {
fetchBazaarProjects();
fetchCatalogEntities();
handleClose();
reset(defaultValues);
}
handleClose();
reset(defaultValues);
};
return (
<ProjectDialog
handleSave={handleSave}
handleSave={handleSubmit}
title="Add project"
isAddForm
defaultValues={defaultValues}
open={open}
projectSelector={
<ProjectSelector
value={selectedEntity?.metadata?.name || ''}
onChange={handleListItemClick}
isFormInvalid={selectedEntity === null}
entities={catalogEntities || []}
onChange={handleEntityClick}
catalogEntities={catalogEntities || []}
disableClearable={false}
defaultValue={null}
label="Select a project"
/>
}
handleClose={handleCloseDialog}
handleClose={handleClose}
/>
);
};
@@ -1,51 +0,0 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { Snackbar, IconButton } from '@material-ui/core';
import CloseIcon from '@material-ui/icons/Close';
import { Alert } from '@material-ui/lab';
type Props = {
open: boolean;
message: JSX.Element;
handleClose: () => void;
};
export const AlertBanner = ({ open, message, handleClose }: Props) => {
return (
<Snackbar
open={open}
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
>
<Alert
severity="error"
action={
<IconButton
color="inherit"
size="small"
onClick={handleClose}
data-testid="error-button-close"
>
<CloseIcon />
</IconButton>
}
>
{message}
</Alert>
</Snackbar>
);
};
@@ -0,0 +1,168 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 {
Grid,
makeStyles,
Card,
CardContent,
Typography,
Link,
GridSize,
} from '@material-ui/core';
import { Avatar } from '@backstage/core-components';
import { AboutField } from '@backstage/plugin-catalog';
import { StatusTag } from '../StatusTag';
import { Member, BazaarProject } from '../../types';
const useStyles = makeStyles({
break: {
wordBreak: 'break-word',
},
});
type Props = {
bazaarProject: BazaarProject;
members: Member[];
descriptionSize: GridSize;
membersSize: GridSize;
};
export const CardContentFields = ({
bazaarProject,
members,
descriptionSize,
membersSize,
}: Props) => {
const classes = useStyles();
return (
<div>
<Card>
<CardContent>
<Grid container>
<Grid item xs={descriptionSize}>
<AboutField label="Description">
{bazaarProject.description
.split('\n')
.map((str: string, i: number) => (
<Typography
key={i}
variant="body2"
paragraph
className={classes.break}
>
{str}
</Typography>
))}
</AboutField>
</Grid>
<Grid
style={{
display: 'flex',
justifyContent: 'flex-end',
}}
item
xs={membersSize}
>
<AboutField label="Latest members">
{members.length ? (
members.slice(0, 7).map((member: Member) => {
return (
<div
style={{
textAlign: 'left',
backgroundColor: '',
marginBottom: '0.3rem',
marginTop: '0.3rem',
display: 'block',
}}
key={member.userId}
>
<Avatar
displayName={member.userId}
customStyles={{
width: '19px',
height: '19px',
fontSize: '8px',
float: 'left',
marginRight: '0.3rem',
marginTop: '0rem',
marginBottom: '0rem',
alignItems: 'left',
textAlign: 'left',
}}
picture={member.picture}
/>
<Link
className={classes.break}
href={`http://github.com/${member.userId}`}
target="_blank"
>
{member?.userId}
</Link>
</div>
);
})
) : (
<div />
)}
</AboutField>
</Grid>
<Grid item xs={2}>
<AboutField label="Status">
<StatusTag status={bazaarProject.status} />
</AboutField>
</Grid>
<Grid item xs={2}>
<AboutField label="size">
<Typography variant="body2">{bazaarProject.size}</Typography>
</AboutField>
</Grid>
<Grid item xs={2}>
<AboutField label="Start date">
<Typography variant="body2">
{bazaarProject.startDate?.substring(0, 10) || ''}
</Typography>
</AboutField>
</Grid>
<Grid item xs={2}>
<AboutField label="End date">
<Typography variant="body2">
{bazaarProject.endDate?.substring(0, 10) || ''}
</Typography>
</AboutField>
</Grid>
<Grid item xs={4}>
<AboutField label="Responsible">
<Typography variant="body2">
{bazaarProject.responsible || ''}
</Typography>
</AboutField>
</Grid>
</Grid>
</CardContent>
</Card>
</div>
);
};
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { AlertBanner } from './AlertBanner';
export { CardContentFields } from './CardContentFields';
@@ -0,0 +1,62 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 Button from '@material-ui/core/Button';
import Dialog from '@material-ui/core/Dialog';
import {
CustomDialogTitle,
DialogActions,
DialogContent,
} from '../CustomDialogTitle';
type Props = {
open: boolean;
handleClose: () => void;
message: (string | JSX.Element)[];
type: 'delete' | 'unlink';
handleSubmit: () => void;
};
export const ConfirmationDialog = ({
open,
handleClose,
message,
type,
handleSubmit,
}: Props) => {
return (
<Dialog
fullWidth
maxWidth="xs"
onClose={handleClose}
aria-labelledby="customized-dialog-title"
open={open}
>
<CustomDialogTitle id="customized-dialog-title" onClose={handleClose}>
{type.charAt(0).toLocaleUpperCase('en-US') + type.slice(1)} project
</CustomDialogTitle>
<DialogContent dividers>{message}</DialogContent>
<DialogActions>
<Button onClick={handleSubmit} color="primary" type="submit">
{type}
</Button>
</DialogActions>
</Dialog>
);
};
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { DeleteProjectDialog } from './DeleteProjectDialog';
export { ConfirmationDialog } from './ConfirmationDialog';
@@ -0,0 +1,87 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 IconButton from '@material-ui/core/IconButton';
import CloseIcon from '@material-ui/icons/Close';
import Typography from '@material-ui/core/Typography';
import MuiDialogTitle from '@material-ui/core/DialogTitle';
import {
Theme,
WithStyles,
withStyles,
createStyles,
} from '@material-ui/core/styles';
import MuiDialogContent from '@material-ui/core/DialogContent';
import MuiDialogActions from '@material-ui/core/DialogActions';
/*
DialogTitleProps, DialogTitle, DialogContent and DialogActions
are copied from the git-release plugin
*/
export interface DialogTitleProps extends WithStyles<typeof styles> {
id: string;
children: React.ReactNode;
onClose: () => void;
}
const styles = (theme: Theme) =>
createStyles({
root: {
margin: 0,
padding: theme.spacing(2),
},
closeButton: {
position: 'absolute',
right: theme.spacing(1),
top: theme.spacing(1),
color: theme.palette.grey[500],
},
});
export const DialogContent = withStyles((theme: Theme) => ({
root: {
padding: theme.spacing(2),
},
}))(MuiDialogContent);
export const DialogActions = withStyles((theme: Theme) => ({
root: {
margin: 0,
padding: theme.spacing(1),
},
}))(MuiDialogActions);
export const CustomDialogTitle = withStyles(styles)(
(props: DialogTitleProps) => {
const { children, classes, onClose, ...other } = props;
return (
<MuiDialogTitle disableTypography className={classes.root} {...other}>
<Typography variant="h6">{children}</Typography>
{onClose ? (
<IconButton
aria-label="close"
className={classes.closeButton}
onClick={onClose}
>
<CloseIcon />
</IconButton>
) : null}
</MuiDialogTitle>
);
},
);
@@ -0,0 +1,21 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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.
*/
export {
CustomDialogTitle,
DialogContent,
DialogActions,
} from './CustomDialogTitle';
@@ -45,7 +45,6 @@ export const DateSelector = ({ name, control, setValue }: Props) => {
<FormControl>
<MuiPickersUtilsProvider utils={LuxonUtils}>
<KeyboardDatePicker
disablePast
disableToolbar
format="dd-MM-yyyy"
label={label}
@@ -1,139 +0,0 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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, { Dispatch, SetStateAction } from 'react';
import {
createStyles,
Theme,
withStyles,
WithStyles,
} from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';
import Dialog from '@material-ui/core/Dialog';
import MuiDialogTitle from '@material-ui/core/DialogTitle';
import MuiDialogContent from '@material-ui/core/DialogContent';
import MuiDialogActions from '@material-ui/core/DialogActions';
import IconButton from '@material-ui/core/IconButton';
import CloseIcon from '@material-ui/icons/Close';
import Typography from '@material-ui/core/Typography';
import { useApi } from '@backstage/core-plugin-api';
import { bazaarApiRef } from '../../api';
import { BazaarProject } from '../../types';
const styles = (theme: Theme) =>
createStyles({
root: {
margin: 0,
padding: theme.spacing(2),
},
closeButton: {
position: 'absolute',
right: theme.spacing(1),
top: theme.spacing(1),
color: theme.palette.grey[500],
},
});
/*
DialogTitleProps, DialogTitle, DialogContent and DialogActions
are copied from the git-release plugin
*/
export interface DialogTitleProps extends WithStyles<typeof styles> {
id: string;
children: React.ReactNode;
onClose: () => void;
}
const DialogTitle = withStyles(styles)((props: DialogTitleProps) => {
const { children, classes, onClose, ...other } = props;
return (
<MuiDialogTitle disableTypography className={classes.root} {...other}>
<Typography variant="h6">{children}</Typography>
{onClose ? (
<IconButton
aria-label="close"
className={classes.closeButton}
onClick={onClose}
>
<CloseIcon />
</IconButton>
) : null}
</MuiDialogTitle>
);
});
const DialogContent = withStyles((theme: Theme) => ({
root: {
padding: theme.spacing(2),
},
}))(MuiDialogContent);
const DialogActions = withStyles((theme: Theme) => ({
root: {
margin: 0,
padding: theme.spacing(1),
},
}))(MuiDialogActions);
type Props = {
bazaarProject: BazaarProject;
openDelete: boolean;
handleClose: () => void;
setIsBazaar: Dispatch<SetStateAction<boolean>>;
};
export const DeleteProjectDialog = ({
bazaarProject,
openDelete,
handleClose,
setIsBazaar,
}: Props) => {
const handleCloseAndClear = () => {
handleClose();
};
const bazaarApi = useApi(bazaarApiRef);
const handleSubmit = async () => {
await bazaarApi.deleteEntity(bazaarProject);
setIsBazaar(false);
handleCloseAndClear();
};
return (
<Dialog
fullWidth
maxWidth="xs"
onClose={handleCloseAndClear}
aria-labelledby="customized-dialog-title"
open={openDelete}
>
<DialogTitle id="customized-dialog-title" onClose={handleCloseAndClear}>
Delete project
</DialogTitle>
<DialogContent dividers>
Are you sure you want to delete this project from the Bazaar?
</DialogContent>
<DialogActions>
<Button onClick={handleSubmit} color="primary" type="submit">
Delete
</Button>
</DialogActions>
</Dialog>
);
};
@@ -17,49 +17,48 @@
import React from 'react';
import { Control, UseFormSetValue } from 'react-hook-form';
import { FormValues } from '../../types';
import { Typography } from '@material-ui/core';
import { DateSelector } from '../DateSelector/DateSelector';
import { Typography, makeStyles } from '@material-ui/core';
type Props = {
control: Control<FormValues, object>;
setValue: UseFormSetValue<FormValues>;
};
const useStyles = makeStyles({
container: {
marginTop: '0.25rem',
textAlign: 'center',
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
},
startDate: {
float: 'left',
},
endDate: {
float: 'right',
},
dash: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
fontSize: '1.5rem',
margin: '0 1rem',
},
});
export const DoubleDateSelector = ({ control, setValue }: Props) => {
const classes = useStyles();
return (
<div
style={{
marginTop: '0.25rem',
textAlign: 'center',
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
}}
>
<div
style={{
float: 'left',
}}
>
<div className={classes.container}>
<div className={classes.startDate}>
<DateSelector name="startDate" control={control} setValue={setValue} />
</div>
<Typography
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
fontSize: '1.5rem',
margin: '0 1rem',
}}
>
-
</Typography>
<div
style={{
float: 'right',
}}
>
<Typography className={classes.dash}>-</Typography>
<div className={classes.endDate}>
<DateSelector name="endDate" control={control} setValue={setValue} />
</div>
</div>
@@ -15,81 +15,121 @@
*/
import React, { useState, useEffect } from 'react';
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import { useApi } from '@backstage/core-plugin-api';
import { ProjectDialog } from '../ProjectDialog';
import { BazaarProject, FormValues, Size, Status } from '../../types';
import { BazaarProject, FormValues } from '../../types';
import { bazaarApiRef } from '../../api';
import { UseFormGetValues } from 'react-hook-form';
import { ConfirmationDialog } from '../ConfirmationDialog';
import { Button, makeStyles } from '@material-ui/core';
type Props = {
entity: Entity;
bazaarProject: BazaarProject;
openEdit: boolean;
handleEditClose: () => void;
handleCardClose?: () => void;
fetchBazaarProject: () => Promise<BazaarProject | null>;
open: boolean;
handleClose: () => void;
isAddForm: boolean;
fetchBazaarProjects?: () => void;
};
const useStyles = makeStyles({
button: {
marginLeft: '0',
marginRight: 'auto',
},
});
export const EditProjectDialog = ({
entity,
bazaarProject,
openEdit,
handleEditClose,
handleCardClose,
fetchBazaarProject,
open,
handleClose,
fetchBazaarProjects,
}: Props) => {
const classes = useStyles();
const bazaarApi = useApi(bazaarApiRef);
const [openDelete, setOpenDelete] = useState(false);
const [defaultValues, setDefaultValues] = useState<FormValues>({
announcement: bazaarProject.announcement,
community: bazaarProject.community,
status: bazaarProject.status,
size: bazaarProject.size,
startDate: bazaarProject?.startDate ?? null,
endDate: bazaarProject?.endDate ?? null,
responsible: bazaarProject.responsible,
...bazaarProject,
startDate: bazaarProject.startDate ?? null,
endDate: bazaarProject.endDate ?? null,
});
const bazaarApi = useApi(bazaarApiRef);
const handleDeleteClose = () => {
setOpenDelete(false);
handleEditClose();
if (handleCardClose) handleCardClose();
};
const handleDeleteSubmit = async () => {
await bazaarApi.deleteProject(bazaarProject.id);
handleDeleteClose();
if (fetchBazaarProjects) fetchBazaarProjects();
};
useEffect(() => {
setDefaultValues({
announcement: bazaarProject.announcement,
community: bazaarProject.community,
status: bazaarProject.status,
size: bazaarProject.size,
startDate: bazaarProject?.startDate ?? null,
endDate: bazaarProject?.endDate ?? null,
responsible: bazaarProject.responsible,
...bazaarProject,
startDate: bazaarProject.startDate ?? null,
endDate: bazaarProject.endDate ?? null,
});
}, [bazaarProject]);
const handleSave: any = async (getValues: UseFormGetValues<FormValues>) => {
const handleEditSubmit: any = async (
getValues: UseFormGetValues<FormValues>,
) => {
const formValues = getValues();
const updateResponse = await bazaarApi.updateMetadata({
name: entity.metadata.name,
entityRef: stringifyEntityRef(entity),
announcement: formValues.announcement,
status: formValues.status as Status,
community: formValues.community,
const updateResponse = await bazaarApi.updateProject({
...formValues,
id: bazaarProject.id,
entityRef: bazaarProject.entityRef,
membersCount: bazaarProject.membersCount,
size: formValues.size as Size,
startDate: formValues?.startDate ?? null,
endDate: formValues?.endDate ?? null,
responsible: formValues.responsible,
});
if (updateResponse.status === 'ok') fetchBazaarProject();
handleClose();
handleEditClose();
};
return (
<ProjectDialog
title="Edit project"
handleSave={handleSave}
isAddForm={false}
defaultValues={defaultValues}
open={open}
handleClose={handleClose}
/>
<div>
<ConfirmationDialog
open={openDelete}
handleClose={handleDeleteClose}
message={[
'Are you sure you want to delete ',
<b>{bazaarProject.name}</b>,
' from the Bazaar?',
]}
type="delete"
handleSubmit={handleDeleteSubmit}
/>
<ProjectDialog
title="Edit project"
handleSave={handleEditSubmit}
isAddForm={false}
defaultValues={defaultValues}
open={openEdit}
handleClose={handleEditClose}
deleteButton={
<Button
color="primary"
type="submit"
className={classes.button}
onClick={() => {
setOpenDelete(true);
}}
>
Delete project
</Button>
}
/>
</div>
);
};
@@ -15,347 +15,48 @@
*/
import React, { useState, useEffect } from 'react';
import {
Grid,
makeStyles,
Card,
CardContent,
CardHeader,
Typography,
Divider,
IconButton,
Popover,
MenuList,
MenuItem,
ListItemText,
Link,
} from '@material-ui/core';
import {
Progress,
HeaderIconLinkRow,
IconLinkVerticalProps,
Avatar,
} from '@backstage/core-components';
import { useEntity } from '@backstage/plugin-catalog-react';
import { AboutField } from '@backstage/plugin-catalog';
import { StatusTag } from '../StatusTag';
import EditIcon from '@material-ui/icons/Edit';
import ChatIcon from '@material-ui/icons/Chat';
import PersonAddIcon from '@material-ui/icons/PersonAdd';
import MoreVertIcon from '@material-ui/icons/MoreVert';
import DeleteIcon from '@material-ui/icons/Delete';
import { EditProjectDialog } from '../EditProjectDialog';
import { DeleteProjectDialog } from '../DeleteProjectDialog';
import ExitToAppIcon from '@material-ui/icons/ExitToApp';
import { useApi, identityApiRef } from '@backstage/core-plugin-api';
import { Member, BazaarProject } from '../../types';
import { bazaarApiRef } from '../../api';
import { Alert } from '@material-ui/lab';
import { useAsyncFn } from 'react-use';
const useStyles = makeStyles({
description: {
wordBreak: 'break-word',
},
icon: {
marginRight: '1.75rem',
},
link: {
color: '#9cc9ff',
'&:hover': {
textDecoration: 'underline',
},
},
memberLink: {
display: 'block',
marginBottom: '0.3rem',
},
});
const sortMembers = (m1: Member, m2: Member) => {
return new Date(m2.joinDate!).getTime() - new Date(m1.joinDate!).getTime();
};
import { useApi } from '@backstage/core-plugin-api';
import { stringifyEntityRef } from '@backstage/catalog-model';
import { useEntity } from '@backstage/plugin-catalog-react';
import { bazaarApiRef } from '../../api';
import { EntityBazaarInfoContent } from '../EntityBazaarInfoContent';
import { Card } from '@material-ui/core';
import { parseBazaarResponse } from '../../util/parseMethods';
export const EntityBazaarInfoCard = () => {
const { entity } = useEntity();
const classes = useStyles();
const bazaarApi = useApi(bazaarApiRef);
const identity = useApi(identityApiRef);
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement>();
const [open, setOpen] = useState(false);
const [popoverOpen, setPopoverOpen] = useState(false);
const [openDelete, setOpenDelete] = useState(false);
const [isMember, setIsMember] = useState(false);
const [isBazaar, setIsBazaar] = useState(false);
const [members, fetchMembers] = useAsyncFn(async () => {
const response = await bazaarApi.getMembers(entity);
const dbMembers = response.data.map((obj: any) => {
const member: Member = {
userId: obj.user_id,
entityRef: obj.entity_ref,
joinDate: obj.join_date,
picture: obj.picture,
};
return member;
});
dbMembers.sort(sortMembers);
return dbMembers;
});
const [bazaarProject, fetchBazaarProject] = useAsyncFn(async () => {
const response = await bazaarApi.getMetadata(entity);
const response = await bazaarApi.getProjectByRef(
stringifyEntityRef(entity),
);
if (response) {
const metadata = await response.json().then((resp: any) => resp.data[0]);
if (metadata) {
return {
entityRef: metadata.entity_ref,
name: metadata.name,
community: metadata.community,
announcement: metadata.announcement,
status: metadata.status,
updatedAt: metadata.updated_at,
membersCount: metadata.members_count,
size: metadata.size,
startDate: metadata.start_date,
endDate: metadata.end_date,
responsible: metadata.responsible,
} as BazaarProject;
}
}
return null;
return await parseBazaarResponse(response);
});
const [isBazaar, setIsBazaar] = useState(bazaarProject.value ?? false);
useEffect(() => {
fetchMembers();
fetchBazaarProject();
}, [fetchMembers, fetchBazaarProject]);
}, [fetchBazaarProject]);
useEffect(() => {
const isBazaarMember =
members?.value
?.map((member: Member) => member.userId)
.indexOf(identity.getUserId()) >= 0;
const isBazaarProject = bazaarProject.value !== null;
const isBazaarProject = bazaarProject.value !== undefined;
setIsMember(isBazaarMember);
setIsBazaar(isBazaarProject);
}, [bazaarProject, members, identity]);
}, [bazaarProject.value]);
const onOpen = (event: React.SyntheticEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
setPopoverOpen(true);
};
const closeEdit = () => {
setOpen(false);
};
const closeDelete = () => {
setOpenDelete(false);
};
const popoverCloseHandler = () => {
setPopoverOpen(false);
};
const handleMembersClick = async () => {
if (!isMember) {
await bazaarApi.addMember(entity);
} else {
await bazaarApi.deleteMember(entity);
}
fetchMembers();
fetchBazaarProject();
};
const links: IconLinkVerticalProps[] = [
{
label: isMember ? 'Leave' : 'Join',
icon: isMember ? <ExitToAppIcon /> : <PersonAddIcon />,
href: '',
onClick: async () => {
handleMembersClick();
},
},
{
label: 'Community',
icon: <ChatIcon />,
href: bazaarProject?.value?.community,
disabled: !bazaarProject?.value?.community || !isMember,
},
];
if (!isBazaar) {
return null;
} else if (bazaarProject.loading || members.loading) {
return <Progress />;
} else if (bazaarProject.error) {
return <Alert severity="error">{bazaarProject?.error?.message}</Alert>;
} else if (members.error) {
return <Alert severity="error">{members?.error?.message}</Alert>;
}
return (
<Card>
{bazaarProject?.value && (
<EditProjectDialog
open={open}
entity={entity}
if (isBazaar) {
return (
<Card>
<EntityBazaarInfoContent
bazaarProject={bazaarProject.value}
fetchBazaarProject={fetchBazaarProject}
handleClose={closeEdit}
isAddForm={false}
/>
)}
{bazaarProject?.value && (
<DeleteProjectDialog
bazaarProject={bazaarProject.value}
openDelete={openDelete}
handleClose={closeDelete}
setIsBazaar={setIsBazaar}
/>
)}
<CardHeader
title="Bazaar"
action={
<IconButton onClick={onOpen}>
<MoreVertIcon />
</IconButton>
}
subheader={<HeaderIconLinkRow links={links} />}
/>
<Divider />
<CardContent>
<Popover
open={popoverOpen}
onClose={popoverCloseHandler}
anchorEl={anchorEl}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
<MenuList>
<MenuItem
onClick={() => {
setOpen(true);
setPopoverOpen(false);
}}
>
<EditIcon className={classes.icon} />
<ListItemText primary="Edit project" />
</MenuItem>
<Divider />
<MenuItem
onClick={() => {
setOpenDelete(true);
setPopoverOpen(false);
}}
>
<DeleteIcon className={classes.icon} />
<ListItemText primary="Remove from Bazaar" />
</MenuItem>
</MenuList>
</Popover>
<Grid container>
<Grid item xs={10}>
<AboutField label="Announcement">
{bazaarProject?.value?.announcement
? bazaarProject?.value?.announcement
.split('\n')
.map((str: string, i: number) => (
<Typography
key={i}
variant="body2"
paragraph
className={classes.description}
>
{str}
</Typography>
))
: 'No announcement'}
</AboutField>
</Grid>
<Grid style={{ marginRight: '0rem' }}>
{' '}
<AboutField label="Latest members">
{members?.value?.length ? (
members.value.slice(0, 7).map((member: Member) => {
return (
<div key={member.userId}>
<Avatar
displayName={member.userId}
customStyles={{
width: '19px',
height: '19px',
fontSize: '8px',
float: 'left',
marginRight: '0.3rem',
marginBottom: '0.25rem',
}}
picture={member.picture}
/>
<Link
className={classes.memberLink}
href={`http://github.com/${member.userId}`}
target="_blank"
>
{member?.userId}
</Link>
</div>
);
})
) : (
<div />
)}
</AboutField>
</Grid>
<Grid item xs={2}>
<AboutField label="Status">
<StatusTag status={bazaarProject?.value?.status || 'proposed'} />
</AboutField>
</Grid>
<Grid item xs={2}>
<AboutField label="size">
<Typography variant="body2">
{bazaarProject?.value?.size}
</Typography>
</AboutField>
</Grid>
<Grid item xs={2}>
<AboutField label="Start date">
<Typography variant="body2">
{bazaarProject?.value?.startDate?.substring(0, 10) || ''}
</Typography>
</AboutField>
</Grid>
<Grid item xs={2}>
<AboutField label="End date">
<Typography variant="body2">
{bazaarProject?.value?.endDate?.substring(0, 10) || ''}
</Typography>
</AboutField>
</Grid>
<Grid item xs={2}>
<AboutField label="Responsible">
<Typography variant="body2">
{bazaarProject?.value?.responsible || ''}
</Typography>
</AboutField>
</Grid>
</Grid>
</CardContent>
</Card>
);
</Card>
);
}
return null;
};
@@ -0,0 +1,192 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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, { useState, useEffect } from 'react';
import { CardHeader, Divider, IconButton } from '@material-ui/core';
import {
HeaderIconLinkRow,
IconLinkVerticalProps,
} from '@backstage/core-components';
import EditIcon from '@material-ui/icons/Edit';
import ChatIcon from '@material-ui/icons/Chat';
import PersonAddIcon from '@material-ui/icons/PersonAdd';
import DashboardIcon from '@material-ui/icons/Dashboard';
import LinkOffIcon from '@material-ui/icons/LinkOff';
import { EditProjectDialog } from '../EditProjectDialog';
import { useApi, identityApiRef } from '@backstage/core-plugin-api';
import { BazaarProject, Member } from '../../types';
import { bazaarApiRef } from '../../api';
import { Alert } from '@material-ui/lab';
import { useAsyncFn } from 'react-use';
import ExitToAppIcon from '@material-ui/icons/ExitToApp';
import { parseEntityRef } from '@backstage/catalog-model';
import { ConfirmationDialog } from '../ConfirmationDialog';
import { CardContentFields } from '../CardContentFields';
import { fetchProjectMembers } from '../../util/fetchMethods';
type Props = {
bazaarProject: BazaarProject | null | undefined;
fetchBazaarProject: () => Promise<BazaarProject | null>;
};
export const EntityBazaarInfoContent = ({
bazaarProject,
fetchBazaarProject,
}: Props) => {
const bazaarApi = useApi(bazaarApiRef);
const identity = useApi(identityApiRef);
const [openEdit, setOpenEdit] = useState(false);
const [isMember, setIsMember] = useState(false);
const [openUnlink, setOpenUnlink] = useState(false);
const [members, fetchMembers] = useAsyncFn(async () => {
return bazaarProject
? await fetchProjectMembers(bazaarApi, bazaarProject)
: [];
});
useEffect(() => {
fetchMembers();
}, [fetchMembers]);
useEffect(() => {
if (members.value) {
const isBazaarMember =
members.value
?.map((member: Member) => member.userId)
.indexOf(identity.getUserId()) >= 0;
setIsMember(isBazaarMember);
}
}, [bazaarProject, members, identity]);
const handleMembersClick = async () => {
if (!isMember) {
await bazaarApi.addMember(bazaarProject?.id!, identity.getUserId());
} else {
await bazaarApi.deleteMember(bazaarProject!.id, identity.getUserId());
}
setIsMember(!isMember);
fetchMembers();
};
const links: IconLinkVerticalProps[] = [
{
label: 'Entity page',
icon: <DashboardIcon />,
disabled: true,
},
{
label: 'Unlink project',
icon: <LinkOffIcon />,
disabled: false,
onClick: () => {
setOpenUnlink(true);
},
},
{
label: isMember ? 'Leave' : 'Join',
icon: isMember ? <ExitToAppIcon /> : <PersonAddIcon />,
href: '',
onClick: async () => {
handleMembersClick();
},
},
{
label: 'Community',
icon: <ChatIcon />,
href: bazaarProject?.community,
disabled: bazaarProject?.community === '' || !isMember,
},
];
const handleEditClose = () => {
setOpenEdit(false);
};
const handleUnlinkClose = () => {
setOpenUnlink(false);
};
const handleUnlinkSubmit = async () => {
const updateResponse = await bazaarApi.updateProject({
...bazaarProject,
entityRef: null,
});
if (updateResponse.status === 'ok') {
handleUnlinkClose();
fetchBazaarProject();
}
};
if (members.error) {
return <Alert severity="error">{members?.error?.message}</Alert>;
}
if (bazaarProject) {
return (
<div>
<EditProjectDialog
bazaarProject={bazaarProject!}
openEdit={openEdit}
handleEditClose={handleEditClose}
fetchBazaarProject={fetchBazaarProject}
/>
{openUnlink && (
<ConfirmationDialog
open={openUnlink}
handleClose={handleUnlinkClose}
message={[
'Are you sure you want to unlink ',
<b>{parseEntityRef(bazaarProject.entityRef!).name}</b>,
' from ',
<b>{bazaarProject.name}</b>,
' ?',
]}
type="unlink"
handleSubmit={handleUnlinkSubmit}
/>
)}
<CardHeader
title={bazaarProject?.name!}
action={
<div>
<IconButton
onClick={() => {
setOpenEdit(true);
}}
>
<EditIcon />
</IconButton>
</div>
}
subheader={<HeaderIconLinkRow links={links} />}
/>
<Divider />
<CardContentFields
bazaarProject={bazaarProject}
members={members.value || []}
descriptionSize={10}
membersSize={2}
/>
</div>
);
}
return null;
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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.
*/
export { EntityBazaarInfoContent } from './EntityBazaarInfoContent';
@@ -36,11 +36,7 @@ export const HomePage = () => {
return (
<div>
<Header
data-testid="bazaar-header"
title="Bazaar"
subtitle="Marketplace for inner source projects"
/>
<Header title="Bazaar" subtitle="Marketplace for inner source projects" />
<RoutedTabs routes={tabContent} />
</div>
);
@@ -0,0 +1,267 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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, { useState, useEffect } from 'react';
import { Card, CardHeader, Divider, IconButton } from '@material-ui/core';
import {
HeaderIconLinkRow,
IconLinkVerticalProps,
} from '@backstage/core-components';
import EditIcon from '@material-ui/icons/Edit';
import ChatIcon from '@material-ui/icons/Chat';
import PersonAddIcon from '@material-ui/icons/PersonAdd';
import InsertLinkIcon from '@material-ui/icons/InsertLink';
import DashboardIcon from '@material-ui/icons/Dashboard';
import CloseIcon from '@material-ui/icons/Close';
import LinkOffIcon from '@material-ui/icons/LinkOff';
import { EditProjectDialog } from '../EditProjectDialog';
import ExitToAppIcon from '@material-ui/icons/ExitToApp';
import {
useApi,
identityApiRef,
useRouteRef,
} from '@backstage/core-plugin-api';
import { Member, BazaarProject } from '../../types';
import { bazaarApiRef } from '../../api';
import { Alert } from '@material-ui/lab';
import { useAsyncFn } from 'react-use';
import {
catalogApiRef,
catalogRouteRef,
} from '@backstage/plugin-catalog-react';
import {
parseEntityName,
stringifyEntityRef,
Entity,
parseEntityRef,
} from '@backstage/catalog-model';
import { ConfirmationDialog } from '../ConfirmationDialog/ConfirmationDialog';
import { CardContentFields } from '../CardContentFields/CardContentFields';
import { LinkProjectDialog } from '../LinkProjectDialog';
import {
fetchCatalogItems,
fetchProjectMembers,
} from '../../util/fetchMethods';
import { parseBazaarResponse } from '../../util/parseMethods';
type Props = {
initProject: BazaarProject;
fetchBazaarProjects: () => Promise<BazaarProject[]>;
handleClose: () => void;
initEntity: Entity;
};
export const HomePageBazaarInfoCard = ({
initProject,
fetchBazaarProjects,
handleClose,
initEntity,
}: Props) => {
const catalogLink = useRouteRef(catalogRouteRef);
const bazaarApi = useApi(bazaarApiRef);
const identity = useApi(identityApiRef);
const catalogApi = useApi(catalogApiRef);
const [openEdit, setOpenEdit] = useState(false);
const [openProjectSelector, setOpenProjectSelector] = useState(false);
const [openUnlink, setOpenUnlink] = useState(false);
const [isMember, setIsMember] = useState(false);
const [catalogEntities, fetchCatalogEntities] = useAsyncFn(async () => {
const entities = await fetchCatalogItems(catalogApi);
const bazaarProjects = await bazaarApi.getProjects();
const bazaarLinkedRefs: string[] = bazaarProjects.data
.filter((entity: any) => entity.entity_ref !== null)
.map((entity: any) => entity.entity_ref);
return entities.filter(
(entity: Entity) =>
!bazaarLinkedRefs.includes(stringifyEntityRef(entity)),
);
});
const [bazaarProject, fetchBazaarProject] = useAsyncFn(async () => {
const response = await bazaarApi.getProjectById(initProject.id);
return await parseBazaarResponse(response);
});
const [members, fetchMembers] = useAsyncFn(async () => {
return fetchProjectMembers(bazaarApi, bazaarProject.value ?? initProject);
});
useEffect(() => {
fetchMembers();
fetchBazaarProject();
fetchCatalogEntities();
}, [fetchMembers, fetchBazaarProject, fetchCatalogEntities]);
useEffect(() => {
if (members.value) {
const isBazaarMember =
members?.value
?.map((member: Member) => member.userId)
.indexOf(identity.getUserId()) >= 0;
setIsMember(isBazaarMember);
}
}, [bazaarProject.value, members, identity]);
const handleMembersClick = async () => {
if (!isMember) {
await bazaarApi.addMember(bazaarProject.value!.id, identity.getUserId());
} else {
await bazaarApi.deleteMember(
bazaarProject.value!.id,
identity.getUserId(),
);
}
setIsMember(!isMember);
fetchMembers();
};
const getEntityPageLink = () => {
if (bazaarProject?.value?.entityRef) {
const { name, kind, namespace } = parseEntityName(
bazaarProject.value.entityRef,
);
return `${catalogLink()}/${namespace}/${kind}/${name}`;
}
return '';
};
const handleLink = () => {
if (bazaarProject.value?.entityRef) {
setOpenUnlink(true);
} else {
fetchCatalogEntities();
setOpenProjectSelector(true);
}
};
const links: IconLinkVerticalProps[] = [
{
label: 'Entity page',
icon: <DashboardIcon />,
href: bazaarProject.value?.entityRef ? getEntityPageLink() : '',
disabled: bazaarProject.value?.entityRef === null,
},
{
label: bazaarProject.value?.entityRef ? 'Unlink project' : 'Link project',
icon: bazaarProject.value?.entityRef ? (
<LinkOffIcon />
) : (
<InsertLinkIcon />
),
onClick: handleLink,
},
{
label: isMember ? 'Leave' : 'Join',
icon: isMember ? <ExitToAppIcon /> : <PersonAddIcon />,
href: '',
onClick: async () => {
handleMembersClick();
},
},
{
label: 'Community',
icon: <ChatIcon />,
href: bazaarProject.value?.community,
disabled: !bazaarProject.value?.community || !isMember,
},
];
const handleUnlinkSubmit = async () => {
const updateResponse = await bazaarApi.updateProject({
...bazaarProject.value,
entityRef: null,
});
if (updateResponse.status === 'ok') {
setOpenUnlink(false);
fetchBazaarProject();
}
};
if (bazaarProject.error) {
return <Alert severity="error">{bazaarProject?.error?.message}</Alert>;
} else if (members.error) {
return <Alert severity="error">{members?.error?.message}</Alert>;
}
return (
<div>
<LinkProjectDialog
openProjectSelector={openProjectSelector}
handleProjectSelectorClose={() => setOpenProjectSelector(false)}
catalogEntities={catalogEntities.value || []}
bazaarProject={bazaarProject.value || initProject}
fetchBazaarProject={fetchBazaarProject}
initEntity={initEntity}
/>
{openUnlink && (
<ConfirmationDialog
open={openUnlink}
handleClose={() => setOpenUnlink(false)}
message={[
'Are you sure you want to unlink ',
<b>{parseEntityRef(bazaarProject.value?.entityRef!).name}</b>,
' from ',
<b>{bazaarProject.value?.name}</b>,
' ?',
]}
type="unlink"
handleSubmit={handleUnlinkSubmit}
/>
)}
<Card>
<EditProjectDialog
bazaarProject={bazaarProject.value || initProject}
openEdit={openEdit}
handleEditClose={() => setOpenEdit(false)}
handleCardClose={handleClose}
fetchBazaarProject={fetchBazaarProject}
fetchBazaarProjects={fetchBazaarProjects}
/>
<CardHeader
title={bazaarProject.value?.name || initProject.name}
action={
<div>
<IconButton onClick={() => setOpenEdit(true)}>
<EditIcon />
</IconButton>
<IconButton onClick={handleClose}>
<CloseIcon />
</IconButton>
</div>
}
subheader={<HeaderIconLinkRow links={links} />}
/>
<Divider />
<CardContentFields
bazaarProject={bazaarProject.value || initProject}
members={members.value || []}
descriptionSize={9}
membersSize={3}
/>
</Card>
</div>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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.
*/
export { HomePageBazaarInfoCard } from './HomePageBazaarInfoCard';
@@ -25,7 +25,7 @@ type Rules = {
};
type Props = {
inputType: 'announcement' | 'community' | 'responsible';
inputType: 'description' | 'community' | 'responsible' | 'name';
error?: FieldError | undefined;
control: Control<FormValues, object>;
helperText?: string;
@@ -60,12 +60,7 @@ export const InputSelector = ({ name, options, control, error }: Props) => {
>
{options.map(option => {
return (
<MenuItem
data-testid="menu-item"
button
key={option}
value={option}
>
<MenuItem button key={option} value={option}>
{option}
</MenuItem>
);
@@ -0,0 +1,97 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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, { useState } from 'react';
import {
Dialog,
DialogActions,
Button,
DialogContent,
makeStyles,
} from '@material-ui/core';
import { ProjectSelector } from '../ProjectSelector';
import { CustomDialogTitle } from '../CustomDialogTitle';
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import { bazaarApiRef } from '../../api';
import { useApi } from '@backstage/core-plugin-api';
import { BazaarProject } from '../../types';
type Props = {
openProjectSelector: boolean;
handleProjectSelectorClose: () => void;
catalogEntities: Entity[];
bazaarProject: BazaarProject;
fetchBazaarProject: () => Promise<BazaarProject | null>;
initEntity: Entity;
};
const useStyles = makeStyles({
content: { padding: '0 1rem' },
});
export const LinkProjectDialog = ({
openProjectSelector,
handleProjectSelectorClose,
catalogEntities,
bazaarProject,
fetchBazaarProject,
initEntity,
}: Props) => {
const classes = useStyles();
const bazaarApi = useApi(bazaarApiRef);
const [selectedEntity, setSelectedEntity] = useState(initEntity);
const handleEntityClick = (entity: Entity) => {
setSelectedEntity(entity);
};
const handleSubmit = async () => {
handleProjectSelectorClose();
const updateResponse = await bazaarApi.updateProject({
...bazaarProject,
entityRef: stringifyEntityRef(selectedEntity!),
});
if (updateResponse.status === 'ok') fetchBazaarProject();
};
return (
<Dialog onClose={handleProjectSelectorClose} open={openProjectSelector}>
<CustomDialogTitle
id="customized-dialog-title"
onClose={handleProjectSelectorClose}
>
Select entity
</CustomDialogTitle>
<DialogContent className={classes.content} dividers>
<ProjectSelector
label=""
onChange={handleEntityClick}
catalogEntities={catalogEntities || []}
disableClearable
defaultValue={catalogEntities[0] || null}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleSubmit} color="primary" type="submit">
OK
</Button>
</DialogActions>
</Dialog>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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.
*/
export { LinkProjectDialog } from './LinkProjectDialog';
@@ -14,85 +14,95 @@
* limitations under the License.
*/
import React from 'react';
import React, { useState } from 'react';
import { ItemCardHeader } from '@backstage/core-components';
import {
Card,
CardActionArea,
CardContent,
Dialog,
makeStyles,
Typography,
} from '@material-ui/core';
import { StatusTag } from '../StatusTag/StatusTag';
import { Link as RouterLink } from 'react-router-dom';
import { catalogRouteRef } from '@backstage/plugin-catalog-react';
import { useRouteRef } from '@backstage/core-plugin-api';
import { BazaarProject } from '../../types';
import { parseEntityName } from '@backstage/catalog-model';
import { DateTime } from 'luxon';
import { HomePageBazaarInfoCard } from '../HomePageBazaarInfoCard';
import { Entity } from '@backstage/catalog-model';
const useStyles = makeStyles({
statusTag: {
display: 'inline-block',
whiteSpace: 'nowrap',
marginBottom: '0.5rem',
},
announcement: {
display: '-webkit-box',
WebkitLineClamp: 5,
WebkitBoxOrient: 'vertical',
marginBottom: '0.8rem',
},
description: {
display: '-webkit-box',
WebkitLineClamp: 7,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
backgroundColor: '',
},
memberCount: {
float: 'right',
},
content: { height: '13rem', marginBottom: '-0.5rem' },
});
type Props = {
bazaarProject: BazaarProject;
project: BazaarProject;
fetchBazaarProjects: () => Promise<BazaarProject[]>;
catalogEntities: Entity[];
};
export const ProjectCard = ({ bazaarProject }: Props) => {
export const ProjectCard = ({
project,
fetchBazaarProjects,
catalogEntities,
}: Props) => {
const classes = useStyles();
const { entityRef, name, status, updatedAt, announcement, membersCount } =
bazaarProject;
const catalogLink = useRouteRef(catalogRouteRef);
const { namespace, kind } = parseEntityName(entityRef);
const [openCard, setOpenCard] = useState(false);
const { id, name, status, updatedAt, description, membersCount } = project;
const handleClose = () => {
setOpenCard(false);
fetchBazaarProjects();
};
return (
<Card key={entityRef as string}>
<CardActionArea
style={{
height: '100%',
overflow: 'hidden',
width: '100%',
}}
component={RouterLink}
to={`${catalogLink()}/${namespace}/${kind}/${name}`}
>
<ItemCardHeader
title={name}
subtitle={`updated ${DateTime.fromISO(
new Date(updatedAt!).toISOString(),
).toRelative({
base: DateTime.now(),
})}`}
<div>
<Dialog fullWidth onClose={handleClose} open={openCard}>
<HomePageBazaarInfoCard
initProject={project}
fetchBazaarProjects={fetchBazaarProjects}
handleClose={handleClose}
initEntity={catalogEntities[0] || null}
/>
<CardContent style={{ height: '12rem' }}>
<StatusTag styles={classes.statusTag} status={status} />
<Typography variant="body2" className={classes.memberCount}>
{membersCount === 1
? `${membersCount} member`
: `${membersCount} members`}
</Typography>
<div style={{ minHeight: '6.5rem', maxHeight: '6.5rem' }}>
<Typography variant="body2" className={classes.announcement}>
{announcement}
</Dialog>
<Card key={id}>
<CardActionArea onClick={() => setOpenCard(true)}>
<ItemCardHeader
title={name}
subtitle={`updated ${DateTime.fromISO(
new Date(updatedAt!).toISOString(),
).toRelative({
base: DateTime.now(),
})}`}
/>
<CardContent className={classes.content}>
<StatusTag styles={classes.statusTag} status={status} />
<Typography variant="body2" className={classes.memberCount}>
{Number(membersCount) === Number(1)
? `${membersCount} member`
: `${membersCount} members`}
</Typography>
</div>
</CardContent>
</CardActionArea>
</Card>
<Typography variant="body2" className={classes.description}>
{description}
</Typography>
</CardContent>
</CardActionArea>
</Card>
</div>
);
};
@@ -15,17 +15,7 @@
*/
import React from 'react';
import {
createStyles,
Theme,
withStyles,
WithStyles,
} from '@material-ui/core/styles';
import MuiDialogTitle from '@material-ui/core/DialogTitle';
import MuiDialogContent from '@material-ui/core/DialogContent';
import MuiDialogActions from '@material-ui/core/DialogActions';
import CloseIcon from '@material-ui/icons/Close';
import { Button, Dialog, Typography, IconButton } from '@material-ui/core';
import { Button, Dialog } from '@material-ui/core';
import {
useForm,
SubmitHandler,
@@ -36,61 +26,11 @@ import { InputField } from '../InputField/InputField';
import { InputSelector } from '../InputSelector/InputSelector';
import { FormValues } from '../../types';
import { DoubleDateSelector } from '../DoubleDateSelector/DoubleDateSelector';
const styles = (theme: Theme) =>
createStyles({
root: {
margin: 0,
padding: theme.spacing(2),
},
closeButton: {
position: 'absolute',
right: theme.spacing(1),
top: theme.spacing(1),
color: theme.palette.grey[500],
},
});
/*
DialogTitleProps, DialogTitle, DialogContent and DialogActions
are copied from the git-release plugin
*/
export interface DialogTitleProps extends WithStyles<typeof styles> {
id: string;
children: React.ReactNode;
onClose: () => void;
}
const DialogTitle = withStyles(styles)((props: DialogTitleProps) => {
const { children, classes, onClose, ...other } = props;
return (
<MuiDialogTitle disableTypography className={classes.root} {...other}>
<Typography variant="h6">{children}</Typography>
{onClose ? (
<IconButton
aria-label="close"
className={classes.closeButton}
onClick={onClose}
>
<CloseIcon />
</IconButton>
) : null}
</MuiDialogTitle>
);
});
const DialogContent = withStyles((theme: Theme) => ({
root: {
padding: theme.spacing(2),
},
}))(MuiDialogContent);
const DialogActions = withStyles((theme: Theme) => ({
root: {
margin: 0,
padding: theme.spacing(1),
},
}))(MuiDialogActions);
import {
CustomDialogTitle,
DialogActions,
DialogContent,
} from '../CustomDialogTitle';
type Props = {
handleSave: (
@@ -102,6 +42,7 @@ type Props = {
defaultValues: FormValues;
open: boolean;
projectSelector?: JSX.Element;
deleteButton?: JSX.Element;
handleClose: () => void;
};
@@ -112,6 +53,7 @@ export const ProjectDialog = ({
defaultValues,
open,
projectSelector,
deleteButton,
handleClose,
}: Props) => {
const {
@@ -123,16 +65,16 @@ export const ProjectDialog = ({
setValue,
} = useForm<FormValues>({
mode: 'onChange',
defaultValues: defaultValues,
defaultValues,
});
const handleCloseAndClear = () => {
handleClose();
reset(defaultValues);
const handleSaveForm = () => {
handleSave(getValues, reset);
};
const handleSaveProject = () => {
handleSave(getValues, reset);
const handleCloseDialog = () => {
handleClose();
reset(defaultValues);
};
return (
@@ -140,24 +82,36 @@ export const ProjectDialog = ({
<Dialog
fullWidth
maxWidth="xs"
onClose={handleCloseAndClear}
onClose={handleCloseDialog}
aria-labelledby="customized-dialog-title"
open={open}
>
<DialogTitle id="customized-dialog-title" onClose={handleCloseAndClear}>
<CustomDialogTitle
id="customized-dialog-title"
onClose={handleCloseDialog}
>
{title}
</DialogTitle>
<DialogContent dividers>
{isAddForm && projectSelector}
</CustomDialogTitle>
<DialogContent style={{ padding: '1rem', paddingTop: '0rem' }} dividers>
<InputField
error={errors.name}
control={control}
rules={{
required: true,
pattern: RegExp('^[a-zA-Z0-9_-]*$'),
}}
inputType="name"
helperText="please enter a url safe project name"
/>
<InputField
error={errors.announcement}
error={errors.description}
control={control}
rules={{
required: true,
}}
inputType="announcement"
helperText="please enter an announcement"
inputType="description"
helperText="please enter a description"
placeholder="Describe who you are and what skills you are looking for"
/>
@@ -184,7 +138,7 @@ export const ProjectDialog = ({
placeholder="Contact person of the project"
/>
<DoubleDateSelector setValue={setValue} control={control} />
{isAddForm && projectSelector}
<InputField
error={errors.community}
@@ -197,11 +151,14 @@ export const ProjectDialog = ({
helperText="please enter a link starting with http/https"
placeholder="Community link to e.g. Teams or Discord"
/>
<DoubleDateSelector setValue={setValue} control={control} />
</DialogContent>
<DialogActions>
{!isAddForm && deleteButton}
<Button
onClick={handleSubmit(handleSaveProject)}
onClick={handleSubmit(handleSaveForm)}
color="primary"
type="submit"
>
@@ -14,15 +14,17 @@
* limitations under the License.
*/
import React, { useState } from 'react';
import React, { ChangeEvent, useState } from 'react';
import { Content } from '@backstage/core-components';
import { ProjectCard } from '../ProjectCard/ProjectCard';
import { makeStyles, Grid, TablePagination } from '@material-ui/core';
import { BazaarProject } from '../../types';
import { Entity } from '@backstage/catalog-model';
type Props = {
bazaarProjects: BazaarProject[];
sortingMethod: (arg0: BazaarProject, arg1: BazaarProject) => number;
fetchBazaarProjects: () => Promise<BazaarProject[]>;
catalogEntities: Entity[];
};
const useStyles = makeStyles({
@@ -32,75 +34,75 @@ const useStyles = makeStyles({
flexWrap: 'wrap',
alignItems: 'center',
},
item: {
minWidth: '20.5rem',
maxWidth: '20.5rem',
width: '20%',
empty: {
height: '10rem',
textAlign: 'center',
verticalAlign: 'middle',
lineHeight: '10rem',
},
pagination: {
marginTop: '1rem',
marginLeft: 'auto',
marginRight: '0',
},
});
export const ProjectPreview = ({ bazaarProjects, sortingMethod }: Props) => {
export const ProjectPreview = ({
bazaarProjects,
fetchBazaarProjects,
catalogEntities,
}: Props) => {
const classes = useStyles();
const [page, setPage] = useState(1);
const [rowsPerPage, setRowsPerPage] = useState(10);
const [rows, setRows] = useState(12);
const handleChangePage = (_: any, newPage: number) => {
const handlePageChange = (_: any, newPage: number) => {
setPage(newPage + 1);
};
const handleChangeRowsPerPage = (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
const handleRowChange = (
event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
setRowsPerPage(parseInt(event.target.value, 10));
setRows(parseInt(event.target.value, 10));
setPage(1);
};
if (!bazaarProjects.length) {
return (
<div
data-testid="empty-bazaar"
style={{
height: '10rem',
textAlign: 'center',
verticalAlign: 'middle',
lineHeight: '10rem',
}}
>
Please add projects to the Bazaar.
</div>
<div className={classes.empty}>Please add projects to the Bazaar.</div>
);
}
bazaarProjects.sort(sortingMethod);
return (
<Content className={classes.content} noPadding>
<Grid wrap="wrap" container spacing={3}>
{bazaarProjects
.slice((page - 1) * rowsPerPage, rowsPerPage * page)
.slice((page - 1) * rows, rows * page)
.map((bazaarProject: BazaarProject, i: number) => {
return (
<Grid key={i} className={classes.item} item xs={3}>
<ProjectCard bazaarProject={bazaarProject} key={i} />
<Grid key={i} item xs={2}>
<ProjectCard
project={bazaarProject}
key={i}
fetchBazaarProjects={fetchBazaarProjects}
catalogEntities={catalogEntities}
/>
</Grid>
);
})}
</Grid>
<TablePagination
className={classes.pagination}
rowsPerPageOptions={[12, 24, 48, 96]}
count={bazaarProjects?.length}
page={page - 1}
onPageChange={handleChangePage}
rowsPerPage={rowsPerPage}
onRowsPerPageChange={handleChangeRowsPerPage}
onPageChange={handlePageChange}
rowsPerPage={rows}
onRowsPerPageChange={handleRowChange}
backIconButtonProps={{ disabled: page === 1 }}
nextIconButtonProps={{
disabled: rowsPerPage * page >= bazaarProjects.length,
}}
style={{
marginTop: '1rem',
marginLeft: 'auto',
marginRight: '0',
disabled: rows * page >= bazaarProjects.length,
}}
/>
</Content>
@@ -17,38 +17,44 @@
import React from 'react';
import { Entity } from '@backstage/catalog-model';
import { Autocomplete } from '@material-ui/lab';
import { TextField } from '@material-ui/core';
import { TextField, makeStyles } from '@material-ui/core';
type Props = {
entities: Entity[];
value: string;
catalogEntities: Entity[];
onChange: (entity: Entity) => void;
isFormInvalid: boolean;
disableClearable: boolean;
defaultValue: Entity | null | undefined;
label: string;
};
const useStyles = makeStyles({
container: { width: '100%', minWidth: '22rem' },
autocomplete: { overflow: 'hidden' },
});
export const ProjectSelector = ({
entities,
value,
catalogEntities,
onChange,
isFormInvalid,
disableClearable,
defaultValue,
label,
}: Props) => {
const classes = useStyles();
return (
<Autocomplete
defaultValue={entities[0]}
options={entities}
getOptionLabel={option => option?.metadata?.name}
renderOption={option => <span>{option?.metadata?.name}</span>}
renderInput={params => (
<TextField
error={isFormInvalid && value === ''}
helperText={
isFormInvalid && value === '' ? 'Please select a project' : ''
}
{...params}
label="Select a project *"
/>
)}
onChange={(_, data) => onChange(data!)}
/>
<div className={classes.container}>
<Autocomplete
className={classes.autocomplete}
fullWidth
disableClearable={disableClearable}
defaultValue={defaultValue}
options={catalogEntities}
getOptionLabel={option => option?.metadata?.name}
renderOption={option => <span>{option?.metadata?.name}</span>}
renderInput={params => <TextField {...params} label={label} />}
onChange={(_, data) => {
onChange(data!);
}}
/>
</div>
);
};
@@ -0,0 +1,52 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { FormControl, MenuItem, Select, makeStyles } from '@material-ui/core';
const useStyles = makeStyles({
select: {
fontSize: 'xx-large',
fontWeight: 'bold',
width: '16rem',
},
});
type Props = {
sortMethodNbr: number;
handleSortMethodChange: any;
};
export const SortMethodSelector = ({
sortMethodNbr,
handleSortMethodChange,
}: Props) => {
const classes = useStyles();
return (
<FormControl fullWidth>
<Select
className={classes.select}
disableUnderline
value={sortMethodNbr}
onChange={handleSortMethodChange}
>
<MenuItem value={0}>Latest updated</MenuItem>
<MenuItem value={1}>A-Z</MenuItem>
<MenuItem value={2}>Most members</MenuItem>
</Select>
</FormControl>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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.
*/
export { SortMethodSelector } from './SortMethodSelector';
@@ -14,17 +14,11 @@
* limitations under the License.
*/
import React, { useEffect, useState } from 'react';
import {
Content,
ContentHeader,
SupportButton,
Progress,
} from '@backstage/core-components';
import React, { ChangeEvent, useEffect, useState } from 'react';
import { Content, SupportButton } from '@backstage/core-components';
import { AddProjectDialog } from '../AddProjectDialog';
import { AlertBanner } from '../AlertBanner';
import { ProjectPreview } from '../ProjectPreview/ProjectPreview';
import { Button, makeStyles, Link } from '@material-ui/core';
import { Button, makeStyles } from '@material-ui/core';
import { useAsyncFn } from 'react-use';
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import { useApi } from '@backstage/core-plugin-api';
@@ -32,14 +26,34 @@ import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { BazaarProject } from '../../types';
import { bazaarApiRef } from '../../api';
import { Alert } from '@material-ui/lab';
import SearchBar from 'material-ui-search-bar';
import { sortByDate, sortByMembers, sortByName } from '../../util/sortMethods';
import { SortMethodSelector } from '../SortMethodSelector';
import { fetchCatalogItems } from '../../util/fetchMethods';
import { parseBazaarProject } from '../../util/parseMethods';
const useStyles = makeStyles({
button: { width: '12rem' },
container: {
marginTop: '2rem',
},
header: {
width: '100%',
display: 'flex',
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
margin: '0 auto',
marginBottom: '1.2rem',
},
search: {
marginRight: '1rem',
height: '2.5rem',
width: '35rem',
},
});
const filterCatalogEntities = (
const getUnlinkedCatalogEntities = (
bazaarProjects: BazaarProject[],
catalogEntities: Entity[],
) => {
@@ -47,86 +61,91 @@ const filterCatalogEntities = (
(project: BazaarProject) => project.entityRef,
);
const filtered = catalogEntities.filter((entity: Entity) => {
return catalogEntities.filter((entity: Entity) => {
return !bazaarProjectRefs?.includes(stringifyEntityRef(entity));
});
return filtered;
};
export const SortView = () => {
const classes = useStyles();
const [openAdd, setOpenAdd] = useState(false);
const [openNoProjects, setOpenNoProjects] = useState(false);
const bazaarApi = useApi(bazaarApiRef);
const catalogApi = useApi(catalogApiRef);
const [filteredCatalogEntites, setFilteredCatalogEntities] =
const classes = useStyles();
const sortMethods = [sortByDate, sortByName, sortByMembers];
const [sortMethodNbr, setSortMethodNbr] = useState(0);
const [openAdd, setOpenAdd] = useState(false);
const [searchValue, setSearchValue] = useState('');
const [unlinkedCatalogEntities, setUnlinkedCatalogEntities] =
useState<Entity[]>();
const compareProjectsByDate = (
a: BazaarProject,
b: BazaarProject,
): number => {
const dateA = new Date(a.updatedAt!).getTime();
const dateB = new Date(b.updatedAt!).getTime();
return dateB - dateA;
};
const handleCloseNoProjects = () => {
setOpenNoProjects(false);
};
const [catalogEntities, fetchCatalogEntities] = useAsyncFn(async () => {
const entities = await catalogApi.getEntities({
filter: {
kind: ['Component', 'Resource'],
},
fields: ['kind', 'metadata.name', 'metadata.namespace'],
});
return entities.items;
return await fetchCatalogItems(catalogApi);
});
const [bazaarProjects, fetchBazaarProjects] = useAsyncFn(async () => {
const response = await bazaarApi.getEntities();
const response = await bazaarApi.getProjects();
const dbProjects: BazaarProject[] = [];
response.data.forEach((project: any) => {
dbProjects.push({
entityRef: project.entity_ref,
name: project.name,
status: project.status,
announcement: project.announcement,
community: project.community,
updatedAt: project.updated_at,
membersCount: project.members_count,
size: project.size,
startDate: project.startDate,
endDate: project.endDate,
responsible: project.responsible,
});
dbProjects.push(parseBazaarProject(project));
});
return dbProjects;
});
const catalogEntityRefs = catalogEntities.value?.map((project: Entity) =>
stringifyEntityRef(project),
);
const getSearchResults = () => {
return bazaarProjects.value
?.filter(project => project.name.includes(searchValue))
.sort(sortMethods[sortMethodNbr]);
};
useEffect(() => {
const filterBrokenLinks = () => {
if (catalogEntityRefs) {
bazaarProjects.value?.forEach(async (project: BazaarProject) => {
if (project.entityRef) {
if (!catalogEntityRefs?.includes(project.entityRef as string)) {
await bazaarApi.updateProject({
...project,
entityRef: null,
});
}
}
});
}
};
filterBrokenLinks();
}, [
bazaarApi,
bazaarProjects.value,
catalogEntityRefs,
catalogEntities.value,
]);
useEffect(() => {
fetchCatalogEntities();
fetchBazaarProjects();
}, [fetchBazaarProjects, fetchCatalogEntities]);
useEffect(() => {
const filteredCatalogEntities = filterCatalogEntities(
const unlinkedCEntities = getUnlinkedCatalogEntities(
bazaarProjects.value || [],
catalogEntities.value || [],
);
if (filteredCatalogEntities) {
setFilteredCatalogEntities(filteredCatalogEntities);
if (unlinkedCEntities) {
setUnlinkedCatalogEntities(unlinkedCEntities);
}
}, [bazaarProjects, catalogEntities]);
if (catalogEntities.loading || bazaarProjects.loading) return <Progress />;
const handleSortMethodChange = (event: ChangeEvent<HTMLInputElement>) => {
setSortMethodNbr(
typeof event.target.value === 'number' ? event.target.value : 0,
);
};
if (catalogEntities.error)
return <Alert severity="error">{catalogEntities.error.message}</Alert>;
@@ -136,38 +155,34 @@ export const SortView = () => {
return (
<Content noPadding>
<AlertBanner
open={openNoProjects}
message={
<div>
No project available. Please{' '}
<Link
style={{ color: 'inherit', fontWeight: 'bold' }}
href="/create"
>
create a project
</Link>{' '}
from a template first.
</div>
}
handleClose={handleCloseNoProjects}
/>
<ContentHeader title="Latest updated">
<div className={classes.header}>
<SortMethodSelector
sortMethodNbr={sortMethodNbr}
handleSortMethodChange={handleSortMethodChange}
/>
<SearchBar
className={classes.search}
value={searchValue}
onChange={newSortMethod => {
setSearchValue(newSortMethod);
}}
onCancelSearch={() => {
setSearchValue('');
}}
/>
<Button
className={classes.button}
variant="contained"
color="primary"
onClick={() => {
if (filteredCatalogEntites?.length !== 0) {
setOpenAdd(true);
} else {
setOpenNoProjects(true);
}
setOpenAdd(true);
}}
>
Add project
</Button>
<AddProjectDialog
catalogEntities={filteredCatalogEntites || []}
catalogEntities={unlinkedCatalogEntities || []}
handleClose={() => {
setOpenAdd(false);
}}
@@ -176,10 +191,11 @@ export const SortView = () => {
fetchCatalogEntities={fetchCatalogEntities}
/>
<SupportButton />
</ContentHeader>
</div>
<ProjectPreview
bazaarProjects={bazaarProjects.value || []}
sortingMethod={compareProjectsByDate}
bazaarProjects={getSearchResults() || []}
fetchBazaarProjects={fetchBazaarProjects}
catalogEntities={unlinkedCatalogEntities || []}
/>
<Content noPadding className={classes.container} />
</Content>
+6 -4
View File
@@ -17,7 +17,7 @@
import { EntityRef } from '@backstage/catalog-model';
export type Member = {
entityRef: EntityRef;
itemId: number;
userId: string;
joinDate?: string;
picture?: string;
@@ -29,10 +29,11 @@ export type Size = 'small' | 'medium' | 'large';
export type BazaarProject = {
name: string;
entityRef: EntityRef;
id: number;
entityRef?: EntityRef;
community: string;
status: Status;
announcement: string;
description: string;
updatedAt?: string;
membersCount: number;
size: Size;
@@ -42,7 +43,8 @@ export type BazaarProject = {
};
export type FormValues = {
announcement: string;
name: string;
description: string;
community: string;
status: string;
size: Size;
+52
View File
@@ -0,0 +1,52 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { sortMembers } from './sortMethods';
import { CatalogApi } from '@backstage/catalog-client';
import { parseMember } from './parseMethods';
import { BazaarProject, Member } from '../types';
import { BazaarApi } from '../api';
import { Entity } from '@backstage/catalog-model';
export const fetchProjectMembers = async (
bazaarApi: BazaarApi,
project: BazaarProject,
): Promise<Member[]> => {
const response = await bazaarApi.getMembers(project.id);
if (response.data.length > 0) {
const dbMembers = response.data.map((member: any) => {
return parseMember(member);
});
dbMembers.sort(sortMembers);
return dbMembers;
}
return [];
};
export const fetchCatalogItems = async (
catalogApi: CatalogApi,
): Promise<Entity[]> => {
const entities = await catalogApi.getEntities({
filter: {
kind: ['Component', 'Resource'],
},
fields: ['kind', 'metadata.name', 'metadata.namespace'],
});
return entities.items;
};
+54
View File
@@ -0,0 +1,54 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { BazaarProject, Member } from '../types';
export const parseBazaarProject = (metadata: any): BazaarProject => {
return {
id: metadata.id,
entityRef: metadata.entity_ref,
name: metadata.name,
community: metadata.community,
description: metadata.description,
status: metadata.status,
updatedAt: metadata.updated_at,
membersCount: metadata.members_count,
size: metadata.size,
startDate: metadata.start_date,
endDate: metadata.end_date,
responsible: metadata.responsible,
} as BazaarProject;
};
export const parseMember = (member: any): Member => {
return {
itemId: member.item_id,
userId: member.user_id,
joinDate: member.join_date,
picture: member.picture,
} as Member;
};
export const parseBazaarResponse = async (response: any) => {
if (response) {
const metadata = await response.json().then((resp: any) => resp.data[0]);
if (metadata) {
return parseBazaarProject(metadata);
}
}
return null;
};
+40
View File
@@ -0,0 +1,40 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { BazaarProject, Member } from '../types';
export const sortMembers = (m1: Member, m2: Member) => {
return new Date(m2.joinDate!).getTime() - new Date(m1.joinDate!).getTime();
};
export const sortByDate = (a: BazaarProject, b: BazaarProject): number => {
const dateA = new Date(a.updatedAt!).getTime();
const dateB = new Date(b.updatedAt!).getTime();
return dateB - dateA;
};
export const sortByName = (a: BazaarProject, b: BazaarProject) => {
if (a.name < b.name) {
return -1;
} else if (a.name > b.name) {
return 1;
}
return 0;
};
export const sortByMembers = (a: BazaarProject, b: BazaarProject) => {
return b.membersCount - a.membersCount;
};