Merge pull request #8496 from lykkeaxlin/bazaar-workflow-changes

[Bazaar] Optional linkage to an Entity
This commit is contained in:
Fredrik Adelöw
2021-12-20 19:57:58 +01:00
committed by GitHub
46 changed files with 1957 additions and 1050 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-bazaar': patch
'@backstage/plugin-bazaar-backend': patch
---
made the linkage between a Bazaar project to a catalog Entity optional
@@ -0,0 +1,171 @@
/*
* 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.
*/
exports.up = async function up(knex) {
if (knex.client.config.client === 'sqlite3') {
await knex.schema.dropTable('metadata');
await knex.schema.createTable('metadata', table => {
table.increments('id').comment('Automatically generated unique ID');
table.text('entity_ref').unique().comment('The ref of the entity');
table.text('name').notNullable().comment('The name of the entity');
table
.text('community')
.comment('Link to where the community can discuss ideas');
table
.text('description')
.notNullable()
.comment('The description of the Bazaar project');
table
.text('status')
.defaultTo('proposed')
.notNullable()
.comment('The status of the Bazaar project');
table
.text('updated_at')
.notNullable()
.comment('Timestamp on ISO 8601 format when entity was last updated');
table
.text('size')
.defaultTo('medium')
.notNullable()
.comment('The estimated magnitude of the project');
table
.text('start_date')
.comment('Optional start date of the project (ISO 8601 format)');
table
.text('end_date')
.comment('Optional end date of the project (ISO 8601 format)');
table
.text('responsible')
.notNullable()
.comment('Contact person of the project');
});
await knex.schema.dropTable('members');
await knex.schema.createTable('members', table => {
table
.integer('item_id')
.references('metadata.id')
.onDelete('CASCADE')
.comment('Id of the associated item');
table
.text('entity_ref')
.references('metadata.entity_ref')
.onDelete('CASCADE')
.comment('The ref of the entity');
table.text('user_id').notNullable().comment('The user id of the member');
table
.dateTime('join_date')
.defaultTo(knex.fn.now())
.notNullable()
.comment('The timestamp when this member joined');
table.text('picture').comment('Link to profile picture');
});
} else {
await knex.schema.alterTable('metadata', table => {
table.renameColumn('announcement', 'description');
table.increments('id').comment('Automatically generated unique ID');
table.string('entity_ref').nullable().alter();
});
await knex.schema.alterTable('members', table => {
table
.integer('item_id')
.references('metadata.id')
.onDelete('CASCADE')
.comment('Id of the associated item');
table.dropColumn('entity_ref');
});
}
};
exports.down = async function down(knex) {
if (knex.client.config.client === 'sqlite3') {
await knex.schema.dropTable('metadata');
await knex.schema.createTable('metadata', table => {
table
.text('entity_ref')
.notNullable()
.unique()
.comment('The ref of the entity');
table.text('name').notNullable().comment('The name of the entity');
table
.text('community')
.comment('Link to where the community can discuss ideas');
table
.text('announcement')
.notNullable()
.comment('The announcement of the Bazaar project');
table
.text('status')
.defaultTo('proposed')
.notNullable()
.comment('The status of the Bazaar project');
table
.text('updated_at')
.notNullable()
.comment('Timestamp on ISO 8601 format when entity was last updated');
table
.text('size')
.defaultTo('medium')
.notNullable()
.comment('The estimated magnitude of the project');
table
.text('start_date')
.comment('Optional start date of the project (ISO 8601 format)');
table
.text('end_date')
.comment('Optional end date of the project (ISO 8601 format)');
table
.text('responsible')
.notNullable()
.comment('Contact person of the project');
});
await knex.schema.dropTable('members');
await knex.schema.createTable('members', table => {
table
.text('entity_ref')
.notNullable()
.references('metadata.entity_ref')
.onDelete('CASCADE')
.comment('The ref of the entity');
table.text('user_id').notNullable().comment('The user id of the member');
table
.dateTime('join_date')
.defaultTo(knex.fn.now())
.notNullable()
.comment('The timestamp when this member joined');
table.text('picture').comment('Link to profile picture');
});
} else {
await knex.schema.alterTable('metadata', table => {
table.renameColumn('description', 'announcement');
table.string('entity_ref').notNullable().alter();
table.dropColumn('id');
});
await knex.schema.alterTable('members', table => {
table.dropColumn('item_id');
table
.text('entity_ref')
.notNullable()
.references('metadata.entity_ref')
.onDelete('CASCADE')
.comment('The ref of the entity');
});
}
};
@@ -22,13 +22,14 @@ const bazaarProject: any = {
entityRef: 'ref1',
community: '',
status: 'proposed',
announcement: 'a',
description: 'a',
membersCount: 0,
startDate: '2021-11-07T13:27:00.000Z',
endDate: null,
size: 'small',
responsible: 'r',
};
describe('DatabaseHandler', () => {
const databases = TestDatabases.create({
ids: ['POSTGRES_13', 'POSTGRES_9', 'SQLITE_3'],
@@ -50,7 +51,7 @@ describe('DatabaseHandler', () => {
await knex('metadata').insert({
entity_ref: bazaarProject.entityRef,
name: bazaarProject.name,
announcement: bazaarProject.announcement,
description: bazaarProject.description,
community: bazaarProject.community,
status: bazaarProject.status,
updated_at: new Date().toISOString(),
@@ -60,10 +61,10 @@ describe('DatabaseHandler', () => {
responsible: bazaarProject.responsible,
});
const res = await dbHandler.getMetadata('ref1');
const res = await dbHandler.getMetadataByRef('ref1');
expect(res).toHaveLength(1);
expect(res[0].announcement).toEqual('a');
expect(res[0].description).toEqual('a');
expect(res[0].community).toEqual('');
expect(res[0].status).toEqual('proposed');
expect(res[0].start_date).toEqual('2021-11-07T13:27:00.000Z');
@@ -44,10 +44,10 @@ export class DatabaseHandler {
}
private columns = [
'members.entity_ref',
'metadata.id',
'metadata.entity_ref',
'metadata.name',
'metadata.announcement',
'metadata.description',
'metadata.status',
'metadata.updated_at',
'metadata.community',
@@ -57,40 +57,52 @@ export class DatabaseHandler {
'metadata.responsible',
];
async getMembers(entityRef: string) {
async getMembers(id: string) {
return await this.database
.select('*')
.from('members')
.where({ entity_ref: entityRef });
.where({ item_id: id });
}
async addMember(userId: string, entityRef: string, picture?: string) {
async addMember(id: number, userId: string, picture?: string) {
await this.database
.insert({
entity_ref: entityRef,
item_id: id,
user_id: userId,
picture: picture,
})
.into('members');
}
async deleteMember(userId: string, entityRef: string) {
async deleteMember(id: number, userId: string) {
return await this.database('members')
.where({ entity_ref: decodeURIComponent(entityRef) })
.where({ item_id: id })
.andWhere('user_id', userId)
.del();
}
async getMetadata(entityRef: string) {
async getMetadataById(id: number) {
const coalesce = this.database.raw(
'coalesce(count(members.entity_ref), 0) as members_count',
'coalesce(count(members.item_id), 0) as members_count',
);
return await this.database('metadata')
.select([...this.columns, coalesce])
.where({ 'metadata.id': id })
.groupBy(this.columns)
.leftJoin('members', 'metadata.id', '=', 'members.item_id');
}
async getMetadataByRef(entityRef: string) {
const coalesce = this.database.raw(
'coalesce(count(members.item_id), 0) as members_count',
);
return await this.database('metadata')
.select([...this.columns, coalesce])
.where({ 'metadata.entity_ref': entityRef })
.groupBy(this.columns)
.leftJoin('members', 'metadata.entity_ref', '=', 'members.entity_ref');
.leftJoin('members', 'metadata.id', '=', 'members.item_id');
}
async insertMetadata(bazaarProject: any) {
@@ -98,7 +110,7 @@ export class DatabaseHandler {
name,
entityRef,
community,
announcement,
description,
status,
size,
startDate,
@@ -108,11 +120,11 @@ export class DatabaseHandler {
await this.database
.insert({
name: name,
name,
entity_ref: entityRef,
community: community,
announcement: announcement,
status: status,
community,
description,
status,
updated_at: new Date().toISOString(),
size,
start_date: startDate,
@@ -124,9 +136,11 @@ export class DatabaseHandler {
async updateMetadata(bazaarProject: any) {
const {
name,
id,
entityRef,
community,
announcement,
description,
status,
size,
startDate,
@@ -134,34 +148,32 @@ export class DatabaseHandler {
responsible,
} = bazaarProject;
return await this.database('metadata')
.where({ entity_ref: entityRef })
.update({
announcement: announcement,
community: community,
status: status,
updated_at: new Date().toISOString(),
size,
start_date: startDate,
end_date: endDate,
responsible,
});
return await this.database('metadata').where({ id: id }).update({
name,
entity_ref: entityRef,
description,
community,
status,
updated_at: new Date().toISOString(),
size,
start_date: startDate,
end_date: endDate,
responsible,
});
}
async deleteMetadata(entityRef: string) {
return await this.database('metadata')
.where({ entity_ref: entityRef })
.del();
async deleteMetadata(id: number) {
return await this.database('metadata').where({ id: id }).del();
}
async getEntities() {
async getProjects() {
const coalesce = this.database.raw(
'coalesce(count(members.entity_ref), 0) as members_count',
'coalesce(count(members.item_id), 0) as members_count',
);
return await this.database('metadata')
.select([...this.columns, coalesce])
.groupBy(this.columns)
.leftJoin('members', 'metadata.entity_ref', '=', 'members.entity_ref');
.leftJoin('members', 'metadata.id', '=', 'members.item_id');
}
}
+33 -22
View File
@@ -40,28 +40,27 @@ export async function createRouter(
const router = Router();
router.use(express.json());
router.get('/members/:ref', async (request, response) => {
const entity_ref = decodeURIComponent(request.params.ref);
const data = await dbHandler.getMembers(entity_ref);
router.get('/projects/:id/members', async (request, response) => {
const members = await dbHandler.getMembers(request.params.id);
if (data?.length) {
response.json({ status: 'ok', data: data });
if (members?.length) {
response.json({ status: 'ok', data: members });
} else {
response.json({ status: 'ok', data: [] });
}
});
router.put('/member', async (request, response) => {
const { user_id, entity_ref, picture } = request.body;
await dbHandler.addMember(user_id, entity_ref, picture);
router.put('/projects/:id/member/:userId', async (request, response) => {
const { id, userId } = request.params;
await dbHandler.addMember(parseInt(id, 10), userId, request.body?.picture);
response.json({ status: 'ok' });
});
router.delete('/member/:ref/:id', async (request, response) => {
const { ref, id } = request.params;
router.delete('/projects/:id/member/:userId', async (request, response) => {
const { id, userId } = request.params;
const count = await dbHandler.deleteMember(id, ref);
const count = await dbHandler.deleteMember(parseInt(id, 10), userId);
if (count) {
response.json({ status: 'ok' });
@@ -70,36 +69,48 @@ export async function createRouter(
}
});
router.get('/metadata/:ref', async (request, response) => {
router.get('/projects/id/:id', async (request, response) => {
const id = decodeURIComponent(request.params.id);
const data = await dbHandler.getMetadataById(parseInt(id, 10));
response.json({ status: 'ok', data: data });
});
router.get('/projects/ref/:ref', async (request, response) => {
const ref = decodeURIComponent(request.params.ref);
const data = await dbHandler.getMetadata(ref);
const data = await dbHandler.getMetadataByRef(ref);
response.json({ status: 'ok', data: data });
});
router.get('/entities', async (_, response) => {
const data = await dbHandler.getEntities();
router.get('/projects', async (_, response) => {
const data = await dbHandler.getProjects();
response.json({ status: 'ok', data: data });
});
router.put('/metadata', async (request, response) => {
router.put('/projects', async (request, response) => {
const bazaarProject = request.body;
const count = await dbHandler.updateMetadata(bazaarProject);
if (count) {
response.json({ status: 'ok' });
} else {
await dbHandler.insertMetadata(bazaarProject);
response.json({ status: 'ok' });
}
});
router.delete('/metadata/:ref', async (request, response) => {
const ref = decodeURIComponent(request.params.ref);
router.post('/projects', async (request, response) => {
const bazaarProject = request.body;
const count = await dbHandler.deleteMetadata(ref);
await dbHandler.insertMetadata(bazaarProject);
response.json({ status: 'ok' });
});
router.delete('/projects/:id', async (request, response) => {
const id = decodeURIComponent(request.params.id);
const count = await dbHandler.deleteMetadata(parseInt(id, 10));
if (count) {
response.json({ status: 'ok' });
+10 -32
View File
@@ -69,61 +69,42 @@ const overviewContent = (
### Layout
The latest modified Bazaar projects are displayed in the Bazaar landing page, located at the Bazaar icon in the sidebar. Each project is represented as a card containing its most relevant data to give an overview of the project. The list of project is paginated.
The latest modified Bazaar projects are displayed in the Bazaar landing page, located at the Bazaar icon in the sidebar. Each project is represented as a card containing its most relevant data to give an overview of the project. It is also possible to sort in alphabetical order or on the number of members. Here you can also search or add project to the Bazaar.
![home](media/bazaar_pr_fullscreen.png)
![home](media/layout.png)
### Workflow
To add a project to the Bazaar, you need to create a project with one of the templates in Backstage. Click the add project-button, choose the project and fill in the form.
To add a project to the bazaar, simply click on the `add-project` button and fill in the form.
The following fields are mandatory:
- announcement - present your idea and what skills you are looking for
- name - name of the project on URL safe format
- description - present your idea and what skills you are looking for
- status - whether or not the project has started
- size - small, medium or large
- responsible - main contact person of the project
The other fields are:
- project - link Bazaar project to existing entity in the catalog
- community link - link to where the project members can communicate, e.g. Teams or Discord link
- start date
- end date
- community link - link where the project members can chat, e.g. Teams or Discord link
When the project is added, you will see the Bazaar information in the Bazaar card on the entity page. There you can join a project, edit or delete it.
When clicking on a Bazaar project a card containing the Bazaar information will show up. If the Bazaar project is linked to an entity, the card is also visible on that entity's EntityPage. From that card it is possible to either link or unlink an entity to a project, edit or delete the project and join the project if it seems interesting to you. Once you have joined a project, you will get access to the community link if it exists.
### Database
The metadata related to the Bazaar is stored in a database. Right now there are two tables, one for storing the metadata and one for storing the members of a Bazaar project.
**metadata**:
- name - name of the entity
- entity_ref - namespace/kind/name of the entity
- announcement - announcement of the project and its current need of skills/team member
- status - status of the project, 'proposed' or 'ongoing'
- updated_at - date when the Bazaar information was last modified (ISO 8601 format)
- size - small, medium or large
- start_date - date when the project is estimated to start (ISO 8601 format)
- end_date - date when the project is estimated to end (ISO 8601 format)
- responsible - main contact person of the project
**members**:
- entity_ref - namespace/kind/name of the entity
- user_name
- join_date - date when the user joined the project (ISO 8601 format)
![home](media/demo.gif)
## Future work and ideas
- Workflow
- Make it possible to add a Bazaar project without linking it to a Backstage entity, this would make it easier to just add an idea to the Bazaar.
- Make it possible for multiple Bazaar project to link to the same catalog entity
- Bazaar landing page
- Add a tab 'My page', where your personal data is displayed. For example: your projects and its latest activities etc.
- Make it possible to sort the project based on e.g. the number of members
- Bazaar tab on the EntityPage
@@ -133,6 +114,3 @@ The metadata related to the Bazaar is stored in a database. Right now there are
- Dialogues
- Extend the dialogue for adding a project with more fields, e.g. the possibility to add images
- Testing
- Add tests to all the components
Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

+1
View File
@@ -34,6 +34,7 @@
"@material-ui/pickers": "^3.3.10",
"@testing-library/jest-dom": "^5.10.1",
"luxon": "^2.0.2",
"material-ui-search-bar": "^1.0.0",
"react-hook-form": "^7.13.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^17.2.4"
+25
View File
@@ -0,0 +1,25 @@
## API Report File for "@backstage/plugin-bazaar"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
/// <reference types="react" />
import * as _backstage_core_plugin_api from '@backstage/core-plugin-api';
// @public (undocumented)
export const BazaarPage: () => JSX.Element;
// @public (undocumented)
export const bazaarPlugin: _backstage_core_plugin_api.BackstagePlugin<
{
root: _backstage_core_plugin_api.RouteRef<undefined>;
},
{}
>;
// @public (undocumented)
export const EntityBazaarInfoCard: () => JSX.Element;
// (No @packageDocumentation comment for this package)
```
+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,119 @@
*/
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;
};
const useStyles = makeStyles({
button: {
marginLeft: '0',
marginRight: 'auto',
},
});
export const EditProjectDialog = ({
entity,
bazaarProject,
openEdit,
handleEditClose,
handleCardClose,
fetchBazaarProject,
open,
handleClose,
}: 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();
fetchBazaarProject();
};
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,200 @@
/*
* 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)
: [];
});
const [userId, fetchUserId] = useAsyncFn(async () => {
return await (
await identity.getProfileInfo()
).displayName;
});
useEffect(() => {
fetchMembers();
fetchUserId();
}, [fetchMembers, fetchUserId]);
useEffect(() => {
if (members.value && userId.value) {
setIsMember(
members.value
?.map((member: Member) => member.userId)
.indexOf(userId.value) >= 0,
);
}
}, [bazaarProject, members, identity, userId.value]);
const handleMembersClick = async () => {
if (userId.value) {
if (!isMember) {
await bazaarApi.addMember(bazaarProject?.id!, userId.value);
} else {
await bazaarApi.deleteMember(bazaarProject!.id, userId.value);
}
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,269 @@
/*
* 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;
handleClose: () => void;
initEntity: Entity;
};
export const HomePageBazaarInfoCard = ({
initProject,
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);
});
const [userId, fetchUserId] = useAsyncFn(async () => {
return await (
await identity.getProfileInfo()
).displayName;
});
useEffect(() => {
fetchMembers();
fetchBazaarProject();
fetchCatalogEntities();
fetchUserId();
}, [fetchMembers, fetchBazaarProject, fetchCatalogEntities, fetchUserId]);
useEffect(() => {
if (members.value && userId.value) {
setIsMember(
members.value
?.map((member: Member) => member.userId)
.indexOf(userId.value) >= 0,
);
}
}, [bazaarProject.value, members, identity, userId.value]);
const handleMembersClick = async () => {
if (userId.value) {
if (!isMember) {
await bazaarApi.addMember(bazaarProject.value!.id, userId.value);
} else {
await bazaarApi.deleteMember(bazaarProject.value!.id, userId.value);
}
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}
/>
<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,94 @@
* 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}
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,25 +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"
placeholder="Describe who you are and what skills you are looking for"
inputType="description"
helperText="please enter a description"
/>
<InputSelector
@@ -184,7 +137,7 @@ export const ProjectDialog = ({
placeholder="Contact person of the project"
/>
<DoubleDateSelector setValue={setValue} control={control} />
{isAddForm && projectSelector}
<InputField
error={errors.community}
@@ -197,11 +150,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;
+49
View File
@@ -0,0 +1,49 @@
/*
* 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 { 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: any): 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;
};
+8
View File
@@ -20239,6 +20239,14 @@ marked@^2.1.1:
resolved "https://registry.npmjs.org/marked/-/marked-2.1.3.tgz#bd017cef6431724fd4b27e0657f5ceb14bff3753"
integrity sha512-/Q+7MGzaETqifOMWYEA7HVMaZb4XbcRfaOzcSsHZEith83KGlvaSG33u0SKu89Mj5h+T8V2hM+8O45Qc5XTgwA==
material-ui-search-bar@^1.0.0:
version "1.0.0"
resolved "https://registry.npmjs.org/material-ui-search-bar/-/material-ui-search-bar-1.0.0.tgz#2652dd5bdc4cb043cffb7144d9c296c120702e62"
integrity sha512-lCNuzMLPBVukVAkcnYKLXHneozsuKZREZNOcc8z9S9scXHqxJzhC9hOS3OC3/YJ+NJEB5lZB9zg1gryBaXEu8w==
dependencies:
classnames "^2.2.5"
prop-types "^15.5.8"
math-expression-evaluator@^1.2.14:
version "1.2.22"
resolved "https://registry.npmjs.org/math-expression-evaluator/-/math-expression-evaluator-1.2.22.tgz#c14dcb3d8b4d150e5dcea9c68c8dad80309b0d5e"