Merge branch 'master' into scaffolder/EntityPicker-arbitrary-values

This commit is contained in:
Jason Nguyen
2021-12-21 17:50:16 -07:00
committed by GitHub
539 changed files with 9860 additions and 3498 deletions
+4 -4
View File
@@ -23,8 +23,8 @@
},
"dependencies": {
"@backstage/catalog-model": "^0.9.7",
"@backstage/core-components": "^0.8.0",
"@backstage/core-plugin-api": "^0.3.0",
"@backstage/core-components": "^0.8.1",
"@backstage/core-plugin-api": "^0.3.1",
"@backstage/plugin-catalog-react": "^0.6.5",
"@backstage/theme": "^0.2.14",
"@material-ui/core": "^4.12.2",
@@ -37,8 +37,8 @@
"react": "^16.13.1 || ^17.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.10.1",
"@backstage/core-app-api": "^0.2.0",
"@backstage/cli": "^0.10.2",
"@backstage/core-app-api": "^0.2.1",
"@backstage/dev-utils": "^0.2.14",
"@backstage/test-utils": "^0.1.24",
"@testing-library/jest-dom": "^5.10.1",
+4 -4
View File
@@ -22,8 +22,8 @@
},
"dependencies": {
"@backstage/config": "^0.1.5",
"@backstage/core-components": "^0.8.0",
"@backstage/core-plugin-api": "^0.3.0",
"@backstage/core-components": "^0.8.1",
"@backstage/core-plugin-api": "^0.3.1",
"@backstage/theme": "^0.2.14",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
@@ -35,8 +35,8 @@
"react": "^16.13.1 || ^17.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.10.1",
"@backstage/core-app-api": "^0.2.0",
"@backstage/cli": "^0.10.2",
"@backstage/core-app-api": "^0.2.1",
"@backstage/dev-utils": "^0.2.14",
"@backstage/test-utils": "^0.1.24",
"@testing-library/jest-dom": "^5.10.1",
+22
View File
@@ -0,0 +1,22 @@
# @backstage/plugin-apache-airflow
## 0.1.0
### Minor Changes
- 9aea335911: Introduces a new plugin for the Apache Airflow workflow management platform.
This implementation has been tested with the Apache Airflow v2 API,
authenticating with basic authentication through the Backstage proxy plugin.
Supported functionality includes:
- Information card of version information of the Airflow instance
- Information card of instance health for the meta-database and scheduler
- Table of DAGs with meta information and status, along with a link to view
details in the Airflow UI
### Patch Changes
- Updated dependencies
- @backstage/core-plugin-api@0.3.1
- @backstage/core-components@0.8.1
+3
View File
@@ -2,6 +2,9 @@
Welcome to the apache-airflow plugin!
This plugin serves as frontend to the REST API exposed by Apache Airflow.
Note only [Airflow v2 (and later)](https://airflow.apache.org/docs/apache-airflow/stable/deprecated-rest-api-ref.html) integrate with the plugin.
## Feature Requests & Ideas
- [ ] Add support for running multiple instances of Airflow for monitoring
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-apache-airflow",
"version": "0.0.0",
"version": "0.1.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -20,8 +20,8 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/core-components": "^0.8.0",
"@backstage/core-plugin-api": "^0.3.0",
"@backstage/core-components": "^0.8.1",
"@backstage/core-plugin-api": "^0.3.1",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
@@ -33,8 +33,8 @@
"react": "^16.13.1 || ^17.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.10.1",
"@backstage/core-app-api": "^0.2.0",
"@backstage/cli": "^0.10.2",
"@backstage/core-app-api": "^0.2.1",
"@backstage/dev-utils": "^0.2.14",
"@backstage/test-utils": "^0.1.24",
"@testing-library/jest-dom": "^5.10.1",
@@ -19,7 +19,6 @@ import { Dag, InstanceStatus, InstanceVersion } from './types';
export const apacheAirflowApiRef = createApiRef<ApacheAirflowApi>({
id: 'plugin.apacheairflow.service',
description: 'Used by the Apache Airflow plugin to make requests',
});
export type ApacheAirflowApi = {
+4 -4
View File
@@ -32,8 +32,8 @@
"dependencies": {
"@asyncapi/react-component": "^1.0.0-next.25",
"@backstage/catalog-model": "^0.9.7",
"@backstage/core-components": "^0.8.0",
"@backstage/core-plugin-api": "^0.3.0",
"@backstage/core-components": "^0.8.1",
"@backstage/core-plugin-api": "^0.3.1",
"@backstage/plugin-catalog": "^0.7.4",
"@backstage/plugin-catalog-react": "^0.6.5",
"@backstage/theme": "^0.2.14",
@@ -54,8 +54,8 @@
"react": "^16.13.1 || ^17.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.10.1",
"@backstage/core-app-api": "^0.2.0",
"@backstage/cli": "^0.10.2",
"@backstage/core-app-api": "^0.2.1",
"@backstage/dev-utils": "^0.2.14",
"@backstage/test-utils": "^0.1.24",
"@testing-library/jest-dom": "^5.10.1",
-1
View File
@@ -20,7 +20,6 @@ import { createApiRef } from '@backstage/core-plugin-api';
export const apiDocsConfigRef = createApiRef<ApiDocsConfig>({
id: 'plugin.api-docs.config',
description: 'Used to configure api-docs widgets',
});
export interface ApiDocsConfig {
+9
View File
@@ -1,5 +1,14 @@
# @backstage/plugin-auth-backend
## 0.5.1
### Patch Changes
- 699c2e9ddc: export minimal typescript types for OIDC provider
- Updated dependencies
- @backstage/backend-common@0.9.14
- @backstage/catalog-model@0.9.8
## 0.5.0
### Minor Changes
+40 -3
View File
@@ -47,6 +47,16 @@ export type AtlassianProviderOptions = {
};
};
// @public
export type AuthHandler<AuthResult> = (
input: AuthResult,
) => Promise<AuthHandlerResult>;
// @public
export type AuthHandlerResult = {
profile: ProfileInfo;
};
// Warning: (ae-missing-release-tag) "AuthProviderFactory" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -258,7 +268,6 @@ export const createOAuth2Provider: (
options?: OAuth2ProviderOptions | undefined,
) => AuthProviderFactory;
// Warning: (ae-forgotten-export) The symbol "OidcProviderOptions" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "createOidcProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -534,6 +543,20 @@ export type OAuthState = {
origin?: string;
};
// @public
export type OidcAuthResult = {
tokenset: TokenSet;
userinfo: UserinfoResponse;
};
// @public
export type OidcProviderOptions = {
authHandler?: AuthHandler<OidcAuthResult>;
signIn?: {
resolver?: SignInResolver<OidcAuthResult>;
};
};
// Warning: (ae-missing-release-tag) "oktaEmailSignInResolver" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -606,6 +629,22 @@ export type SamlProviderOptions = {
};
};
// @public
export type SignInInfo<AuthResult> = {
profile: ProfileInfo;
result: AuthResult;
};
// @public
export type SignInResolver<AuthResult> = (
info: SignInInfo<AuthResult>,
context: {
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
logger: Logger_2;
},
) => Promise<BackstageSignInResult>;
// Warning: (ae-missing-release-tag) "TokenIssuer" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
@@ -637,8 +676,6 @@ export type WebMessageResponse =
// Warnings were encountered during analysis:
//
// src/identity/types.d.ts:31:9 - (ae-forgotten-export) The symbol "AnyJWK" needs to be exported by the entry point index.d.ts
// src/providers/atlassian/provider.d.ts:37:5 - (ae-forgotten-export) The symbol "AuthHandler" needs to be exported by the entry point index.d.ts
// src/providers/atlassian/provider.d.ts:42:9 - (ae-forgotten-export) The symbol "SignInResolver" needs to be exported by the entry point index.d.ts
// src/providers/aws-alb/provider.d.ts:77:5 - (ae-forgotten-export) The symbol "AwsAlbResult" needs to be exported by the entry point index.d.ts
// src/providers/github/provider.d.ts:71:58 - (tsdoc-escape-greater-than) The ">" character should be escaped using a backslash to avoid confusion with an HTML tag
// src/providers/github/provider.d.ts:71:90 - (tsdoc-escape-greater-than) The ">" character should be escaped using a backslash to avoid confusion with an HTML tag
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/plugin-auth-backend",
"description": "A Backstage backend plugin that handles authentication",
"version": "0.5.0",
"version": "0.5.1",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -30,9 +30,9 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.9.13",
"@backstage/backend-common": "^0.9.14",
"@backstage/catalog-client": "^0.5.2",
"@backstage/catalog-model": "^0.9.7",
"@backstage/catalog-model": "^0.9.8",
"@backstage/config": "^0.1.11",
"@backstage/errors": "^0.1.5",
"@backstage/test-utils": "^0.1.24",
@@ -73,7 +73,7 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.10.1",
"@backstage/cli": "^0.10.2",
"@types/body-parser": "^1.19.0",
"@types/cookie-parser": "^1.4.2",
"@types/express-session": "^1.17.2",
@@ -17,7 +17,7 @@
import express from 'express';
import { THOUSAND_DAYS_MS, TEN_MINUTES_MS, OAuthAdapter } from './OAuthAdapter';
import { encodeState } from './helpers';
import { OAuthHandlers } from './types';
import { OAuthHandlers, OAuthResponse } from './types';
const mockResponseData = {
providerInfo: {
@@ -36,6 +36,12 @@ const mockResponseData = {
},
};
function mkTokenBody(payload: unknown): string {
return Buffer.from(JSON.stringify(payload), 'utf8')
.toString('base64')
.replace(/=/g, '');
}
describe('OAuthAdapter', () => {
class MyAuthProvider implements OAuthHandlers {
async start() {
@@ -249,4 +255,86 @@ describe('OAuthAdapter', () => {
'Refresh token is not supported for provider test-provider',
);
});
it('correctly populates incomplete identities', async () => {
const mockRefresh = jest.fn<Promise<OAuthResponse>, [express.Request]>();
const oauthProvider = new OAuthAdapter(
{
refresh: mockRefresh,
start: jest.fn(),
handler: jest.fn(),
} as OAuthHandlers,
{
...oAuthProviderOptions,
tokenIssuer: {
issueToken: async ({ claims }) => `a.${mkTokenBody(claims)}.a`,
listPublicKeys: async () => ({ keys: [] }),
},
disableRefresh: false,
isOriginAllowed: () => false,
},
);
const mockRequest = {
header: () => 'XMLHttpRequest',
cookies: {
'test-provider-refresh-token': 'token',
},
query: {},
} as unknown as express.Request;
const mockResponse = {
json: jest.fn().mockReturnThis(),
status: jest.fn().mockReturnThis(),
} as unknown as express.Response;
// Without a token
mockRefresh.mockResolvedValueOnce({
...mockResponseData,
backstageIdentity: {
id: 'foo',
token: '',
},
});
await oauthProvider.refresh(mockRequest, mockResponse);
expect(mockResponse.json).toHaveBeenCalledTimes(1);
expect(mockResponse.json).toHaveBeenLastCalledWith({
...mockResponseData,
backstageIdentity: {
id: 'foo',
token: `a.${mkTokenBody({ sub: 'user:default/foo' })}.a`,
idToken: `a.${mkTokenBody({ sub: 'user:default/foo' })}.a`,
identity: {
type: 'user',
userEntityRef: 'user:default/foo',
ownershipEntityRefs: [],
},
},
});
// With a token
mockRefresh.mockResolvedValueOnce({
...mockResponseData,
backstageIdentity: {
id: 'foo',
token: `z.${mkTokenBody({ sub: 'user:my-ns/foo' })}.z`,
},
});
await oauthProvider.refresh(mockRequest, mockResponse);
expect(mockResponse.json).toHaveBeenCalledTimes(2);
expect(mockResponse.json).toHaveBeenLastCalledWith({
...mockResponseData,
backstageIdentity: {
id: 'foo',
token: `z.${mkTokenBody({ sub: 'user:my-ns/foo' })}.z`,
idToken: `z.${mkTokenBody({ sub: 'user:my-ns/foo' })}.z`,
identity: {
type: 'user',
userEntityRef: 'user:my-ns/foo',
ownershipEntityRefs: [],
},
},
});
});
});
@@ -17,6 +17,11 @@
import express from 'express';
import crypto from 'crypto';
import { URL } from 'url';
import {
ENTITY_DEFAULT_NAMESPACE,
parseEntityRef,
stringifyEntityRef,
} from '@backstage/catalog-model';
import {
AuthProviderRouteHandlers,
AuthProviderConfig,
@@ -243,8 +248,14 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
return prepareBackstageIdentityResponse(identity);
}
const userEntityRef = stringifyEntityRef(
parseEntityRef(identity.id, {
defaultKind: 'user',
defaultNamespace: ENTITY_DEFAULT_NAMESPACE,
}),
);
const token = await this.options.tokenIssuer.issueToken({
claims: { sub: identity.id },
claims: { sub: userEntityRef },
});
return prepareBackstageIdentityResponse({ ...identity, token });
@@ -34,6 +34,10 @@ export type {
AuthProviderRouteHandlers,
AuthProviderFactoryOptions,
AuthProviderFactory,
AuthHandler,
AuthHandlerResult,
SignInResolver,
SignInInfo,
} from './types';
// These types are needed for a postMessage from the login pop-up
@@ -13,5 +13,5 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export type { OidcAuthResult, OidcProviderOptions } from './provider';
export { createOidcProvider } from './provider';
@@ -56,7 +56,11 @@ type OidcImpl = {
client: Client;
};
type AuthResult = {
/**
* authentication result for the OIDC which includes the token set and user information (a profile response sent by OIDC server)
* @public
*/
export type OidcAuthResult = {
tokenset: TokenSet;
userinfo: UserinfoResponse;
};
@@ -66,8 +70,8 @@ export type Options = OAuthProviderOptions & {
scope?: string;
prompt?: string;
tokenSignedResponseAlg?: string;
signInResolver?: SignInResolver<AuthResult>;
authHandler: AuthHandler<AuthResult>;
signInResolver?: SignInResolver<OidcAuthResult>;
authHandler: AuthHandler<OidcAuthResult>;
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
logger: Logger;
@@ -78,8 +82,8 @@ export class OidcAuthProvider implements OAuthHandlers {
private readonly scope?: string;
private readonly prompt?: string;
private readonly signInResolver?: SignInResolver<AuthResult>;
private readonly authHandler: AuthHandler<AuthResult>;
private readonly signInResolver?: SignInResolver<OidcAuthResult>;
private readonly authHandler: AuthHandler<OidcAuthResult>;
private readonly tokenIssuer: TokenIssuer;
private readonly catalogIdentityClient: CatalogIdentityClient;
private readonly logger: Logger;
@@ -113,7 +117,7 @@ export class OidcAuthProvider implements OAuthHandlers {
): Promise<{ response: OAuthResponse; refreshToken?: string }> {
const { strategy } = await this.implementation;
const strategyResponse = await executeFrameHandlerStrategy<
AuthResult,
OidcAuthResult,
PrivateInfo
>(req, strategy);
const {
@@ -158,7 +162,7 @@ export class OidcAuthProvider implements OAuthHandlers {
(
tokenset: TokenSet,
userinfo: UserinfoResponse,
done: PassportDoneCallback<AuthResult, PrivateInfo>,
done: PassportDoneCallback<OidcAuthResult, PrivateInfo>,
) => {
if (typeof done !== 'function') {
throw new Error(
@@ -180,7 +184,7 @@ export class OidcAuthProvider implements OAuthHandlers {
// Use this function to grab the user profile info from the token
// Then populate the profile with it
private async handleResult(result: AuthResult): Promise<OAuthResponse> {
private async handleResult(result: OidcAuthResult): Promise<OAuthResponse> {
const { profile } = await this.authHandler(result);
const response: OAuthResponse = {
providerInfo: {
@@ -210,27 +214,37 @@ export class OidcAuthProvider implements OAuthHandlers {
}
}
export const oAuth2DefaultSignInResolver: SignInResolver<AuthResult> = async (
info,
ctx,
) => {
const { profile } = info;
export const oAuth2DefaultSignInResolver: SignInResolver<OidcAuthResult> =
async (info, ctx) => {
const { profile } = info;
if (!profile.email) {
throw new Error('Profile contained no email');
}
const userId = profile.email.split('@')[0];
const token = await ctx.tokenIssuer.issueToken({
claims: { sub: userId, ent: [`user:default/${userId}`] },
});
return { id: userId, token };
};
if (!profile.email) {
throw new Error('Profile contained no email');
}
const userId = profile.email.split('@')[0];
const token = await ctx.tokenIssuer.issueToken({
claims: { sub: userId, ent: [`user:default/${userId}`] },
});
return { id: userId, token };
};
/**
* OIDC provider callback options. An auth handler and a sign in resolver
* can be passed while creating a OIDC provider.
*
* authHandler : called after sign in was successful, a new object must be returned which includes a profile
* signInResolver: called after sign in was successful, expects to return a new {@link BackstageSignInResult}
*
* Both options are optional. There is fallback for authHandler where the default handler expect an e-mail explicitly
* otherwise it throws an error
*
* @public
*/
export type OidcProviderOptions = {
authHandler?: AuthHandler<AuthResult>;
authHandler?: AuthHandler<OidcAuthResult>;
signIn?: {
resolver?: SignInResolver<AuthResult>;
resolver?: SignInResolver<OidcAuthResult>;
};
};
@@ -260,7 +274,7 @@ export const createOidcProvider = (
tokenIssuer,
});
const authHandler: AuthHandler<AuthResult> = options?.authHandler
const authHandler: AuthHandler<OidcAuthResult> = options?.authHandler
? options.authHandler
: async ({ userinfo }) => ({
profile: {
@@ -271,7 +285,7 @@ export const createOidcProvider = (
});
const signInResolverFn =
options?.signIn?.resolver ?? oAuth2DefaultSignInResolver;
const signInResolver: SignInResolver<AuthResult> = info =>
const signInResolver: SignInResolver<OidcAuthResult> = info =>
signInResolverFn(info, {
catalogIdentityClient,
tokenIssuer,
@@ -240,6 +240,10 @@ export type ProfileInfo = {
picture?: string;
};
/**
* type of sign in information context, includes the profile information and authentication result which contains auth. related information
* @public
*/
export type SignInInfo<AuthResult> = {
/**
* The simple profile passed down for use in the frontend.
@@ -252,6 +256,11 @@ export type SignInInfo<AuthResult> = {
result: AuthResult;
};
/**
* Sign in resolver type describes the function which handles the result of a successful authentication
* and it must return a valid {@link BackstageSignInResult}
* @public
*/
export type SignInResolver<AuthResult> = (
info: SignInInfo<AuthResult>,
context: {
@@ -261,6 +270,10 @@ export type SignInResolver<AuthResult> = (
},
) => Promise<BackstageSignInResult>;
/**
* The return type of authentication handler which must contain a valid profile information
* @public
*/
export type AuthHandlerResult = { profile: ProfileInfo };
/**
@@ -270,6 +283,8 @@ export type AuthHandlerResult = { profile: ProfileInfo };
*
* Throwing an error in the function will cause the authentication to fail, making it
* possible to use this function as a way to limit access to a certain group of users.
*
* @public
*/
export type AuthHandler<AuthResult> = (
input: AuthResult,
+61
View File
@@ -1,5 +1,66 @@
# @backstage/plugin-azure-devops-backend
## 0.2.5
### Patch Changes
- daf32e2c9b: Created some initial filters that can be used to create pull request columns:
- All
- AssignedToUser
- AssignedToCurrentUser
- AssignedToTeam
- AssignedToTeams
- AssignedToCurrentUsersTeams
- CreatedByUser
- CreatedByCurrentUser
- CreatedByTeam
- CreatedByTeams
- CreatedByCurrentUsersTeams
Example custom column creation:
```tsx
const COLUMN_CONFIGS: PullRequestColumnConfig[] = [
{
title: 'Created by me',
filters: [{ type: FilterType.CreatedByCurrentUser }],
},
{
title: 'Created by Backstage Core',
filters: [
{
type: FilterType.CreatedByTeam,
teamName: 'Backstage Core',
},
],
},
{
title: 'Assigned to my teams',
filters: [{ type: FilterType.AssignedToCurrentUsersTeams }],
},
{
title: 'Other PRs',
filters: [{ type: FilterType.All }],
simplified: true,
},
];
<Route
path="/azure-pull-requests"
element={
<AzurePullRequestsPage
projectName="{PROJECT_NAME}"
defaultColumnConfigs={COLUMN_CONFIGS}
/>
}
/>;
```
- Updated dependencies
- @backstage/backend-common@0.9.14
- @backstage/plugin-azure-devops-common@0.1.3
## 0.2.4
### Patch Changes
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-azure-devops-backend",
"version": "0.2.4",
"version": "0.2.5",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -20,9 +20,9 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.9.13",
"@backstage/backend-common": "^0.9.14",
"@backstage/config": "^0.1.11",
"@backstage/plugin-azure-devops-common": "^0.1.2",
"@backstage/plugin-azure-devops-common": "^0.1.3",
"@types/express": "^4.17.6",
"azure-devops-node-api": "^11.0.1",
"express": "^4.17.1",
@@ -31,7 +31,7 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.10.1",
"@backstage/cli": "^0.10.2",
"@types/supertest": "^2.0.8",
"supertest": "^4.0.2",
"msw": "^0.35.0"
@@ -215,6 +215,7 @@ function convertReviewer(
return {
id: identityRef.id,
displayName: identityRef.displayName,
uniqueName: identityRef.uniqueName,
imageUrl: getAvatarUrl(identityRef),
isRequired: identityRef.isRequired,
isContainer: identityRef.isContainer,
+57
View File
@@ -1,5 +1,62 @@
# @backstage/plugin-azure-devops-common
## 0.1.3
### Patch Changes
- daf32e2c9b: Created some initial filters that can be used to create pull request columns:
- All
- AssignedToUser
- AssignedToCurrentUser
- AssignedToTeam
- AssignedToTeams
- AssignedToCurrentUsersTeams
- CreatedByUser
- CreatedByCurrentUser
- CreatedByTeam
- CreatedByTeams
- CreatedByCurrentUsersTeams
Example custom column creation:
```tsx
const COLUMN_CONFIGS: PullRequestColumnConfig[] = [
{
title: 'Created by me',
filters: [{ type: FilterType.CreatedByCurrentUser }],
},
{
title: 'Created by Backstage Core',
filters: [
{
type: FilterType.CreatedByTeam,
teamName: 'Backstage Core',
},
],
},
{
title: 'Assigned to my teams',
filters: [{ type: FilterType.AssignedToCurrentUsersTeams }],
},
{
title: 'Other PRs',
filters: [{ type: FilterType.All }],
simplified: true,
},
];
<Route
path="/azure-pull-requests"
element={
<AzurePullRequestsPage
projectName="{PROJECT_NAME}"
defaultColumnConfigs={COLUMN_CONFIGS}
/>
}
/>;
```
## 0.1.2
### Patch Changes
@@ -61,6 +61,10 @@ export interface CreatedBy {
// (undocumented)
imageUrl?: string;
// (undocumented)
teamIds?: string[];
// (undocumented)
teamNames?: string[];
// (undocumented)
uniqueName?: string;
}
@@ -254,6 +258,8 @@ export interface Reviewer {
// (undocumented)
isRequired?: boolean;
// (undocumented)
uniqueName?: string;
// (undocumented)
voteStatus: PullRequestVoteStatus;
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-azure-devops-common",
"version": "0.1.2",
"version": "0.1.3",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -29,7 +29,7 @@
"clean": "backstage-cli clean"
},
"devDependencies": {
"@backstage/cli": "^0.10.1"
"@backstage/cli": "^0.10.2"
},
"files": [
"dist"
+3
View File
@@ -145,6 +145,7 @@ export interface DashboardPullRequest {
export interface Reviewer {
id?: string;
displayName?: string;
uniqueName?: string;
imageUrl?: string;
isRequired?: boolean;
isContainer?: boolean;
@@ -164,6 +165,8 @@ export interface CreatedBy {
displayName?: string;
uniqueName?: string;
imageUrl?: string;
teamIds?: string[];
teamNames?: string[];
}
export interface Repository {
+64
View File
@@ -1,5 +1,69 @@
# @backstage/plugin-azure-devops
## 0.1.7
### Patch Changes
- daf32e2c9b: Created some initial filters that can be used to create pull request columns:
- All
- AssignedToUser
- AssignedToCurrentUser
- AssignedToTeam
- AssignedToTeams
- AssignedToCurrentUsersTeams
- CreatedByUser
- CreatedByCurrentUser
- CreatedByTeam
- CreatedByTeams
- CreatedByCurrentUsersTeams
Example custom column creation:
```tsx
const COLUMN_CONFIGS: PullRequestColumnConfig[] = [
{
title: 'Created by me',
filters: [{ type: FilterType.CreatedByCurrentUser }],
},
{
title: 'Created by Backstage Core',
filters: [
{
type: FilterType.CreatedByTeam,
teamName: 'Backstage Core',
},
],
},
{
title: 'Assigned to my teams',
filters: [{ type: FilterType.AssignedToCurrentUsersTeams }],
},
{
title: 'Other PRs',
filters: [{ type: FilterType.All }],
simplified: true,
},
];
<Route
path="/azure-pull-requests"
element={
<AzurePullRequestsPage
projectName="{PROJECT_NAME}"
defaultColumnConfigs={COLUMN_CONFIGS}
/>
}
/>;
```
- Updated dependencies
- @backstage/core-plugin-api@0.3.1
- @backstage/core-components@0.8.1
- @backstage/plugin-azure-devops-common@0.1.3
- @backstage/catalog-model@0.9.8
- @backstage/plugin-catalog-react@0.6.7
## 0.1.6
### Patch Changes
+163
View File
@@ -6,9 +6,55 @@
/// <reference types="react" />
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common';
import { Entity } from '@backstage/catalog-model';
import { SvgIconProps } from '@material-ui/core';
// Warning: (ae-missing-release-tag) "AllFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type AllFilter = BaseFilter & {
type: FilterType.All;
};
// Warning: (ae-missing-release-tag) "AssignedToTeamFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type AssignedToTeamFilter = BaseFilter & {
type: FilterType.AssignedToTeam;
teamId: string;
};
// Warning: (ae-missing-release-tag) "AssignedToTeamsFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type AssignedToTeamsFilter = BaseFilter &
(
| {
type: FilterType.AssignedToTeams;
teamIds: string[];
}
| {
type: FilterType.AssignedToCurrentUsersTeams;
teamIds?: string[];
}
);
// Warning: (ae-missing-release-tag) "AssignedToUserFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type AssignedToUserFilter = BaseFilter &
(
| {
type: FilterType.AssignedToUser;
email: string;
}
| {
type: FilterType.AssignedToCurrentUser;
email?: string;
}
);
// Warning: (ae-missing-release-tag) "azureDevOpsPlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -25,11 +71,71 @@ export const AzurePullRequestsIcon: (props: SvgIconProps) => JSX.Element;
export const AzurePullRequestsPage: ({
projectName,
pollingInterval,
defaultColumnConfigs,
}: {
projectName?: string | undefined;
pollingInterval?: number | undefined;
defaultColumnConfigs?: PullRequestColumnConfig[] | undefined;
}) => JSX.Element;
// Warning: (ae-missing-release-tag) "BaseFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type BaseFilter = {
type: FilterType;
};
// Warning: (ae-missing-release-tag) "CreatedByTeamFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type CreatedByTeamFilter = BaseFilter &
({
type: FilterType.CreatedByTeam;
} & (
| {
teamId: string;
}
| {
teamName: string;
}
));
// Warning: (ae-missing-release-tag) "CreatedByTeamsFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type CreatedByTeamsFilter = BaseFilter &
(
| ({
type: FilterType.CreatedByTeams;
} & (
| {
teamIds: string[];
}
| {
teamNames: string[];
}
))
| {
type: FilterType.CreatedByCurrentUsersTeams;
teamIds?: string[];
}
);
// Warning: (ae-missing-release-tag) "CreatedByUserFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type CreatedByUserFilter = BaseFilter &
(
| {
type: FilterType.CreatedByUser;
email: string;
}
| {
type: FilterType.CreatedByCurrentUser;
email?: string;
}
);
// Warning: (ae-missing-release-tag) "EntityAzurePipelinesContent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -48,10 +154,67 @@ export const EntityAzurePullRequestsContent: ({
defaultLimit?: number | undefined;
}) => JSX.Element;
// Warning: (ae-missing-release-tag) "Filter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type Filter =
| AssignedToUserFilter
| CreatedByUserFilter
| AssignedToTeamFilter
| CreatedByTeamFilter
| AssignedToTeamsFilter
| CreatedByTeamsFilter
| AllFilter;
// Warning: (ae-missing-release-tag) "FilterType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export enum FilterType {
// (undocumented)
All = 'All',
// (undocumented)
AssignedToCurrentUser = 'AssignedToCurrentUser',
// (undocumented)
AssignedToCurrentUsersTeams = 'AssignedToCurrentUsersTeams',
// (undocumented)
AssignedToTeam = 'AssignedToTeam',
// (undocumented)
AssignedToTeams = 'AssignedToTeams',
// (undocumented)
AssignedToUser = 'AssignedToUser',
// (undocumented)
CreatedByCurrentUser = 'CreatedByCurrentUser',
// (undocumented)
CreatedByCurrentUsersTeams = 'CreatedByCurrentUsersTeams',
// (undocumented)
CreatedByTeam = 'CreatedByTeam',
// (undocumented)
CreatedByTeams = 'CreatedByTeams',
// (undocumented)
CreatedByUser = 'CreatedByUser',
}
// Warning: (ae-missing-release-tag) "isAzureDevOpsAvailable" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const isAzureDevOpsAvailable: (entity: Entity) => boolean;
// Warning: (ae-missing-release-tag) "PullRequestColumnConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export interface PullRequestColumnConfig {
// (undocumented)
filters: Filter[];
// (undocumented)
simplified?: boolean;
// (undocumented)
title: string;
}
// Warning: (ae-missing-release-tag) "PullRequestFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type PullRequestFilter = (pullRequest: DashboardPullRequest) => boolean;
// (No @packageDocumentation comment for this package)
```
+8 -8
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-azure-devops",
"version": "0.1.6",
"version": "0.1.7",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -27,12 +27,12 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.9.7",
"@backstage/core-components": "^0.8.0",
"@backstage/core-plugin-api": "^0.3.0",
"@backstage/catalog-model": "^0.9.8",
"@backstage/core-components": "^0.8.1",
"@backstage/core-plugin-api": "^0.3.1",
"@backstage/errors": "^0.1.4",
"@backstage/plugin-azure-devops-common": "^0.1.2",
"@backstage/plugin-catalog-react": "^0.6.5",
"@backstage/plugin-azure-devops-common": "^0.1.3",
"@backstage/plugin-catalog-react": "^0.6.7",
"@backstage/theme": "^0.2.14",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
@@ -46,8 +46,8 @@
"react": "^16.13.1 || ^17.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.10.1",
"@backstage/core-app-api": "^0.2.0",
"@backstage/cli": "^0.10.2",
"@backstage/core-app-api": "^0.2.1",
"@backstage/dev-utils": "^0.2.14",
"@backstage/test-utils": "^0.1.24",
"@testing-library/jest-dom": "^5.10.1",
@@ -27,8 +27,6 @@ import { createApiRef } from '@backstage/core-plugin-api';
export const azureDevOpsApiRef = createApiRef<AzureDevOpsApi>({
id: 'plugin.azure-devops.service',
description:
'Used by the Azure DevOps plugin to make requests to accompanying backend',
});
export interface AzureDevOpsApi {
@@ -21,56 +21,17 @@ import {
Progress,
ResponseErrorPanel,
} from '@backstage/core-components';
import { PullRequestGroup, PullRequestGroupConfig } from './lib/types';
import React, { useEffect, useState } from 'react';
import { getCreatedByUserFilter, getPullRequestGroups } from './lib/utils';
import { useDashboardPullRequests, useUserEmail } from '../../hooks';
import { PullRequestColumnConfig, PullRequestGroup } from './lib/types';
import React, { useState } from 'react';
import { getPullRequestGroupConfigs, getPullRequestGroups } from './lib/utils';
import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common';
import { FilterType } from './lib/filters';
import { PullRequestGrid } from './lib/PullRequestGrid';
function usePullRequestGroupConfigs(
userEmail: string | undefined,
): PullRequestGroupConfig[] {
const [pullRequestGroupConfigs, setPullRequestGroupConfigs] = useState<
PullRequestGroupConfig[]
>([]);
useEffect(() => {
const prGroupConfigs: PullRequestGroupConfig[] = [
{ title: 'Created by me', filter: getCreatedByUserFilter(userEmail) },
{ title: 'Other PRs', filter: _ => true, simplified: false },
];
setPullRequestGroupConfigs(prGroupConfigs);
}, [userEmail]);
return pullRequestGroupConfigs;
}
function usePullRequestGroups(
pullRequests: DashboardPullRequest[] | undefined,
pullRequestGroupConfigs: PullRequestGroupConfig[],
): PullRequestGroup[] {
const [pullRequestGroups, setPullRequestGroups] = useState<
PullRequestGroup[]
>([]);
useEffect(() => {
if (pullRequests) {
const groups = getPullRequestGroups(
pullRequests,
pullRequestGroupConfigs,
);
setPullRequestGroups(groups);
}
}, [pullRequests, pullRequestGroupConfigs]);
return pullRequestGroups;
}
import { useDashboardPullRequests } from '../../hooks';
import { useFilterProcessor } from './lib/hooks';
type PullRequestsPageContentProps = {
pullRequestGroups: PullRequestGroup[];
pullRequestGroups: PullRequestGroup[] | undefined;
loading: boolean;
error?: Error;
};
@@ -80,7 +41,7 @@ const PullRequestsPageContent = ({
loading,
error,
}: PullRequestsPageContentProps) => {
if (loading && pullRequestGroups.length <= 0) {
if (loading && (!pullRequestGroups || pullRequestGroups.length <= 0)) {
return <Progress />;
}
@@ -88,25 +49,50 @@ const PullRequestsPageContent = ({
return <ResponseErrorPanel error={error} />;
}
return <PullRequestGrid pullRequestGroups={pullRequestGroups} />;
return <PullRequestGrid pullRequestGroups={pullRequestGroups ?? []} />;
};
const DEFAULT_COLUMN_CONFIGS: PullRequestColumnConfig[] = [
{
title: 'Created by me',
filters: [{ type: FilterType.CreatedByCurrentUser }],
simplified: false,
},
{
title: 'Other PRs',
filters: [{ type: FilterType.All }],
simplified: true,
},
];
type PullRequestsPageProps = {
projectName?: string;
pollingInterval?: number;
defaultColumnConfigs?: PullRequestColumnConfig[];
};
export const PullRequestsPage = ({
projectName,
pollingInterval,
defaultColumnConfigs,
}: PullRequestsPageProps) => {
const { pullRequests, loading, error } = useDashboardPullRequests(
projectName,
pollingInterval,
);
const userEmail = useUserEmail();
const pullRequestGroupConfigs = usePullRequestGroupConfigs(userEmail);
const pullRequestGroups = usePullRequestGroups(
const [columnConfigs] = useState(
defaultColumnConfigs ?? DEFAULT_COLUMN_CONFIGS,
);
const filterProcessor = useFilterProcessor();
const pullRequestGroupConfigs = getPullRequestGroupConfigs(
columnConfigs,
filterProcessor,
);
const pullRequestGroups = getPullRequestGroups(
pullRequests,
pullRequestGroupConfigs,
);
@@ -15,3 +15,17 @@
*/
export { PullRequestsPage } from './PullRequestsPage';
export type { PullRequestColumnConfig } from './lib/types';
export { FilterType } from './lib/filters';
export type {
BaseFilter,
Filter,
PullRequestFilter,
AssignedToUserFilter,
CreatedByUserFilter,
AssignedToTeamFilter,
CreatedByTeamFilter,
AssignedToTeamsFilter,
CreatedByTeamsFilter,
AllFilter,
} from './lib/filters';
@@ -0,0 +1,27 @@
/*
* 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 { BaseFilter, FilterType, PullRequestFilter } from './types';
import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common';
export type AllFilter = BaseFilter & {
type: FilterType.All;
};
export function createAllFilter(): PullRequestFilter {
return (_pullRequest: DashboardPullRequest): boolean => true;
}
@@ -0,0 +1,39 @@
/*
* 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 { BaseFilter, FilterType, PullRequestFilter } from './types';
import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common';
import { stringArrayHas } from '../../../../utils';
export type AssignedToTeamFilter = BaseFilter & {
type: FilterType.AssignedToTeam;
teamId: string;
};
export function createAssignedToTeamFilter(
filter: AssignedToTeamFilter,
): PullRequestFilter {
return (pullRequest: DashboardPullRequest): boolean => {
const reviewerIds = pullRequest.reviewers?.map(reviewer => reviewer.id);
if (!reviewerIds) {
return false;
}
return stringArrayHas(reviewerIds, filter.teamId, true);
};
}
@@ -0,0 +1,51 @@
/*
* 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 { BaseFilter, FilterType, PullRequestFilter } from './types';
import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common';
import { createAssignedToTeamFilter } from './assignedToTeamFilter';
export type AssignedToTeamsFilter = BaseFilter &
(
| {
type: FilterType.AssignedToTeams;
teamIds: string[];
}
| {
type: FilterType.AssignedToCurrentUsersTeams;
teamIds?: string[];
}
);
export function createAssignedToTeamsFilter(
filter: AssignedToTeamsFilter,
): PullRequestFilter {
const teamIds = filter.teamIds;
return (pullRequest: DashboardPullRequest): boolean => {
if (!teamIds) {
return false;
}
return teamIds.some(teamId => {
return createAssignedToTeamFilter({
type: FilterType.AssignedToTeam,
teamId,
})(pullRequest);
});
};
}
@@ -0,0 +1,50 @@
/*
* 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 { BaseFilter, FilterType, PullRequestFilter } from './types';
import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common';
import { stringArrayHas } from '../../../../utils';
export type AssignedToUserFilter = BaseFilter &
(
| {
type: FilterType.AssignedToUser;
email: string;
}
| {
type: FilterType.AssignedToCurrentUser;
email?: string;
}
);
export function createAssignedToUserFilter(
filter: AssignedToUserFilter,
): PullRequestFilter {
const email = filter.email;
return (pullRequest: DashboardPullRequest): boolean => {
const uniqueNames = pullRequest.reviewers?.map(
reviewer => reviewer.uniqueName,
);
if (!email || !uniqueNames) {
return false;
}
return stringArrayHas(uniqueNames, email, true);
};
}
@@ -0,0 +1,150 @@
/*
* 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 { Filter, FilterType } from './types';
import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common';
import { createFilter } from './createFilter';
describe('createFilter', () => {
const pullRequest = {
createdBy: {
uniqueName: 'user1@backstage.com',
teamIds: ['team1Id', 'team2Id'],
},
reviewers: [
{ uniqueName: 'user2@backstage.com' },
{ id: 'team2Id' },
{ id: 'team3Id' },
],
} as DashboardPullRequest;
const testCases: Array<{ filter: Filter; result: boolean }> = [
{
filter: {
type: FilterType.AssignedToUser,
email: 'user2@backstage.com',
},
result: true,
},
{
filter: {
type: FilterType.AssignedToUser,
email: 'random-user@backstage.com',
},
result: false,
},
{
filter: {
type: FilterType.CreatedByUser,
email: 'user1@backstage.com',
},
result: true,
},
{
filter: {
type: FilterType.CreatedByUser,
email: 'random-user@backstage.com',
},
result: false,
},
{
filter: {
type: FilterType.AssignedToTeam,
teamId: 'team2Id',
},
result: true,
},
{
filter: {
type: FilterType.AssignedToTeam,
teamId: 'randomTeamId',
},
result: false,
},
{
filter: {
type: FilterType.CreatedByTeam,
teamId: 'team1Id',
},
result: true,
},
{
filter: {
type: FilterType.CreatedByTeam,
teamId: 'randomTeamId',
},
result: false,
},
{
filter: {
type: FilterType.AssignedToTeams,
teamIds: ['team2Id', 'randomTeamId'],
},
result: true,
},
{
filter: {
type: FilterType.AssignedToTeams,
teamIds: ['team2Id', 'team3Id'],
},
result: true,
},
{
filter: {
type: FilterType.AssignedToTeams,
teamIds: ['randomTeam1Id', 'randomTeam2Id'],
},
result: false,
},
{
filter: {
type: FilterType.CreatedByTeams,
teamIds: ['team1Id', 'randomTeamId'],
},
result: true,
},
{
filter: {
type: FilterType.CreatedByTeams,
teamIds: ['team1Id', 'team2Id'],
},
result: true,
},
{
filter: {
type: FilterType.CreatedByTeams,
teamIds: ['randomTeam1Id', 'randomTeam2Id'],
},
result: false,
},
{
filter: {
type: FilterType.All,
},
result: true,
},
];
testCases.forEach(({ filter, result }) => {
it(`should return ${String(result)} when pull request ${
result ? 'is' : 'is not'
} ${filter.type}`, () => {
const pullRequestFilter = createFilter(filter);
expect(pullRequestFilter(pullRequest)).toBe(result);
});
});
});
@@ -0,0 +1,71 @@
/*
* 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 { Filter, FilterType, PullRequestFilter } from './types';
import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common';
import { createAllFilter } from './allFilter';
import { createAssignedToTeamFilter } from './assignedToTeamFilter';
import { createAssignedToTeamsFilter } from './assignedToTeamsFilter';
import { createAssignedToUserFilter } from './assignedToUserFilter';
import { createCreatedByTeamFilter } from './createdByTeamFilter';
import { createCreatedByTeamsFilter } from './createdByTeamsFilter';
import { createCreatedByUserFilter } from './createdByUserFilter';
export function createFilter(filters: Filter | Filter[]): PullRequestFilter {
const mapFilter = (filter: Filter): PullRequestFilter => {
switch (filter.type) {
case FilterType.AssignedToUser:
case FilterType.AssignedToCurrentUser:
return createAssignedToUserFilter(filter);
case FilterType.CreatedByUser:
case FilterType.CreatedByCurrentUser:
return createCreatedByUserFilter(filter);
case FilterType.AssignedToTeam:
return createAssignedToTeamFilter(filter);
case FilterType.CreatedByTeam:
return createCreatedByTeamFilter(filter);
case FilterType.AssignedToTeams:
case FilterType.AssignedToCurrentUsersTeams:
return createAssignedToTeamsFilter(filter);
case FilterType.CreatedByTeams:
case FilterType.CreatedByCurrentUsersTeams:
return createCreatedByTeamsFilter(filter);
case FilterType.All:
return createAllFilter();
default:
return _ => false;
}
};
if (Array.isArray(filters)) {
if (filters.length === 1) {
return mapFilter(filters[0]);
}
return (pullRequest: DashboardPullRequest): boolean =>
filters.every(filter => mapFilter(filter)(pullRequest));
}
return mapFilter(filters);
}
@@ -0,0 +1,42 @@
/*
* 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 { BaseFilter, FilterType, PullRequestFilter } from './types';
import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common';
import { stringArrayHas } from '../../../../utils';
export type CreatedByTeamFilter = BaseFilter &
({
type: FilterType.CreatedByTeam;
} & ({ teamId: string } | { teamName: string }));
export function createCreatedByTeamFilter(
filter: CreatedByTeamFilter,
): PullRequestFilter {
return (pullRequest: DashboardPullRequest): boolean => {
const [createdByTeams, team] =
'teamId' in filter
? [pullRequest.createdBy?.teamIds, filter.teamId]
: [pullRequest.createdBy?.teamNames, filter.teamName];
if (!createdByTeams) {
return false;
}
return stringArrayHas(createdByTeams, team, true);
};
}
@@ -0,0 +1,65 @@
/*
* 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 { BaseFilter, FilterType, PullRequestFilter } from './types';
import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common';
import { createCreatedByTeamFilter } from './createdByTeamFilter';
export type CreatedByTeamsFilter = BaseFilter &
(
| ({
type: FilterType.CreatedByTeams;
} & ({ teamIds: string[] } | { teamNames: string[] }))
| {
type: FilterType.CreatedByCurrentUsersTeams;
teamIds?: string[];
}
);
export function createCreatedByTeamsFilter(
filter: CreatedByTeamsFilter,
): PullRequestFilter {
return (pullRequest: DashboardPullRequest): boolean => {
if ('teamNames' in filter) {
const teamNames = filter.teamNames;
if (!teamNames) {
return false;
}
return teamNames.some(teamName => {
return createCreatedByTeamFilter({
type: FilterType.CreatedByTeam,
teamName,
})(pullRequest);
});
}
const teamIds = filter.teamIds;
if (!teamIds) {
return false;
}
return teamIds.some(teamId => {
return createCreatedByTeamFilter({
type: FilterType.CreatedByTeam,
teamId,
})(pullRequest);
});
};
}
@@ -0,0 +1,48 @@
/*
* 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 { BaseFilter, FilterType, PullRequestFilter } from './types';
import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common';
import { equalsIgnoreCase } from '../../../../utils';
export type CreatedByUserFilter = BaseFilter &
(
| {
type: FilterType.CreatedByUser;
email: string;
}
| {
type: FilterType.CreatedByCurrentUser;
email?: string;
}
);
export function createCreatedByUserFilter(
filter: CreatedByUserFilter,
): PullRequestFilter {
const email = filter.email;
return (pullRequest: DashboardPullRequest): boolean => {
const uniqueName = pullRequest.createdBy?.uniqueName;
if (!email || !uniqueName) {
return false;
}
return equalsIgnoreCase(email, uniqueName);
};
}
@@ -0,0 +1,30 @@
/*
* 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 { createFilter } from './createFilter';
export { FilterTypes, FilterType } from './types';
export type {
BaseFilter,
Filter,
PullRequestFilter,
AssignedToUserFilter,
CreatedByUserFilter,
AssignedToTeamFilter,
CreatedByTeamFilter,
AssignedToTeamsFilter,
CreatedByTeamsFilter,
AllFilter,
} from './types';
@@ -0,0 +1,83 @@
/*
* 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 { AllFilter } from './allFilter';
import { AssignedToTeamFilter } from './assignedToTeamFilter';
import { AssignedToTeamsFilter } from './assignedToTeamsFilter';
import { AssignedToUserFilter } from './assignedToUserFilter';
import { CreatedByTeamFilter } from './createdByTeamFilter';
import { CreatedByTeamsFilter } from './createdByTeamsFilter';
import { CreatedByUserFilter } from './createdByUserFilter';
import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common';
export enum FilterType {
All = 'All',
// Assigned To
AssignedToUser = 'AssignedToUser',
AssignedToCurrentUser = 'AssignedToCurrentUser',
AssignedToTeam = 'AssignedToTeam',
AssignedToTeams = 'AssignedToTeams',
AssignedToCurrentUsersTeams = 'AssignedToCurrentUsersTeams',
// Created By
CreatedByUser = 'CreatedByUser',
CreatedByCurrentUser = 'CreatedByCurrentUser',
CreatedByTeam = 'CreatedByTeam',
CreatedByTeams = 'CreatedByTeams',
CreatedByCurrentUsersTeams = 'CreatedByCurrentUsersTeams',
}
export const FilterTypes = [
FilterType.All,
FilterType.AssignedToUser,
FilterType.AssignedToCurrentUser,
FilterType.AssignedToTeam,
FilterType.AssignedToTeams,
FilterType.AssignedToCurrentUsersTeams,
FilterType.CreatedByUser,
FilterType.CreatedByCurrentUser,
FilterType.CreatedByTeam,
FilterType.CreatedByTeams,
FilterType.CreatedByCurrentUsersTeams,
] as const;
export type BaseFilter = {
type: FilterType;
};
export type Filter =
| AssignedToUserFilter
| CreatedByUserFilter
| AssignedToTeamFilter
| CreatedByTeamFilter
| AssignedToTeamsFilter
| CreatedByTeamsFilter
| AllFilter;
export type {
AssignedToUserFilter,
CreatedByUserFilter,
AssignedToTeamFilter,
CreatedByTeamFilter,
AssignedToTeamsFilter,
CreatedByTeamsFilter,
AllFilter,
};
export type PullRequestFilter = (pullRequest: DashboardPullRequest) => boolean;
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { AlertBanner } from './AlertBanner';
export * from './useFilterProcessor';
@@ -0,0 +1,39 @@
/*
* 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 { Filter, FilterType } from '../filters';
import { useUserEmail } from '../../../../hooks';
export function useFilterProcessor(): (filters: Filter[]) => Filter[] {
const userEmail = useUserEmail();
return (filters: Filter[]): Filter[] => {
for (const filter of filters) {
switch (filter.type) {
case FilterType.AssignedToCurrentUser:
case FilterType.CreatedByCurrentUser:
filter.email = userEmail;
break;
default:
break;
}
}
return filters;
};
}
@@ -14,23 +14,24 @@
* limitations under the License.
*/
import {
DashboardPullRequest,
Team,
} from '@backstage/plugin-azure-devops-common';
import { Filter, PullRequestFilter } from './filters';
export interface PullRequestGroup {
import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common';
export interface PullRequestColumnConfig {
title: string;
pullRequests: DashboardPullRequest[];
filters: Filter[];
simplified?: boolean;
}
export type PullRequestFilter = (pullRequest: DashboardPullRequest) => boolean;
export type TeamFilter = (team: Team) => boolean;
export interface PullRequestGroupConfig {
title: string;
filter: PullRequestFilter;
simplified?: boolean;
}
export interface PullRequestGroup {
title: string;
pullRequests: DashboardPullRequest[];
simplified?: boolean;
}
@@ -19,33 +19,7 @@ import {
PullRequestVoteStatus,
Reviewer,
} from '@backstage/plugin-azure-devops-common';
import {
arrayExtract,
getCreatedByUserFilter,
getPullRequestGroups,
reviewerFilter,
} from './utils';
describe('getCreatedByUserFilter', () => {
it('should filter if pull request is created by user', () => {
const userEmail = 'user1@backstage.com';
const pr = {
createdBy: { uniqueName: userEmail },
} as DashboardPullRequest;
const result = getCreatedByUserFilter(userEmail)(pr);
expect(result).toBe(true);
});
it('should not filter if pull request is not created by user', () => {
const userEmail1 = 'user1@backstage.com';
const userEmail2 = 'user2@backstage.com';
const pr = {
createdBy: { uniqueName: userEmail1 },
} as DashboardPullRequest;
const result = getCreatedByUserFilter(userEmail2)(pr);
expect(result).toBe(false);
});
});
import { arrayExtract, getPullRequestGroups, reviewerFilter } from './utils';
describe('reviewerFilter', () => {
it('should return false if reviewer has no vote and is not required', () => {
@@ -128,11 +102,15 @@ describe('getPullRequestGroups', () => {
];
const configs = [
{ title: 'Created by me', filter: getCreatedByUserFilter(userEmail) },
{
title: 'Created by me',
filter: (pullRequest: DashboardPullRequest): boolean =>
pullRequest.createdBy?.uniqueName === userEmail,
},
{ title: 'Other PRs', filter: (_: unknown) => true, simplified: true },
];
const result = getPullRequestGroups(pullRequests, configs);
const result = getPullRequestGroups(pullRequests, configs) ?? [];
expect(result.length).toBe(2);
@@ -19,25 +19,13 @@ import {
PullRequestVoteStatus,
Reviewer,
} from '@backstage/plugin-azure-devops-common';
import { Filter, createFilter } from './filters';
import {
PullRequestFilter,
PullRequestColumnConfig,
PullRequestGroup,
PullRequestGroupConfig,
} from './types';
/**
* Creates a filter that matches pull requests created by `userEmail`.
* @param userEmail an email to filter by.
* @returns a filter for pull requests created by `userEmail`.
*/
export function getCreatedByUserFilter(
userEmail: string | undefined,
): PullRequestFilter {
return (pullRequest: DashboardPullRequest): boolean =>
pullRequest.createdBy?.uniqueName?.toLocaleLowerCase() ===
userEmail?.toLocaleLowerCase();
}
/**
* Filters a reviewer based on vote status and if the reviewer is required.
* @param reviewer a reviewer to filter.
@@ -97,9 +85,13 @@ export function arrayExtract<T>(arr: T[], filter: (value: T) => unknown): T[] {
* @returns a list of pull request groups.
*/
export function getPullRequestGroups(
pullRequests: DashboardPullRequest[],
pullRequests: DashboardPullRequest[] | undefined,
configs: PullRequestGroupConfig[],
): PullRequestGroup[] {
): PullRequestGroup[] | undefined {
if (!pullRequests) {
return undefined;
}
const remainingPullRequests: DashboardPullRequest[] = [...pullRequests];
const pullRequestGroups: PullRequestGroup[] = [];
@@ -115,3 +107,17 @@ export function getPullRequestGroups(
return pullRequestGroups;
}
export function getPullRequestGroupConfigs(
columnConfigs: PullRequestColumnConfig[],
filterProcessor: (filters: Filter[]) => Filter[],
): PullRequestGroupConfig[] {
return columnConfigs.map(columnConfig => {
const filters = filterProcessor(columnConfig.filters);
return {
title: columnConfig.title,
filter: createFilter(filters),
simplified: columnConfig.simplified,
};
});
}
+15
View File
@@ -23,3 +23,18 @@ export {
} from './plugin';
export { AzurePullRequestsIcon } from './components/AzurePullRequestsIcon';
export { FilterType } from './components/PullRequestsPage';
export type {
PullRequestColumnConfig,
BaseFilter,
Filter,
PullRequestFilter,
AssignedToUserFilter,
CreatedByUserFilter,
AssignedToTeamFilter,
CreatedByTeamFilter,
AssignedToTeamsFilter,
CreatedByTeamsFilter,
AllFilter,
} from './components/PullRequestsPage';
@@ -0,0 +1,34 @@
/*
* 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 function arrayHas<T>(arr: T[], value: T): boolean {
return new Set<T>(arr).has(value);
}
export function stringArrayHas(
arr: Array<string | undefined>,
value: string | undefined,
ignoreCase: boolean = false,
): boolean {
if (ignoreCase) {
return arrayHas(
arr.map(a => a?.toLocaleLowerCase('en-US')),
value?.toLocaleLowerCase('en-US'),
);
}
return arrayHas(arr, value);
}
@@ -0,0 +1,19 @@
/*
* 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 function equalsIgnoreCase(str1: string, str2: string): boolean {
return str1.toLocaleLowerCase('en-US') === str2.toLocaleLowerCase('en-US');
}
+19
View File
@@ -0,0 +1,19 @@
/*
* 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 * from './arrayHas';
export * from './equalsIgnoreCase';
export * from './getDurationFromDates';
+4 -4
View File
@@ -28,8 +28,8 @@
},
"dependencies": {
"@backstage/catalog-model": "^0.9.7",
"@backstage/core-components": "^0.8.0",
"@backstage/core-plugin-api": "^0.3.0",
"@backstage/core-components": "^0.8.1",
"@backstage/core-plugin-api": "^0.3.1",
"@backstage/errors": "^0.1.5",
"@backstage/plugin-catalog-react": "^0.6.5",
"@backstage/theme": "^0.2.14",
@@ -43,8 +43,8 @@
"react": "^16.13.1 || ^17.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.10.1",
"@backstage/core-app-api": "^0.2.0",
"@backstage/cli": "^0.10.2",
"@backstage/core-app-api": "^0.2.1",
"@backstage/dev-utils": "^0.2.14",
"@backstage/test-utils": "^0.1.24",
"@testing-library/jest-dom": "^5.10.1",
-1
View File
@@ -19,7 +19,6 @@ import { createApiRef } from '@backstage/core-plugin-api';
export const badgesApiRef = createApiRef<BadgesApi>({
id: 'plugin.badges.client',
description: 'Used to make requests to the badges backend',
});
export type BadgeStyle =
@@ -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

+4 -3
View File
@@ -23,8 +23,8 @@
"dependencies": {
"@backstage/catalog-model": "^0.9.7",
"@backstage/cli": "^0.10.1",
"@backstage/core-components": "^0.8.0",
"@backstage/core-plugin-api": "^0.3.0",
"@backstage/core-components": "^0.8.1",
"@backstage/core-plugin-api": "^0.3.1",
"@backstage/plugin-catalog": "^0.7.4",
"@backstage/plugin-catalog-react": "^0.6.5",
"@date-io/luxon": "1.x",
@@ -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"
@@ -42,7 +43,7 @@
"react": "^16.13.1 || ^17.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.10.1",
"@backstage/cli": "^0.10.2",
"@backstage/dev-utils": "^0.2.14",
"@testing-library/jest-dom": "^5.10.1",
"cross-fetch": "^3.0.6"
+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 -39
View File
@@ -14,33 +14,34 @@
* 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',
description: 'Used to make requests towards the bazaar backend',
});
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 +56,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 +69,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 +105,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 { DeleteProjectDialog } from './DeleteProjectDialog';
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>
);
};
@@ -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 { 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;
};
+4 -4
View File
@@ -22,8 +22,8 @@
},
"dependencies": {
"@backstage/catalog-model": "^0.9.7",
"@backstage/core-components": "^0.8.0",
"@backstage/core-plugin-api": "^0.3.0",
"@backstage/core-components": "^0.8.1",
"@backstage/core-plugin-api": "^0.3.1",
"@backstage/plugin-catalog-react": "^0.6.5",
"@backstage/theme": "^0.2.14",
"@material-ui/core": "^4.12.2",
@@ -40,8 +40,8 @@
"react": "^16.13.1 || ^17.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.10.1",
"@backstage/core-app-api": "^0.2.0",
"@backstage/cli": "^0.10.2",
"@backstage/core-app-api": "^0.2.1",
"@backstage/dev-utils": "^0.2.14",
"@backstage/test-utils": "^0.1.24",
"@testing-library/jest-dom": "^5.10.1",
-2
View File
@@ -26,8 +26,6 @@ import {
export const bitriseApiRef = createApiRef<BitriseApi>({
id: 'plugin.bitrise.service',
description:
'Used by the BitriseCI plugin to retrieve information about builds.',
});
export const bitrisePlugin = createPlugin({
+9
View File
@@ -1,5 +1,14 @@
# @backstage/plugin-catalog-backend
## 0.19.2
### Patch Changes
- 3368f27aef: Fixed the handling of optional locations so that the catalog no longer logs `NotFoundError`s for missing optional locations.
- Updated dependencies
- @backstage/backend-common@0.9.14
- @backstage/catalog-model@0.9.8
## 0.19.1
### Patch Changes
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/plugin-catalog-backend",
"description": "The Backstage backend plugin that provides the Backstage catalog",
"version": "0.19.1",
"version": "0.19.2",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -30,9 +30,9 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.9.13",
"@backstage/backend-common": "^0.9.14",
"@backstage/catalog-client": "^0.5.2",
"@backstage/catalog-model": "^0.9.7",
"@backstage/catalog-model": "^0.9.8",
"@backstage/config": "^0.1.11",
"@backstage/errors": "^0.1.5",
"@backstage/integration": "^0.6.10",
@@ -63,7 +63,7 @@
},
"devDependencies": {
"@backstage/backend-test-utils": "^0.1.10",
"@backstage/cli": "^0.10.1",
"@backstage/cli": "^0.10.2",
"@backstage/test-utils": "^0.1.24",
"@types/core-js": "^2.5.4",
"@types/git-url-parse": "^9.0.0",

Some files were not shown because too many files have changed in this diff Show More