Merge pull request #12870 from kuangp/feat/playlists
feat(playlists): implement playlist plugin
This commit is contained in:
@@ -0,0 +1 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
@@ -0,0 +1,88 @@
|
||||
# Playlist Backend
|
||||
|
||||
Welcome to the playlist backend plugin!
|
||||
|
||||
# Installation
|
||||
|
||||
## Install the package
|
||||
|
||||
```bash
|
||||
# From your Backstage root directory
|
||||
yarn add --cwd packages/backend @backstage/plugin-playlist-backend
|
||||
```
|
||||
|
||||
## Adding the plugin to your `packages/backend`
|
||||
|
||||
You'll need to add the plugin to the router in your `backend` package. You can do this by creating a file called `packages/backend/src/plugins/playlist.ts`
|
||||
|
||||
```tsx
|
||||
import { IdentityClient } from '@backstage/plugin-auth-node';
|
||||
import { createRouter } from '@backstage/plugin-playlist-backend';
|
||||
import { Router } from 'express';
|
||||
import { PluginEnvironment } from '../types';
|
||||
|
||||
export default async function createPlugin(
|
||||
env: PluginEnvironment,
|
||||
): Promise<Router> {
|
||||
return await createRouter({
|
||||
database: env.database,
|
||||
discovery: env.discovery,
|
||||
identity: env.identity,
|
||||
logger: env.logger,
|
||||
permissions: env.permissions,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
With the `playlist.ts` router setup in place, add the router to `packages/backend/src/index.ts`:
|
||||
|
||||
```diff
|
||||
+import playlist from './plugins/playlist';
|
||||
|
||||
async function main() {
|
||||
...
|
||||
const createEnv = makeCreateEnv(config);
|
||||
|
||||
const catalogEnv = useHotMemoize(module, () => createEnv('catalog'));
|
||||
+ const playlistEnv = useHotMemoize(module, () => createEnv('playlist'));
|
||||
|
||||
const apiRouter = Router();
|
||||
+ apiRouter.use('/playlist', await playlist(playlistEnv));
|
||||
...
|
||||
apiRouter.use(notFoundHandler());
|
||||
|
||||
```
|
||||
|
||||
## Setting up plugin permissions
|
||||
|
||||
You configure permissions for specific playlist actions by importing the supported set of permissions from the [playlist-common](../playlist-common/README.md) package along with the custom rules/conditions provided here to incorporate into your [permission policy](https://backstage.io/docs/permissions/writing-a-policy).
|
||||
|
||||
This package also exports a `DefaultPlaylistPermissionPolicy` which contains a recommended default permissions policy you can apply as a "sub-policy" in your app:
|
||||
|
||||
```diff
|
||||
# packages/backend/src/plugins/permission.ts
|
||||
|
||||
+import { DefaultPlaylistPermissionPolicy, isPlaylistPermission } from '@backstage/plugin-playlist-backend';
|
||||
...
|
||||
class BackstagePermissionPolicy implements PermissionPolicy {
|
||||
+ private playlistPermissionPolicy = new DefaultPlaylistPermissionPolicy();
|
||||
|
||||
async handle(
|
||||
request: PolicyQuery,
|
||||
user?: BackstageIdentityResponse,
|
||||
): Promise<PolicyDecision> {
|
||||
+ if (isPlaylistPermission(request.permission)) {
|
||||
+ return this.playlistPermissionPolicy.handle(request, user);
|
||||
+ }
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
export default async function createPlugin(env: PluginEnvironment): Promise<Router> {
|
||||
return await createRouter({
|
||||
config: env.config,
|
||||
logger: env.logger,
|
||||
discovery: env.discovery,
|
||||
policy: new BackstagePermissionPolicy(),
|
||||
...
|
||||
```
|
||||
@@ -0,0 +1,96 @@
|
||||
## API Report File for "@backstage/plugin-playlist-backend"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import { BackstageIdentityResponse } from '@backstage/plugin-auth-node';
|
||||
import { ConditionalPolicyDecision } from '@backstage/plugin-permission-common';
|
||||
import { Conditions } from '@backstage/plugin-permission-node';
|
||||
import express from 'express';
|
||||
import { IdentityApi } from '@backstage/plugin-auth-node';
|
||||
import { Logger } from 'winston';
|
||||
import { Permission } from '@backstage/plugin-permission-common';
|
||||
import { PermissionCondition } from '@backstage/plugin-permission-common';
|
||||
import { PermissionCriteria } from '@backstage/plugin-permission-common';
|
||||
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
|
||||
import { PermissionPolicy } from '@backstage/plugin-permission-node';
|
||||
import { PermissionRule } from '@backstage/plugin-permission-node';
|
||||
import { PlaylistMetadata } from '@backstage/plugin-playlist-common';
|
||||
import { PluginDatabaseManager } from '@backstage/backend-common';
|
||||
import { PluginEndpointDiscovery } from '@backstage/backend-common';
|
||||
import { PolicyDecision } from '@backstage/plugin-permission-common';
|
||||
import { PolicyQuery } from '@backstage/plugin-permission-node';
|
||||
import { ResourcePermission } from '@backstage/plugin-permission-common';
|
||||
|
||||
// @public (undocumented)
|
||||
export const createPlaylistConditionalDecision: (
|
||||
permission: ResourcePermission<'playlist-list'>,
|
||||
conditions: PermissionCriteria<
|
||||
PermissionCondition<'playlist-list', unknown[]>
|
||||
>,
|
||||
) => ConditionalPolicyDecision;
|
||||
|
||||
// @public (undocumented)
|
||||
export function createRouter(options: RouterOptions): Promise<express.Router>;
|
||||
|
||||
// @public
|
||||
export class DefaultPlaylistPermissionPolicy implements PermissionPolicy {
|
||||
// (undocumented)
|
||||
handle(
|
||||
request: PolicyQuery,
|
||||
user?: BackstageIdentityResponse,
|
||||
): Promise<PolicyDecision>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export const isPlaylistPermission: (permission: Permission) => boolean;
|
||||
|
||||
// @public (undocumented)
|
||||
export type ListPlaylistsFilter =
|
||||
| {
|
||||
allOf: ListPlaylistsFilter[];
|
||||
}
|
||||
| {
|
||||
anyOf: ListPlaylistsFilter[];
|
||||
}
|
||||
| {
|
||||
not: ListPlaylistsFilter;
|
||||
}
|
||||
| ListPlaylistsMatchFilter;
|
||||
|
||||
// @public (undocumented)
|
||||
export type ListPlaylistsMatchFilter = {
|
||||
key: string;
|
||||
values: any[];
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export const playlistConditions: Conditions<{
|
||||
isOwner: PermissionRule<
|
||||
PlaylistMetadata,
|
||||
ListPlaylistsFilter,
|
||||
'playlist-list',
|
||||
[userOwnershipRefs: string[]]
|
||||
>;
|
||||
isPublic: PermissionRule<
|
||||
PlaylistMetadata,
|
||||
ListPlaylistsFilter,
|
||||
'playlist-list',
|
||||
[]
|
||||
>;
|
||||
}>;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface RouterOptions {
|
||||
// (undocumented)
|
||||
database: PluginDatabaseManager;
|
||||
// (undocumented)
|
||||
discovery: PluginEndpointDiscovery;
|
||||
// (undocumented)
|
||||
identity: IdentityApi;
|
||||
// (undocumented)
|
||||
logger: Logger;
|
||||
// (undocumented)
|
||||
permissions: PermissionEvaluator;
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright 2022 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) {
|
||||
await knex.schema.createTable('playlists', table => {
|
||||
table.comment('Playlists table');
|
||||
table.uuid('id').primary().comment('Automatically generated unique ID');
|
||||
table.text('name').notNullable().comment('The name of the playlist');
|
||||
table.text('description').comment('The description of the playlist');
|
||||
table.string('owner').notNullable().comment('The owner entity ref');
|
||||
table
|
||||
.boolean('public')
|
||||
.defaultTo(false)
|
||||
.notNullable()
|
||||
.comment('Whether the playlist is public');
|
||||
});
|
||||
|
||||
await knex.schema.createTable('entities', table => {
|
||||
table.comment('The table of playlist entities');
|
||||
table
|
||||
.uuid('playlist_id')
|
||||
.notNullable()
|
||||
.references('playlists.id')
|
||||
.onDelete('CASCADE')
|
||||
.comment('The id of the playlist this entity belongs to');
|
||||
table.string('entity_ref').notNullable().comment('A entity ref');
|
||||
table.unique(['playlist_id', 'entity_ref'], {
|
||||
indexName: 'playlist_entity_composite_index',
|
||||
});
|
||||
});
|
||||
|
||||
await knex.schema.createTable('followers', table => {
|
||||
table.comment('The table of playlist followers');
|
||||
table
|
||||
.uuid('playlist_id')
|
||||
.notNullable()
|
||||
.references('playlists.id')
|
||||
.onDelete('CASCADE')
|
||||
.comment('The id of the playlist being followed');
|
||||
table.string('user_ref').notNullable().comment('A user entity ref');
|
||||
table.unique(['playlist_id', 'user_ref'], {
|
||||
indexName: 'playlist_follower_composite_index',
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function down(knex) {
|
||||
await knex.schema.dropTable('playlists');
|
||||
await knex.schema.dropTable('entities');
|
||||
await knex.schema.dropTable('followers');
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "@backstage/plugin-playlist-backend",
|
||||
"version": "0.0.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"backstage": {
|
||||
"role": "backend-plugin"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "backstage-cli package start",
|
||||
"build": "backstage-cli package build",
|
||||
"lint": "backstage-cli package lint",
|
||||
"test": "backstage-cli package test",
|
||||
"clean": "backstage-cli package clean",
|
||||
"prepack": "backstage-cli package prepack",
|
||||
"postpack": "backstage-cli package postpack"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "^0.15.1-next.3",
|
||||
"@backstage/backend-test-utils": "^0.1.28-next.3",
|
||||
"@backstage/catalog-client": "1.1.0-next.2",
|
||||
"@backstage/catalog-model": "^1.1.1-next.0",
|
||||
"@backstage/config": "^1.0.2-next.0",
|
||||
"@backstage/errors": "^1.1.1-next.0",
|
||||
"@backstage/plugin-auth-node": "^0.2.5-next.3",
|
||||
"@backstage/plugin-permission-common": "^0.6.4-next.2",
|
||||
"@backstage/plugin-permission-node": "^0.6.5-next.3",
|
||||
"@backstage/plugin-playlist-common": "^0.0.0",
|
||||
"@types/express": "*",
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^4.1.0",
|
||||
"knex": "^2.0.0",
|
||||
"node-fetch": "^2.6.7",
|
||||
"uuid": "^8.2.0",
|
||||
"winston": "^3.2.1",
|
||||
"yn": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.19.0-next.3",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"msw": "^0.47.0",
|
||||
"supertest": "^6.1.3"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"migrations/**/*.{js,d.ts}"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright 2022 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Playlist backend plugin
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
export * from './service';
|
||||
export {
|
||||
createPlaylistConditionalDecision,
|
||||
DefaultPlaylistPermissionPolicy,
|
||||
isPlaylistPermission,
|
||||
playlistConditions,
|
||||
} from './permissions';
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* Copyright 2022 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 { BackstageIdentityResponse } from '@backstage/plugin-auth-node';
|
||||
import {
|
||||
AuthorizeResult,
|
||||
createPermission,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
import {
|
||||
permissions,
|
||||
PLAYLIST_LIST_RESOURCE_TYPE,
|
||||
} from '@backstage/plugin-playlist-common';
|
||||
|
||||
import { playlistConditions } from './conditions';
|
||||
import { DefaultPlaylistPermissionPolicy } from './DefaultPlaylistPermissionPolicy';
|
||||
|
||||
describe('DefaultPlaylistPermissionPolicy', () => {
|
||||
const policy = new DefaultPlaylistPermissionPolicy();
|
||||
const mockUser: BackstageIdentityResponse = {
|
||||
token: 'token',
|
||||
identity: {
|
||||
type: 'user',
|
||||
ownershipEntityRefs: ['user:default/me', 'group:default/owner'],
|
||||
userEntityRef: 'user:default/me',
|
||||
},
|
||||
};
|
||||
|
||||
it('should deny non-playlist permissions', async () => {
|
||||
const mockPermission = createPermission({
|
||||
name: 'test.permission',
|
||||
attributes: { action: 'read' },
|
||||
});
|
||||
|
||||
expect(await policy.handle({ permission: mockPermission })).toEqual({
|
||||
result: AuthorizeResult.DENY,
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow create permissions', async () => {
|
||||
expect(
|
||||
await policy.handle({ permission: permissions.playlistListCreate }),
|
||||
).toEqual({ result: AuthorizeResult.ALLOW });
|
||||
});
|
||||
|
||||
it('should return a conditional decision for read permissions', async () => {
|
||||
expect(
|
||||
await policy.handle(
|
||||
{ permission: permissions.playlistListRead },
|
||||
mockUser,
|
||||
),
|
||||
).toEqual({
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
pluginId: 'playlist',
|
||||
resourceType: PLAYLIST_LIST_RESOURCE_TYPE,
|
||||
conditions: {
|
||||
anyOf: [
|
||||
playlistConditions.isOwner([
|
||||
'user:default/me',
|
||||
'group:default/owner',
|
||||
]),
|
||||
playlistConditions.isPublic(),
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return a conditional decision for followers update permissions', async () => {
|
||||
expect(
|
||||
await policy.handle(
|
||||
{ permission: permissions.playlistFollowersUpdate },
|
||||
mockUser,
|
||||
),
|
||||
).toEqual({
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
pluginId: 'playlist',
|
||||
resourceType: PLAYLIST_LIST_RESOURCE_TYPE,
|
||||
conditions: {
|
||||
anyOf: [
|
||||
playlistConditions.isOwner([
|
||||
'user:default/me',
|
||||
'group:default/owner',
|
||||
]),
|
||||
playlistConditions.isPublic(),
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return a conditional decision for update permissions', async () => {
|
||||
expect(
|
||||
await policy.handle(
|
||||
{ permission: permissions.playlistListUpdate },
|
||||
mockUser,
|
||||
),
|
||||
).toEqual({
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
pluginId: 'playlist',
|
||||
resourceType: PLAYLIST_LIST_RESOURCE_TYPE,
|
||||
conditions: playlistConditions.isOwner([
|
||||
'user:default/me',
|
||||
'group:default/owner',
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it('should return a conditional decision for delete permissions', async () => {
|
||||
expect(
|
||||
await policy.handle(
|
||||
{ permission: permissions.playlistListDelete },
|
||||
mockUser,
|
||||
),
|
||||
).toEqual({
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
pluginId: 'playlist',
|
||||
resourceType: PLAYLIST_LIST_RESOURCE_TYPE,
|
||||
conditions: playlistConditions.isOwner([
|
||||
'user:default/me',
|
||||
'group:default/owner',
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright 2022 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 { BackstageIdentityResponse } from '@backstage/plugin-auth-node';
|
||||
import {
|
||||
AuthorizeResult,
|
||||
isPermission,
|
||||
Permission,
|
||||
PolicyDecision,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
import {
|
||||
PermissionPolicy,
|
||||
PolicyQuery,
|
||||
} from '@backstage/plugin-permission-node';
|
||||
import { permissions } from '@backstage/plugin-playlist-common';
|
||||
|
||||
import {
|
||||
createPlaylistConditionalDecision,
|
||||
playlistConditions,
|
||||
} from './conditions';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const isPlaylistPermission = (permission: Permission) =>
|
||||
Object.values(permissions).some(playlistPermission =>
|
||||
isPermission(permission, playlistPermission),
|
||||
);
|
||||
|
||||
/**
|
||||
* Default policy for the Playlist plugin. This should be applied as a "sub-policy"
|
||||
* of your master permission policy for Playlist permission requests only.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class DefaultPlaylistPermissionPolicy implements PermissionPolicy {
|
||||
async handle(
|
||||
request: PolicyQuery,
|
||||
user?: BackstageIdentityResponse,
|
||||
): Promise<PolicyDecision> {
|
||||
// Reject permissions we don't know how to evaluate in case this policy was incorrectly applied
|
||||
if (!isPlaylistPermission(request.permission)) {
|
||||
return { result: AuthorizeResult.DENY };
|
||||
}
|
||||
|
||||
// Anyone should be allowed to create a new playlist
|
||||
if (isPermission(request.permission, permissions.playlistListCreate)) {
|
||||
return { result: AuthorizeResult.ALLOW };
|
||||
}
|
||||
|
||||
// Reading and following/unfollowing playlists is allowed if it is public or owned
|
||||
if (
|
||||
isPermission(request.permission, permissions.playlistListRead) ||
|
||||
isPermission(request.permission, permissions.playlistFollowersUpdate)
|
||||
) {
|
||||
return createPlaylistConditionalDecision(request.permission, {
|
||||
anyOf: [
|
||||
playlistConditions.isOwner(user?.identity.ownershipEntityRefs ?? []),
|
||||
playlistConditions.isPublic(),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Updating or deleting playlists is only allowed for owners
|
||||
if (
|
||||
isPermission(request.permission, permissions.playlistListUpdate) ||
|
||||
isPermission(request.permission, permissions.playlistListDelete)
|
||||
) {
|
||||
return createPlaylistConditionalDecision(
|
||||
request.permission,
|
||||
playlistConditions.isOwner(user?.identity.ownershipEntityRefs ?? []),
|
||||
);
|
||||
}
|
||||
|
||||
return { result: AuthorizeResult.ALLOW };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright 2022 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 { PLAYLIST_LIST_RESOURCE_TYPE } from '@backstage/plugin-playlist-common';
|
||||
import {
|
||||
ConditionTransformer,
|
||||
createConditionExports,
|
||||
createConditionTransformer,
|
||||
} from '@backstage/plugin-permission-node';
|
||||
|
||||
import { ListPlaylistsFilter } from '../service';
|
||||
import { rules } from './rules';
|
||||
|
||||
const { conditions, createConditionalDecision } = createConditionExports({
|
||||
pluginId: 'playlist',
|
||||
resourceType: PLAYLIST_LIST_RESOURCE_TYPE,
|
||||
rules,
|
||||
});
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const playlistConditions = conditions;
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const createPlaylistConditionalDecision = createConditionalDecision;
|
||||
|
||||
export const transformConditions: ConditionTransformer<ListPlaylistsFilter> =
|
||||
createConditionTransformer(Object.values(rules));
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright 2022 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 './conditions';
|
||||
export * from './rules';
|
||||
export * from './DefaultPlaylistPermissionPolicy';
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright 2022 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 { makeCreatePermissionRule } from '@backstage/plugin-permission-node';
|
||||
import {
|
||||
PLAYLIST_LIST_RESOURCE_TYPE,
|
||||
PlaylistMetadata,
|
||||
} from '@backstage/plugin-playlist-common';
|
||||
|
||||
import { ListPlaylistsFilter } from '../service';
|
||||
|
||||
const createPlaylistPermissionRule = makeCreatePermissionRule<
|
||||
PlaylistMetadata,
|
||||
ListPlaylistsFilter,
|
||||
typeof PLAYLIST_LIST_RESOURCE_TYPE
|
||||
>();
|
||||
|
||||
const isOwner = createPlaylistPermissionRule({
|
||||
name: 'IS_OWNER',
|
||||
description: 'Should allow only if the playlist belongs to the user',
|
||||
resourceType: PLAYLIST_LIST_RESOURCE_TYPE,
|
||||
apply: (list: PlaylistMetadata, userOwnershipRefs: string[]) =>
|
||||
userOwnershipRefs.includes(list.owner),
|
||||
toQuery: (userOwnershipRefs: string[]) => ({
|
||||
key: 'owner',
|
||||
values: userOwnershipRefs,
|
||||
}),
|
||||
});
|
||||
|
||||
const isPublic = createPlaylistPermissionRule({
|
||||
name: 'IS_PUBLIC',
|
||||
description: 'Should allow only if the playlist is public',
|
||||
resourceType: PLAYLIST_LIST_RESOURCE_TYPE,
|
||||
apply: (list: PlaylistMetadata) => list.public,
|
||||
toQuery: () => ({ key: 'public', values: [true] }),
|
||||
});
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const rules = { isOwner, isPublic };
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2022 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 { getRootLogger } from '@backstage/backend-common';
|
||||
import yn from 'yn';
|
||||
import { startStandaloneServer } from './service/standaloneServer';
|
||||
|
||||
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007;
|
||||
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
|
||||
const logger = getRootLogger();
|
||||
|
||||
startStandaloneServer({ port, enableCors, logger }).catch(err => {
|
||||
logger.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
logger.info('CTRL+C pressed; exiting.');
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -0,0 +1,349 @@
|
||||
/*
|
||||
* Copyright 2022 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 { DatabaseHandler } from './DatabaseHandler';
|
||||
import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils';
|
||||
import { BackstageUserIdentity } from '@backstage/plugin-auth-node';
|
||||
import { Knex } from 'knex';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
describe('DatabaseHandler', () => {
|
||||
const databases = TestDatabases.create();
|
||||
|
||||
async function createDatabaseHandler(databaseId: TestDatabaseId) {
|
||||
const knex = await databases.init(databaseId);
|
||||
return {
|
||||
knex,
|
||||
dbHandler: await DatabaseHandler.create({ database: knex }),
|
||||
};
|
||||
}
|
||||
|
||||
describe.each(databases.eachSupportedId())(
|
||||
'%p',
|
||||
databaseId => {
|
||||
let knex: Knex;
|
||||
let dbHandler: DatabaseHandler;
|
||||
const playlist1Id = uuid();
|
||||
const playlist2Id = uuid();
|
||||
const playlist3Id = uuid();
|
||||
const user: BackstageUserIdentity = {
|
||||
type: 'user',
|
||||
userEntityRef: 'user:default/foo',
|
||||
ownershipEntityRefs: ['user:default/foo', 'group:default/foo-group'],
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
({ knex, dbHandler } = await createDatabaseHandler(databaseId));
|
||||
await knex('playlists').insert([
|
||||
{
|
||||
id: playlist1Id,
|
||||
name: 'test',
|
||||
description: 'test description',
|
||||
owner: 'user:default/foo',
|
||||
public: true,
|
||||
},
|
||||
{
|
||||
id: playlist2Id,
|
||||
name: 'test 2',
|
||||
description: 'test description 2',
|
||||
owner: 'group:default/foo-group',
|
||||
public: false,
|
||||
},
|
||||
{
|
||||
id: playlist3Id,
|
||||
name: 'test 3',
|
||||
description: 'test description 3',
|
||||
owner: 'user:default/bar',
|
||||
public: false,
|
||||
},
|
||||
]);
|
||||
|
||||
await knex('entities').insert([
|
||||
{
|
||||
playlist_id: playlist1Id,
|
||||
entity_ref: 'component:default/ent0',
|
||||
},
|
||||
{
|
||||
playlist_id: playlist1Id,
|
||||
entity_ref: 'component:default/ent1',
|
||||
},
|
||||
{
|
||||
playlist_id: playlist1Id,
|
||||
entity_ref: 'component:default/ent2',
|
||||
},
|
||||
{
|
||||
playlist_id: playlist2Id,
|
||||
entity_ref: 'component:default/ent1',
|
||||
},
|
||||
]);
|
||||
|
||||
await knex('followers').insert([
|
||||
{
|
||||
playlist_id: playlist2Id,
|
||||
user_ref: 'user:default/some-user',
|
||||
},
|
||||
{
|
||||
playlist_id: playlist2Id,
|
||||
user_ref: 'user:default/bar',
|
||||
},
|
||||
{
|
||||
playlist_id: playlist2Id,
|
||||
user_ref: 'user:default/foo',
|
||||
},
|
||||
{
|
||||
playlist_id: playlist3Id,
|
||||
user_ref: 'user:default/foo',
|
||||
},
|
||||
]);
|
||||
}, 30000);
|
||||
|
||||
it('listPlaylists', async () => {
|
||||
const allPlaylists = [
|
||||
{
|
||||
id: playlist1Id,
|
||||
name: 'test',
|
||||
description: 'test description',
|
||||
owner: 'user:default/foo',
|
||||
public: true,
|
||||
entities: 3,
|
||||
followers: 0,
|
||||
isFollowing: false,
|
||||
},
|
||||
{
|
||||
id: playlist2Id,
|
||||
name: 'test 2',
|
||||
description: 'test description 2',
|
||||
owner: 'group:default/foo-group',
|
||||
public: false,
|
||||
entities: 1,
|
||||
followers: 3,
|
||||
isFollowing: true,
|
||||
},
|
||||
{
|
||||
id: playlist3Id,
|
||||
name: 'test 3',
|
||||
description: 'test description 3',
|
||||
owner: 'user:default/bar',
|
||||
public: false,
|
||||
entities: 0,
|
||||
followers: 1,
|
||||
isFollowing: true,
|
||||
},
|
||||
];
|
||||
|
||||
const playlists = await dbHandler.listPlaylists(user);
|
||||
expect(playlists.length).toEqual(3);
|
||||
expect(playlists).toEqual(expect.arrayContaining(allPlaylists));
|
||||
|
||||
const followedPlaylists = await dbHandler.listPlaylists(user, {
|
||||
key: 'public',
|
||||
values: [true],
|
||||
});
|
||||
expect(followedPlaylists).toEqual([allPlaylists[0]]);
|
||||
|
||||
const ownedPlaylists = await dbHandler.listPlaylists(user, {
|
||||
key: 'owner',
|
||||
values: user.ownershipEntityRefs,
|
||||
});
|
||||
expect(ownedPlaylists.length).toEqual(2);
|
||||
expect(ownedPlaylists).toEqual(
|
||||
expect.arrayContaining([allPlaylists[0], allPlaylists[1]]),
|
||||
);
|
||||
});
|
||||
|
||||
it('createPlaylist', async () => {
|
||||
const newList = {
|
||||
name: 'new list',
|
||||
description: 'new description',
|
||||
owner: 'user:default/new',
|
||||
public: true,
|
||||
};
|
||||
|
||||
const newPlaylistId = await dbHandler.createPlaylist(newList);
|
||||
|
||||
const newPlaylist = await knex('playlists').where('id', newPlaylistId);
|
||||
expect(
|
||||
newPlaylist.map(list => ({ ...list, public: Boolean(list.public) })),
|
||||
).toEqual([
|
||||
{
|
||||
...newList,
|
||||
id: newPlaylistId,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('getPlaylist', async () => {
|
||||
const playlist = await dbHandler.getPlaylist(playlist1Id, user);
|
||||
expect(playlist).toEqual({
|
||||
id: playlist1Id,
|
||||
name: 'test',
|
||||
description: 'test description',
|
||||
owner: 'user:default/foo',
|
||||
public: true,
|
||||
entities: 3,
|
||||
followers: 0,
|
||||
isFollowing: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('updatePlaylist', async () => {
|
||||
const update = {
|
||||
id: playlist1Id,
|
||||
name: 'test rename',
|
||||
description: 'test new description',
|
||||
owner: 'user:default/new-foo',
|
||||
public: false,
|
||||
};
|
||||
await dbHandler.updatePlaylist(update);
|
||||
|
||||
const updatedPlaylist = await knex('playlists').where(
|
||||
'id',
|
||||
playlist1Id,
|
||||
);
|
||||
expect(
|
||||
updatedPlaylist.map(list => ({
|
||||
...list,
|
||||
public: Boolean(list.public),
|
||||
})),
|
||||
).toEqual([update]);
|
||||
});
|
||||
|
||||
it('deletePlaylist', async () => {
|
||||
await dbHandler.deletePlaylist(playlist1Id);
|
||||
const deleted = await knex('playlists').where('id', playlist1Id);
|
||||
expect(deleted.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('addPlaylistEntities', async () => {
|
||||
await dbHandler.addPlaylistEntities(playlist1Id, [
|
||||
'component:default/ent1',
|
||||
'component:default/ent3',
|
||||
'component:default/ent4',
|
||||
]);
|
||||
|
||||
const entities = await knex('entities').where(
|
||||
'playlist_id',
|
||||
playlist1Id,
|
||||
);
|
||||
expect(entities.length).toEqual(5);
|
||||
expect(entities).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
playlist_id: playlist1Id,
|
||||
entity_ref: 'component:default/ent0',
|
||||
},
|
||||
{
|
||||
playlist_id: playlist1Id,
|
||||
entity_ref: 'component:default/ent1',
|
||||
},
|
||||
{
|
||||
playlist_id: playlist1Id,
|
||||
entity_ref: 'component:default/ent2',
|
||||
},
|
||||
{
|
||||
playlist_id: playlist1Id,
|
||||
entity_ref: 'component:default/ent3',
|
||||
},
|
||||
{
|
||||
playlist_id: playlist1Id,
|
||||
entity_ref: 'component:default/ent4',
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('getPlaylistEntities', async () => {
|
||||
const entities = await dbHandler.getPlaylistEntities(playlist1Id);
|
||||
expect(entities.length).toEqual(3);
|
||||
expect(entities).toEqual(
|
||||
expect.arrayContaining([
|
||||
'component:default/ent0',
|
||||
'component:default/ent1',
|
||||
'component:default/ent2',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('removePlaylistEntities', async () => {
|
||||
await dbHandler.removePlaylistEntities(playlist1Id, [
|
||||
'component:default/ent1',
|
||||
'component:default/ent2',
|
||||
]);
|
||||
|
||||
const entities = await knex('entities').where(
|
||||
'playlist_id',
|
||||
playlist1Id,
|
||||
);
|
||||
expect(entities).toEqual([
|
||||
{
|
||||
playlist_id: playlist1Id,
|
||||
entity_ref: 'component:default/ent0',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('followPlaylist', async () => {
|
||||
await dbHandler.followPlaylist(playlist1Id, user);
|
||||
|
||||
const playlist1Followers = await knex('followers').where(
|
||||
'playlist_id',
|
||||
playlist1Id,
|
||||
);
|
||||
expect(playlist1Followers).toEqual([
|
||||
{
|
||||
playlist_id: playlist1Id,
|
||||
user_ref: 'user:default/foo',
|
||||
},
|
||||
]);
|
||||
|
||||
await dbHandler.followPlaylist(playlist3Id, user);
|
||||
|
||||
const playlist3Followers = await knex('followers').where(
|
||||
'playlist_id',
|
||||
playlist3Id,
|
||||
);
|
||||
expect(playlist3Followers).toEqual([
|
||||
{
|
||||
playlist_id: playlist3Id,
|
||||
user_ref: 'user:default/foo',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('unfollowPlaylist', async () => {
|
||||
await dbHandler.unfollowPlaylist(playlist2Id, user);
|
||||
|
||||
const followers = await knex('followers').where(
|
||||
'playlist_id',
|
||||
playlist2Id,
|
||||
);
|
||||
expect(followers).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
playlist_id: playlist2Id,
|
||||
user_ref: 'user:default/some-user',
|
||||
},
|
||||
{
|
||||
playlist_id: playlist2Id,
|
||||
user_ref: 'user:default/bar',
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
},
|
||||
60000,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
/*
|
||||
* Copyright 2022 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 { resolvePackagePath } from '@backstage/backend-common';
|
||||
import { BackstageUserIdentity } from '@backstage/plugin-auth-node';
|
||||
import { Playlist, PlaylistMetadata } from '@backstage/plugin-playlist-common';
|
||||
import { Knex } from 'knex';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import {
|
||||
ListPlaylistsFilter,
|
||||
ListPlaylistsMatchFilter,
|
||||
} from './ListPlaylistsFilter';
|
||||
|
||||
const migrationsDir = resolvePackagePath(
|
||||
'@backstage/plugin-playlist-backend',
|
||||
'migrations',
|
||||
);
|
||||
|
||||
function isMatchFilter(
|
||||
filter: ListPlaylistsMatchFilter | ListPlaylistsFilter,
|
||||
): filter is ListPlaylistsMatchFilter {
|
||||
return filter.hasOwnProperty('key');
|
||||
}
|
||||
|
||||
function isOrFilter(
|
||||
filter: { anyOf: ListPlaylistsFilter[] } | ListPlaylistsFilter,
|
||||
): filter is { anyOf: ListPlaylistsFilter[] } {
|
||||
return filter.hasOwnProperty('anyOf');
|
||||
}
|
||||
|
||||
function isNegationFilter(
|
||||
filter: { not: ListPlaylistsFilter } | ListPlaylistsFilter,
|
||||
): filter is { not: ListPlaylistsFilter } {
|
||||
return filter.hasOwnProperty('not');
|
||||
}
|
||||
|
||||
function parseFilter(
|
||||
filter: ListPlaylistsFilter,
|
||||
query: Knex.QueryBuilder,
|
||||
db: Knex,
|
||||
negate: boolean = false,
|
||||
): Knex.QueryBuilder {
|
||||
if (isMatchFilter(filter)) {
|
||||
return query.andWhere(function filterFunction() {
|
||||
if (filter.values.length === 1) {
|
||||
this.where(filter.key, negate ? '!=' : '=', filter.values[0]);
|
||||
} else {
|
||||
this.andWhere(filter.key, negate ? 'not in' : 'in', filter.values);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (isNegationFilter(filter)) {
|
||||
return parseFilter(filter.not, query, db, !negate);
|
||||
}
|
||||
|
||||
return query[negate ? 'andWhereNot' : 'andWhere'](function filterFunction() {
|
||||
if (isOrFilter(filter)) {
|
||||
for (const subFilter of filter.anyOf ?? []) {
|
||||
this.orWhere(subQuery => parseFilter(subFilter, subQuery, db));
|
||||
}
|
||||
} else {
|
||||
for (const subFilter of filter.allOf ?? []) {
|
||||
this.andWhere(subQuery => parseFilter(subFilter, subQuery, db));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
type Options = {
|
||||
database: Knex;
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export class DatabaseHandler {
|
||||
static async create(options: Options): Promise<DatabaseHandler> {
|
||||
const { database } = options;
|
||||
|
||||
await database.migrate.latest({
|
||||
directory: migrationsDir,
|
||||
});
|
||||
|
||||
return new DatabaseHandler(options);
|
||||
}
|
||||
|
||||
private readonly database: Knex;
|
||||
|
||||
private constructor(options: Options) {
|
||||
this.database = options.database;
|
||||
}
|
||||
|
||||
private playlistColumns = ['id', 'name', 'description', 'owner', 'public'];
|
||||
|
||||
async listPlaylists(
|
||||
user: BackstageUserIdentity,
|
||||
filter?: ListPlaylistsFilter,
|
||||
): Promise<Playlist[]> {
|
||||
let playlistQuery = this.database<Omit<Playlist, 'isFollowing'>>(
|
||||
'playlists',
|
||||
)
|
||||
.select(
|
||||
...this.playlistColumns,
|
||||
this.database.raw('COALESCE(entities, 0) AS entities'),
|
||||
this.database.raw('COALESCE(followers, 0) AS followers'),
|
||||
)
|
||||
.leftOuterJoin(
|
||||
this.database('entities')
|
||||
.as('e')
|
||||
.select('playlist_id')
|
||||
.count('entity_ref', { as: 'entities' })
|
||||
.groupBy('playlist_id'),
|
||||
'playlists.id',
|
||||
'e.playlist_id',
|
||||
)
|
||||
.leftOuterJoin(
|
||||
this.database('followers')
|
||||
.as('f')
|
||||
.select('playlist_id')
|
||||
.count('user_ref', { as: 'followers' })
|
||||
.groupBy('playlist_id'),
|
||||
'playlists.id',
|
||||
'f.playlist_id',
|
||||
);
|
||||
|
||||
if (filter) {
|
||||
playlistQuery = parseFilter(filter, playlistQuery, this.database);
|
||||
}
|
||||
|
||||
const playlists = await playlistQuery;
|
||||
|
||||
const followedPlaylists = (
|
||||
await this.database('followers')
|
||||
.select('playlist_id')
|
||||
.where('user_ref', user.userEntityRef)
|
||||
).map(follows => follows.playlist_id);
|
||||
|
||||
return playlists.map(list => ({
|
||||
...list,
|
||||
entities: Number(list.entities),
|
||||
followers: Number(list.followers),
|
||||
public: Boolean(list.public),
|
||||
isFollowing: followedPlaylists.includes(list.id),
|
||||
}));
|
||||
}
|
||||
|
||||
async createPlaylist(
|
||||
playlist: Omit<PlaylistMetadata, 'id'>,
|
||||
): Promise<string> {
|
||||
const id = uuid();
|
||||
const newPlaylist = await this.database('playlists').insert(
|
||||
{
|
||||
...playlist,
|
||||
id,
|
||||
},
|
||||
['id'],
|
||||
);
|
||||
|
||||
// MySQL does not support returning from inserts so return generated uuid if undefined
|
||||
return newPlaylist[0].id ?? id;
|
||||
}
|
||||
|
||||
async getPlaylist(
|
||||
id: string,
|
||||
user?: BackstageUserIdentity,
|
||||
): Promise<Playlist | undefined> {
|
||||
const playlist = await this.database<Omit<Playlist, 'isFollowing'>>(
|
||||
'playlists',
|
||||
)
|
||||
.select(
|
||||
...this.playlistColumns,
|
||||
this.database.raw('COALESCE(entities, 0) AS entities'),
|
||||
this.database.raw('COALESCE(followers, 0) AS followers'),
|
||||
)
|
||||
.where('id', id)
|
||||
.leftOuterJoin(
|
||||
this.database('entities')
|
||||
.as('e')
|
||||
.select('playlist_id')
|
||||
.count('entity_ref', { as: 'entities' })
|
||||
.groupBy('playlist_id'),
|
||||
'playlists.id',
|
||||
'e.playlist_id',
|
||||
)
|
||||
.leftOuterJoin(
|
||||
this.database('followers')
|
||||
.as('f')
|
||||
.select('playlist_id')
|
||||
.count('user_ref', { as: 'followers' })
|
||||
.groupBy('playlist_id'),
|
||||
'playlists.id',
|
||||
'f.playlist_id',
|
||||
);
|
||||
|
||||
if (!playlist.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const followedPlaylists = user
|
||||
? (
|
||||
await this.database('followers')
|
||||
.select('playlist_id')
|
||||
.where('user_ref', user.userEntityRef)
|
||||
).map(follows => follows.playlist_id)
|
||||
: [];
|
||||
|
||||
return {
|
||||
...playlist[0],
|
||||
entities: Number(playlist[0].entities),
|
||||
followers: Number(playlist[0].followers),
|
||||
public: Boolean(playlist[0].public),
|
||||
isFollowing: followedPlaylists.includes(playlist[0].id),
|
||||
};
|
||||
}
|
||||
|
||||
async updatePlaylist(playlist: PlaylistMetadata) {
|
||||
await this.database('playlists').where('id', playlist.id).update(playlist);
|
||||
}
|
||||
|
||||
async deletePlaylist(id: string) {
|
||||
await this.database('playlists').where('id', id).del();
|
||||
}
|
||||
|
||||
async addPlaylistEntities(playlistId: string, entityRefs: string[]) {
|
||||
await this.database('entities')
|
||||
.insert(
|
||||
entityRefs.map(ref => ({ playlist_id: playlistId, entity_ref: ref })),
|
||||
)
|
||||
.onConflict(['playlist_id', 'entity_ref'])
|
||||
.ignore();
|
||||
}
|
||||
|
||||
async getPlaylistEntities(playlistId: string): Promise<string[]> {
|
||||
return (
|
||||
await this.database('entities')
|
||||
.select('entity_ref')
|
||||
.where('playlist_id', playlistId)
|
||||
).map(entity => entity.entity_ref);
|
||||
}
|
||||
|
||||
async removePlaylistEntities(playlistId: string, entityRefs: string[]) {
|
||||
await this.database('entities')
|
||||
.where(builder =>
|
||||
entityRefs.forEach(ref =>
|
||||
builder.orWhere({ playlist_id: playlistId, entity_ref: ref }),
|
||||
),
|
||||
)
|
||||
.del();
|
||||
}
|
||||
|
||||
async followPlaylist(playlistId: string, user: BackstageUserIdentity) {
|
||||
await this.database('followers')
|
||||
.insert({ playlist_id: playlistId, user_ref: user.userEntityRef })
|
||||
.onConflict(['playlist_id', 'user_ref'])
|
||||
.ignore();
|
||||
}
|
||||
|
||||
async unfollowPlaylist(playlistId: string, user: BackstageUserIdentity) {
|
||||
await this.database('followers')
|
||||
.where({ playlist_id: playlistId, user_ref: user.userEntityRef })
|
||||
.del();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* Copyright 2022 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 {
|
||||
parseListPlaylistsFilterParams,
|
||||
parseListPlaylistsFilterString,
|
||||
} from './ListPlaylistsFilter';
|
||||
|
||||
describe('parseListPlaylistsFilterParams', () => {
|
||||
it('translates empty query to empty list', () => {
|
||||
const result = parseListPlaylistsFilterParams({});
|
||||
expect(result).toEqual(undefined);
|
||||
});
|
||||
|
||||
it('supports single-string format', () => {
|
||||
const result = parseListPlaylistsFilterParams({ filter: 'a=1' })!;
|
||||
expect(result).toEqual({
|
||||
anyOf: [{ allOf: [{ key: 'a', values: ['1'] }] }],
|
||||
});
|
||||
});
|
||||
|
||||
it('supports array-of-strings format', () => {
|
||||
const result = parseListPlaylistsFilterParams({
|
||||
filter: ['a=1', 'b=2'],
|
||||
});
|
||||
expect(result).toEqual({
|
||||
anyOf: [
|
||||
{ allOf: [{ key: 'a', values: ['1'] }] },
|
||||
{ allOf: [{ key: 'b', values: ['2'] }] },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('merges values within each filter', () => {
|
||||
const result = parseListPlaylistsFilterParams({
|
||||
filter: ['a=1', 'b=2,b=3,c=4'],
|
||||
});
|
||||
expect(result).toEqual({
|
||||
anyOf: [
|
||||
{ allOf: [{ key: 'a', values: ['1'] }] },
|
||||
{
|
||||
allOf: [
|
||||
{ key: 'b', values: ['2', '3'] },
|
||||
{ key: 'c', values: ['4'] },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('throws for non-strings', () => {
|
||||
expect(() => parseListPlaylistsFilterParams({ filter: [3] })).toThrow(
|
||||
'Invalid filter',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseListPlaylistsFilterString', () => {
|
||||
it('works for the happy path', () => {
|
||||
expect(parseListPlaylistsFilterString('')).toBeUndefined();
|
||||
expect(parseListPlaylistsFilterString('a=1,b=2,a=3')).toEqual([
|
||||
{ key: 'a', values: ['1', '3'] },
|
||||
{ key: 'b', values: ['2'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('trims values', () => {
|
||||
expect(parseListPlaylistsFilterString(' a = 1 , b = 2 , a = 3 ')).toEqual([
|
||||
{ key: 'a', values: ['1', '3'] },
|
||||
{ key: 'b', values: ['2'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects malformed strings', () => {
|
||||
expect(() => parseListPlaylistsFilterString('x=2,=a')).toThrow(
|
||||
"Invalid filter, '=a' is not a valid statement (expected a string of the form a=b)",
|
||||
);
|
||||
expect(() => parseListPlaylistsFilterString('x=2,a=')).toThrow(
|
||||
"Invalid filter, 'a=' is not a valid statement (expected a string of the form a=b)",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright 2022 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 { InputError } from '@backstage/errors';
|
||||
|
||||
// TODO(kuangp): this filter shape was basically plagiarized from catalog-backend,
|
||||
// it should probably be abstracted into a common plugin module to allow others to reuse the same pattern
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type ListPlaylistsMatchFilter = {
|
||||
key: string;
|
||||
values: any[];
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type ListPlaylistsFilter =
|
||||
| { allOf: ListPlaylistsFilter[] }
|
||||
| { anyOf: ListPlaylistsFilter[] }
|
||||
| { not: ListPlaylistsFilter }
|
||||
| ListPlaylistsMatchFilter;
|
||||
|
||||
export function parseListPlaylistsFilterParams(
|
||||
params: Record<string, unknown>,
|
||||
): ListPlaylistsFilter | undefined {
|
||||
if (!params.filter) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Each filter string is on the form a=b,c=d
|
||||
const filterStrings = [params.filter].flat();
|
||||
if (filterStrings.some(p => typeof p !== 'string')) {
|
||||
throw new InputError('Invalid filter');
|
||||
}
|
||||
|
||||
// Outer array: "any of the inner ones"
|
||||
// Inner arrays: "all of these must match"
|
||||
const filters = (filterStrings as string[])
|
||||
.map(parseListPlaylistsFilterString)
|
||||
.filter(Boolean);
|
||||
if (!filters.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { anyOf: filters.map(f => ({ allOf: f! })) };
|
||||
}
|
||||
|
||||
export function parseListPlaylistsFilterString(
|
||||
filterString: string,
|
||||
): ListPlaylistsMatchFilter[] | undefined {
|
||||
const statements = filterString
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (!statements.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const filtersByKey = new Map<string, ListPlaylistsMatchFilter>();
|
||||
|
||||
for (const statement of statements) {
|
||||
const equalsIndex = statement.indexOf('=');
|
||||
|
||||
const key =
|
||||
equalsIndex === -1 ? statement : statement.substr(0, equalsIndex).trim();
|
||||
const value =
|
||||
equalsIndex === -1 ? undefined : statement.substr(equalsIndex + 1).trim();
|
||||
|
||||
if (!key || !value) {
|
||||
throw new InputError(
|
||||
`Invalid filter, '${statement}' is not a valid statement (expected a string of the form a=b)`,
|
||||
);
|
||||
}
|
||||
|
||||
const f = filtersByKey.has(key)
|
||||
? filtersByKey.get(key)
|
||||
: filtersByKey.set(key, { key, values: [] }).get(key);
|
||||
|
||||
f!.values.push(value);
|
||||
}
|
||||
|
||||
return [...filtersByKey.values()];
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright 2022 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 './router';
|
||||
export type {
|
||||
ListPlaylistsFilter,
|
||||
ListPlaylistsMatchFilter,
|
||||
} from './ListPlaylistsFilter';
|
||||
@@ -0,0 +1,570 @@
|
||||
/*
|
||||
* Copyright 2022 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 {
|
||||
DatabaseManager,
|
||||
getVoidLogger,
|
||||
PluginEndpointDiscovery,
|
||||
} from '@backstage/backend-common';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { IdentityApi } from '@backstage/plugin-auth-node';
|
||||
import { AuthorizeResult } from '@backstage/plugin-permission-common';
|
||||
import { permissions } from '@backstage/plugin-playlist-common';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
|
||||
import { createRouter } from './router';
|
||||
|
||||
const sampleEntities = [
|
||||
{
|
||||
kind: 'system',
|
||||
metadata: {
|
||||
namespace: 'default',
|
||||
name: 'test-ent-system',
|
||||
title: 'Test Ent',
|
||||
description: 'test ent description',
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'component',
|
||||
metadata: {
|
||||
namespace: 'default',
|
||||
name: 'test-ent',
|
||||
title: 'Test Ent 2',
|
||||
description: 'test ent description 2',
|
||||
},
|
||||
spec: {
|
||||
type: 'library',
|
||||
},
|
||||
},
|
||||
];
|
||||
const mockGetEntties = jest
|
||||
.fn()
|
||||
.mockImplementation(async () => ({ items: sampleEntities }));
|
||||
jest.mock('@backstage/catalog-client', () => ({
|
||||
CatalogClient: jest
|
||||
.fn()
|
||||
.mockImplementation(() => ({ getEntities: mockGetEntties })),
|
||||
}));
|
||||
|
||||
jest.mock('@backstage/plugin-auth-node', () => ({
|
||||
getBearerTokenFromAuthorizationHeader: () => 'token',
|
||||
}));
|
||||
|
||||
const mockConditionFilter = { key: 'test', values: ['test-val'] };
|
||||
jest.mock('../permissions', () => ({
|
||||
...jest.requireActual('../permissions'),
|
||||
transformConditions: () => mockConditionFilter,
|
||||
}));
|
||||
|
||||
const mockPlaylist = {
|
||||
id: 'playlist-id',
|
||||
name: 'test-playlist',
|
||||
owner: 'group:default/owner',
|
||||
public: true,
|
||||
entities: 2,
|
||||
followers: 4,
|
||||
isFollowing: false,
|
||||
};
|
||||
|
||||
const mockEntities = [
|
||||
'component:default/test-ent',
|
||||
'system:default/test-ent-system',
|
||||
];
|
||||
|
||||
const mockDbHandler = {
|
||||
listPlaylists: jest.fn().mockImplementation(async () => [mockPlaylist]),
|
||||
createPlaylist: jest.fn().mockImplementation(async () => 'playlist-id'),
|
||||
getPlaylist: jest.fn().mockImplementation(async () => mockPlaylist),
|
||||
updatePlaylist: jest.fn().mockImplementation(async () => {}),
|
||||
deletePlaylist: jest.fn().mockImplementation(async () => {}),
|
||||
addPlaylistEntities: jest.fn().mockImplementation(async () => {}),
|
||||
getPlaylistEntities: jest.fn().mockImplementation(async () => mockEntities),
|
||||
removePlaylistEntities: jest.fn().mockImplementation(async () => {}),
|
||||
followPlaylist: jest.fn().mockImplementation(async () => {}),
|
||||
unfollowPlaylist: jest.fn().mockImplementation(async () => {}),
|
||||
};
|
||||
|
||||
jest.mock('./DatabaseHandler', () => ({
|
||||
DatabaseHandler: { create: async () => mockDbHandler },
|
||||
}));
|
||||
|
||||
describe('createRouter', () => {
|
||||
let app: express.Express;
|
||||
|
||||
const createDatabase = () =>
|
||||
DatabaseManager.fromConfig(
|
||||
new ConfigReader({
|
||||
backend: {
|
||||
database: {
|
||||
client: 'better-sqlite3',
|
||||
connection: ':memory:',
|
||||
},
|
||||
},
|
||||
}),
|
||||
).forPlugin('playlist');
|
||||
|
||||
const mockedAuthorize = jest
|
||||
.fn()
|
||||
.mockImplementation(async () => [{ result: AuthorizeResult.ALLOW }]);
|
||||
const mockedAuthorizeConditional = jest
|
||||
.fn()
|
||||
.mockImplementation(async () => [{ result: AuthorizeResult.ALLOW }]);
|
||||
const mockPermissionEvaluator = {
|
||||
authorize: mockedAuthorize,
|
||||
authorizeConditional: mockedAuthorizeConditional,
|
||||
};
|
||||
|
||||
const mockUser = {
|
||||
type: 'user',
|
||||
ownershipEntityRefs: ['user:default/me', 'group:default/owner'],
|
||||
userEntityRef: 'user:default/me',
|
||||
};
|
||||
const mockIdentityClient = {
|
||||
getIdentity: jest
|
||||
.fn()
|
||||
.mockImplementation(async () => ({ identity: mockUser })),
|
||||
} as unknown as IdentityApi;
|
||||
|
||||
const discovery: jest.Mocked<PluginEndpointDiscovery> = {
|
||||
getBaseUrl: jest.fn(),
|
||||
getExternalBaseUrl: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const router = await createRouter({
|
||||
database: createDatabase(),
|
||||
discovery,
|
||||
identity: mockIdentityClient,
|
||||
logger: getVoidLogger(),
|
||||
permissions: mockPermissionEvaluator,
|
||||
});
|
||||
|
||||
app = express().use(router);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /', () => {
|
||||
const mockRequestFilter = {
|
||||
anyOf: [
|
||||
{ allOf: [{ key: 'mock', values: ['test'] }] },
|
||||
{ allOf: [{ key: 'foo', values: ['bar'] }] },
|
||||
],
|
||||
};
|
||||
|
||||
it('should respond correctly if unauthorized', async () => {
|
||||
mockedAuthorizeConditional.mockImplementationOnce(async () => [
|
||||
{ result: AuthorizeResult.DENY },
|
||||
]);
|
||||
const response = await request(app).get('/').send();
|
||||
|
||||
expect(mockedAuthorizeConditional).toHaveBeenCalledWith(
|
||||
[{ permission: permissions.playlistListRead }],
|
||||
{ token: 'token' },
|
||||
);
|
||||
expect(mockDbHandler.listPlaylists).not.toHaveBeenCalled();
|
||||
expect(response.status).toEqual(403);
|
||||
});
|
||||
|
||||
it('should get playlists correctly', async () => {
|
||||
let response = await request(app).get('/').send();
|
||||
expect(mockDbHandler.listPlaylists).toHaveBeenLastCalledWith(
|
||||
mockUser,
|
||||
undefined,
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual([mockPlaylist]);
|
||||
|
||||
mockedAuthorizeConditional.mockImplementationOnce(async () => [
|
||||
{ result: AuthorizeResult.CONDITIONAL },
|
||||
]);
|
||||
response = await request(app).get('/').send();
|
||||
expect(mockDbHandler.listPlaylists).toHaveBeenLastCalledWith(
|
||||
mockUser,
|
||||
mockConditionFilter,
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual([mockPlaylist]);
|
||||
});
|
||||
|
||||
it('should get filtered playlists correctly', async () => {
|
||||
let response = await request(app)
|
||||
.get('/?filter=mock=test&filter=foo=bar')
|
||||
.send();
|
||||
expect(mockDbHandler.listPlaylists).toHaveBeenLastCalledWith(
|
||||
mockUser,
|
||||
mockRequestFilter,
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual([mockPlaylist]);
|
||||
|
||||
mockedAuthorizeConditional.mockImplementationOnce(async () => [
|
||||
{ result: AuthorizeResult.CONDITIONAL },
|
||||
]);
|
||||
response = await request(app)
|
||||
.get('/?filter=mock=test&filter=foo=bar')
|
||||
.send();
|
||||
expect(mockDbHandler.listPlaylists).toHaveBeenLastCalledWith(mockUser, {
|
||||
allOf: [mockRequestFilter, mockConditionFilter],
|
||||
});
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual([mockPlaylist]);
|
||||
});
|
||||
|
||||
it('should get editable playlists correctly', async () => {
|
||||
let response = await request(app).get('/?editable=true').send();
|
||||
expect(mockedAuthorizeConditional).toHaveBeenCalledWith(
|
||||
[{ permission: permissions.playlistListUpdate }],
|
||||
{ token: 'token' },
|
||||
);
|
||||
expect(mockDbHandler.listPlaylists).toHaveBeenLastCalledWith(
|
||||
mockUser,
|
||||
undefined,
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual([mockPlaylist]);
|
||||
|
||||
response = await request(app)
|
||||
.get('/?editable=true&filter=mock=test&filter=foo=bar')
|
||||
.send();
|
||||
expect(mockDbHandler.listPlaylists).toHaveBeenLastCalledWith(
|
||||
mockUser,
|
||||
mockRequestFilter,
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual([mockPlaylist]);
|
||||
|
||||
mockedAuthorizeConditional.mockImplementation(async () => [
|
||||
{ result: AuthorizeResult.CONDITIONAL },
|
||||
]);
|
||||
response = await request(app).get('/?editable=true').send();
|
||||
expect(mockDbHandler.listPlaylists).toHaveBeenLastCalledWith(mockUser, {
|
||||
allOf: [mockConditionFilter, mockConditionFilter],
|
||||
});
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual([mockPlaylist]);
|
||||
|
||||
mockedAuthorizeConditional.mockImplementation(async () => [
|
||||
{ result: AuthorizeResult.CONDITIONAL },
|
||||
]);
|
||||
response = await request(app)
|
||||
.get('/?editable=true&filter=mock=test&filter=foo=bar')
|
||||
.send();
|
||||
expect(mockDbHandler.listPlaylists).toHaveBeenLastCalledWith(mockUser, {
|
||||
allOf: [
|
||||
{ allOf: [mockRequestFilter, mockConditionFilter] },
|
||||
mockConditionFilter,
|
||||
],
|
||||
});
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual([mockPlaylist]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /', () => {
|
||||
const body = { name: 'new-playlist' };
|
||||
|
||||
it('should respond correctly if unauthorized', async () => {
|
||||
mockedAuthorize.mockImplementationOnce(async () => [
|
||||
{ result: AuthorizeResult.DENY },
|
||||
]);
|
||||
const response = await request(app).post('/').send(body);
|
||||
|
||||
expect(mockedAuthorize).toHaveBeenCalledWith(
|
||||
[{ permission: permissions.playlistListCreate }],
|
||||
{ token: 'token' },
|
||||
);
|
||||
expect(mockDbHandler.createPlaylist).not.toHaveBeenCalled();
|
||||
expect(response.status).toEqual(403);
|
||||
});
|
||||
|
||||
it('should create a playlist correctly', async () => {
|
||||
const response = await request(app).post('/').send(body);
|
||||
expect(mockDbHandler.createPlaylist).toHaveBeenCalledWith(body);
|
||||
expect(response.status).toEqual(201);
|
||||
expect(response.body).toEqual('playlist-id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /:playlistId', () => {
|
||||
it('should respond correctly if unauthorized', async () => {
|
||||
mockedAuthorize.mockImplementationOnce(async () => [
|
||||
{ result: AuthorizeResult.DENY },
|
||||
]);
|
||||
const response = await request(app).get('/playlist-id').send();
|
||||
|
||||
expect(mockedAuthorize).toHaveBeenCalledWith(
|
||||
[
|
||||
{
|
||||
permission: permissions.playlistListRead,
|
||||
resourceRef: 'playlist-id',
|
||||
},
|
||||
],
|
||||
{ token: 'token' },
|
||||
);
|
||||
expect(mockDbHandler.getPlaylist).not.toHaveBeenCalled();
|
||||
expect(response.status).toEqual(403);
|
||||
});
|
||||
|
||||
it('should get a playlist correctly', async () => {
|
||||
const response = await request(app).get('/playlist-id').send();
|
||||
expect(mockDbHandler.getPlaylist).toHaveBeenCalledWith(
|
||||
'playlist-id',
|
||||
mockUser,
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual(mockPlaylist);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /:playlistId', () => {
|
||||
it('should respond correctly if unauthorized', async () => {
|
||||
mockedAuthorize.mockImplementationOnce(async () => [
|
||||
{ result: AuthorizeResult.DENY },
|
||||
]);
|
||||
const response = await request(app)
|
||||
.put('/playlist-id')
|
||||
.send(mockPlaylist);
|
||||
|
||||
expect(mockedAuthorize).toHaveBeenCalledWith(
|
||||
[
|
||||
{
|
||||
permission: permissions.playlistListUpdate,
|
||||
resourceRef: 'playlist-id',
|
||||
},
|
||||
],
|
||||
{ token: 'token' },
|
||||
);
|
||||
expect(mockDbHandler.updatePlaylist).not.toHaveBeenCalled();
|
||||
expect(response.status).toEqual(403);
|
||||
});
|
||||
|
||||
it('should update a playlist correctly', async () => {
|
||||
const response = await request(app)
|
||||
.put('/playlist-id')
|
||||
.send(mockPlaylist);
|
||||
expect(mockDbHandler.updatePlaylist).toHaveBeenCalledWith(mockPlaylist);
|
||||
expect(response.status).toEqual(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /:playlistId', () => {
|
||||
it('should respond correctly if unauthorized', async () => {
|
||||
mockedAuthorize.mockImplementationOnce(async () => [
|
||||
{ result: AuthorizeResult.DENY },
|
||||
]);
|
||||
const response = await request(app).delete('/playlist-id').send();
|
||||
|
||||
expect(mockedAuthorize).toHaveBeenCalledWith(
|
||||
[
|
||||
{
|
||||
permission: permissions.playlistListDelete,
|
||||
resourceRef: 'playlist-id',
|
||||
},
|
||||
],
|
||||
{ token: 'token' },
|
||||
);
|
||||
expect(mockDbHandler.deletePlaylist).not.toHaveBeenCalled();
|
||||
expect(response.status).toEqual(403);
|
||||
});
|
||||
|
||||
it('should delete a playlist correctly', async () => {
|
||||
const response = await request(app).delete('/playlist-id').send();
|
||||
expect(mockDbHandler.deletePlaylist).toHaveBeenCalledWith('playlist-id');
|
||||
expect(response.status).toEqual(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /:playlistId/entities', () => {
|
||||
it('should respond correctly if unauthorized', async () => {
|
||||
mockedAuthorize.mockImplementationOnce(async () => [
|
||||
{ result: AuthorizeResult.DENY },
|
||||
]);
|
||||
const response = await request(app)
|
||||
.post('/playlist-id/entities')
|
||||
.send(['component:default/test-ent']);
|
||||
|
||||
expect(mockedAuthorize).toHaveBeenCalledWith(
|
||||
[
|
||||
{
|
||||
permission: permissions.playlistListUpdate,
|
||||
resourceRef: 'playlist-id',
|
||||
},
|
||||
],
|
||||
{ token: 'token' },
|
||||
);
|
||||
expect(mockDbHandler.addPlaylistEntities).not.toHaveBeenCalled();
|
||||
expect(response.status).toEqual(403);
|
||||
});
|
||||
|
||||
it('should add entities to a playlist correctly', async () => {
|
||||
const response = await request(app)
|
||||
.post('/playlist-id/entities')
|
||||
.send(mockEntities);
|
||||
expect(mockDbHandler.addPlaylistEntities).toHaveBeenCalledWith(
|
||||
'playlist-id',
|
||||
mockEntities,
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /:playlistId/entities', () => {
|
||||
it('should respond correctly if unauthorized', async () => {
|
||||
mockedAuthorize.mockImplementationOnce(async () => [
|
||||
{ result: AuthorizeResult.DENY },
|
||||
]);
|
||||
const response = await request(app).get('/playlist-id/entities').send();
|
||||
|
||||
expect(mockedAuthorize).toHaveBeenCalledWith(
|
||||
[
|
||||
{
|
||||
permission: permissions.playlistListRead,
|
||||
resourceRef: 'playlist-id',
|
||||
},
|
||||
],
|
||||
{ token: 'token' },
|
||||
);
|
||||
expect(mockDbHandler.getPlaylistEntities).not.toHaveBeenCalled();
|
||||
expect(mockGetEntties).not.toHaveBeenCalled();
|
||||
expect(response.status).toEqual(403);
|
||||
});
|
||||
|
||||
it('should get entities from a playlist correctly', async () => {
|
||||
const response = await request(app).get('/playlist-id/entities').send();
|
||||
expect(mockDbHandler.getPlaylistEntities).toHaveBeenCalledWith(
|
||||
'playlist-id',
|
||||
);
|
||||
expect(mockGetEntties).toHaveBeenCalledWith(
|
||||
{
|
||||
filter: [
|
||||
{
|
||||
kind: 'component',
|
||||
'metadata.namespace': 'default',
|
||||
'metadata.name': 'test-ent',
|
||||
},
|
||||
{
|
||||
kind: 'system',
|
||||
'metadata.namespace': 'default',
|
||||
'metadata.name': 'test-ent-system',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ token: 'token' },
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual(sampleEntities);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /:playlistId/entities', () => {
|
||||
it('should respond correctly if unauthorized', async () => {
|
||||
mockedAuthorize.mockImplementationOnce(async () => [
|
||||
{ result: AuthorizeResult.DENY },
|
||||
]);
|
||||
const response = await request(app)
|
||||
.delete('/playlist-id/entities')
|
||||
.send(mockEntities);
|
||||
|
||||
expect(mockedAuthorize).toHaveBeenCalledWith(
|
||||
[
|
||||
{
|
||||
permission: permissions.playlistListUpdate,
|
||||
resourceRef: 'playlist-id',
|
||||
},
|
||||
],
|
||||
{ token: 'token' },
|
||||
);
|
||||
expect(mockDbHandler.removePlaylistEntities).not.toHaveBeenCalled();
|
||||
expect(response.status).toEqual(403);
|
||||
});
|
||||
|
||||
it('should delete entities from a playlist correctly', async () => {
|
||||
const response = await request(app)
|
||||
.delete('/playlist-id/entities')
|
||||
.send(mockEntities);
|
||||
expect(mockDbHandler.removePlaylistEntities).toHaveBeenCalledWith(
|
||||
'playlist-id',
|
||||
mockEntities,
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /:playlistId/followers', () => {
|
||||
it('should respond correctly if unauthorized', async () => {
|
||||
mockedAuthorize.mockImplementationOnce(async () => [
|
||||
{ result: AuthorizeResult.DENY },
|
||||
]);
|
||||
const response = await request(app).post('/playlist-id/followers').send();
|
||||
|
||||
expect(mockedAuthorize).toHaveBeenCalledWith(
|
||||
[
|
||||
{
|
||||
permission: permissions.playlistFollowersUpdate,
|
||||
resourceRef: 'playlist-id',
|
||||
},
|
||||
],
|
||||
{ token: 'token' },
|
||||
);
|
||||
expect(mockDbHandler.followPlaylist).not.toHaveBeenCalled();
|
||||
expect(response.status).toEqual(403);
|
||||
});
|
||||
|
||||
it('should follow a playlist correctly', async () => {
|
||||
const response = await request(app).post('/playlist-id/followers').send();
|
||||
expect(mockDbHandler.followPlaylist).toHaveBeenCalledWith(
|
||||
'playlist-id',
|
||||
mockUser,
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /:playlistId/followers', () => {
|
||||
it('should respond correctly if unauthorized', async () => {
|
||||
mockedAuthorize.mockImplementationOnce(async () => [
|
||||
{ result: AuthorizeResult.DENY },
|
||||
]);
|
||||
const response = await request(app)
|
||||
.delete('/playlist-id/followers')
|
||||
.send();
|
||||
|
||||
expect(mockedAuthorize).toHaveBeenCalledWith(
|
||||
[
|
||||
{
|
||||
permission: permissions.playlistFollowersUpdate,
|
||||
resourceRef: 'playlist-id',
|
||||
},
|
||||
],
|
||||
{ token: 'token' },
|
||||
);
|
||||
expect(mockDbHandler.unfollowPlaylist).not.toHaveBeenCalled();
|
||||
expect(response.status).toEqual(403);
|
||||
});
|
||||
|
||||
it('should unfollow a playlist correctly', async () => {
|
||||
const response = await request(app)
|
||||
.delete('/playlist-id/followers')
|
||||
.send();
|
||||
expect(mockDbHandler.unfollowPlaylist).toHaveBeenCalledWith(
|
||||
'playlist-id',
|
||||
mockUser,
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
/*
|
||||
* Copyright 2022 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 {
|
||||
errorHandler,
|
||||
PluginDatabaseManager,
|
||||
PluginEndpointDiscovery,
|
||||
} from '@backstage/backend-common';
|
||||
import { CatalogClient } from '@backstage/catalog-client';
|
||||
import { parseEntityRef } from '@backstage/catalog-model';
|
||||
import { NotAllowedError } from '@backstage/errors';
|
||||
import {
|
||||
getBearerTokenFromAuthorizationHeader,
|
||||
IdentityApi,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
import {
|
||||
AuthorizePermissionRequest,
|
||||
AuthorizeResult,
|
||||
PermissionEvaluator,
|
||||
QueryPermissionRequest,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node';
|
||||
import {
|
||||
PLAYLIST_LIST_RESOURCE_TYPE,
|
||||
permissions,
|
||||
} from '@backstage/plugin-playlist-common';
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { Logger } from 'winston';
|
||||
|
||||
import { rules, transformConditions } from '../permissions';
|
||||
import { DatabaseHandler } from './DatabaseHandler';
|
||||
import { parseListPlaylistsFilterParams } from './ListPlaylistsFilter';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface RouterOptions {
|
||||
database: PluginDatabaseManager;
|
||||
discovery: PluginEndpointDiscovery;
|
||||
identity: IdentityApi;
|
||||
logger: Logger;
|
||||
permissions: PermissionEvaluator;
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export async function createRouter(
|
||||
options: RouterOptions,
|
||||
): Promise<express.Router> {
|
||||
const {
|
||||
database,
|
||||
discovery,
|
||||
identity,
|
||||
logger,
|
||||
permissions: permissionEvaluator,
|
||||
} = options;
|
||||
|
||||
logger.info('Initializing Playlist backend');
|
||||
|
||||
const catalogClient = new CatalogClient({ discoveryApi: discovery });
|
||||
const db = await database.getClient();
|
||||
const dbHandler = await DatabaseHandler.create({ database: db });
|
||||
|
||||
const evaluateRequestPermission = async (
|
||||
request: express.Request,
|
||||
permission: AuthorizePermissionRequest | QueryPermissionRequest,
|
||||
conditional: boolean = false,
|
||||
) => {
|
||||
const token = getBearerTokenFromAuthorizationHeader(
|
||||
request.header('authorization'),
|
||||
);
|
||||
|
||||
const user = await identity.getIdentity({ request });
|
||||
if (!user) {
|
||||
throw new NotAllowedError('Unauthorized');
|
||||
}
|
||||
|
||||
const decision = conditional
|
||||
? (
|
||||
await permissionEvaluator.authorizeConditional(
|
||||
[permission as QueryPermissionRequest],
|
||||
{ token },
|
||||
)
|
||||
)[0]
|
||||
: (
|
||||
await permissionEvaluator.authorize(
|
||||
[permission as AuthorizePermissionRequest],
|
||||
{ token },
|
||||
)
|
||||
)[0];
|
||||
|
||||
if (decision.result === AuthorizeResult.DENY) {
|
||||
throw new NotAllowedError('Unauthorized');
|
||||
}
|
||||
|
||||
return { decision, user: user.identity };
|
||||
};
|
||||
|
||||
const permissionIntegrationRouter = createPermissionIntegrationRouter({
|
||||
getResources: resourceRefs =>
|
||||
Promise.all(resourceRefs.map(ref => dbHandler.getPlaylist(ref))),
|
||||
resourceType: PLAYLIST_LIST_RESOURCE_TYPE,
|
||||
rules: Object.values(rules),
|
||||
});
|
||||
|
||||
const router = Router();
|
||||
router.use(express.json());
|
||||
router.use(permissionIntegrationRouter);
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const { decision, user } = await evaluateRequestPermission(
|
||||
req,
|
||||
{ permission: permissions.playlistListRead },
|
||||
true,
|
||||
);
|
||||
|
||||
let filter = parseListPlaylistsFilterParams(req.query);
|
||||
if (decision.result === AuthorizeResult.CONDITIONAL) {
|
||||
const conditionsFilter = transformConditions(decision.conditions);
|
||||
filter = filter
|
||||
? { allOf: [filter, conditionsFilter] }
|
||||
: conditionsFilter;
|
||||
}
|
||||
|
||||
if (req.query.editable) {
|
||||
const { decision: updatePermissionDecision } =
|
||||
await evaluateRequestPermission(
|
||||
req,
|
||||
{ permission: permissions.playlistListUpdate },
|
||||
true,
|
||||
);
|
||||
|
||||
if (updatePermissionDecision.result === AuthorizeResult.CONDITIONAL) {
|
||||
const updateConditionsFilter = transformConditions(
|
||||
updatePermissionDecision.conditions,
|
||||
);
|
||||
filter = filter
|
||||
? { allOf: [filter, updateConditionsFilter] }
|
||||
: updateConditionsFilter;
|
||||
}
|
||||
}
|
||||
|
||||
const playlists = await dbHandler.listPlaylists(user, filter);
|
||||
res.json(playlists);
|
||||
});
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
await evaluateRequestPermission(req, {
|
||||
permission: permissions.playlistListCreate,
|
||||
});
|
||||
const playlistId = await dbHandler.createPlaylist(req.body);
|
||||
res.status(201).json(playlistId);
|
||||
});
|
||||
|
||||
router.get('/:playlistId', async (req, res) => {
|
||||
const { user } = await evaluateRequestPermission(req, {
|
||||
permission: permissions.playlistListRead,
|
||||
resourceRef: req.params.playlistId,
|
||||
});
|
||||
const playlist = await dbHandler.getPlaylist(req.params.playlistId, user);
|
||||
res.json(playlist);
|
||||
});
|
||||
|
||||
router.put('/:playlistId', async (req, res) => {
|
||||
await evaluateRequestPermission(req, {
|
||||
permission: permissions.playlistListUpdate,
|
||||
resourceRef: req.params.playlistId,
|
||||
});
|
||||
await dbHandler.updatePlaylist({ ...req.body, id: req.params.playlistId });
|
||||
res.status(200).end();
|
||||
});
|
||||
|
||||
router.delete('/:playlistId', async (req, res) => {
|
||||
await evaluateRequestPermission(req, {
|
||||
permission: permissions.playlistListDelete,
|
||||
resourceRef: req.params.playlistId,
|
||||
});
|
||||
await dbHandler.deletePlaylist(req.params.playlistId);
|
||||
res.status(200).end();
|
||||
});
|
||||
|
||||
router.post('/:playlistId/entities', async (req, res) => {
|
||||
await evaluateRequestPermission(req, {
|
||||
permission: permissions.playlistListUpdate,
|
||||
resourceRef: req.params.playlistId,
|
||||
});
|
||||
await dbHandler.addPlaylistEntities(req.params.playlistId, req.body);
|
||||
res.status(200).end();
|
||||
});
|
||||
|
||||
router.get('/:playlistId/entities', async (req, res) => {
|
||||
await evaluateRequestPermission(req, {
|
||||
permission: permissions.playlistListRead,
|
||||
resourceRef: req.params.playlistId,
|
||||
});
|
||||
|
||||
const entityRefs = await dbHandler.getPlaylistEntities(
|
||||
req.params.playlistId,
|
||||
);
|
||||
if (!entityRefs.length) {
|
||||
res.json([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const filter = entityRefs.map(ref => {
|
||||
const compoundRef = parseEntityRef(ref);
|
||||
return {
|
||||
kind: compoundRef.kind,
|
||||
'metadata.namespace': compoundRef.namespace,
|
||||
'metadata.name': compoundRef.name,
|
||||
};
|
||||
});
|
||||
|
||||
const token = getBearerTokenFromAuthorizationHeader(
|
||||
req.header('authorization'),
|
||||
);
|
||||
|
||||
// TODO(kuanpg): entities in this playlist that no longer exist in the catalog will be
|
||||
// excluded from this response, we need a way to clean up these orphaned refs potentially
|
||||
// via catalog events (https://github.com/backstage/backstage/issues/8219)
|
||||
//
|
||||
// Note: This will also enforce catalog permissions and will only return entities for which the current user has access to
|
||||
const entities = (await catalogClient.getEntities({ filter }, { token }))
|
||||
.items;
|
||||
|
||||
res.json(entities);
|
||||
});
|
||||
|
||||
router.delete('/:playlistId/entities', async (req, res) => {
|
||||
await evaluateRequestPermission(req, {
|
||||
permission: permissions.playlistListUpdate,
|
||||
resourceRef: req.params.playlistId,
|
||||
});
|
||||
await dbHandler.removePlaylistEntities(req.params.playlistId, req.body);
|
||||
res.status(200).end();
|
||||
});
|
||||
|
||||
router.post('/:playlistId/followers', async (req, res) => {
|
||||
const { user } = await evaluateRequestPermission(req, {
|
||||
permission: permissions.playlistFollowersUpdate,
|
||||
resourceRef: req.params.playlistId,
|
||||
});
|
||||
await dbHandler.followPlaylist(req.params.playlistId, user);
|
||||
res.status(200).end();
|
||||
});
|
||||
|
||||
router.delete('/:playlistId/followers', async (req, res) => {
|
||||
const { user } = await evaluateRequestPermission(req, {
|
||||
permission: permissions.playlistFollowersUpdate,
|
||||
resourceRef: req.params.playlistId,
|
||||
});
|
||||
await dbHandler.unfollowPlaylist(req.params.playlistId, user);
|
||||
res.status(200).end();
|
||||
});
|
||||
|
||||
router.use(errorHandler());
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright 2022 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 {
|
||||
createServiceBuilder,
|
||||
DatabaseManager,
|
||||
loadBackendConfig,
|
||||
ServerTokenManager,
|
||||
SingleHostDiscovery,
|
||||
useHotMemoize,
|
||||
} from '@backstage/backend-common';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { DefaultIdentityClient } from '@backstage/plugin-auth-node';
|
||||
import { ServerPermissionClient } from '@backstage/plugin-permission-node';
|
||||
import { Server } from 'http';
|
||||
import { Logger } from 'winston';
|
||||
import { createRouter } from './router';
|
||||
|
||||
export interface ServerOptions {
|
||||
port: number;
|
||||
enableCors: boolean;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export async function startStandaloneServer(
|
||||
options: ServerOptions,
|
||||
): Promise<Server> {
|
||||
const logger = options.logger.child({ service: 'playlist-backend' });
|
||||
const config = await loadBackendConfig({ logger, argv: process.argv });
|
||||
const discovery = SingleHostDiscovery.fromConfig(config);
|
||||
|
||||
const database = useHotMemoize(module, () => {
|
||||
const manager = DatabaseManager.fromConfig(
|
||||
new ConfigReader({
|
||||
backend: {
|
||||
database: { client: 'better-sqlite3', connection: ':memory:' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
return manager.forPlugin('playlist');
|
||||
});
|
||||
|
||||
const identity = DefaultIdentityClient.create({
|
||||
discovery,
|
||||
issuer: await discovery.getExternalBaseUrl('auth'),
|
||||
});
|
||||
|
||||
const tokenManager = ServerTokenManager.fromConfig(config, {
|
||||
logger,
|
||||
});
|
||||
const permissions = ServerPermissionClient.fromConfig(config, {
|
||||
discovery,
|
||||
tokenManager,
|
||||
});
|
||||
|
||||
logger.debug('Starting application server...');
|
||||
const router = await createRouter({
|
||||
database,
|
||||
discovery,
|
||||
identity,
|
||||
logger,
|
||||
permissions,
|
||||
});
|
||||
|
||||
let service = createServiceBuilder(module)
|
||||
.setPort(options.port)
|
||||
.addRouter('/playlist', router);
|
||||
if (options.enableCors) {
|
||||
service = service.enableCors({ origin: 'http://localhost:3000' });
|
||||
}
|
||||
|
||||
return await service.start().catch(err => {
|
||||
logger.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.hot?.accept();
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2022 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 {};
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
@@ -0,0 +1,3 @@
|
||||
# Playlist Common
|
||||
|
||||
Common functionalities, types, and permissions for the playlist plugin.
|
||||
@@ -0,0 +1,36 @@
|
||||
## API Report File for "@backstage/plugin-playlist-common"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import { BasicPermission } from '@backstage/plugin-permission-common';
|
||||
import { ResourcePermission } from '@backstage/plugin-permission-common';
|
||||
|
||||
// @public (undocumented)
|
||||
export const permissions: {
|
||||
playlistListCreate: BasicPermission;
|
||||
playlistListRead: ResourcePermission<'playlist-list'>;
|
||||
playlistListUpdate: ResourcePermission<'playlist-list'>;
|
||||
playlistListDelete: ResourcePermission<'playlist-list'>;
|
||||
playlistFollowersUpdate: ResourcePermission<'playlist-list'>;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type Playlist = PlaylistMetadata & {
|
||||
entities: number;
|
||||
followers: number;
|
||||
isFollowing: boolean;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export const PLAYLIST_LIST_RESOURCE_TYPE = 'playlist-list';
|
||||
|
||||
// @public (undocumented)
|
||||
export type PlaylistMetadata = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
owner: string;
|
||||
public: boolean;
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@backstage/plugin-playlist-common",
|
||||
"description": "Common functionalities for the playlist plugin",
|
||||
"version": "0.0.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
"module": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"backstage": {
|
||||
"role": "common-library"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "backstage-cli package build",
|
||||
"lint": "backstage-cli package lint",
|
||||
"test": "backstage-cli package test",
|
||||
"clean": "backstage-cli package clean",
|
||||
"prepack": "backstage-cli package prepack",
|
||||
"postpack": "backstage-cli package postpack"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/plugin-permission-common": "^0.6.4-next.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.19.0-next.3"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright 2022 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Common functionalities for the playlist plugin.
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export * from './types';
|
||||
export * from './permissions';
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright 2022 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 { createPermission } from '@backstage/plugin-permission-common';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const PLAYLIST_LIST_RESOURCE_TYPE = 'playlist-list';
|
||||
|
||||
const playlistListCreate = createPermission({
|
||||
name: 'playlist.list.create',
|
||||
attributes: { action: 'create' },
|
||||
});
|
||||
|
||||
const playlistListRead = createPermission({
|
||||
name: 'playlist.list.read',
|
||||
attributes: { action: 'read' },
|
||||
resourceType: PLAYLIST_LIST_RESOURCE_TYPE,
|
||||
});
|
||||
|
||||
const playlistListUpdate = createPermission({
|
||||
name: 'playlist.list.update',
|
||||
attributes: { action: 'update' },
|
||||
resourceType: PLAYLIST_LIST_RESOURCE_TYPE,
|
||||
});
|
||||
|
||||
const playlistListDelete = createPermission({
|
||||
name: 'playlist.list.delete',
|
||||
attributes: { action: 'delete' },
|
||||
resourceType: PLAYLIST_LIST_RESOURCE_TYPE,
|
||||
});
|
||||
|
||||
const playlistFollowersUpdate = createPermission({
|
||||
name: 'playlist.followers.update',
|
||||
attributes: { action: 'update' },
|
||||
resourceType: PLAYLIST_LIST_RESOURCE_TYPE,
|
||||
});
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const permissions = {
|
||||
playlistListCreate,
|
||||
playlistListRead,
|
||||
playlistListUpdate,
|
||||
playlistListDelete,
|
||||
playlistFollowersUpdate,
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2022 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 {};
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright 2022 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type PlaylistMetadata = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
owner: string;
|
||||
public: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type Playlist = PlaylistMetadata & {
|
||||
entities: number;
|
||||
followers: number;
|
||||
isFollowing: boolean;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
@@ -0,0 +1,128 @@
|
||||
# Playlist Plugin
|
||||
|
||||
Welcome to the playlist plugin!
|
||||
|
||||
This plugin allows you to create, share, and follow custom collections of entities available in the Backstage catalog.
|
||||
|
||||
## Setup
|
||||
|
||||
Install this plugin:
|
||||
|
||||
```bash
|
||||
# From your Backstage root directory
|
||||
yarn --cwd packages/app add @backstage/plugin-playlist
|
||||
```
|
||||
|
||||
### Add the plugin to your `packages/app`
|
||||
|
||||
Add the root page that the playlist plugin provides to your app. You can
|
||||
choose any path for the route, but we recommend the following:
|
||||
|
||||
```diff
|
||||
// packages/app/src/App.tsx
|
||||
+import { PlaylistIndexPage } from '@backstage/plugin-playlist';
|
||||
|
||||
|
||||
<FlatRoutes>
|
||||
<Route path="/catalog" element={<CatalogIndexPage />} />
|
||||
<Route path="/catalog/:namespace/:kind/:name" element={<CatalogEntityPage />}>
|
||||
{entityPage}
|
||||
</Route>
|
||||
+ <Route path="/playlist" element={<PlaylistIndexPage />} />
|
||||
...
|
||||
</FlatRoutes>
|
||||
```
|
||||
|
||||
You may also want to add a link to the playlist page to your application sidebar:
|
||||
|
||||
```diff
|
||||
// packages/app/src/components/Root/Root.tsx
|
||||
+import PlaylistPlayIcon from '@material-ui/icons/PlaylistPlay';
|
||||
|
||||
export const Root = ({ children }: PropsWithChildren<{}>) => (
|
||||
<SidebarPage>
|
||||
<Sidebar>
|
||||
+ <SidebarItem icon={PlaylistPlayIcon} to="playlist" text="Playlists" />
|
||||
...
|
||||
</Sidebar>
|
||||
```
|
||||
|
||||
### Entity Pages
|
||||
|
||||
You can also make the following changes to add the playlist context menu to your `EntityPage.tsx`
|
||||
to be able to add entities to playlists directly from your entity pages:
|
||||
|
||||
First we need to add the following imports:
|
||||
|
||||
```ts
|
||||
import { EntityPlaylistDialog } from '@backstage/plugin-playlist';
|
||||
import PlaylistAddIcon from '@material-ui/icons/PlaylistAdd';
|
||||
```
|
||||
|
||||
Next we'll update the React import that looks like this:
|
||||
|
||||
```ts
|
||||
import React from 'react';
|
||||
```
|
||||
|
||||
To look like this:
|
||||
|
||||
```ts
|
||||
import React, { ReactNode, useMemo, useState } from 'react';
|
||||
```
|
||||
|
||||
Then we have to add this chunk of code after all the imports but before any of the other code:
|
||||
|
||||
```ts
|
||||
const EntityLayoutWrapper = (props: { children?: ReactNode }) => {
|
||||
const [playlistDialogOpen, setPlaylistDialogOpen] = useState(false);
|
||||
|
||||
const extraMenuItems = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
title: 'Add to playlist',
|
||||
Icon: PlaylistAddIcon,
|
||||
onClick: () => setPlaylistDialogOpen(true),
|
||||
},
|
||||
];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EntityLayout UNSTABLE_extraContextMenuItems={extraMenuItems}>
|
||||
{props.children}
|
||||
</EntityLayout>
|
||||
<EntityPlaylistDialog
|
||||
open={playlistDialogOpen}
|
||||
onClose={() => setPlaylistDialogOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
The last step is to wrap all the entity pages in the `EntityLayoutWrapper` like this:
|
||||
|
||||
```diff
|
||||
const defaultEntityPage = (
|
||||
+ <EntityLayoutWrapper>
|
||||
<EntityLayout.Route path="/" title="Overview">
|
||||
{overviewContent}
|
||||
</EntityLayout.Route>
|
||||
|
||||
<EntityLayout.Route path="/docs" title="Docs">
|
||||
<EntityTechdocsContent />
|
||||
</EntityLayout.Route>
|
||||
|
||||
<EntityLayout.Route path="/todos" title="TODOs">
|
||||
<EntityTodoContent />
|
||||
</EntityLayout.Route>
|
||||
+ </EntityLayoutWrapper>
|
||||
);
|
||||
```
|
||||
|
||||
Note: the above only shows an example for the `defaultEntityPage` for a full example of this you can look at [this EntityPage](../../packages/app/src/components/catalog/EntityPage.tsx)
|
||||
|
||||
## Links
|
||||
|
||||
- [playlist-backend](../playlist-backend) provides the backend API for this frontend.
|
||||
@@ -0,0 +1,106 @@
|
||||
## API Report File for "@backstage/plugin-playlist"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
/// <reference types="react" />
|
||||
|
||||
import { ApiRef } from '@backstage/core-plugin-api';
|
||||
import { BackstagePlugin } from '@backstage/core-plugin-api';
|
||||
import { DiscoveryApi } from '@backstage/core-plugin-api';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { FetchApi } from '@backstage/core-plugin-api';
|
||||
import { Playlist } from '@backstage/plugin-playlist-common';
|
||||
import { PlaylistMetadata } from '@backstage/plugin-playlist-common';
|
||||
import { RouteRef } from '@backstage/core-plugin-api';
|
||||
|
||||
// @public (undocumented)
|
||||
export const EntityPlaylistDialog: (
|
||||
props: EntityPlaylistDialogProps,
|
||||
) => JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export type EntityPlaylistDialogProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export interface GetAllPlaylistsRequest {
|
||||
// (undocumented)
|
||||
editable?: boolean;
|
||||
filter?:
|
||||
| Record<string, string | string[] | null>[]
|
||||
| Record<string, string | string[] | null>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface PlaylistApi {
|
||||
// (undocumented)
|
||||
addPlaylistEntities(playlistId: string, entityRefs: string[]): Promise<void>;
|
||||
// (undocumented)
|
||||
createPlaylist(playlist: Omit<PlaylistMetadata, 'id'>): Promise<string>;
|
||||
// (undocumented)
|
||||
deletePlaylist(playlistId: string): Promise<void>;
|
||||
// (undocumented)
|
||||
followPlaylist(playlistId: string): Promise<void>;
|
||||
// (undocumented)
|
||||
getAllPlaylists(req: GetAllPlaylistsRequest): Promise<Playlist[]>;
|
||||
// (undocumented)
|
||||
getPlaylist(playlistId: string): Promise<Playlist>;
|
||||
// (undocumented)
|
||||
getPlaylistEntities(playlistId: string): Promise<Entity[]>;
|
||||
// (undocumented)
|
||||
removePlaylistEntities(
|
||||
playlistId: string,
|
||||
entityRefs: string[],
|
||||
): Promise<void>;
|
||||
// (undocumented)
|
||||
unfollowPlaylist(playlistId: string): Promise<void>;
|
||||
// (undocumented)
|
||||
updatePlaylist(playlist: PlaylistMetadata): Promise<void>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export const playlistApiRef: ApiRef<PlaylistApi>;
|
||||
|
||||
// @public (undocumented)
|
||||
export class PlaylistClient implements PlaylistApi {
|
||||
constructor(options: { discoveryApi: DiscoveryApi; fetchApi: FetchApi });
|
||||
// (undocumented)
|
||||
addPlaylistEntities(playlistId: string, entityRefs: string[]): Promise<void>;
|
||||
// (undocumented)
|
||||
createPlaylist(playlist: Omit<PlaylistMetadata, 'id'>): Promise<string>;
|
||||
// (undocumented)
|
||||
deletePlaylist(playlistId: string): Promise<void>;
|
||||
// (undocumented)
|
||||
followPlaylist(playlistId: string): Promise<void>;
|
||||
// (undocumented)
|
||||
getAllPlaylists(req?: GetAllPlaylistsRequest): Promise<Playlist[]>;
|
||||
// (undocumented)
|
||||
getPlaylist(playlistId: string): Promise<Playlist>;
|
||||
// (undocumented)
|
||||
getPlaylistEntities(playlistId: string): Promise<Entity[]>;
|
||||
// (undocumented)
|
||||
removePlaylistEntities(
|
||||
playlistId: string,
|
||||
entityRefs: string[],
|
||||
): Promise<void>;
|
||||
// (undocumented)
|
||||
unfollowPlaylist(playlistId: string): Promise<void>;
|
||||
// (undocumented)
|
||||
updatePlaylist(playlist: PlaylistMetadata): Promise<void>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export const PlaylistIndexPage: () => JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export const playlistPlugin: BackstagePlugin<
|
||||
{
|
||||
root: RouteRef<undefined>;
|
||||
},
|
||||
{},
|
||||
{}
|
||||
>;
|
||||
```
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2022 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 { createDevApp } from '@backstage/dev-utils';
|
||||
import { playlistPlugin, PlaylistIndexPage } from '../src/plugin';
|
||||
|
||||
createDevApp()
|
||||
.registerPlugin(playlistPlugin)
|
||||
.addPage({
|
||||
element: <PlaylistIndexPage />,
|
||||
title: 'Root Page',
|
||||
path: '/playlist',
|
||||
})
|
||||
.render();
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "@backstage/plugin-playlist",
|
||||
"version": "0.0.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"backstage": {
|
||||
"role": "frontend-plugin"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "backstage-cli package start",
|
||||
"build": "backstage-cli package build",
|
||||
"lint": "backstage-cli package lint",
|
||||
"test": "backstage-cli package test",
|
||||
"clean": "backstage-cli package clean",
|
||||
"prepack": "backstage-cli package prepack",
|
||||
"postpack": "backstage-cli package postpack"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/catalog-model": "^1.1.1-next.0",
|
||||
"@backstage/core-components": "^0.11.1-next.3",
|
||||
"@backstage/core-plugin-api": "^1.0.6-next.3",
|
||||
"@backstage/errors": "^1.1.1-next.0",
|
||||
"@backstage/plugin-catalog-common": "^1.0.6-next.0",
|
||||
"@backstage/plugin-catalog-react": "^1.1.4-next.2",
|
||||
"@backstage/plugin-permission-common": "^0.6.4-next.2",
|
||||
"@backstage/plugin-permission-react": "^0.4.5-next.2",
|
||||
"@backstage/plugin-playlist-common": "^0.0.0",
|
||||
"@backstage/plugin-search-react": "^1.1.0-next.2",
|
||||
"@backstage/theme": "^0.2.16",
|
||||
"@material-ui/core": "^4.9.13",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "^4.0.0-alpha.57",
|
||||
"lodash": "^4.17.21",
|
||||
"qs": "^6.9.4",
|
||||
"react-hook-form": "^7.13.0",
|
||||
"react-use": "^17.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.13.1 || ^17.0.0",
|
||||
"react-router": "6.0.0-beta.0 || ^6.3.0",
|
||||
"react-router-dom": "6.0.0-beta.0 || ^6.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.19.0-next.3",
|
||||
"@backstage/core-app-api": "^1.1.0-next.3",
|
||||
"@backstage/dev-utils": "^1.0.6-next.2",
|
||||
"@backstage/test-utils": "^1.2.0-next.3",
|
||||
"@testing-library/jest-dom": "^5.10.1",
|
||||
"@testing-library/react": "^12.1.3",
|
||||
"@testing-library/react-hooks": "^8.0.0",
|
||||
"@testing-library/user-event": "^14.0.0",
|
||||
"@types/node": "*",
|
||||
"cross-fetch": "^3.1.5",
|
||||
"msw": "^0.47.0",
|
||||
"swr": "^1.1.2"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright 2022 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 { Entity } from '@backstage/catalog-model';
|
||||
import { createApiRef } from '@backstage/core-plugin-api';
|
||||
import { Playlist, PlaylistMetadata } from '@backstage/plugin-playlist-common';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const playlistApiRef = createApiRef<PlaylistApi>({
|
||||
id: 'plugin.playlist.service',
|
||||
});
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface GetAllPlaylistsRequest {
|
||||
/**
|
||||
* If multiple filter sets are given as an array, then there is effectively an
|
||||
* OR between each filter set.
|
||||
*
|
||||
* Within one filter set, there is effectively an AND between the various
|
||||
* keys.
|
||||
*
|
||||
* Within one key, if there are more than one value, then there is effectively
|
||||
* an OR between them.
|
||||
*/
|
||||
filter?:
|
||||
| Record<string, string | string[] | null>[]
|
||||
| Record<string, string | string[] | null>;
|
||||
|
||||
// If true, will filter results that satisfies the playlist.list.update permission
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface PlaylistApi {
|
||||
getAllPlaylists(req: GetAllPlaylistsRequest): Promise<Playlist[]>;
|
||||
|
||||
createPlaylist(playlist: Omit<PlaylistMetadata, 'id'>): Promise<string>;
|
||||
|
||||
getPlaylist(playlistId: string): Promise<Playlist>;
|
||||
|
||||
updatePlaylist(playlist: PlaylistMetadata): Promise<void>;
|
||||
|
||||
deletePlaylist(playlistId: string): Promise<void>;
|
||||
|
||||
addPlaylistEntities(playlistId: string, entityRefs: string[]): Promise<void>;
|
||||
|
||||
getPlaylistEntities(playlistId: string): Promise<Entity[]>;
|
||||
|
||||
removePlaylistEntities(
|
||||
playlistId: string,
|
||||
entityRefs: string[],
|
||||
): Promise<void>;
|
||||
|
||||
followPlaylist(playlistId: string): Promise<void>;
|
||||
|
||||
unfollowPlaylist(playlistId: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
/*
|
||||
* Copyright 2022 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 { MockFetchApi, setupRequestMockHandlers } from '@backstage/test-utils';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
|
||||
import { PlaylistClient } from './PlaylistClient';
|
||||
|
||||
const server = setupServer();
|
||||
|
||||
describe('PlaylistClient', () => {
|
||||
setupRequestMockHandlers(server);
|
||||
const mockBaseUrl = 'http://backstage/api/playlist';
|
||||
const discoveryApi = { getBaseUrl: async () => mockBaseUrl };
|
||||
const fetchApi = new MockFetchApi();
|
||||
|
||||
let client: PlaylistClient;
|
||||
beforeEach(() => {
|
||||
client = new PlaylistClient({ discoveryApi, fetchApi });
|
||||
});
|
||||
|
||||
describe('getAllPlaylists', () => {
|
||||
const expectedResp = [
|
||||
{
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
owner: 'owner',
|
||||
public: true,
|
||||
entities: 1,
|
||||
followers: 2,
|
||||
isFollowing: true,
|
||||
},
|
||||
];
|
||||
|
||||
it('should fetch playlists from correct endpoint', async () => {
|
||||
server.use(
|
||||
rest.get(`${mockBaseUrl}/`, (_, res, ctx) =>
|
||||
res(ctx.json(expectedResp)),
|
||||
),
|
||||
);
|
||||
const response = await client.getAllPlaylists();
|
||||
expect(response).toEqual(expectedResp);
|
||||
});
|
||||
|
||||
it('should fetch editable playlists correctly', async () => {
|
||||
expect.assertions(2);
|
||||
|
||||
server.use(
|
||||
rest.get(`${mockBaseUrl}/`, (req, res, ctx) => {
|
||||
expect(req.url.search).toBe('?editable=true');
|
||||
return res(ctx.json(expectedResp));
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await client.getAllPlaylists({ editable: true });
|
||||
expect(response).toEqual(expectedResp);
|
||||
});
|
||||
|
||||
it('builds multiple search filters properly', async () => {
|
||||
expect.assertions(2);
|
||||
|
||||
server.use(
|
||||
rest.get(`${mockBaseUrl}/`, (req, res, ctx) => {
|
||||
expect(req.url.search).toBe(
|
||||
'?filter=a=1,b=2,b=3,%C3%B6=%3D&filter=a=2&editable=true',
|
||||
);
|
||||
return res(ctx.json(expectedResp));
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await client.getAllPlaylists({
|
||||
editable: true,
|
||||
filter: [
|
||||
{
|
||||
a: '1',
|
||||
b: ['2', '3'],
|
||||
ö: '=',
|
||||
},
|
||||
{
|
||||
a: '2',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(response).toEqual(expectedResp);
|
||||
});
|
||||
|
||||
it('builds single search filter properly', async () => {
|
||||
expect.assertions(2);
|
||||
|
||||
server.use(
|
||||
rest.get(`${mockBaseUrl}/`, (req, res, ctx) => {
|
||||
expect(req.url.search).toBe('?filter=a=1,b=2,b=3,%C3%B6=%3D');
|
||||
return res(ctx.json(expectedResp));
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await client.getAllPlaylists({
|
||||
filter: {
|
||||
a: '1',
|
||||
b: ['2', '3'],
|
||||
ö: '=',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response).toEqual(expectedResp);
|
||||
});
|
||||
});
|
||||
|
||||
it('createPlaylist', async () => {
|
||||
expect.assertions(2);
|
||||
|
||||
const newPlaylist = {
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
owner: 'owner',
|
||||
public: true,
|
||||
};
|
||||
|
||||
server.use(
|
||||
rest.post(`${mockBaseUrl}/`, (req, res, ctx) => {
|
||||
expect(req.body).toEqual(newPlaylist);
|
||||
return res(ctx.json('123'));
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await client.createPlaylist(newPlaylist);
|
||||
|
||||
expect(response).toEqual('123');
|
||||
});
|
||||
|
||||
it('getPlaylist', async () => {
|
||||
const playlist = {
|
||||
id: '123',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
owner: 'owner',
|
||||
public: true,
|
||||
entities: 1,
|
||||
followers: 2,
|
||||
isFollowing: true,
|
||||
};
|
||||
|
||||
server.use(
|
||||
rest.get(`${mockBaseUrl}/123`, (_, res, ctx) => res(ctx.json(playlist))),
|
||||
);
|
||||
|
||||
const response = await client.getPlaylist('123');
|
||||
expect(response).toEqual(playlist);
|
||||
});
|
||||
|
||||
it('updatePlaylist', async () => {
|
||||
expect.assertions(1);
|
||||
|
||||
const playlist = {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
owner: 'owner',
|
||||
public: true,
|
||||
};
|
||||
|
||||
server.use(
|
||||
rest.put(`${mockBaseUrl}/id`, (req, res) => {
|
||||
expect(req.body).toEqual(playlist);
|
||||
return res();
|
||||
}),
|
||||
);
|
||||
|
||||
await client.updatePlaylist(playlist);
|
||||
});
|
||||
|
||||
it('deletePlaylist', async () => {
|
||||
expect.assertions(1);
|
||||
|
||||
server.use(
|
||||
rest.delete(`${mockBaseUrl}/id`, (_, res) => {
|
||||
// eslint requires at least 1 assertion so this is here is verify this handler is called
|
||||
expect(true).toBe(true);
|
||||
return res();
|
||||
}),
|
||||
);
|
||||
|
||||
await client.deletePlaylist('id');
|
||||
});
|
||||
|
||||
it('addPlaylistEntities', async () => {
|
||||
expect.assertions(1);
|
||||
|
||||
const entities = ['component:default/ent1', 'component:default/ent2'];
|
||||
|
||||
server.use(
|
||||
rest.post(`${mockBaseUrl}/id/entities`, (req, res) => {
|
||||
expect(req.body).toEqual(entities);
|
||||
return res();
|
||||
}),
|
||||
);
|
||||
|
||||
await client.addPlaylistEntities('id', entities);
|
||||
});
|
||||
|
||||
it('getPlaylistEntities', async () => {
|
||||
const entities = [
|
||||
{
|
||||
kind: 'system',
|
||||
metadata: {
|
||||
namespace: 'default',
|
||||
name: 'test-ent',
|
||||
title: 'Test Ent',
|
||||
description: 'test ent description',
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'component',
|
||||
metadata: {
|
||||
namespace: 'foo',
|
||||
name: 'test-ent2',
|
||||
title: 'Test Ent 2',
|
||||
description: 'test ent description 2',
|
||||
},
|
||||
spec: {
|
||||
type: 'library',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
server.use(
|
||||
rest.get(`${mockBaseUrl}/id/entities`, (_, res, ctx) =>
|
||||
res(ctx.json(entities)),
|
||||
),
|
||||
);
|
||||
|
||||
const response = await client.getPlaylistEntities('id');
|
||||
expect(response).toEqual(entities);
|
||||
});
|
||||
|
||||
it('removePlaylistEntities', async () => {
|
||||
expect.assertions(1);
|
||||
|
||||
const entities = ['component:default/ent1', 'component:default/ent2'];
|
||||
|
||||
server.use(
|
||||
rest.delete(`${mockBaseUrl}/id/entities`, (req, res) => {
|
||||
expect(req.body).toEqual(entities);
|
||||
return res();
|
||||
}),
|
||||
);
|
||||
|
||||
await client.removePlaylistEntities('id', entities);
|
||||
});
|
||||
|
||||
it('followPlaylist', async () => {
|
||||
expect.assertions(1);
|
||||
|
||||
server.use(
|
||||
rest.post(`${mockBaseUrl}/id/followers`, (_, res) => {
|
||||
// eslint requires at least 1 assertion so this is here is verify this handler is called
|
||||
expect(true).toBe(true);
|
||||
return res();
|
||||
}),
|
||||
);
|
||||
|
||||
await client.followPlaylist('id');
|
||||
});
|
||||
|
||||
it('unfollowPlaylist', async () => {
|
||||
expect.assertions(1);
|
||||
|
||||
server.use(
|
||||
rest.delete(`${mockBaseUrl}/id/followers`, (_, res) => {
|
||||
// eslint requires at least 1 assertion so this is here is verify this handler is called
|
||||
expect(true).toBe(true);
|
||||
return res();
|
||||
}),
|
||||
);
|
||||
|
||||
await client.unfollowPlaylist('id');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* Copyright 2022 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 { Entity } from '@backstage/catalog-model';
|
||||
import { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api';
|
||||
import { ResponseError } from '@backstage/errors';
|
||||
import { Playlist, PlaylistMetadata } from '@backstage/plugin-playlist-common';
|
||||
|
||||
import { GetAllPlaylistsRequest, PlaylistApi } from './PlaylistApi';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export class PlaylistClient implements PlaylistApi {
|
||||
private readonly discoveryApi: DiscoveryApi;
|
||||
private readonly fetchApi: FetchApi;
|
||||
|
||||
constructor(options: { discoveryApi: DiscoveryApi; fetchApi: FetchApi }) {
|
||||
this.discoveryApi = options.discoveryApi;
|
||||
this.fetchApi = options.fetchApi;
|
||||
}
|
||||
|
||||
async getAllPlaylists(req: GetAllPlaylistsRequest = {}): Promise<Playlist[]> {
|
||||
const { filter = [], editable } = req;
|
||||
const params: string[] = [];
|
||||
|
||||
// the "outer array" defined by `filter` occurrences corresponds to "anyOf" filters
|
||||
// the "inner array" defined within a `filter` param corresponds to "allOf" filters
|
||||
for (const filterItem of [filter].flat()) {
|
||||
const filterParts: string[] = [];
|
||||
for (const [key, value] of Object.entries(filterItem)) {
|
||||
for (const v of [value].flat()) {
|
||||
if (typeof v === 'string') {
|
||||
filterParts.push(
|
||||
`${encodeURIComponent(key)}=${encodeURIComponent(v)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (filterParts.length) {
|
||||
params.push(`filter=${filterParts.join(',')}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (editable) {
|
||||
params.push('editable=true');
|
||||
}
|
||||
|
||||
const query = params.length ? `?${params.join('&')}` : '';
|
||||
const baseUrl = await this.discoveryApi.getBaseUrl('playlist');
|
||||
const resp = await this.fetchApi.fetch(`${baseUrl}/${query}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
throw await ResponseError.fromResponse(resp);
|
||||
}
|
||||
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
async createPlaylist(
|
||||
playlist: Omit<PlaylistMetadata, 'id'>,
|
||||
): Promise<string> {
|
||||
const baseUrl = await this.discoveryApi.getBaseUrl('playlist');
|
||||
const resp = await this.fetchApi.fetch(`${baseUrl}/`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
body: JSON.stringify(playlist),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
throw await ResponseError.fromResponse(resp);
|
||||
}
|
||||
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
async getPlaylist(playlistId: string): Promise<Playlist> {
|
||||
const baseUrl = await this.discoveryApi.getBaseUrl('playlist');
|
||||
const resp = await this.fetchApi.fetch(`${baseUrl}/${playlistId}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
throw await ResponseError.fromResponse(resp);
|
||||
}
|
||||
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
async updatePlaylist(playlist: PlaylistMetadata) {
|
||||
const baseUrl = await this.discoveryApi.getBaseUrl('playlist');
|
||||
const resp = await this.fetchApi.fetch(`${baseUrl}/${playlist.id}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(playlist),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
throw await ResponseError.fromResponse(resp);
|
||||
}
|
||||
}
|
||||
|
||||
async deletePlaylist(playlistId: string) {
|
||||
const baseUrl = await this.discoveryApi.getBaseUrl('playlist');
|
||||
const resp = await this.fetchApi.fetch(`${baseUrl}/${playlistId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
throw await ResponseError.fromResponse(resp);
|
||||
}
|
||||
}
|
||||
|
||||
async addPlaylistEntities(playlistId: string, entityRefs: string[]) {
|
||||
const baseUrl = await this.discoveryApi.getBaseUrl('playlist');
|
||||
const resp = await this.fetchApi.fetch(
|
||||
`${baseUrl}/${playlistId}/entities`,
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
body: JSON.stringify(entityRefs),
|
||||
},
|
||||
);
|
||||
|
||||
if (!resp.ok) {
|
||||
throw await ResponseError.fromResponse(resp);
|
||||
}
|
||||
}
|
||||
|
||||
async getPlaylistEntities(playlistId: string): Promise<Entity[]> {
|
||||
const baseUrl = await this.discoveryApi.getBaseUrl('playlist');
|
||||
const resp = await this.fetchApi.fetch(
|
||||
`${baseUrl}/${playlistId}/entities`,
|
||||
{ method: 'GET' },
|
||||
);
|
||||
|
||||
if (!resp.ok) {
|
||||
throw await ResponseError.fromResponse(resp);
|
||||
}
|
||||
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
async removePlaylistEntities(playlistId: string, entityRefs: string[]) {
|
||||
const baseUrl = await this.discoveryApi.getBaseUrl('playlist');
|
||||
const resp = await this.fetchApi.fetch(
|
||||
`${baseUrl}/${playlistId}/entities`,
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify(entityRefs),
|
||||
},
|
||||
);
|
||||
|
||||
if (!resp.ok) {
|
||||
throw await ResponseError.fromResponse(resp);
|
||||
}
|
||||
}
|
||||
|
||||
async followPlaylist(playlistId: string) {
|
||||
const baseUrl = await this.discoveryApi.getBaseUrl('playlist');
|
||||
const resp = await this.fetchApi.fetch(
|
||||
`${baseUrl}/${playlistId}/followers`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
|
||||
if (!resp.ok) {
|
||||
throw await ResponseError.fromResponse(resp);
|
||||
}
|
||||
}
|
||||
|
||||
async unfollowPlaylist(playlistId: string) {
|
||||
const baseUrl = await this.discoveryApi.getBaseUrl('playlist');
|
||||
const resp = await this.fetchApi.fetch(
|
||||
`${baseUrl}/${playlistId}/followers`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
|
||||
if (!resp.ok) {
|
||||
throw await ResponseError.fromResponse(resp);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2022 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 './PlaylistClient';
|
||||
export * from './PlaylistApi';
|
||||
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* Copyright 2022 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 { ErrorApi, errorApiRef } from '@backstage/core-plugin-api';
|
||||
import { AuthorizeResult } from '@backstage/plugin-permission-common';
|
||||
import {
|
||||
PermissionApi,
|
||||
permissionApiRef,
|
||||
} from '@backstage/plugin-permission-react';
|
||||
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
import { Button } from '@material-ui/core';
|
||||
import { fireEvent, waitFor } from '@testing-library/react';
|
||||
import { act } from '@testing-library/react-hooks';
|
||||
import React from 'react';
|
||||
import { SWRConfig } from 'swr';
|
||||
|
||||
import { PlaylistApi, playlistApiRef } from '../../api';
|
||||
import { rootRouteRef } from '../../routes';
|
||||
import { CreatePlaylistButton } from './CreatePlaylistButton';
|
||||
|
||||
jest.mock('../PlaylistEditDialog', () => ({
|
||||
PlaylistEditDialog: ({ onSave, open }: { onSave: Function; open: boolean }) =>
|
||||
open ? (
|
||||
<Button
|
||||
data-testid="mock-playlist-edit-dialog"
|
||||
onClick={() => onSave()}
|
||||
/>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
const navigateMock = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
describe('<CreatePlaylistButton/>', () => {
|
||||
const mockErrorPost = jest.fn();
|
||||
const errorApi: Partial<ErrorApi> = { post: mockErrorPost };
|
||||
|
||||
const mockCreatePlaylist = jest.fn().mockImplementation(async () => '123');
|
||||
const playlistApi: Partial<PlaylistApi> = {
|
||||
createPlaylist: mockCreatePlaylist,
|
||||
};
|
||||
|
||||
const mockAuthorize = jest
|
||||
.fn()
|
||||
.mockImplementation(async () => ({ result: AuthorizeResult.ALLOW }));
|
||||
const permissionApi: Partial<PermissionApi> = { authorize: mockAuthorize };
|
||||
|
||||
const render = () => {
|
||||
// SWR used by the usePermission hook needs cache to be reset for each test
|
||||
return renderInTestApp(
|
||||
<SWRConfig value={{ provider: () => new Map() }}>
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[errorApiRef, errorApi],
|
||||
[permissionApiRef, permissionApi],
|
||||
[playlistApiRef, playlistApi],
|
||||
]}
|
||||
>
|
||||
<CreatePlaylistButton />
|
||||
</TestApiProvider>
|
||||
</SWRConfig>,
|
||||
{ mountedRoutes: { '/playlists': rootRouteRef } },
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockAuthorize.mockClear();
|
||||
mockCreatePlaylist.mockClear();
|
||||
navigateMock.mockClear();
|
||||
});
|
||||
|
||||
it('should be disabled if not authorized', async () => {
|
||||
mockAuthorize.mockImplementationOnce(async () => ({
|
||||
result: AuthorizeResult.DENY,
|
||||
}));
|
||||
const rendered = await render();
|
||||
expect(rendered.getByRole('button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should open the PlaylistEditDialog when clicked', async () => {
|
||||
const rendered = await render();
|
||||
|
||||
expect(rendered.getByRole('button')).not.toBeDisabled();
|
||||
expect(rendered.queryByTestId('mock-playlist-edit-dialog')).toBeNull();
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(rendered.getByRole('button'));
|
||||
});
|
||||
expect(
|
||||
rendered.queryByTestId('mock-playlist-edit-dialog'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should create and navigate to a new playlist on save', async () => {
|
||||
const rendered = await render();
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(rendered.getByRole('button'));
|
||||
fireEvent.click(rendered.getByTestId('mock-playlist-edit-dialog'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreatePlaylist).toHaveBeenCalled();
|
||||
expect(navigateMock).toHaveBeenCalledWith('/playlists/123');
|
||||
});
|
||||
});
|
||||
|
||||
it('should post an error when save fails', async () => {
|
||||
const saveError = new Error('mock error');
|
||||
mockCreatePlaylist.mockImplementationOnce(async () => {
|
||||
throw saveError;
|
||||
});
|
||||
const rendered = await render();
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(rendered.getByRole('button'));
|
||||
fireEvent.click(rendered.getByTestId('mock-playlist-edit-dialog'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreatePlaylist).toHaveBeenCalled();
|
||||
expect(navigateMock).not.toHaveBeenCalled();
|
||||
expect(mockErrorPost).toHaveBeenCalledWith(saveError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright 2022 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 { errorApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
import { usePermission } from '@backstage/plugin-permission-react';
|
||||
import {
|
||||
permissions,
|
||||
PlaylistMetadata,
|
||||
} from '@backstage/plugin-playlist-common';
|
||||
import { Button, IconButton, useMediaQuery } from '@material-ui/core';
|
||||
import AddCircleOutline from '@material-ui/icons/AddCircleOutline';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { playlistApiRef } from '../../api';
|
||||
import { playlistRouteRef } from '../../routes';
|
||||
import { PlaylistEditDialog } from '../PlaylistEditDialog';
|
||||
|
||||
export const CreatePlaylistButton = () => {
|
||||
const navigate = useNavigate();
|
||||
const errorApi = useApi(errorApiRef);
|
||||
const playlistApi = useApi(playlistApiRef);
|
||||
const playlistRoute = useRouteRef(playlistRouteRef);
|
||||
const [openDialog, setOpenDialog] = useState(false);
|
||||
const { allowed } = usePermission({
|
||||
permission: permissions.playlistListCreate,
|
||||
});
|
||||
const isXSScreen = useMediaQuery<BackstageTheme>(theme =>
|
||||
theme.breakpoints.down('xs'),
|
||||
);
|
||||
|
||||
const savePlaylist = useCallback(
|
||||
async (playlist: Omit<PlaylistMetadata, 'id'>) => {
|
||||
try {
|
||||
const playlistId = await playlistApi.createPlaylist(playlist);
|
||||
navigate(playlistRoute({ playlistId }));
|
||||
} catch (e) {
|
||||
errorApi.post(e);
|
||||
}
|
||||
},
|
||||
[errorApi, navigate, playlistApi, playlistRoute],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isXSScreen ? (
|
||||
<IconButton
|
||||
disabled={!allowed}
|
||||
color="primary"
|
||||
title="Create Playlist"
|
||||
size="small"
|
||||
onClick={() => setOpenDialog(true)}
|
||||
>
|
||||
<AddCircleOutline />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Button
|
||||
disabled={!allowed}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => setOpenDialog(true)}
|
||||
>
|
||||
Create Playlist
|
||||
</Button>
|
||||
)}
|
||||
<PlaylistEditDialog
|
||||
open={openDialog}
|
||||
onClose={() => setOpenDialog(false)}
|
||||
onSave={savePlaylist}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2022 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 './CreatePlaylistButton';
|
||||
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
* Copyright 2022 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 { Entity } from '@backstage/catalog-model';
|
||||
import { AlertApi, alertApiRef } from '@backstage/core-plugin-api';
|
||||
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
import { EntityProvider } from '@backstage/plugin-catalog-react';
|
||||
import { AuthorizeResult } from '@backstage/plugin-permission-common';
|
||||
import {
|
||||
PermissionApi,
|
||||
permissionApiRef,
|
||||
} from '@backstage/plugin-permission-react';
|
||||
import { Button } from '@material-ui/core';
|
||||
import { fireEvent, getByRole, waitFor } from '@testing-library/react';
|
||||
import { act } from '@testing-library/react-hooks';
|
||||
import React from 'react';
|
||||
import { SWRConfig } from 'swr';
|
||||
|
||||
import { PlaylistApi, playlistApiRef } from '../../api';
|
||||
import { rootRouteRef } from '../../routes';
|
||||
import { EntityPlaylistDialog } from './EntityPlaylistDialog';
|
||||
|
||||
const navigateMock = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
jest.mock('../PlaylistEditDialog', () => ({
|
||||
PlaylistEditDialog: ({ onSave, open }: { onSave: Function; open: boolean }) =>
|
||||
open ? (
|
||||
<Button
|
||||
data-testid="mock-playlist-edit-dialog"
|
||||
onClick={() => onSave()}
|
||||
/>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
describe('EntityPlaylistDialog', () => {
|
||||
const samplePlaylists = [
|
||||
{
|
||||
id: 'id1',
|
||||
name: 'playlist-1',
|
||||
owner: 'group:default/some-owner',
|
||||
public: true,
|
||||
entities: 1,
|
||||
followers: 2,
|
||||
isFollowing: false,
|
||||
},
|
||||
{
|
||||
id: 'id2',
|
||||
name: 'playlist-2',
|
||||
owner: 'group:default/another-owner',
|
||||
public: true,
|
||||
entities: 2,
|
||||
followers: 1,
|
||||
isFollowing: true,
|
||||
},
|
||||
];
|
||||
|
||||
const alertApi: Partial<AlertApi> = { post: jest.fn() };
|
||||
const playlistApi: Partial<PlaylistApi> = {
|
||||
addPlaylistEntities: jest.fn().mockImplementation(async () => {}),
|
||||
createPlaylist: jest.fn().mockImplementation(async () => '123'),
|
||||
getAllPlaylists: jest.fn().mockImplementation(async () => samplePlaylists),
|
||||
};
|
||||
const mockAuthorize = jest
|
||||
.fn()
|
||||
.mockImplementation(async () => ({ result: AuthorizeResult.ALLOW }));
|
||||
const permissionApi: Partial<PermissionApi> = { authorize: mockAuthorize };
|
||||
const mockEntity = {
|
||||
kind: 'component',
|
||||
metadata: { name: 'mock' },
|
||||
} as Entity;
|
||||
|
||||
const mockOnClose = jest.fn();
|
||||
|
||||
// SWR used by the usePermission hook needs cache to be reset for each test
|
||||
const render = async () =>
|
||||
renderInTestApp(
|
||||
<SWRConfig value={{ provider: () => new Map() }}>
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[alertApiRef, alertApi],
|
||||
[permissionApiRef, permissionApi],
|
||||
[playlistApiRef, playlistApi],
|
||||
]}
|
||||
>
|
||||
<EntityProvider entity={mockEntity}>
|
||||
<EntityPlaylistDialog open onClose={mockOnClose} />
|
||||
</EntityProvider>
|
||||
</TestApiProvider>
|
||||
,
|
||||
</SWRConfig>,
|
||||
{ mountedRoutes: { '/playlists': rootRouteRef } },
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mockAuthorize.mockClear();
|
||||
mockOnClose.mockClear();
|
||||
navigateMock.mockClear();
|
||||
});
|
||||
|
||||
it('list available playlists', async () => {
|
||||
const rendered = await render();
|
||||
expect(rendered.getByText('Create new playlist')).toBeInTheDocument();
|
||||
expect(rendered.getByText('playlist-1')).toBeInTheDocument();
|
||||
expect(rendered.getByText('playlist-2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show a create playlist option if unauthorized', async () => {
|
||||
mockAuthorize.mockImplementationOnce(async () => ({
|
||||
result: AuthorizeResult.DENY,
|
||||
}));
|
||||
const rendered = await render();
|
||||
expect(rendered.queryByText('Create new playlist')).toBeNull();
|
||||
});
|
||||
|
||||
it('filters playlists via search text', async () => {
|
||||
const rendered = await render();
|
||||
|
||||
expect(rendered.getByText('playlist-1')).toBeInTheDocument();
|
||||
expect(rendered.getByText('playlist-2')).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
fireEvent.input(
|
||||
getByRole(
|
||||
rendered.getByTestId('entity-playlist-dialog-search'),
|
||||
'textbox',
|
||||
),
|
||||
{
|
||||
target: {
|
||||
value: 'playlist-2',
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
expect(rendered.queryByText('playlist-1')).toBeNull();
|
||||
expect(rendered.getByText('playlist-2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should add the current entity to the selected playlist', async () => {
|
||||
const rendered = await render();
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(rendered.getByText('playlist-2'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(playlistApi.addPlaylistEntities).toHaveBeenCalledWith('id2', [
|
||||
'component:default/mock',
|
||||
]);
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
expect(alertApi.post).toHaveBeenCalledWith({
|
||||
message: 'Entity added to playlist-2',
|
||||
severity: 'success',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should open PlaylistEditDialog to create a new playlist with the current entity', async () => {
|
||||
const rendered = await render();
|
||||
|
||||
expect(rendered.queryByTestId('mock-playlist-edit-dialog')).toBeNull();
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(rendered.getByText('Create new playlist'));
|
||||
});
|
||||
|
||||
expect(
|
||||
rendered.queryByTestId('mock-playlist-edit-dialog'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(rendered.getByTestId('mock-playlist-edit-dialog'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(playlistApi.createPlaylist).toHaveBeenCalled();
|
||||
expect(playlistApi.addPlaylistEntities).toHaveBeenCalledWith('123', [
|
||||
'component:default/mock',
|
||||
]);
|
||||
expect(navigateMock).toHaveBeenCalledWith('/playlists/123');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
/*
|
||||
* Copyright 2022 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 { parseEntityRef, stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import { ResponseErrorPanel } from '@backstage/core-components';
|
||||
import { alertApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
humanizeEntityRef,
|
||||
useAsyncEntity,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { usePermission } from '@backstage/plugin-permission-react';
|
||||
import {
|
||||
permissions,
|
||||
Playlist,
|
||||
PlaylistMetadata,
|
||||
} from '@backstage/plugin-playlist-common';
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
LinearProgress,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
makeStyles,
|
||||
TextField,
|
||||
} from '@material-ui/core';
|
||||
import ClearButton from '@material-ui/icons/Clear';
|
||||
import PlaylistAddIcon from '@material-ui/icons/PlaylistAdd';
|
||||
import SearchIcon from '@material-ui/icons/Search';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import useAsyncFn from 'react-use/lib/useAsyncFn';
|
||||
|
||||
import { playlistApiRef } from '../../api';
|
||||
import { playlistRouteRef } from '../../routes';
|
||||
import { PlaylistEditDialog } from '../PlaylistEditDialog';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
dialog: {
|
||||
height: '50%',
|
||||
},
|
||||
dialogTitle: {
|
||||
paddingBottom: '5px',
|
||||
},
|
||||
dialogContent: {
|
||||
paddingLeft: '5px',
|
||||
paddingRight: '5px',
|
||||
},
|
||||
search: {
|
||||
fontSize: '16px',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type EntityPlaylistDialogProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const EntityPlaylistDialog = (props: EntityPlaylistDialogProps) => {
|
||||
const { open, onClose } = props;
|
||||
|
||||
const classes = useStyles();
|
||||
const navigate = useNavigate();
|
||||
const { entity } = useAsyncEntity();
|
||||
const alertApi = useApi(alertApiRef);
|
||||
const playlistApi = useApi(playlistApiRef);
|
||||
const playlistRoute = useRouteRef(playlistRouteRef);
|
||||
const [search, setSearch] = useState('');
|
||||
const [openEditDialog, setOpenEditDialog] = useState(false);
|
||||
|
||||
const { allowed: createAllowed } = usePermission({
|
||||
permission: permissions.playlistListCreate,
|
||||
});
|
||||
|
||||
const closeDialog = useCallback(() => {
|
||||
setSearch('');
|
||||
onClose();
|
||||
}, [onClose, setSearch]);
|
||||
|
||||
const [{ error, loading, value: playlists }, loadPlaylists] = useAsyncFn(
|
||||
() => playlistApi.getAllPlaylists({ editable: true }),
|
||||
[playlistApi],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadPlaylists();
|
||||
}
|
||||
}, [loadPlaylists, open]);
|
||||
|
||||
const createNewPlaylist = useCallback(
|
||||
async (playlist: Omit<PlaylistMetadata, 'id'>) => {
|
||||
try {
|
||||
const playlistId = await playlistApi.createPlaylist(playlist);
|
||||
await playlistApi.addPlaylistEntities(playlistId, [
|
||||
stringifyEntityRef(entity!),
|
||||
]);
|
||||
navigate(playlistRoute({ playlistId }));
|
||||
} catch (e) {
|
||||
alertApi.post({
|
||||
message: `Failed to add entity to playlist: ${e}`,
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
},
|
||||
[alertApi, entity, navigate, playlistApi, playlistRoute],
|
||||
);
|
||||
|
||||
const [{ loading: addEntityLoading }, addToPlaylist] = useAsyncFn(
|
||||
async (playlist: Playlist) => {
|
||||
try {
|
||||
await playlistApi.addPlaylistEntities(playlist.id, [
|
||||
stringifyEntityRef(entity!),
|
||||
]);
|
||||
closeDialog();
|
||||
alertApi.post({
|
||||
message: `Entity added to ${playlist.name}`,
|
||||
severity: 'success',
|
||||
});
|
||||
} catch (e) {
|
||||
alertApi.post({
|
||||
message: `Failed to add entity to playlist: ${e}`,
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
},
|
||||
[alertApi, closeDialog, entity, playlistApi],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
classes={{ paper: classes.dialog }}
|
||||
fullWidth
|
||||
maxWidth="xs"
|
||||
onClose={closeDialog}
|
||||
open={open}
|
||||
>
|
||||
{(loading || addEntityLoading) && <LinearProgress />}
|
||||
<DialogTitle className={classes.dialogTitle}>
|
||||
Add to Playlist
|
||||
<TextField
|
||||
fullWidth
|
||||
data-testid="entity-playlist-dialog-search"
|
||||
InputProps={{
|
||||
classes: {
|
||||
input: classes.search,
|
||||
},
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon fontSize="small" />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={() => setSearch('')}>
|
||||
<ClearButton fontSize="small" />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
margin="dense"
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Search"
|
||||
size="small"
|
||||
value={search}
|
||||
variant="outlined"
|
||||
/>
|
||||
</DialogTitle>
|
||||
<DialogContent className={classes.dialogContent}>
|
||||
{error && (
|
||||
<ResponseErrorPanel title="Error loading playlists" error={error} />
|
||||
)}
|
||||
{playlists && entity && (
|
||||
<List>
|
||||
{createAllowed && (
|
||||
<ListItem
|
||||
button
|
||||
disabled={addEntityLoading}
|
||||
divider
|
||||
onClick={() => setOpenEditDialog(true)}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<PlaylistAddIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Create new playlist" />
|
||||
</ListItem>
|
||||
)}
|
||||
{playlists
|
||||
.filter(
|
||||
list =>
|
||||
!search ||
|
||||
list.name
|
||||
.toLocaleLowerCase('en-US')
|
||||
.includes(search.toLocaleLowerCase('en-US')),
|
||||
)
|
||||
.map(list => (
|
||||
<React.Fragment key={list.id}>
|
||||
<ListItem
|
||||
button
|
||||
disabled={addEntityLoading}
|
||||
divider
|
||||
onClick={() => addToPlaylist(list)}
|
||||
>
|
||||
<ListItemText
|
||||
primary={list.name}
|
||||
secondary={`by ${humanizeEntityRef(
|
||||
parseEntityRef(list.owner),
|
||||
{ defaultKind: 'group' },
|
||||
)} · ${list.entities} entities ${
|
||||
!list.public ? '· Private' : ''
|
||||
}`}
|
||||
/>
|
||||
</ListItem>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button color="primary" onClick={closeDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<PlaylistEditDialog
|
||||
open={openEditDialog}
|
||||
onClose={() => setOpenEditDialog(false)}
|
||||
onSave={createNewPlaylist}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2022 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 './EntityPlaylistDialog';
|
||||
@@ -0,0 +1,286 @@
|
||||
/*
|
||||
* Copyright 2022 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 { ApiProvider } from '@backstage/core-app-api';
|
||||
import {
|
||||
ConfigApi,
|
||||
configApiRef,
|
||||
IdentityApi,
|
||||
identityApiRef,
|
||||
storageApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { Playlist } from '@backstage/plugin-playlist-common';
|
||||
import { MockStorageApi, TestApiRegistry } from '@backstage/test-utils';
|
||||
import { fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { MockPlaylistListProvider } from '../../testUtils';
|
||||
import { PlaylistOwnerFilter } from '../PlaylistOwnerPicker';
|
||||
import { PersonalListPicker } from './PersonalListPicker';
|
||||
|
||||
const mockConfigApi = {
|
||||
getOptionalString: () => 'Test Company',
|
||||
} as Partial<ConfigApi>;
|
||||
|
||||
const mockIdentityApi = {
|
||||
getBackstageIdentity: async () => ({
|
||||
ownershipEntityRefs: ['user:default/owner', 'group:default/some-owner'],
|
||||
}),
|
||||
} as Partial<IdentityApi>;
|
||||
|
||||
const apis = TestApiRegistry.from(
|
||||
[configApiRef, mockConfigApi],
|
||||
[identityApiRef, mockIdentityApi],
|
||||
[storageApiRef, MockStorageApi.create()],
|
||||
);
|
||||
|
||||
const backendPlaylists: Playlist[] = [
|
||||
{
|
||||
id: 'id1',
|
||||
name: 'playlist-1',
|
||||
owner: 'group:default/some-owner',
|
||||
public: true,
|
||||
entities: 1,
|
||||
followers: 2,
|
||||
isFollowing: false,
|
||||
},
|
||||
{
|
||||
id: 'id2',
|
||||
name: 'playlist-2',
|
||||
owner: 'group:default/another-owner',
|
||||
public: true,
|
||||
entities: 2,
|
||||
followers: 1,
|
||||
isFollowing: true,
|
||||
},
|
||||
{
|
||||
id: 'id3',
|
||||
name: 'playlist-3',
|
||||
owner: 'group:default/another-owner',
|
||||
public: true,
|
||||
entities: 2,
|
||||
followers: 1,
|
||||
isFollowing: true,
|
||||
},
|
||||
{
|
||||
id: 'id4',
|
||||
name: 'playlist-4',
|
||||
owner: 'user:default/owner',
|
||||
public: true,
|
||||
entities: 2,
|
||||
followers: 1,
|
||||
isFollowing: true,
|
||||
},
|
||||
];
|
||||
|
||||
describe('<PersonalListPicker />', () => {
|
||||
const updateFilters = jest.fn() as any;
|
||||
|
||||
beforeEach(() => {
|
||||
updateFilters.mockClear();
|
||||
});
|
||||
|
||||
it('renders filter groups', async () => {
|
||||
const { queryByText } = render(
|
||||
<ApiProvider apis={apis}>
|
||||
<MockPlaylistListProvider value={{ backendPlaylists }}>
|
||||
<PersonalListPicker />
|
||||
</MockPlaylistListProvider>
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByText('Personal')).toBeInTheDocument();
|
||||
expect(queryByText('Test Company')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders filters', async () => {
|
||||
const { getAllByRole } = render(
|
||||
<ApiProvider apis={apis}>
|
||||
<MockPlaylistListProvider value={{ backendPlaylists }}>
|
||||
<PersonalListPicker />
|
||||
</MockPlaylistListProvider>
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
getAllByRole('menuitem').map(({ textContent }) => textContent),
|
||||
).toEqual(['Owned 2', 'Following 3', 'All 4']);
|
||||
});
|
||||
});
|
||||
|
||||
it('respects other frontend filters in counts', async () => {
|
||||
const { getAllByRole } = render(
|
||||
<ApiProvider apis={apis}>
|
||||
<MockPlaylistListProvider
|
||||
value={{
|
||||
backendPlaylists,
|
||||
filters: {
|
||||
owners: new PlaylistOwnerFilter(['group:default/another-owner']),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<PersonalListPicker />
|
||||
</MockPlaylistListProvider>
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
getAllByRole('menuitem').map(({ textContent }) => textContent),
|
||||
).toEqual(['Owned -', 'Following 2', 'All 2']);
|
||||
});
|
||||
});
|
||||
|
||||
it('respects the query parameter filter value', async () => {
|
||||
const queryParameters = { personal: 'owned' };
|
||||
render(
|
||||
<ApiProvider apis={apis}>
|
||||
<MockPlaylistListProvider
|
||||
value={{ backendPlaylists, updateFilters, queryParameters }}
|
||||
>
|
||||
<PersonalListPicker />
|
||||
</MockPlaylistListProvider>
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateFilters.mock.lastCall[0].personal.value).toBe('owned');
|
||||
});
|
||||
});
|
||||
|
||||
it('updates personal filter when a menuitem is selected', async () => {
|
||||
const { getByText } = render(
|
||||
<ApiProvider apis={apis}>
|
||||
<MockPlaylistListProvider value={{ backendPlaylists, updateFilters }}>
|
||||
<PersonalListPicker />
|
||||
</MockPlaylistListProvider>
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(getByText('Following'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateFilters.mock.lastCall[0].personal.value).toBe('following');
|
||||
});
|
||||
});
|
||||
|
||||
it('responds to external queryParameters changes', async () => {
|
||||
const rendered = render(
|
||||
<ApiProvider apis={apis}>
|
||||
<MockPlaylistListProvider
|
||||
value={{
|
||||
backendPlaylists,
|
||||
updateFilters,
|
||||
queryParameters: { personal: 'all' },
|
||||
}}
|
||||
>
|
||||
<PersonalListPicker />
|
||||
</MockPlaylistListProvider>
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateFilters.mock.lastCall[0].personal.value).toBe('all');
|
||||
});
|
||||
|
||||
rendered.rerender(
|
||||
<ApiProvider apis={apis}>
|
||||
<MockPlaylistListProvider
|
||||
value={{
|
||||
backendPlaylists,
|
||||
updateFilters,
|
||||
queryParameters: { personal: 'owned' },
|
||||
}}
|
||||
>
|
||||
<PersonalListPicker />
|
||||
</MockPlaylistListProvider>
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateFilters.mock.lastCall[0].personal.value).toBe('owned');
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(['owned', 'following'])(
|
||||
'filter resetting for %s playlists',
|
||||
type => {
|
||||
const picker = (props: { loading: boolean; mockData: any }) => (
|
||||
<ApiProvider apis={apis}>
|
||||
<MockPlaylistListProvider
|
||||
value={{
|
||||
backendPlaylists: backendPlaylists.map(p => ({
|
||||
...p,
|
||||
...props.mockData,
|
||||
})),
|
||||
queryParameters: { personal: type },
|
||||
updateFilters,
|
||||
loading: props.loading,
|
||||
}}
|
||||
>
|
||||
<PersonalListPicker />
|
||||
</MockPlaylistListProvider>
|
||||
</ApiProvider>
|
||||
);
|
||||
|
||||
describe(`when there are no ${type} playlists`, () => {
|
||||
const mockData =
|
||||
type === 'owned'
|
||||
? { owner: 'group:default/no-owner' }
|
||||
: { isFollowing: false };
|
||||
|
||||
it('does not reset the filter while playlists are loading', async () => {
|
||||
render(picker({ loading: true, mockData }));
|
||||
await waitFor(() => {
|
||||
expect(updateFilters.mock.lastCall[0].personal.value).not.toBe(
|
||||
'all',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('resets the filter to "all" when playlists are loaded', async () => {
|
||||
render(picker({ loading: false, mockData }));
|
||||
await waitFor(() => {
|
||||
expect(updateFilters.mock.lastCall[0].personal.value).toBe('all');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(`when there are some ${type} playlists present`, () => {
|
||||
const mockData = {};
|
||||
|
||||
it('does not reset the filter while playlists are loading', async () => {
|
||||
render(picker({ loading: true, mockData }));
|
||||
await waitFor(() => {
|
||||
expect(updateFilters.mock.lastCall[0].personal.value).not.toBe(
|
||||
'all',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not reset the filter when playlists are loaded', async () => {
|
||||
render(picker({ loading: false, mockData }));
|
||||
await waitFor(() => {
|
||||
expect(updateFilters.mock.lastCall[0].personal.value).toBe(type);
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,293 @@
|
||||
/*
|
||||
* Copyright 2022 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 {
|
||||
configApiRef,
|
||||
IconComponent,
|
||||
identityApiRef,
|
||||
useApi,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { Playlist } from '@backstage/plugin-playlist-common';
|
||||
import {
|
||||
Card,
|
||||
List,
|
||||
ListItemIcon,
|
||||
ListItemSecondaryAction,
|
||||
ListItemText,
|
||||
makeStyles,
|
||||
MenuItem,
|
||||
Theme,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import PlaylistPlayIcon from '@material-ui/icons/PlaylistPlay';
|
||||
import SettingsIcon from '@material-ui/icons/Settings';
|
||||
import { compact } from 'lodash';
|
||||
import React, { Fragment, useEffect, useMemo, useState } from 'react';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
|
||||
import { usePlaylistList } from '../../hooks';
|
||||
import { PlaylistFilter } from '../../types';
|
||||
|
||||
export const enum PersonalListFilterValue {
|
||||
owned = 'owned',
|
||||
following = 'following',
|
||||
all = 'all',
|
||||
}
|
||||
|
||||
export class PersonalListFilter implements PlaylistFilter {
|
||||
constructor(
|
||||
readonly value: PersonalListFilterValue,
|
||||
readonly isOwnedPlaylist: (playlist: Playlist) => boolean,
|
||||
) {}
|
||||
|
||||
filterPlaylist(playlist: Playlist): boolean {
|
||||
switch (this.value) {
|
||||
case PersonalListFilterValue.owned:
|
||||
return this.isOwnedPlaylist(playlist);
|
||||
case PersonalListFilterValue.following:
|
||||
return playlist.isFollowing;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
toQueryValue(): string {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
|
||||
const useStyles = makeStyles<Theme>(theme => ({
|
||||
root: {
|
||||
backgroundColor: 'rgba(0, 0, 0, .11)',
|
||||
boxShadow: 'none',
|
||||
margin: theme.spacing(1, 0, 1, 0),
|
||||
},
|
||||
title: {
|
||||
margin: theme.spacing(1, 0, 0, 1),
|
||||
textTransform: 'uppercase',
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
listIcon: {
|
||||
minWidth: 30,
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
menuItem: {
|
||||
minHeight: theme.spacing(6),
|
||||
},
|
||||
groupWrapper: {
|
||||
margin: theme.spacing(1, 1, 2, 1),
|
||||
},
|
||||
}));
|
||||
|
||||
type ButtonGroup = {
|
||||
name: string;
|
||||
items: {
|
||||
id: PersonalListFilterValue;
|
||||
label: string;
|
||||
icon?: IconComponent;
|
||||
}[];
|
||||
};
|
||||
|
||||
function getFilterGroups(orgName: string | undefined): ButtonGroup[] {
|
||||
return [
|
||||
{
|
||||
name: 'Personal',
|
||||
items: [
|
||||
{
|
||||
id: PersonalListFilterValue.owned,
|
||||
label: 'Owned',
|
||||
icon: SettingsIcon,
|
||||
},
|
||||
{
|
||||
id: PersonalListFilterValue.following,
|
||||
label: 'Following',
|
||||
icon: PlaylistPlayIcon,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: orgName ?? 'Company',
|
||||
items: [
|
||||
{
|
||||
id: PersonalListFilterValue.all,
|
||||
label: 'All',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const PersonalListPicker = () => {
|
||||
const classes = useStyles();
|
||||
const configApi = useApi(configApiRef);
|
||||
const identityApi = useApi(identityApiRef);
|
||||
const orgName = configApi.getOptionalString('organization.name') ?? 'Company';
|
||||
const filterGroups = getFilterGroups(orgName);
|
||||
|
||||
const { loading: loadingOwnership, value: ownershipRefs } =
|
||||
useAsync(async () => {
|
||||
const { ownershipEntityRefs } = await identityApi.getBackstageIdentity();
|
||||
return ownershipEntityRefs;
|
||||
}, []);
|
||||
|
||||
const isOwnedPlaylist = useMemo(() => {
|
||||
const myOwnerRefs = new Set(ownershipRefs ?? []);
|
||||
return (playlist: Playlist) => myOwnerRefs.has(playlist.owner);
|
||||
}, [ownershipRefs]);
|
||||
|
||||
const {
|
||||
filters,
|
||||
updateFilters,
|
||||
backendPlaylists,
|
||||
queryParameters: { personal: personalParameter },
|
||||
loading: loadingBackendPlaylists,
|
||||
} = usePlaylistList();
|
||||
|
||||
const loading = loadingBackendPlaylists || loadingOwnership;
|
||||
|
||||
// Static filters; used for generating counts of potentially unselected options
|
||||
const ownedFilter = useMemo(
|
||||
() =>
|
||||
new PersonalListFilter(PersonalListFilterValue.owned, isOwnedPlaylist),
|
||||
[isOwnedPlaylist],
|
||||
);
|
||||
const followingFilter = useMemo(
|
||||
() =>
|
||||
new PersonalListFilter(
|
||||
PersonalListFilterValue.following,
|
||||
isOwnedPlaylist,
|
||||
),
|
||||
[isOwnedPlaylist],
|
||||
);
|
||||
|
||||
const queryParamPersonalFilter = useMemo(
|
||||
() => [personalParameter].flat()[0],
|
||||
[personalParameter],
|
||||
);
|
||||
|
||||
const [selectedPersonalFilter, setSelectedPersonalFilter] = useState(
|
||||
queryParamPersonalFilter ?? PersonalListFilterValue.all,
|
||||
);
|
||||
|
||||
// To show proper counts for each section, apply all other frontend filters _except_ the personal
|
||||
// filter that's controlled by this picker.
|
||||
const playlistsWithoutPersonalFilter = useMemo(
|
||||
() =>
|
||||
backendPlaylists.filter(playlist =>
|
||||
compact(Object.values({ ...filters, personal: undefined })).every(
|
||||
(filter: PlaylistFilter) =>
|
||||
!filter.filterPlaylist || filter.filterPlaylist(playlist),
|
||||
),
|
||||
),
|
||||
[filters, backendPlaylists],
|
||||
);
|
||||
|
||||
const filterCounts = useMemo<Record<string, number>>(
|
||||
() => ({
|
||||
all: playlistsWithoutPersonalFilter.length,
|
||||
following: playlistsWithoutPersonalFilter.filter(playlist =>
|
||||
followingFilter.filterPlaylist(playlist),
|
||||
).length,
|
||||
owned: playlistsWithoutPersonalFilter.filter(playlist =>
|
||||
ownedFilter.filterPlaylist(playlist),
|
||||
).length,
|
||||
}),
|
||||
[playlistsWithoutPersonalFilter, followingFilter, ownedFilter],
|
||||
);
|
||||
|
||||
// Set selected personal filter on query parameter updates; this happens at initial page load and from
|
||||
// external updates to the page location.
|
||||
useEffect(() => {
|
||||
if (queryParamPersonalFilter) {
|
||||
setSelectedPersonalFilter(queryParamPersonalFilter);
|
||||
}
|
||||
}, [queryParamPersonalFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!loading &&
|
||||
!!selectedPersonalFilter &&
|
||||
selectedPersonalFilter !== PersonalListFilterValue.all &&
|
||||
filterCounts[selectedPersonalFilter] === 0
|
||||
) {
|
||||
setSelectedPersonalFilter(PersonalListFilterValue.all);
|
||||
}
|
||||
}, [
|
||||
loading,
|
||||
filterCounts,
|
||||
selectedPersonalFilter,
|
||||
setSelectedPersonalFilter,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
updateFilters({
|
||||
personal: selectedPersonalFilter
|
||||
? new PersonalListFilter(
|
||||
selectedPersonalFilter as PersonalListFilterValue,
|
||||
isOwnedPlaylist,
|
||||
)
|
||||
: undefined,
|
||||
});
|
||||
}, [selectedPersonalFilter, isOwnedPlaylist, updateFilters]);
|
||||
|
||||
return (
|
||||
<Card className={classes.root}>
|
||||
{filterGroups.map(group => (
|
||||
<Fragment key={group.name}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
component="span"
|
||||
className={classes.title}
|
||||
>
|
||||
{group.name}
|
||||
</Typography>
|
||||
<Card className={classes.groupWrapper}>
|
||||
<List disablePadding dense role="menu" aria-label={group.name}>
|
||||
{group.items.map(item => (
|
||||
<MenuItem
|
||||
role="none presentation"
|
||||
key={item.id}
|
||||
button
|
||||
divider
|
||||
onClick={() => setSelectedPersonalFilter(item.id)}
|
||||
selected={item.id === selectedPersonalFilter}
|
||||
className={classes.menuItem}
|
||||
disabled={filterCounts[item.id] === 0}
|
||||
data-testid={`personal-picker-${item.id}`}
|
||||
tabIndex={0}
|
||||
ContainerProps={{ role: 'menuitem' }}
|
||||
>
|
||||
{item.icon && (
|
||||
<ListItemIcon className={classes.listIcon}>
|
||||
<item.icon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
)}
|
||||
<ListItemText>
|
||||
<Typography variant="body1">{item.label} </Typography>
|
||||
</ListItemText>
|
||||
<ListItemSecondaryAction>
|
||||
{filterCounts[item.id] || '-'}
|
||||
</ListItemSecondaryAction>
|
||||
</MenuItem>
|
||||
))}
|
||||
</List>
|
||||
</Card>
|
||||
</Fragment>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2022 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 './PersonalListPicker';
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright 2022 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 { entityRouteRef } from '@backstage/plugin-catalog-react';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { lightTheme } from '@backstage/theme';
|
||||
import { ThemeProvider } from '@material-ui/core';
|
||||
import React from 'react';
|
||||
|
||||
import { rootRouteRef } from '../../routes';
|
||||
import { PlaylistCard } from './PlaylistCard';
|
||||
|
||||
describe('<PlaylistCard/>', () => {
|
||||
it('renders playlist info', async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<PlaylistCard
|
||||
playlist={{
|
||||
id: 'id1',
|
||||
name: 'playlist-1',
|
||||
description: 'test description',
|
||||
owner: 'group:default/some-owner',
|
||||
public: true,
|
||||
entities: 3,
|
||||
followers: 2,
|
||||
isFollowing: false,
|
||||
}}
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/playlists': rootRouteRef,
|
||||
'/catalog/:namespace/:kind/:name': entityRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(rendered.getByText('playlist-1')).toBeInTheDocument();
|
||||
expect(rendered.getByText('test description')).toBeInTheDocument();
|
||||
expect(rendered.getByText('some-owner')).toBeInTheDocument();
|
||||
expect(rendered.getByText('3 entities')).toBeInTheDocument();
|
||||
expect(rendered.getByText('2 followers')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* Copyright 2022 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 {
|
||||
Button,
|
||||
ItemCardHeader,
|
||||
MarkdownContent,
|
||||
} from '@backstage/core-components';
|
||||
import { useRouteRef } from '@backstage/core-plugin-api';
|
||||
import { EntityRefLinks } from '@backstage/plugin-catalog-react';
|
||||
import { Playlist } from '@backstage/plugin-playlist-common';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardActions,
|
||||
CardContent,
|
||||
CardMedia,
|
||||
Chip,
|
||||
makeStyles,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import LockIcon from '@material-ui/icons/Lock';
|
||||
import React from 'react';
|
||||
|
||||
import { playlistRouteRef } from '../../routes';
|
||||
|
||||
const useStyles = makeStyles((theme: BackstageTheme) => ({
|
||||
cardHeader: {
|
||||
position: 'relative',
|
||||
},
|
||||
title: {
|
||||
backgroundImage: theme.getPageTheme({ themeId: 'home' }).backgroundImage,
|
||||
},
|
||||
box: {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
'-webkit-line-clamp': 10,
|
||||
'-webkit-box-orient': 'vertical',
|
||||
paddingBottom: '0.8em',
|
||||
},
|
||||
label: {
|
||||
color: theme.palette.text.secondary,
|
||||
textTransform: 'uppercase',
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 'bold',
|
||||
letterSpacing: 0.5,
|
||||
lineHeight: 1,
|
||||
paddingBottom: '0.2rem',
|
||||
},
|
||||
chip: {
|
||||
marginRight: 'auto',
|
||||
},
|
||||
privateIcon: {
|
||||
position: 'absolute',
|
||||
top: theme.spacing(0.5),
|
||||
right: theme.spacing(0.5),
|
||||
padding: '0.25rem',
|
||||
},
|
||||
}));
|
||||
|
||||
export type PlaylistCardProps = {
|
||||
playlist: Playlist;
|
||||
};
|
||||
|
||||
export const PlaylistCard = ({ playlist }: PlaylistCardProps) => {
|
||||
const classes = useStyles();
|
||||
const playlistRoute = useRouteRef(playlistRouteRef);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardMedia className={classes.cardHeader}>
|
||||
{!playlist.public && (
|
||||
<Tooltip className={classes.privateIcon} title="Private">
|
||||
<LockIcon />
|
||||
</Tooltip>
|
||||
)}
|
||||
<ItemCardHeader title={playlist.name} classes={{ root: classes.title }}>
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={`${playlist.entities} entities`}
|
||||
/>
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={`${playlist.followers} followers`}
|
||||
/>
|
||||
</ItemCardHeader>
|
||||
</CardMedia>
|
||||
<CardContent style={{ display: 'grid' }}>
|
||||
<Box className={classes.box}>
|
||||
<Typography variant="body2" className={classes.label}>
|
||||
Description
|
||||
</Typography>
|
||||
<MarkdownContent content={playlist.description ?? ''} />
|
||||
</Box>
|
||||
<Box className={classes.box}>
|
||||
<Typography variant="body2" className={classes.label}>
|
||||
Owner
|
||||
</Typography>
|
||||
<EntityRefLinks entityRefs={[playlist.owner]} defaultKind="group" />
|
||||
</Box>
|
||||
</CardContent>
|
||||
<CardActions>
|
||||
<Button
|
||||
color="primary"
|
||||
aria-label={`Choose ${playlist.name}`}
|
||||
to={playlistRoute({ playlistId: playlist.id })}
|
||||
>
|
||||
Choose
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2022 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 './PlaylistCard';
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright 2022 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 { IdentityApi, identityApiRef } from '@backstage/core-plugin-api';
|
||||
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
import { fireEvent, getByRole, waitFor } from '@testing-library/react';
|
||||
import { act } from '@testing-library/react-hooks';
|
||||
import React from 'react';
|
||||
|
||||
import { PlaylistEditDialog } from './PlaylistEditDialog';
|
||||
|
||||
describe('<PlaylistEditDialog/>', () => {
|
||||
it('handle saving with an edited playlist', async () => {
|
||||
const identityApi: Partial<IdentityApi> = {
|
||||
getBackstageIdentity: async () => ({
|
||||
type: 'user',
|
||||
userEntityRef: 'user:default/me',
|
||||
ownershipEntityRefs: ['group:default/test-owner', 'user:default/me'],
|
||||
}),
|
||||
};
|
||||
|
||||
const mockOnSave = jest.fn().mockImplementation(async () => {});
|
||||
const rendered = await renderInTestApp(
|
||||
<TestApiProvider apis={[[identityApiRef, identityApi]]}>
|
||||
<PlaylistEditDialog open onClose={jest.fn()} onSave={mockOnSave} />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
fireEvent.input(
|
||||
getByRole(rendered.getByTestId('edit-dialog-name-input'), 'textbox'),
|
||||
{
|
||||
target: {
|
||||
value: 'test playlist',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
fireEvent.input(
|
||||
getByRole(
|
||||
rendered.getByTestId('edit-dialog-description-input'),
|
||||
'textbox',
|
||||
),
|
||||
{
|
||||
target: {
|
||||
value: 'test description',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
fireEvent.mouseDown(
|
||||
getByRole(rendered.getByTestId('edit-dialog-owner-select'), 'button'),
|
||||
);
|
||||
|
||||
fireEvent.click(rendered.getByText('test-owner'));
|
||||
|
||||
fireEvent.click(
|
||||
getByRole(rendered.getByTestId('edit-dialog-public-option'), 'radio'),
|
||||
);
|
||||
|
||||
fireEvent.click(rendered.getByTestId('edit-dialog-save-button'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSave).toHaveBeenCalledWith({
|
||||
name: 'test playlist',
|
||||
description: 'test description',
|
||||
owner: 'group:default/test-owner',
|
||||
public: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* Copyright 2022 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 { parseEntityRef } from '@backstage/catalog-model';
|
||||
import { identityApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import { humanizeEntityRef } from '@backstage/plugin-catalog-react';
|
||||
import { PlaylistMetadata } from '@backstage/plugin-playlist-common';
|
||||
import {
|
||||
Button,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
InputLabel,
|
||||
makeStyles,
|
||||
MenuItem,
|
||||
LinearProgress,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Select,
|
||||
TextField,
|
||||
} from '@material-ui/core';
|
||||
import React from 'react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
import useAsyncFn from 'react-use/lib/useAsyncFn';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
buttonWrapper: {
|
||||
position: 'relative',
|
||||
},
|
||||
buttonProgress: {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
marginTop: -12,
|
||||
marginLeft: -12,
|
||||
},
|
||||
});
|
||||
|
||||
export type PlaylistEditDialogProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (playlist: Omit<PlaylistMetadata, 'id'>) => Promise<void>;
|
||||
playlist?: Omit<PlaylistMetadata, 'id'>;
|
||||
};
|
||||
|
||||
export const PlaylistEditDialog = ({
|
||||
open,
|
||||
onClose,
|
||||
onSave,
|
||||
playlist = {
|
||||
name: '',
|
||||
description: '',
|
||||
owner: '',
|
||||
public: false,
|
||||
},
|
||||
}: PlaylistEditDialogProps) => {
|
||||
const classes = useStyles();
|
||||
const identityApi = useApi(identityApiRef);
|
||||
|
||||
const { loading: loadingOwnership, value: ownershipRefs } =
|
||||
useAsync(async () => {
|
||||
const { ownershipEntityRefs } = await identityApi.getBackstageIdentity();
|
||||
return ownershipEntityRefs;
|
||||
}, []);
|
||||
|
||||
const defaultValues = {
|
||||
...playlist,
|
||||
public: playlist.public.toString(),
|
||||
};
|
||||
|
||||
const {
|
||||
control,
|
||||
formState: { errors },
|
||||
handleSubmit,
|
||||
reset,
|
||||
} = useForm({ defaultValues });
|
||||
|
||||
const [saving, savePlaylist] = useAsyncFn(
|
||||
formValues =>
|
||||
onSave({ ...formValues, public: JSON.parse(formValues.public) }),
|
||||
[onSave],
|
||||
);
|
||||
|
||||
const closeDialog = () => {
|
||||
if (!saving.loading) {
|
||||
onClose();
|
||||
reset(defaultValues);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog fullWidth maxWidth="xs" onClose={closeDialog} open={open}>
|
||||
<DialogContent>
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
rules={{ required: true }}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
disabled={saving.loading}
|
||||
data-testid="edit-dialog-name-input"
|
||||
error={!!errors.name}
|
||||
fullWidth
|
||||
label="Name"
|
||||
margin="dense"
|
||||
placeholder="Give your playlist name"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
disabled={saving.loading}
|
||||
data-testid="edit-dialog-description-input"
|
||||
fullWidth
|
||||
label="Description"
|
||||
margin="dense"
|
||||
multiline
|
||||
placeholder="Describe your playlist"
|
||||
type="text"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{loadingOwnership ? (
|
||||
<LinearProgress />
|
||||
) : (
|
||||
<Controller
|
||||
name="owner"
|
||||
control={control}
|
||||
rules={{ required: true }}
|
||||
render={({ field }) => (
|
||||
<FormControl
|
||||
disabled={saving.loading}
|
||||
error={!!errors.owner}
|
||||
fullWidth
|
||||
required
|
||||
margin="dense"
|
||||
>
|
||||
<InputLabel>Owner</InputLabel>
|
||||
<Select {...field} data-testid="edit-dialog-owner-select">
|
||||
{ownershipRefs?.map(ref => (
|
||||
<MenuItem key={ref} value={ref}>
|
||||
{humanizeEntityRef(parseEntityRef(ref), {
|
||||
defaultKind: 'group',
|
||||
})}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Controller
|
||||
name="public"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControl disabled={saving.loading} margin="dense">
|
||||
<RadioGroup {...field} row>
|
||||
<FormControlLabel
|
||||
value="false"
|
||||
label="Private"
|
||||
control={<Radio data-testid="edit-dialog-private-option" />}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="true"
|
||||
label="Public"
|
||||
control={<Radio data-testid="edit-dialog-public-option" />}
|
||||
/>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button color="primary" disabled={saving.loading} onClick={closeDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
<div className={classes.buttonWrapper}>
|
||||
<Button
|
||||
color="primary"
|
||||
disabled={saving.loading}
|
||||
onClick={handleSubmit(savePlaylist)}
|
||||
data-testid="edit-dialog-save-button"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
{saving.loading && (
|
||||
<CircularProgress size={24} className={classes.buttonProgress} />
|
||||
)}
|
||||
</div>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2022 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 './PlaylistEditDialog';
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright 2022 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 { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
import { AuthorizeResult } from '@backstage/plugin-permission-common';
|
||||
import {
|
||||
PermissionApi,
|
||||
permissionApiRef,
|
||||
} from '@backstage/plugin-permission-react';
|
||||
import React from 'react';
|
||||
|
||||
import { PlaylistApi, playlistApiRef } from '../../api';
|
||||
import { rootRouteRef } from '../../routes';
|
||||
import { PlaylistIndexPage } from './PlaylistIndexPage';
|
||||
|
||||
const playlistApi: Partial<PlaylistApi> = {
|
||||
getAllPlaylists: async () => [],
|
||||
};
|
||||
|
||||
const permissionApi: Partial<PermissionApi> = {
|
||||
authorize: async () => ({ result: AuthorizeResult.ALLOW }),
|
||||
};
|
||||
|
||||
describe('PlaylistIndexPage', () => {
|
||||
it('should render', async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[permissionApiRef, permissionApi],
|
||||
[playlistApiRef, playlistApi],
|
||||
]}
|
||||
>
|
||||
<PlaylistIndexPage />
|
||||
</TestApiProvider>,
|
||||
{ mountedRoutes: { '/playlists': rootRouteRef } },
|
||||
);
|
||||
expect(rendered.getByText('Playlists')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright 2022 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 {
|
||||
PageWithHeader,
|
||||
Content,
|
||||
ContentHeader,
|
||||
SupportButton,
|
||||
} from '@backstage/core-components';
|
||||
import { CatalogFilterLayout } from '@backstage/plugin-catalog-react';
|
||||
|
||||
import { PlaylistListProvider } from '../../hooks';
|
||||
import { CreatePlaylistButton } from '../CreatePlaylistButton';
|
||||
import { PersonalListPicker } from '../PersonalListPicker';
|
||||
import { PlaylistList } from '../PlaylistList';
|
||||
import { PlaylistOwnerPicker } from '../PlaylistOwnerPicker';
|
||||
import { PlaylistSearchBar } from '../PlaylistSearchBar';
|
||||
import { PlaylistSortPicker } from '../PlaylistSortPicker';
|
||||
|
||||
export const PlaylistIndexPage = () => (
|
||||
<PageWithHeader themeId="home" title="Playlists">
|
||||
<PlaylistListProvider>
|
||||
<Content>
|
||||
<ContentHeader title="">
|
||||
<PlaylistSortPicker />
|
||||
<CreatePlaylistButton />
|
||||
<SupportButton />
|
||||
</ContentHeader>
|
||||
<CatalogFilterLayout>
|
||||
<CatalogFilterLayout.Filters>
|
||||
<PlaylistSearchBar />
|
||||
<PersonalListPicker />
|
||||
<PlaylistOwnerPicker />
|
||||
</CatalogFilterLayout.Filters>
|
||||
<CatalogFilterLayout.Content>
|
||||
<PlaylistList />
|
||||
</CatalogFilterLayout.Content>
|
||||
</CatalogFilterLayout>
|
||||
</Content>
|
||||
</PlaylistListProvider>
|
||||
</PageWithHeader>
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2022 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 { PlaylistIndexPage } from './PlaylistIndexPage';
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright 2022 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 { Playlist } from '@backstage/plugin-playlist-common';
|
||||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { MockPlaylistListProvider } from '../../testUtils';
|
||||
import { PlaylistList } from './PlaylistList';
|
||||
|
||||
jest.mock('../PlaylistCard', () => ({
|
||||
PlaylistCard: ({ playlist }: { playlist: Playlist }) => (
|
||||
<div>{playlist.name}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('<PlaylistList/>', () => {
|
||||
it('renders error on error', () => {
|
||||
const rendered = render(
|
||||
<MockPlaylistListProvider value={{ error: new Error('Test Error') }}>
|
||||
<PlaylistList />
|
||||
</MockPlaylistListProvider>,
|
||||
);
|
||||
|
||||
expect(rendered.getByText('Test Error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles no playlists', () => {
|
||||
const rendered = render(
|
||||
<MockPlaylistListProvider value={{ playlists: [] }}>
|
||||
<PlaylistList />
|
||||
</MockPlaylistListProvider>,
|
||||
);
|
||||
|
||||
expect(
|
||||
rendered.getByText('No playlists found that match your filter.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders playlists', () => {
|
||||
const rendered = render(
|
||||
<MockPlaylistListProvider
|
||||
value={{
|
||||
playlists: [
|
||||
{
|
||||
id: 'id1',
|
||||
name: 'playlist-1',
|
||||
owner: 'group:default/some-owner',
|
||||
public: true,
|
||||
entities: 1,
|
||||
followers: 2,
|
||||
isFollowing: false,
|
||||
},
|
||||
{
|
||||
id: 'id2',
|
||||
name: 'playlist-2',
|
||||
owner: 'group:default/another-owner',
|
||||
public: true,
|
||||
entities: 2,
|
||||
followers: 1,
|
||||
isFollowing: true,
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<PlaylistList />
|
||||
</MockPlaylistListProvider>,
|
||||
);
|
||||
|
||||
expect(rendered.getByText('playlist-1')).toBeInTheDocument();
|
||||
expect(rendered.getByText('playlist-2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright 2022 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 {
|
||||
Content,
|
||||
ItemCardGrid,
|
||||
Progress,
|
||||
WarningPanel,
|
||||
} from '@backstage/core-components';
|
||||
import { Typography } from '@material-ui/core';
|
||||
|
||||
import { usePlaylistList } from '../../hooks';
|
||||
import { PlaylistCard } from '../PlaylistCard';
|
||||
|
||||
export const PlaylistList = () => {
|
||||
const { loading, error, playlists } = usePlaylistList();
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading && <Progress />}
|
||||
|
||||
{error && (
|
||||
<WarningPanel title="Oops! Something went wrong loading playlists">
|
||||
{error.message}
|
||||
</WarningPanel>
|
||||
)}
|
||||
|
||||
{!error && !loading && !playlists.length && (
|
||||
<Typography variant="body2">
|
||||
No playlists found that match your filter.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Content>
|
||||
<ItemCardGrid>
|
||||
{playlists?.length > 0 &&
|
||||
playlists.map(playlist => (
|
||||
<PlaylistCard key={playlist.id} playlist={playlist} />
|
||||
))}
|
||||
</ItemCardGrid>
|
||||
</Content>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2022 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 './PlaylistList';
|
||||
@@ -0,0 +1,215 @@
|
||||
/*
|
||||
* Copyright 2022 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 { parseEntityRef } from '@backstage/catalog-model';
|
||||
import { humanizeEntityRef } from '@backstage/plugin-catalog-react';
|
||||
import { Playlist } from '@backstage/plugin-playlist-common';
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MockPlaylistListProvider } from '../../testUtils';
|
||||
import {
|
||||
PlaylistOwnerFilter,
|
||||
PlaylistOwnerPicker,
|
||||
} from './PlaylistOwnerPicker';
|
||||
|
||||
const samplePlaylists: Playlist[] = [
|
||||
{
|
||||
id: 'id1',
|
||||
name: 'playlist-1',
|
||||
owner: 'group:default/some-owner',
|
||||
public: true,
|
||||
entities: 1,
|
||||
followers: 2,
|
||||
isFollowing: false,
|
||||
},
|
||||
{
|
||||
id: 'id2',
|
||||
name: 'playlist-2',
|
||||
owner: 'group:default/another-owner',
|
||||
public: true,
|
||||
entities: 2,
|
||||
followers: 1,
|
||||
isFollowing: true,
|
||||
},
|
||||
{
|
||||
id: 'id3',
|
||||
name: 'playlist-3',
|
||||
owner: 'group:default/another-owner',
|
||||
public: true,
|
||||
entities: 2,
|
||||
followers: 1,
|
||||
isFollowing: true,
|
||||
},
|
||||
{
|
||||
id: 'id4',
|
||||
name: 'playlist-4',
|
||||
owner: 'user:default/owner',
|
||||
public: true,
|
||||
entities: 2,
|
||||
followers: 1,
|
||||
isFollowing: true,
|
||||
},
|
||||
];
|
||||
|
||||
describe('<PlaylistOwnerPicker/>', () => {
|
||||
it('renders all owners', () => {
|
||||
const rendered = render(
|
||||
<MockPlaylistListProvider
|
||||
value={{
|
||||
playlists: samplePlaylists,
|
||||
backendPlaylists: samplePlaylists,
|
||||
}}
|
||||
>
|
||||
<PlaylistOwnerPicker />
|
||||
</MockPlaylistListProvider>,
|
||||
);
|
||||
expect(rendered.getByText('Owner')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(rendered.getByTestId('owner-picker-expand'));
|
||||
samplePlaylists
|
||||
.map(p =>
|
||||
humanizeEntityRef(parseEntityRef(p.owner), { defaultKind: 'group' }),
|
||||
)
|
||||
.forEach(owner => {
|
||||
expect(rendered.getByText(owner as string)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders unique owners in alphabetical order', () => {
|
||||
const rendered = render(
|
||||
<MockPlaylistListProvider
|
||||
value={{
|
||||
playlists: samplePlaylists,
|
||||
backendPlaylists: samplePlaylists,
|
||||
}}
|
||||
>
|
||||
<PlaylistOwnerPicker />
|
||||
</MockPlaylistListProvider>,
|
||||
);
|
||||
expect(rendered.getByText('Owner')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(rendered.getByTestId('owner-picker-expand'));
|
||||
|
||||
expect(rendered.getAllByRole('option').map(o => o.textContent)).toEqual([
|
||||
'another-owner',
|
||||
'some-owner',
|
||||
'user:owner',
|
||||
]);
|
||||
});
|
||||
|
||||
it('respects the query parameter filter value', () => {
|
||||
const updateFilters = jest.fn();
|
||||
const queryParameters = { owners: ['group:default/another-owner'] };
|
||||
render(
|
||||
<MockPlaylistListProvider
|
||||
value={{
|
||||
updateFilters,
|
||||
queryParameters,
|
||||
playlists: samplePlaylists,
|
||||
backendPlaylists: samplePlaylists,
|
||||
}}
|
||||
>
|
||||
<PlaylistOwnerPicker />
|
||||
</MockPlaylistListProvider>,
|
||||
);
|
||||
|
||||
expect(updateFilters).toHaveBeenLastCalledWith({
|
||||
owners: new PlaylistOwnerFilter(['group:default/another-owner']),
|
||||
});
|
||||
});
|
||||
|
||||
it('adds owners to filters', () => {
|
||||
const updateFilters = jest.fn();
|
||||
const rendered = render(
|
||||
<MockPlaylistListProvider
|
||||
value={{
|
||||
playlists: samplePlaylists,
|
||||
backendPlaylists: samplePlaylists,
|
||||
updateFilters,
|
||||
}}
|
||||
>
|
||||
<PlaylistOwnerPicker />
|
||||
</MockPlaylistListProvider>,
|
||||
);
|
||||
expect(updateFilters).toHaveBeenLastCalledWith({
|
||||
owners: undefined,
|
||||
});
|
||||
|
||||
fireEvent.click(rendered.getByTestId('owner-picker-expand'));
|
||||
fireEvent.click(rendered.getByText('some-owner'));
|
||||
expect(updateFilters).toHaveBeenLastCalledWith({
|
||||
owners: new PlaylistOwnerFilter(['group:default/some-owner']),
|
||||
});
|
||||
});
|
||||
|
||||
it('removes owners from filters', () => {
|
||||
const updateFilters = jest.fn();
|
||||
const rendered = render(
|
||||
<MockPlaylistListProvider
|
||||
value={{
|
||||
playlists: samplePlaylists,
|
||||
backendPlaylists: samplePlaylists,
|
||||
updateFilters,
|
||||
filters: {
|
||||
owners: new PlaylistOwnerFilter(['group:default/some-owner']),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<PlaylistOwnerPicker />
|
||||
</MockPlaylistListProvider>,
|
||||
);
|
||||
expect(updateFilters).toHaveBeenLastCalledWith({
|
||||
owners: new PlaylistOwnerFilter(['group:default/some-owner']),
|
||||
});
|
||||
fireEvent.click(rendered.getByTestId('owner-picker-expand'));
|
||||
expect(rendered.getByLabelText('some-owner')).toBeChecked();
|
||||
|
||||
fireEvent.click(rendered.getByLabelText('some-owner'));
|
||||
expect(updateFilters).toHaveBeenLastCalledWith({
|
||||
owner: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('responds to external queryParameters changes', () => {
|
||||
const updateFilters = jest.fn();
|
||||
const rendered = render(
|
||||
<MockPlaylistListProvider
|
||||
value={{
|
||||
updateFilters,
|
||||
queryParameters: { owners: ['group:default/team-a'] },
|
||||
}}
|
||||
>
|
||||
<PlaylistOwnerPicker />
|
||||
</MockPlaylistListProvider>,
|
||||
);
|
||||
expect(updateFilters).toHaveBeenLastCalledWith({
|
||||
owners: new PlaylistOwnerFilter(['group:default/team-a']),
|
||||
});
|
||||
rendered.rerender(
|
||||
<MockPlaylistListProvider
|
||||
value={{
|
||||
updateFilters,
|
||||
queryParameters: { owners: ['group:default/team-b'] },
|
||||
}}
|
||||
>
|
||||
<PlaylistOwnerPicker />
|
||||
</MockPlaylistListProvider>,
|
||||
);
|
||||
expect(updateFilters).toHaveBeenLastCalledWith({
|
||||
owners: new PlaylistOwnerFilter(['group:default/team-b']),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* Copyright 2022 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 { parseEntityRef } from '@backstage/catalog-model';
|
||||
import { humanizeEntityRef } from '@backstage/plugin-catalog-react';
|
||||
import { Playlist } from '@backstage/plugin-playlist-common';
|
||||
import {
|
||||
Box,
|
||||
Checkbox,
|
||||
Chip,
|
||||
FormControlLabel,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import CheckBoxIcon from '@material-ui/icons/CheckBox';
|
||||
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank';
|
||||
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
|
||||
import { Autocomplete } from '@material-ui/lab';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { usePlaylistList } from '../../hooks';
|
||||
import { PlaylistFilter } from '../../types';
|
||||
|
||||
export class PlaylistOwnerFilter implements PlaylistFilter {
|
||||
constructor(readonly values: string[]) {}
|
||||
|
||||
filterPlaylist(playlist: Playlist): boolean {
|
||||
return this.values.some(v => playlist.owner === v);
|
||||
}
|
||||
|
||||
toQueryValue(): string[] {
|
||||
return this.values;
|
||||
}
|
||||
}
|
||||
|
||||
const icon = <CheckBoxOutlineBlankIcon fontSize="small" />;
|
||||
const checkedIcon = <CheckBoxIcon fontSize="small" />;
|
||||
|
||||
export const PlaylistOwnerPicker = () => {
|
||||
const {
|
||||
updateFilters,
|
||||
backendPlaylists,
|
||||
filters,
|
||||
queryParameters: { owners: ownersParameter },
|
||||
} = usePlaylistList();
|
||||
|
||||
const queryParamOwners = useMemo(
|
||||
() => [ownersParameter].flat().filter(Boolean) as string[],
|
||||
[ownersParameter],
|
||||
);
|
||||
|
||||
const [selectedOwners, setSelectedOwners] = useState(
|
||||
queryParamOwners.length ? queryParamOwners : filters.owners?.values ?? [],
|
||||
);
|
||||
|
||||
// Set selected owners on query parameter updates; this happens at initial page load and from
|
||||
// external updates to the page location.
|
||||
useEffect(() => {
|
||||
if (queryParamOwners.length) {
|
||||
setSelectedOwners(queryParamOwners);
|
||||
}
|
||||
}, [queryParamOwners]);
|
||||
|
||||
useEffect(() => {
|
||||
updateFilters({
|
||||
owners: selectedOwners.length
|
||||
? new PlaylistOwnerFilter(selectedOwners)
|
||||
: undefined,
|
||||
});
|
||||
}, [selectedOwners, updateFilters]);
|
||||
|
||||
const availableOwners = useMemo(
|
||||
() => [...new Set(backendPlaylists.map(p => p.owner))].sort(),
|
||||
[backendPlaylists],
|
||||
);
|
||||
|
||||
if (!availableOwners.length) return null;
|
||||
|
||||
return (
|
||||
<Box pb={1} pt={1}>
|
||||
<Typography variant="button" component="label">
|
||||
Owner
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={availableOwners}
|
||||
value={selectedOwners}
|
||||
onChange={(_: object, value: string[]) => setSelectedOwners(value)}
|
||||
renderTags={(values: string[], getTagProps: Function) =>
|
||||
values.map((val, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
size="small"
|
||||
label={humanizeEntityRef(parseEntityRef(val), {
|
||||
defaultKind: 'group',
|
||||
})}
|
||||
{...getTagProps({ index })}
|
||||
/>
|
||||
))
|
||||
}
|
||||
renderOption={(option, { selected }) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
icon={icon}
|
||||
checkedIcon={checkedIcon}
|
||||
checked={selected}
|
||||
/>
|
||||
}
|
||||
label={humanizeEntityRef(parseEntityRef(option), {
|
||||
defaultKind: 'group',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
size="small"
|
||||
popupIcon={<ExpandMoreIcon data-testid="owner-picker-expand" />}
|
||||
renderInput={params => <TextField {...params} variant="outlined" />}
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2022 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 './PlaylistOwnerPicker';
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* Copyright 2022 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 {
|
||||
CatalogApi,
|
||||
catalogApiRef,
|
||||
entityRouteRef,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { SearchApi, searchApiRef } from '@backstage/plugin-search-react';
|
||||
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
import { fireEvent, getByText } from '@testing-library/react';
|
||||
import { act } from '@testing-library/react-hooks';
|
||||
import React from 'react';
|
||||
|
||||
import { AddEntitiesDrawer } from './AddEntitiesDrawer';
|
||||
|
||||
describe('AddEntitiesDrawer', () => {
|
||||
const catalogApi: Partial<CatalogApi> = {
|
||||
getEntityFacets: jest.fn().mockImplementation(async () => ({
|
||||
facets: {
|
||||
kind: [{ value: 'component' }],
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
const mockOnAdd = jest.fn();
|
||||
const sampleCurrentEntities = [
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'system',
|
||||
metadata: { namespace: 'default', name: 'test-ent' },
|
||||
},
|
||||
];
|
||||
|
||||
const mockSearchResults = [
|
||||
{
|
||||
type: 'software-catalog',
|
||||
document: {
|
||||
title: 'Test Ent',
|
||||
text: 'This is test ent',
|
||||
location: '/catalog/default/system/test-ent',
|
||||
kind: 'system',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'software-catalog',
|
||||
document: {
|
||||
title: 'Test Ent 2',
|
||||
text: 'This is test ent 2',
|
||||
location: '/catalog/foo/component/test-ent2',
|
||||
kind: 'component',
|
||||
type: 'library',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'software-catalog',
|
||||
document: {
|
||||
title: 'Test Ent 3',
|
||||
text: 'This is test ent 3',
|
||||
location: '/catalog/bar/api/test-ent3',
|
||||
kind: 'api',
|
||||
type: 'openapi',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const searchApi: Partial<SearchApi> = {
|
||||
query: jest
|
||||
.fn()
|
||||
.mockImplementation(async () => ({ results: mockSearchResults })),
|
||||
};
|
||||
|
||||
const element = (
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[catalogApiRef, catalogApi],
|
||||
[searchApiRef, searchApi],
|
||||
]}
|
||||
>
|
||||
<AddEntitiesDrawer
|
||||
open
|
||||
onAdd={mockOnAdd}
|
||||
onClose={jest.fn()}
|
||||
currentEntities={sampleCurrentEntities}
|
||||
/>
|
||||
</TestApiProvider>
|
||||
);
|
||||
|
||||
const render = async () =>
|
||||
renderInTestApp(element, {
|
||||
mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef },
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnAdd.mockClear();
|
||||
});
|
||||
|
||||
it('should render available entities correctly', async () => {
|
||||
const rendered = await render();
|
||||
expect(searchApi.query).toHaveBeenLastCalledWith({
|
||||
filters: {},
|
||||
term: '',
|
||||
types: ['software-catalog'],
|
||||
});
|
||||
|
||||
expect(rendered.getByText('Test Ent')).toBeInTheDocument();
|
||||
expect(rendered.getByText('This is test ent')).toBeInTheDocument();
|
||||
expect(rendered.getByText('Kind: system')).toBeInTheDocument();
|
||||
|
||||
expect(rendered.getByText('Test Ent 2')).toBeInTheDocument();
|
||||
expect(rendered.getByText('This is test ent 2')).toBeInTheDocument();
|
||||
expect(rendered.getByText('Kind: component')).toBeInTheDocument();
|
||||
expect(rendered.getByText('Type: library')).toBeInTheDocument();
|
||||
|
||||
expect(rendered.getByText('Test Ent 3')).toBeInTheDocument();
|
||||
expect(rendered.getByText('This is test ent 3')).toBeInTheDocument();
|
||||
expect(rendered.getByText('Kind: api')).toBeInTheDocument();
|
||||
expect(rendered.getByText('Type: openapi')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should disable options that are already added', async () => {
|
||||
const rendered = await render();
|
||||
|
||||
const addButtons = rendered.getAllByTestId('entity-drawer-add-button');
|
||||
expect(addButtons.length).toEqual(3);
|
||||
|
||||
expect(getByText(addButtons[0], 'Added')).toBeInTheDocument();
|
||||
expect(addButtons[0]).toBeDisabled();
|
||||
|
||||
expect(getByText(addButtons[1], 'Add')).toBeInTheDocument();
|
||||
expect(addButtons[1]).not.toBeDisabled();
|
||||
|
||||
expect(getByText(addButtons[2], 'Add')).toBeInTheDocument();
|
||||
expect(addButtons[2]).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('should add entities correctly', async () => {
|
||||
const rendered = await render();
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(rendered.getAllByTestId('entity-drawer-add-button')[1]);
|
||||
});
|
||||
|
||||
expect(mockOnAdd).toHaveBeenCalledWith('component:foo/test-ent2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
/*
|
||||
* Copyright 2022 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 {
|
||||
Entity,
|
||||
getCompoundEntityRef,
|
||||
stringifyEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import { useApi, useRouteRef } from '@backstage/core-plugin-api';
|
||||
import { CatalogEntityDocument } from '@backstage/plugin-catalog-common';
|
||||
import { catalogApiRef, entityRouteRef } from '@backstage/plugin-catalog-react';
|
||||
import {
|
||||
SearchBar,
|
||||
SearchContextProvider,
|
||||
SearchFilter,
|
||||
SearchResult,
|
||||
SearchResultPager,
|
||||
useSearch,
|
||||
} from '@backstage/plugin-search-react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
createStyles,
|
||||
Divider,
|
||||
Drawer,
|
||||
Grid,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemSecondaryAction,
|
||||
ListItemText,
|
||||
makeStyles,
|
||||
Paper,
|
||||
Theme,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
paper: {
|
||||
width: '50%',
|
||||
padding: theme.spacing(2.5),
|
||||
},
|
||||
searchBarContainer: {
|
||||
borderRadius: 30,
|
||||
display: 'flex',
|
||||
height: '2.4em',
|
||||
},
|
||||
gridContainer: {
|
||||
height: '100%',
|
||||
},
|
||||
searchResults: {
|
||||
overflow: 'auto',
|
||||
},
|
||||
itemContainer: {
|
||||
flexWrap: 'wrap',
|
||||
paddingRight: '75px',
|
||||
},
|
||||
itemText: {
|
||||
width: '100%',
|
||||
wordBreak: 'break-word',
|
||||
marginBottom: '1rem',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const RestrictCatalogIndexResults = () => {
|
||||
const { setTypes } = useSearch();
|
||||
useEffect(() => setTypes(['software-catalog']), [setTypes]);
|
||||
return null;
|
||||
};
|
||||
|
||||
export type AddEntitiesDrawerProps = {
|
||||
currentEntities: Entity[];
|
||||
open: boolean;
|
||||
onAdd: (entityRef: string) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const AddEntitiesDrawer = ({
|
||||
currentEntities,
|
||||
open,
|
||||
onAdd,
|
||||
onClose,
|
||||
}: AddEntitiesDrawerProps) => {
|
||||
const classes = useStyles();
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const entityRoute = useRouteRef(entityRouteRef);
|
||||
const entityLocationRegex = useMemo(() => {
|
||||
const forwardSlashRegex = new RegExp('/', 'g');
|
||||
const locationRegex = entityRoute({
|
||||
namespace: '(?<namespace>.+?)',
|
||||
kind: '(?<kind>.+?)',
|
||||
name: '(?<name>.+?)',
|
||||
}).replace(forwardSlashRegex, '\\/');
|
||||
|
||||
return new RegExp(`${locationRegex}$`);
|
||||
}, [entityRoute]);
|
||||
|
||||
const currentEntityLocations = useMemo(
|
||||
() =>
|
||||
currentEntities.map(entity =>
|
||||
entityRoute(getCompoundEntityRef(entity)).toLocaleLowerCase('en-US'),
|
||||
),
|
||||
[currentEntities, entityRoute],
|
||||
);
|
||||
|
||||
const getEntityKinds = async () => {
|
||||
return (
|
||||
await catalogApi.getEntityFacets({ facets: ['kind'] })
|
||||
).facets.kind.map(f => f.value);
|
||||
};
|
||||
|
||||
const addEntity = useCallback(
|
||||
entityResult => {
|
||||
// TODO (kuangp): this parsing of the location is not great. Ideally `CatalogEntityDocument`
|
||||
// contains the `metadata.name` field so we can derive the full ref and we only fall back to
|
||||
// parsing location if it's missing (ie. for older versions)
|
||||
const { groups } = entityResult.location.match(entityLocationRegex);
|
||||
if (groups) {
|
||||
onAdd(stringifyEntityRef(groups));
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`Failed to parse entity ref from entity location: ${entityResult.location}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
[entityLocationRegex, onAdd],
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
classes={{
|
||||
paper: classes.paper,
|
||||
}}
|
||||
anchor="right"
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
>
|
||||
<SearchContextProvider>
|
||||
<RestrictCatalogIndexResults />
|
||||
<Grid container direction="column" className={classes.gridContainer}>
|
||||
<Grid item>
|
||||
<Typography variant="h5">
|
||||
Let's find something for your playlist
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Paper className={classes.searchBarContainer}>
|
||||
<SearchBar />
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<SearchFilter.Select
|
||||
label="Kind"
|
||||
name="kind"
|
||||
values={getEntityKinds}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs className={classes.searchResults}>
|
||||
<SearchResult>
|
||||
{({ results }) => (
|
||||
<List>
|
||||
{results.map(({ document }) => (
|
||||
<React.Fragment key={document.location}>
|
||||
<ListItem alignItems="flex-start">
|
||||
<div className={classes.itemContainer}>
|
||||
<ListItemText
|
||||
className={classes.itemText}
|
||||
primaryTypographyProps={{ variant: 'h6' }}
|
||||
primary={document.title}
|
||||
secondary={document.text}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Button
|
||||
color="primary"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
data-testid="entity-drawer-add-button"
|
||||
disabled={currentEntityLocations.includes(
|
||||
document.location.toLocaleLowerCase('en-US'),
|
||||
)}
|
||||
onClick={() => addEntity(document)}
|
||||
>
|
||||
{currentEntityLocations.includes(
|
||||
document.location.toLocaleLowerCase('en-US'),
|
||||
)
|
||||
? 'Added'
|
||||
: 'Add'}
|
||||
</Button>
|
||||
</ListItemSecondaryAction>
|
||||
<Box>
|
||||
{(document as CatalogEntityDocument).kind && (
|
||||
<Chip
|
||||
label={`Kind: ${
|
||||
(document as CatalogEntityDocument).kind
|
||||
}`}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
{(document as CatalogEntityDocument).type && (
|
||||
<Chip
|
||||
label={`Type: ${
|
||||
(document as CatalogEntityDocument).type
|
||||
}`}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</div>
|
||||
</ListItem>
|
||||
<Divider component="li" />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</SearchResult>
|
||||
<SearchResultPager />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</SearchContextProvider>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
* Copyright 2022 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 { ErrorApi, errorApiRef } from '@backstage/core-plugin-api';
|
||||
import { entityRouteRef } from '@backstage/plugin-catalog-react';
|
||||
import { AuthorizeResult } from '@backstage/plugin-permission-common';
|
||||
import {
|
||||
PermissionApi,
|
||||
permissionApiRef,
|
||||
} from '@backstage/plugin-permission-react';
|
||||
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
import { Button } from '@material-ui/core';
|
||||
import { fireEvent, waitFor } from '@testing-library/react';
|
||||
import { act } from '@testing-library/react-hooks';
|
||||
import React from 'react';
|
||||
import { SWRConfig } from 'swr';
|
||||
|
||||
import { PlaylistApi, playlistApiRef } from '../../api';
|
||||
import { PlaylistEntitiesTable } from './PlaylistEntitiesTable';
|
||||
|
||||
jest.mock('./AddEntitiesDrawer', () => ({
|
||||
AddEntitiesDrawer: ({ onAdd, open }: { onAdd: Function; open: boolean }) =>
|
||||
open ? (
|
||||
<Button
|
||||
data-testid="mock-add-entities-drawer"
|
||||
onClick={() => onAdd('api:test/my-ent')}
|
||||
/>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
describe('PlaylistEntitiesTable', () => {
|
||||
const errorApi: Partial<ErrorApi> = { post: jest.fn() };
|
||||
const sampleEntities = [
|
||||
{
|
||||
kind: 'system',
|
||||
metadata: {
|
||||
namespace: 'default',
|
||||
name: 'test-ent',
|
||||
title: 'Test Ent',
|
||||
description: 'test ent description',
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'component',
|
||||
metadata: {
|
||||
namespace: 'foo',
|
||||
name: 'test-ent2',
|
||||
title: 'Test Ent 2',
|
||||
description: 'test ent description 2',
|
||||
},
|
||||
spec: {
|
||||
type: 'library',
|
||||
},
|
||||
},
|
||||
];
|
||||
const playlistApi: Partial<PlaylistApi> = {
|
||||
getPlaylistEntities: jest
|
||||
.fn()
|
||||
.mockImplementation(async () => sampleEntities),
|
||||
addPlaylistEntities: jest.fn().mockImplementation(async () => {}),
|
||||
removePlaylistEntities: jest.fn().mockImplementation(async () => {}),
|
||||
};
|
||||
|
||||
const mockAuthorize = jest
|
||||
.fn()
|
||||
.mockImplementation(async () => ({ result: AuthorizeResult.ALLOW }));
|
||||
const permissionApi: Partial<PermissionApi> = { authorize: mockAuthorize };
|
||||
|
||||
// SWR used by the usePermission hook needs cache to be reset for each test
|
||||
const element = (
|
||||
<SWRConfig value={{ provider: () => new Map() }}>
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[errorApiRef, errorApi],
|
||||
[permissionApiRef, permissionApi],
|
||||
[playlistApiRef, playlistApi],
|
||||
]}
|
||||
>
|
||||
<PlaylistEntitiesTable playlistId="playlist-id" />
|
||||
</TestApiProvider>
|
||||
</SWRConfig>
|
||||
);
|
||||
|
||||
const render = async () =>
|
||||
renderInTestApp(element, {
|
||||
mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef },
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockAuthorize.mockClear();
|
||||
});
|
||||
|
||||
it('renders playlist entities and options', async () => {
|
||||
const rendered = await render();
|
||||
|
||||
expect(playlistApi.getPlaylistEntities).toHaveBeenCalledWith('playlist-id');
|
||||
|
||||
expect(rendered.getByText('Test Ent')).toBeInTheDocument();
|
||||
expect(rendered.getByText('system')).toBeInTheDocument();
|
||||
expect(rendered.getByText('test ent description')).toBeInTheDocument();
|
||||
|
||||
expect(rendered.getByText('Test Ent 2')).toBeInTheDocument();
|
||||
expect(rendered.getByText('component - library')).toBeInTheDocument();
|
||||
expect(rendered.getByText('test ent description 2')).toBeInTheDocument();
|
||||
|
||||
const removeButtons = rendered.getAllByTitle('Remove from playlist');
|
||||
expect(removeButtons.length).toEqual(2);
|
||||
removeButtons.forEach(b => expect(b).toBeInTheDocument());
|
||||
expect(rendered.getByTitle('Add entities to playlist')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has no edit options if unauthorized', async () => {
|
||||
mockAuthorize.mockImplementationOnce(async () => ({
|
||||
result: AuthorizeResult.DENY,
|
||||
}));
|
||||
|
||||
const rendered = await render();
|
||||
expect(rendered.queryByTitle('Remove from playlist')).toBeNull();
|
||||
expect(rendered.queryByTitle('Add entities to playlist')).toBeNull();
|
||||
});
|
||||
|
||||
it('removes a entity correctly', async () => {
|
||||
const rendered = await render();
|
||||
|
||||
const removeButtons = rendered.getAllByTitle('Remove from playlist');
|
||||
act(() => {
|
||||
fireEvent.click(removeButtons[0]);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(playlistApi.removePlaylistEntities).toHaveBeenCalledWith(
|
||||
'playlist-id',
|
||||
['system:default/test-ent'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('adds an entity correctly', async () => {
|
||||
const rendered = await render();
|
||||
|
||||
expect(rendered.queryByTestId('mock-add-entities-drawer')).toBeNull();
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(rendered.getByTitle('Add entities to playlist'));
|
||||
});
|
||||
|
||||
expect(
|
||||
rendered.queryByTestId('mock-add-entities-drawer'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(rendered.getByTestId('mock-add-entities-drawer'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(playlistApi.addPlaylistEntities).toHaveBeenCalledWith(
|
||||
'playlist-id',
|
||||
['api:test/my-ent'],
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* Copyright 2022 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 { Entity, stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import {
|
||||
ErrorPanel,
|
||||
SubvalueCell,
|
||||
Table,
|
||||
TableFilter,
|
||||
} from '@backstage/core-components';
|
||||
import { errorApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import { EntityRefLink } from '@backstage/plugin-catalog-react';
|
||||
import { usePermission } from '@backstage/plugin-permission-react';
|
||||
import { permissions } from '@backstage/plugin-playlist-common';
|
||||
import AddBoxIcon from '@material-ui/icons/AddBox';
|
||||
import DeleteIcon from '@material-ui/icons/Delete';
|
||||
import SearchIcon from '@material-ui/icons/Search';
|
||||
import React, { forwardRef, useCallback, useEffect, useState } from 'react';
|
||||
import useAsyncFn from 'react-use/lib/useAsyncFn';
|
||||
|
||||
import { playlistApiRef } from '../../api';
|
||||
import { AddEntitiesDrawer } from './AddEntitiesDrawer';
|
||||
|
||||
export const PlaylistEntitiesTable = ({
|
||||
playlistId,
|
||||
}: {
|
||||
playlistId: string;
|
||||
}) => {
|
||||
const errorApi = useApi(errorApiRef);
|
||||
const playlistApi = useApi(playlistApiRef);
|
||||
const [openAddEntitiesDrawer, setOpenAddEntitiesDrawer] = useState(false);
|
||||
|
||||
const { allowed: editAllowed } = usePermission({
|
||||
permission: permissions.playlistListUpdate,
|
||||
resourceRef: playlistId,
|
||||
});
|
||||
|
||||
const [{ value: entities, loading, error }, loadEntities] = useAsyncFn(
|
||||
() => playlistApi.getPlaylistEntities(playlistId),
|
||||
[playlistApi],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadEntities();
|
||||
}, [loadEntities]);
|
||||
|
||||
const addEntity = useCallback(
|
||||
async (entityRef: string) => {
|
||||
try {
|
||||
await playlistApi.addPlaylistEntities(playlistId, [entityRef]);
|
||||
loadEntities();
|
||||
} catch (e) {
|
||||
errorApi.post(e);
|
||||
}
|
||||
},
|
||||
[errorApi, loadEntities, playlistApi, playlistId],
|
||||
);
|
||||
|
||||
const removeEntity = useCallback(
|
||||
async (_, entity: Entity | Entity[]) => {
|
||||
try {
|
||||
await playlistApi.removePlaylistEntities(
|
||||
playlistId,
|
||||
[entity].flat().map(stringifyEntityRef),
|
||||
);
|
||||
loadEntities();
|
||||
} catch (e) {
|
||||
errorApi.post(e);
|
||||
}
|
||||
},
|
||||
[errorApi, loadEntities, playlistApi, playlistId],
|
||||
);
|
||||
|
||||
const actions = editAllowed
|
||||
? [
|
||||
{
|
||||
icon: DeleteIcon,
|
||||
tooltip: 'Remove from playlist',
|
||||
onClick: removeEntity,
|
||||
},
|
||||
{
|
||||
icon: AddBoxIcon,
|
||||
tooltip: 'Add entities to playlist',
|
||||
isFreeAction: true,
|
||||
onClick: () => setOpenAddEntitiesDrawer(true),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const columns = [
|
||||
// These hidden columns are defined to allow them to be searchable/filterable
|
||||
{ title: 'Kind', field: 'kind', hidden: true, searchable: true },
|
||||
{ title: 'Type', field: 'spec.type', hidden: true, searchable: true },
|
||||
{ title: 'Title', field: 'metadata.title', hidden: true, searchable: true },
|
||||
{
|
||||
title: 'Name',
|
||||
field: 'metadata.name',
|
||||
highlight: true,
|
||||
width: '25%',
|
||||
customSort: (a: Entity, b: Entity) => {
|
||||
const titleA = (a.metadata.title ?? a.metadata.name) as string;
|
||||
const titleB = (b.metadata.title ?? b.metadata.name) as string;
|
||||
return titleA.localeCompare(titleB);
|
||||
},
|
||||
render: (entity: Entity) => (
|
||||
<SubvalueCell
|
||||
value={
|
||||
<EntityRefLink
|
||||
entityRef={entity}
|
||||
defaultKind={entity.kind}
|
||||
title={entity.metadata.title}
|
||||
/>
|
||||
}
|
||||
subvalue={`${entity.kind}${
|
||||
entity.spec?.type ? ` - ${entity.spec?.type}` : ''
|
||||
}`}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ title: 'Description', field: 'metadata.description', width: '75%' },
|
||||
];
|
||||
|
||||
const filters: TableFilter[] = [
|
||||
{ column: 'Kind', type: 'multiple-select' },
|
||||
{ column: 'Type', type: 'multiple-select' },
|
||||
];
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorPanel
|
||||
defaultExpanded
|
||||
title="Failed to load entities"
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table<Entity>
|
||||
actions={actions}
|
||||
columns={columns}
|
||||
data={entities ?? []}
|
||||
filters={filters}
|
||||
icons={{
|
||||
...Table.icons,
|
||||
Search: forwardRef((props, ref) => (
|
||||
<SearchIcon {...props} ref={ref} />
|
||||
)),
|
||||
}}
|
||||
isLoading={loading}
|
||||
localization={{ toolbar: { searchPlaceholder: 'Search' } }}
|
||||
options={{
|
||||
actionsColumnIndex: -1,
|
||||
emptyRowsWhenPaging: false,
|
||||
loadingType: 'linear',
|
||||
pageSize: 20,
|
||||
pageSizeOptions: [20, 50, 100],
|
||||
paging: true,
|
||||
showEmptyDataSourceMessage: !loading,
|
||||
}}
|
||||
/>
|
||||
<AddEntitiesDrawer
|
||||
currentEntities={entities ?? []}
|
||||
open={openAddEntitiesDrawer}
|
||||
onAdd={addEntity}
|
||||
onClose={() => setOpenAddEntitiesDrawer(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
* Copyright 2022 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 { ErrorApi, errorApiRef } from '@backstage/core-plugin-api';
|
||||
import { entityRouteRef } from '@backstage/plugin-catalog-react';
|
||||
import {
|
||||
AuthorizeResult,
|
||||
isPermission,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
import {
|
||||
PermissionApi,
|
||||
permissionApiRef,
|
||||
} from '@backstage/plugin-permission-react';
|
||||
import { permissions, Playlist } from '@backstage/plugin-playlist-common';
|
||||
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
import { Button } from '@material-ui/core';
|
||||
import { fireEvent, waitFor } from '@testing-library/react';
|
||||
import { act } from '@testing-library/react-hooks';
|
||||
import React from 'react';
|
||||
import { SWRConfig } from 'swr';
|
||||
|
||||
import { PlaylistApi, playlistApiRef } from '../../api';
|
||||
import { rootRouteRef } from '../../routes';
|
||||
import { PlaylistHeader } from './PlaylistHeader';
|
||||
|
||||
const navigateMock = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
jest.mock('../PlaylistEditDialog', () => ({
|
||||
PlaylistEditDialog: ({
|
||||
onSave,
|
||||
open,
|
||||
playlist,
|
||||
}: {
|
||||
onSave: Function;
|
||||
open: boolean;
|
||||
playlist: Partial<Playlist>;
|
||||
}) =>
|
||||
open ? (
|
||||
<Button
|
||||
data-testid="mock-playlist-edit-dialog"
|
||||
onClick={() => onSave(playlist)}
|
||||
/>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
describe('PlaylistHeader', () => {
|
||||
const testPlaylist = {
|
||||
id: 'id1',
|
||||
name: 'playlist-1',
|
||||
description: 'test description',
|
||||
owner: 'group:default/some-owner',
|
||||
public: true,
|
||||
entities: 1,
|
||||
followers: 2,
|
||||
isFollowing: false,
|
||||
};
|
||||
|
||||
const errorApi: Partial<ErrorApi> = { post: jest.fn() };
|
||||
const playlistApi: Partial<PlaylistApi> = {
|
||||
updatePlaylist: jest.fn().mockImplementation(async () => {}),
|
||||
deletePlaylist: jest.fn().mockImplementation(async () => {}),
|
||||
};
|
||||
const mockAuthorize = jest
|
||||
.fn()
|
||||
.mockImplementation(async () => ({ result: AuthorizeResult.ALLOW }));
|
||||
const permissionApi: Partial<PermissionApi> = { authorize: mockAuthorize };
|
||||
|
||||
const mockOnUpdate = jest.fn();
|
||||
|
||||
// SWR used by the usePermission hook needs cache to be reset for each test
|
||||
const element = (
|
||||
<SWRConfig value={{ provider: () => new Map() }}>
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[errorApiRef, errorApi],
|
||||
[permissionApiRef, permissionApi],
|
||||
[playlistApiRef, playlistApi],
|
||||
]}
|
||||
>
|
||||
<PlaylistHeader playlist={testPlaylist} onUpdate={mockOnUpdate} />
|
||||
</TestApiProvider>
|
||||
</SWRConfig>
|
||||
);
|
||||
|
||||
const render = async () =>
|
||||
renderInTestApp(element, {
|
||||
mountedRoutes: {
|
||||
'/playlists': rootRouteRef,
|
||||
'/catalog/:namespace/:kind/:name': entityRouteRef,
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockAuthorize.mockClear();
|
||||
mockOnUpdate.mockClear();
|
||||
navigateMock.mockClear();
|
||||
});
|
||||
|
||||
it('show render basic playlist info', async () => {
|
||||
const rendered = await render();
|
||||
expect(rendered.getByText('playlist-1')).toBeInTheDocument();
|
||||
expect(rendered.getByText('2 followers')).toBeInTheDocument();
|
||||
expect(rendered.getByText('some-owner')).toBeInTheDocument();
|
||||
expect(rendered.queryByText('private')).toBeNull();
|
||||
|
||||
testPlaylist.public = false;
|
||||
rendered.rerender(element);
|
||||
expect(rendered.queryByText('private')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has edit and delete options enabled if authorized', async () => {
|
||||
const rendered = await render();
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(rendered.getByTestId('header-action-menu'));
|
||||
});
|
||||
|
||||
const actionItems = rendered.getAllByTestId('header-action-item');
|
||||
// .toBeDisabled() matcher doesn't seem to work with disabled header action items
|
||||
// https://github.com/testing-library/jest-dom/issues/144
|
||||
expect(
|
||||
actionItems.find(e => e.innerHTML.includes('Edit Details')),
|
||||
).toHaveAttribute('aria-disabled', 'false');
|
||||
expect(
|
||||
actionItems.find(e => e.innerHTML.includes('Delete Playlist')),
|
||||
).toHaveAttribute('aria-disabled', 'false');
|
||||
});
|
||||
|
||||
it('has edit option disabled if not authorized', async () => {
|
||||
mockAuthorize.mockImplementation(async req => ({
|
||||
result: isPermission(req.permission, permissions.playlistListUpdate)
|
||||
? AuthorizeResult.DENY
|
||||
: AuthorizeResult.ALLOW,
|
||||
}));
|
||||
|
||||
const rendered = await render();
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(rendered.getByTestId('header-action-menu'));
|
||||
});
|
||||
|
||||
const actionItems = rendered.getAllByTestId('header-action-item');
|
||||
expect(
|
||||
actionItems.find(e => e.innerHTML.includes('Edit Details')),
|
||||
).toHaveAttribute('aria-disabled', 'true');
|
||||
expect(
|
||||
actionItems.find(e => e.innerHTML.includes('Delete Playlist')),
|
||||
).toHaveAttribute('aria-disabled', 'false');
|
||||
});
|
||||
|
||||
it('has delete option disabled if not authorized', async () => {
|
||||
mockAuthorize.mockImplementation(async req => ({
|
||||
result: isPermission(req.permission, permissions.playlistListDelete)
|
||||
? AuthorizeResult.DENY
|
||||
: AuthorizeResult.ALLOW,
|
||||
}));
|
||||
|
||||
const rendered = await render();
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(rendered.getByTestId('header-action-menu'));
|
||||
});
|
||||
|
||||
const actionItems = rendered.getAllByTestId('header-action-item');
|
||||
expect(
|
||||
actionItems.find(e => e.innerHTML.includes('Edit Details')),
|
||||
).toHaveAttribute('aria-disabled', 'false');
|
||||
expect(
|
||||
actionItems.find(e => e.innerHTML.includes('Delete Playlist')),
|
||||
).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it('should edit the playlist with the PlaylistEditDialog', async () => {
|
||||
const rendered = await render();
|
||||
|
||||
expect(rendered.queryByTestId('mock-playlist-edit-dialog')).toBeNull();
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(rendered.getByTestId('header-action-menu'));
|
||||
fireEvent.click(
|
||||
rendered
|
||||
.getAllByTestId('header-action-item')
|
||||
.find(e => e.innerHTML.includes('Edit Details'))!,
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
rendered.queryByTestId('mock-playlist-edit-dialog'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(rendered.getByTestId('mock-playlist-edit-dialog'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(playlistApi.updatePlaylist).toHaveBeenCalledWith({
|
||||
id: testPlaylist.id,
|
||||
name: testPlaylist.name,
|
||||
description: testPlaylist.description,
|
||||
owner: testPlaylist.owner,
|
||||
public: testPlaylist.public,
|
||||
});
|
||||
expect(mockOnUpdate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle deleting a playlist', async () => {
|
||||
const rendered = await render();
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(rendered.getByTestId('header-action-menu'));
|
||||
fireEvent.click(
|
||||
rendered
|
||||
.getAllByTestId('header-action-item')
|
||||
.find(e => e.innerHTML.includes('Delete Playlist'))!,
|
||||
);
|
||||
fireEvent.click(rendered.getByTestId('delete-playlist-dialog-button'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(playlistApi.deletePlaylist).toHaveBeenCalledWith('id1');
|
||||
expect(navigateMock).toHaveBeenCalledWith('/playlists');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
* Copyright 2022 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 {
|
||||
Header,
|
||||
HeaderActionMenu,
|
||||
HeaderLabel,
|
||||
} from '@backstage/core-components';
|
||||
import { errorApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api';
|
||||
import { EntityRefLink } from '@backstage/plugin-catalog-react';
|
||||
import { usePermission } from '@backstage/plugin-permission-react';
|
||||
import {
|
||||
permissions,
|
||||
Playlist,
|
||||
PlaylistMetadata,
|
||||
} from '@backstage/plugin-playlist-common';
|
||||
import {
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
makeStyles,
|
||||
} from '@material-ui/core';
|
||||
import EditIcon from '@material-ui/icons/Edit';
|
||||
import DeleteIcon from '@material-ui/icons/Delete';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import useAsyncFn from 'react-use/lib/useAsyncFn';
|
||||
|
||||
import { playlistApiRef } from '../../api';
|
||||
import { rootRouteRef } from '../../routes';
|
||||
import { PlaylistEditDialog } from '../PlaylistEditDialog';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
buttonWrapper: {
|
||||
position: 'relative',
|
||||
},
|
||||
buttonProgress: {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
marginTop: -12,
|
||||
marginLeft: -12,
|
||||
},
|
||||
chip: {
|
||||
marginTop: '5px',
|
||||
marginBottom: '5px',
|
||||
},
|
||||
});
|
||||
|
||||
export type PlaylistHeaderProps = {
|
||||
playlist: Playlist;
|
||||
onUpdate: () => void;
|
||||
};
|
||||
|
||||
export const PlaylistHeader = ({ playlist, onUpdate }: PlaylistHeaderProps) => {
|
||||
const classes = useStyles();
|
||||
const errorApi = useApi(errorApiRef);
|
||||
const playlistApi = useApi(playlistApiRef);
|
||||
const navigate = useNavigate();
|
||||
const rootRoute = useRouteRef(rootRouteRef);
|
||||
const [openEditDialog, setOpenEditDialog] = useState(false);
|
||||
const [openDeleteDialog, setOpenDeleteDialog] = useState(false);
|
||||
|
||||
const { allowed: editAllowed } = usePermission({
|
||||
permission: permissions.playlistListUpdate,
|
||||
resourceRef: playlist.id,
|
||||
});
|
||||
|
||||
const { allowed: deleteAllowed } = usePermission({
|
||||
permission: permissions.playlistListDelete,
|
||||
resourceRef: playlist.id,
|
||||
});
|
||||
|
||||
const updatePlaylist = useCallback(
|
||||
async (update: Omit<PlaylistMetadata, 'id'>) => {
|
||||
try {
|
||||
await playlistApi.updatePlaylist({ ...update, id: playlist.id });
|
||||
setOpenEditDialog(false);
|
||||
onUpdate();
|
||||
} catch (e) {
|
||||
errorApi.post(e);
|
||||
}
|
||||
},
|
||||
[errorApi, onUpdate, playlist, playlistApi],
|
||||
);
|
||||
|
||||
const [deleting, deletePlaylist] = useAsyncFn(async () => {
|
||||
try {
|
||||
await playlistApi.deletePlaylist(playlist.id);
|
||||
navigate(rootRoute());
|
||||
} catch (e) {
|
||||
errorApi.post(e);
|
||||
}
|
||||
}, [playlistApi]);
|
||||
|
||||
return (
|
||||
<Header
|
||||
type={!playlist.public ? 'private' : undefined}
|
||||
title={playlist.name}
|
||||
subtitle={
|
||||
<>
|
||||
<Chip
|
||||
className={classes.chip}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={`${playlist.followers} followers`}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<HeaderLabel
|
||||
label="Owner"
|
||||
value={
|
||||
<EntityRefLink
|
||||
entityRef={playlist.owner}
|
||||
defaultKind="group"
|
||||
color="inherit"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<HeaderActionMenu
|
||||
actionItems={[
|
||||
{
|
||||
label: 'Edit Details',
|
||||
icon: <EditIcon />,
|
||||
disabled: !editAllowed,
|
||||
onClick: () => setOpenEditDialog(true),
|
||||
},
|
||||
{
|
||||
label: 'Delete Playlist',
|
||||
icon: <DeleteIcon />,
|
||||
disabled: !deleteAllowed,
|
||||
onClick: () => setOpenDeleteDialog(true),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<PlaylistEditDialog
|
||||
open={openEditDialog}
|
||||
onClose={() => setOpenEditDialog(false)}
|
||||
onSave={updatePlaylist}
|
||||
playlist={{
|
||||
name: playlist.name,
|
||||
description: playlist.description,
|
||||
owner: playlist.owner,
|
||||
public: playlist.public,
|
||||
}}
|
||||
/>
|
||||
<Dialog
|
||||
open={openDeleteDialog}
|
||||
onClose={() => setOpenDeleteDialog(false)}
|
||||
>
|
||||
<DialogContent>
|
||||
Are you sure you want to delete <b>{playlist.name}</b> (
|
||||
{playlist.followers} followers) ?
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
color="primary"
|
||||
disabled={deleting.loading}
|
||||
onClick={() => setOpenDeleteDialog(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<div className={classes.buttonWrapper}>
|
||||
<Button
|
||||
color="secondary"
|
||||
data-testid="delete-playlist-dialog-button"
|
||||
disabled={deleting.loading}
|
||||
onClick={deletePlaylist}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
{deleting.loading && (
|
||||
<CircularProgress size={24} className={classes.buttonProgress} />
|
||||
)}
|
||||
</div>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Header>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Copyright 2022 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 { ErrorApi, errorApiRef } from '@backstage/core-plugin-api';
|
||||
import { AuthorizeResult } from '@backstage/plugin-permission-common';
|
||||
import {
|
||||
PermissionApi,
|
||||
permissionApiRef,
|
||||
} from '@backstage/plugin-permission-react';
|
||||
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
import { fireEvent, getByText, waitFor } from '@testing-library/react';
|
||||
import { act } from '@testing-library/react-hooks';
|
||||
import React from 'react';
|
||||
import { SWRConfig } from 'swr';
|
||||
|
||||
import { PlaylistApi, playlistApiRef } from '../../api';
|
||||
import { PlaylistPage } from './PlaylistPage';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: () => ({ playlistId: 'id1' }),
|
||||
}));
|
||||
|
||||
// Mock out nested components to simplify tests
|
||||
jest.mock('./PlaylistEntitiesTable', () => ({
|
||||
PlaylistEntitiesTable: () => null,
|
||||
}));
|
||||
|
||||
jest.mock('./PlaylistHeader', () => ({
|
||||
PlaylistHeader: () => null,
|
||||
}));
|
||||
|
||||
describe('PlaylistPage', () => {
|
||||
const testPlaylist = {
|
||||
id: 'id1',
|
||||
name: 'playlist-1',
|
||||
description: 'test description',
|
||||
owner: 'group:default/some-owner',
|
||||
public: true,
|
||||
entities: 1,
|
||||
followers: 2,
|
||||
isFollowing: false,
|
||||
};
|
||||
|
||||
const errorApi: Partial<ErrorApi> = { post: jest.fn() };
|
||||
const playlistApi: Partial<PlaylistApi> = {
|
||||
getPlaylist: jest.fn().mockImplementation(async () => testPlaylist),
|
||||
followPlaylist: jest.fn().mockImplementation(async () => {}),
|
||||
unfollowPlaylist: jest.fn().mockImplementation(async () => {}),
|
||||
};
|
||||
const mockAuthorize = jest
|
||||
.fn()
|
||||
.mockImplementation(async () => ({ result: AuthorizeResult.ALLOW }));
|
||||
const permissionApi: Partial<PermissionApi> = { authorize: mockAuthorize };
|
||||
|
||||
// SWR used by the usePermission hook needs cache to be reset for each test
|
||||
const element = (
|
||||
<SWRConfig value={{ provider: () => new Map() }}>
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[errorApiRef, errorApi],
|
||||
[permissionApiRef, permissionApi],
|
||||
[playlistApiRef, playlistApi],
|
||||
]}
|
||||
>
|
||||
<PlaylistPage />
|
||||
</TestApiProvider>
|
||||
</SWRConfig>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mockAuthorize.mockClear();
|
||||
});
|
||||
|
||||
it('show render the playlist info', async () => {
|
||||
const rendered = await renderInTestApp(element);
|
||||
expect(playlistApi.getPlaylist).toHaveBeenCalledWith('id1');
|
||||
expect(rendered.getByText('test description')).toBeInTheDocument();
|
||||
expect(
|
||||
rendered.getByTestId('playlist-page-follow-button'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
getByText(rendered.getByTestId('playlist-page-follow-button'), 'Follow'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render the follow button if unauthorized', async () => {
|
||||
mockAuthorize.mockImplementationOnce(async () => ({
|
||||
result: AuthorizeResult.DENY,
|
||||
}));
|
||||
const rendered = await renderInTestApp(element);
|
||||
expect(rendered.queryByTestId('playlist-page-follow-button')).toBeNull();
|
||||
});
|
||||
|
||||
it('should reflect and toggle the following state of the playlist', async () => {
|
||||
const rendered = await renderInTestApp(element);
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(rendered.getByTestId('playlist-page-follow-button'));
|
||||
testPlaylist.isFollowing = true;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(playlistApi.followPlaylist).toHaveBeenCalledWith('id1');
|
||||
expect(
|
||||
getByText(
|
||||
rendered.getByTestId('playlist-page-follow-button'),
|
||||
'Following',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(rendered.getByTestId('playlist-page-follow-button'));
|
||||
testPlaylist.isFollowing = false;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(playlistApi.unfollowPlaylist).toHaveBeenCalledWith('id1');
|
||||
expect(
|
||||
getByText(
|
||||
rendered.getByTestId('playlist-page-follow-button'),
|
||||
'Follow',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* Copyright 2022 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 { Content, ErrorPage, Page } from '@backstage/core-components';
|
||||
import { errorApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import { ResponseError } from '@backstage/errors';
|
||||
import { usePermission } from '@backstage/plugin-permission-react';
|
||||
import { permissions } from '@backstage/plugin-playlist-common';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Divider,
|
||||
LinearProgress,
|
||||
makeStyles,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import useAsyncFn from 'react-use/lib/useAsyncFn';
|
||||
|
||||
import { playlistApiRef } from '../../api';
|
||||
import { PlaylistEntitiesTable } from './PlaylistEntitiesTable';
|
||||
import { PlaylistHeader } from './PlaylistHeader';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
followButton: {
|
||||
top: '6px',
|
||||
right: '8px',
|
||||
},
|
||||
});
|
||||
|
||||
export const PlaylistPage = () => {
|
||||
const classes = useStyles();
|
||||
const errorApi = useApi(errorApiRef);
|
||||
const playlistApi = useApi(playlistApiRef);
|
||||
const { playlistId } = useParams();
|
||||
|
||||
const [{ value: playlist, loading, error }, loadPlaylist] = useAsyncFn(
|
||||
() => playlistApi.getPlaylist(playlistId!),
|
||||
[playlistApi],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadPlaylist();
|
||||
}, [loadPlaylist]);
|
||||
|
||||
const { allowed: followAllowed } = usePermission({
|
||||
permission: permissions.playlistFollowersUpdate,
|
||||
resourceRef: playlist?.id,
|
||||
});
|
||||
|
||||
const [{ loading: loadingFollowRequest }, followPlaylist] =
|
||||
useAsyncFn(async () => {
|
||||
try {
|
||||
if (playlist!.isFollowing) {
|
||||
await playlistApi.unfollowPlaylist(playlist!.id);
|
||||
} else {
|
||||
await playlistApi.followPlaylist(playlist!.id);
|
||||
}
|
||||
|
||||
loadPlaylist();
|
||||
} catch (e) {
|
||||
errorApi.post(e);
|
||||
}
|
||||
}, [errorApi, loadPlaylist, playlist, playlistApi]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorPage
|
||||
status={(error as ResponseError).response?.status.toString()}
|
||||
statusMessage={error.toString()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading && <LinearProgress />}
|
||||
<Page themeId="home">
|
||||
{playlist && (
|
||||
<>
|
||||
<PlaylistHeader playlist={playlist} onUpdate={loadPlaylist} />
|
||||
<Content>
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="About"
|
||||
action={
|
||||
followAllowed && (
|
||||
<Button
|
||||
color="primary"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
data-testid="playlist-page-follow-button"
|
||||
className={classes.followButton}
|
||||
disabled={loadingFollowRequest}
|
||||
onClick={followPlaylist}
|
||||
>
|
||||
{playlist.isFollowing ? 'Following' : 'Follow'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Divider />
|
||||
<CardContent>
|
||||
<Typography variant="body2">
|
||||
{playlist.description}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<br />
|
||||
<PlaylistEntitiesTable playlistId={playlist.id} />
|
||||
</Content>
|
||||
</>
|
||||
)}
|
||||
</Page>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2022 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 './PlaylistPage';
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright 2022 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 { fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import { DefaultPlaylistFilters } from '../../hooks';
|
||||
import { MockPlaylistListProvider } from '../../testUtils';
|
||||
import { PlaylistSearchBar, PlaylistTextFilter } from './PlaylistSearchBar';
|
||||
|
||||
describe('PlaylistSearchBar', () => {
|
||||
it('should display search value and execute set callback', async () => {
|
||||
const updateFilters = jest.fn();
|
||||
|
||||
const filters: DefaultPlaylistFilters = {
|
||||
text: new PlaylistTextFilter('hello'),
|
||||
};
|
||||
|
||||
const { getByDisplayValue } = render(
|
||||
<MockPlaylistListProvider value={{ updateFilters, filters }}>
|
||||
<PlaylistSearchBar />
|
||||
</MockPlaylistListProvider>,
|
||||
);
|
||||
|
||||
const searchInput = getByDisplayValue('hello');
|
||||
expect(searchInput).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: 'world' } });
|
||||
await waitFor(() => expect(updateFilters.mock.calls.length).toBe(1));
|
||||
expect(updateFilters).toHaveBeenCalledWith({
|
||||
text: new PlaylistTextFilter('world'),
|
||||
});
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: '' } });
|
||||
await waitFor(() => expect(updateFilters.mock.calls.length).toBe(2));
|
||||
expect(updateFilters).toHaveBeenCalledWith({
|
||||
text: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Copyright 2022 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 { Playlist } from '@backstage/plugin-playlist-common';
|
||||
import {
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
InputAdornment,
|
||||
makeStyles,
|
||||
Toolbar,
|
||||
} from '@material-ui/core';
|
||||
import Clear from '@material-ui/icons/Clear';
|
||||
import Search from '@material-ui/icons/Search';
|
||||
import React, { useState } from 'react';
|
||||
import useDebounce from 'react-use/lib/useDebounce';
|
||||
|
||||
import { usePlaylistList } from '../../hooks';
|
||||
import { PlaylistFilter } from '../../types';
|
||||
|
||||
export class PlaylistTextFilter implements PlaylistFilter {
|
||||
constructor(readonly value: string) {}
|
||||
|
||||
filterPlaylist(playlist: Playlist): boolean {
|
||||
const upperCaseValue = this.value.toLocaleUpperCase('en-US');
|
||||
return (
|
||||
playlist.name.toLocaleUpperCase('en-US').includes(upperCaseValue) ||
|
||||
!!playlist.description
|
||||
?.toLocaleUpperCase('en-US')
|
||||
.includes(upperCaseValue)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const useStyles = makeStyles(_theme => ({
|
||||
searchToolbar: {
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
export const PlaylistSearchBar = () => {
|
||||
const classes = useStyles();
|
||||
|
||||
const { filters, updateFilters } = usePlaylistList();
|
||||
const [search, setSearch] = useState(filters.text?.value ?? '');
|
||||
|
||||
useDebounce(
|
||||
() => {
|
||||
updateFilters({
|
||||
text: search.length ? new PlaylistTextFilter(search) : undefined,
|
||||
});
|
||||
},
|
||||
250,
|
||||
[search, updateFilters],
|
||||
);
|
||||
|
||||
return (
|
||||
<Toolbar className={classes.searchToolbar}>
|
||||
<FormControl>
|
||||
<Input
|
||||
aria-label="search"
|
||||
id="input-with-icon-adornment"
|
||||
placeholder="Search"
|
||||
autoComplete="off"
|
||||
onChange={event => setSearch(event.target.value)}
|
||||
value={search}
|
||||
startAdornment={
|
||||
<InputAdornment position="start">
|
||||
<Search />
|
||||
</InputAdornment>
|
||||
}
|
||||
endAdornment={
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="clear search"
|
||||
onClick={() => setSearch('')}
|
||||
edge="end"
|
||||
disabled={search.length === 0}
|
||||
>
|
||||
<Clear />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</Toolbar>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2022 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 './PlaylistSearchBar';
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright 2022 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 { fireEvent, getByRole, render, waitFor } from '@testing-library/react';
|
||||
import { act } from '@testing-library/react-hooks';
|
||||
import React from 'react';
|
||||
|
||||
import { MockPlaylistListProvider } from '../../testUtils';
|
||||
import {
|
||||
DefaultPlaylistSortTypes,
|
||||
DefaultSortCompareFunctions,
|
||||
PlaylistSortPicker,
|
||||
} from './PlaylistSortPicker';
|
||||
|
||||
describe('PlaylistSortPicker', () => {
|
||||
it('should update sort based on selection', async () => {
|
||||
const updateSort = jest.fn();
|
||||
|
||||
const { getByText, getByTestId } = render(
|
||||
<MockPlaylistListProvider value={{ updateSort }}>
|
||||
<PlaylistSortPicker />
|
||||
</MockPlaylistListProvider>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
fireEvent.mouseDown(
|
||||
getByRole(getByTestId('sort-picker-select'), 'button'),
|
||||
);
|
||||
});
|
||||
const abcSort = getByText('Alphabetical');
|
||||
expect(abcSort).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(abcSort);
|
||||
await waitFor(() => {
|
||||
expect(updateSort).toHaveBeenLastCalledWith(
|
||||
DefaultSortCompareFunctions[DefaultPlaylistSortTypes.alphabetical],
|
||||
);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fireEvent.mouseDown(
|
||||
getByRole(getByTestId('sort-picker-select'), 'button'),
|
||||
);
|
||||
});
|
||||
const entitiesSort = getByText('# Entities');
|
||||
expect(entitiesSort).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(entitiesSort);
|
||||
await waitFor(() => {
|
||||
expect(updateSort).toHaveBeenLastCalledWith(
|
||||
DefaultSortCompareFunctions[DefaultPlaylistSortTypes.numEntities],
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright 2022 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 { Playlist } from '@backstage/plugin-playlist-common';
|
||||
import {
|
||||
ListSubheader,
|
||||
makeStyles,
|
||||
MenuItem,
|
||||
Select,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import SwapVertIcon from '@material-ui/icons/SwapVert';
|
||||
import React from 'react';
|
||||
import useEffectOnce from 'react-use/lib/useEffectOnce';
|
||||
|
||||
import { usePlaylistList } from '../../hooks';
|
||||
import { PlaylistSortCompareFunction } from '../../types';
|
||||
|
||||
export const enum DefaultPlaylistSortTypes {
|
||||
popular = 'popular',
|
||||
alphabetical = 'alphabetical',
|
||||
numEntities = 'numEntities',
|
||||
}
|
||||
|
||||
export const DefaultSortCompareFunctions: {
|
||||
[type in DefaultPlaylistSortTypes]: PlaylistSortCompareFunction;
|
||||
} = {
|
||||
[DefaultPlaylistSortTypes.popular]: (a: Playlist, b: Playlist) => {
|
||||
if (a.followers < b.followers) {
|
||||
return 1;
|
||||
} else if (a.followers > b.followers) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
[DefaultPlaylistSortTypes.alphabetical]: (a: Playlist, b: Playlist) =>
|
||||
a.name.localeCompare(b.name),
|
||||
[DefaultPlaylistSortTypes.numEntities]: (a: Playlist, b: Playlist) => {
|
||||
if (a.entities < b.entities) {
|
||||
return 1;
|
||||
} else if (a.entities > b.entities) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
};
|
||||
|
||||
const sortTypeLabels = {
|
||||
[DefaultPlaylistSortTypes.popular]: 'Popular',
|
||||
[DefaultPlaylistSortTypes.alphabetical]: 'Alphabetical',
|
||||
[DefaultPlaylistSortTypes.numEntities]: '# Entities',
|
||||
};
|
||||
|
||||
const useStyles = makeStyles({
|
||||
select: {
|
||||
width: '10rem',
|
||||
marginRight: '1rem',
|
||||
},
|
||||
icon: {
|
||||
verticalAlign: 'bottom',
|
||||
},
|
||||
});
|
||||
|
||||
export const PlaylistSortPicker = () => {
|
||||
const classes = useStyles();
|
||||
const { updateSort } = usePlaylistList();
|
||||
|
||||
useEffectOnce(() =>
|
||||
updateSort(DefaultSortCompareFunctions[DefaultPlaylistSortTypes.popular]),
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
className={classes.select}
|
||||
disableUnderline
|
||||
data-testid="sort-picker-select"
|
||||
renderValue={val => (
|
||||
<Typography>
|
||||
<SwapVertIcon className={classes.icon} />
|
||||
{sortTypeLabels[val as DefaultPlaylistSortTypes]}
|
||||
</Typography>
|
||||
)}
|
||||
defaultValue={DefaultPlaylistSortTypes.popular}
|
||||
onChange={e =>
|
||||
updateSort(
|
||||
DefaultSortCompareFunctions[
|
||||
e.target.value as DefaultPlaylistSortTypes
|
||||
],
|
||||
)
|
||||
}
|
||||
>
|
||||
<ListSubheader onClickCapture={e => e.stopPropagation()}>
|
||||
Sort By
|
||||
</ListSubheader>
|
||||
{Object.entries(sortTypeLabels).map(([val, label]) => (
|
||||
<MenuItem key={val} value={val}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2022 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 './PlaylistSortPicker';
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright 2022 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 { PlaylistIndexPage } from '../PlaylistIndexPage';
|
||||
import { PlaylistPage } from '../PlaylistPage';
|
||||
import { Router } from './Router';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
|
||||
jest.mock('../PlaylistIndexPage', () => ({
|
||||
PlaylistIndexPage: jest.fn(() => null),
|
||||
}));
|
||||
|
||||
jest.mock('../PlaylistPage', () => ({
|
||||
PlaylistPage: jest.fn(() => null),
|
||||
}));
|
||||
|
||||
describe('Router', () => {
|
||||
beforeEach(() => {
|
||||
(PlaylistPage as jest.Mock).mockClear();
|
||||
(PlaylistIndexPage as jest.Mock).mockClear();
|
||||
});
|
||||
describe('/', () => {
|
||||
it('should render the PlaylistIndexPage', async () => {
|
||||
await renderInTestApp(<Router />);
|
||||
expect(PlaylistIndexPage).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('/:playlistId', () => {
|
||||
it('should render the PlaylistPage page', async () => {
|
||||
await renderInTestApp(<Router />, {
|
||||
routeEntries: ['/my-playlist'],
|
||||
});
|
||||
expect(PlaylistPage).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright 2022 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 { Routes, Route } from 'react-router';
|
||||
|
||||
import { playlistRouteRef } from '../../routes';
|
||||
import { PlaylistIndexPage } from '../PlaylistIndexPage';
|
||||
import { PlaylistPage } from '../PlaylistPage';
|
||||
|
||||
export const Router = () => (
|
||||
<Routes>
|
||||
<Route path="/" element={<PlaylistIndexPage />} />
|
||||
<Route path={playlistRouteRef.path} element={<PlaylistPage />} />
|
||||
</Routes>
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2022 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 './Router';
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2022 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 './CreatePlaylistButton';
|
||||
export * from './EntityPlaylistDialog';
|
||||
export * from './PersonalListPicker';
|
||||
export * from './PlaylistCard';
|
||||
export * from './PlaylistEditDialog';
|
||||
export * from './PlaylistIndexPage';
|
||||
export * from './PlaylistList';
|
||||
export * from './PlaylistOwnerPicker';
|
||||
export * from './PlaylistPage';
|
||||
export * from './PlaylistSearchBar';
|
||||
export * from './PlaylistSortPicker';
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2022 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 './usePlaylistList';
|
||||
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* Copyright 2022 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 {
|
||||
ConfigApi,
|
||||
configApiRef,
|
||||
IdentityApi,
|
||||
identityApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { Playlist } from '@backstage/plugin-playlist-common';
|
||||
import { TestApiProvider } from '@backstage/test-utils';
|
||||
import { act, renderHook } from '@testing-library/react-hooks';
|
||||
import qs from 'qs';
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { PlaylistApi, playlistApiRef } from '../api';
|
||||
import {
|
||||
DefaultSortCompareFunctions,
|
||||
DefaultPlaylistSortTypes,
|
||||
PersonalListFilter,
|
||||
PersonalListFilterValue,
|
||||
PersonalListPicker,
|
||||
PlaylistOwnerPicker,
|
||||
} from '../components';
|
||||
import { PlaylistListProvider, usePlaylistList } from './usePlaylistList';
|
||||
|
||||
const playlists: Playlist[] = [
|
||||
{
|
||||
id: 'id1',
|
||||
name: 'list-1',
|
||||
owner: 'user:default/guest',
|
||||
public: true,
|
||||
entities: 2,
|
||||
followers: 5,
|
||||
isFollowing: false,
|
||||
},
|
||||
{
|
||||
id: 'id2',
|
||||
name: 'list-2',
|
||||
owner: 'group:default/foo',
|
||||
public: true,
|
||||
entities: 3,
|
||||
followers: 2,
|
||||
isFollowing: true,
|
||||
},
|
||||
];
|
||||
|
||||
const mockConfigApi = {
|
||||
getOptionalString: () => '',
|
||||
} as Partial<ConfigApi>;
|
||||
|
||||
const mockIdentityApi: Partial<IdentityApi> = {
|
||||
getBackstageIdentity: async () => ({
|
||||
type: 'user',
|
||||
userEntityRef: 'user:default/guest',
|
||||
ownershipEntityRefs: [],
|
||||
}),
|
||||
};
|
||||
|
||||
const mockPlaylistApi: Partial<PlaylistApi> = {
|
||||
getAllPlaylists: jest.fn().mockImplementation(async () => playlists),
|
||||
};
|
||||
|
||||
const wrapper = ({
|
||||
location,
|
||||
children,
|
||||
}: PropsWithChildren<{
|
||||
location?: string;
|
||||
}>) => {
|
||||
return (
|
||||
<MemoryRouter initialEntries={[location ?? '']}>
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[configApiRef, mockConfigApi],
|
||||
[playlistApiRef, mockPlaylistApi],
|
||||
[identityApiRef, mockIdentityApi],
|
||||
]}
|
||||
>
|
||||
<PlaylistListProvider>
|
||||
<PersonalListPicker />
|
||||
<PlaylistOwnerPicker />
|
||||
{children}
|
||||
</PlaylistListProvider>
|
||||
</TestApiProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('<PlaylistListProvider />', () => {
|
||||
const origReplaceState = window.history.replaceState;
|
||||
beforeEach(() => {
|
||||
window.history.replaceState = jest.fn();
|
||||
});
|
||||
afterEach(() => {
|
||||
window.history.replaceState = origReplaceState;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('resolves backend filters', async () => {
|
||||
const { result, waitForValueToChange } = renderHook(
|
||||
() => usePlaylistList(),
|
||||
{
|
||||
wrapper,
|
||||
},
|
||||
);
|
||||
await waitForValueToChange(() => result.current.backendPlaylists);
|
||||
expect(result.current.backendPlaylists.length).toBe(2);
|
||||
expect(mockPlaylistApi.getAllPlaylists).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves frontend filters', async () => {
|
||||
const { result, waitFor } = renderHook(() => usePlaylistList(), {
|
||||
wrapper,
|
||||
});
|
||||
await waitFor(() => !!result.current.playlists.length);
|
||||
expect(result.current.backendPlaylists.length).toBe(2);
|
||||
|
||||
act(() =>
|
||||
result.current.updateFilters({
|
||||
personal: new PersonalListFilter(
|
||||
PersonalListFilterValue.owned,
|
||||
playlist => playlist.name === 'list-1',
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.backendPlaylists.length).toBe(2);
|
||||
expect(result.current.playlists.length).toBe(1);
|
||||
expect(mockPlaylistApi.getAllPlaylists).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves query param filter values', async () => {
|
||||
const query = qs.stringify({
|
||||
filters: { personal: 'all', owners: ['user:default/guest'] },
|
||||
});
|
||||
const { result, waitFor } = renderHook(() => usePlaylistList(), {
|
||||
wrapper,
|
||||
initialProps: {
|
||||
location: `/playlist?${query}`,
|
||||
},
|
||||
});
|
||||
await waitFor(() => !!result.current.queryParameters);
|
||||
expect(result.current.queryParameters).toEqual({
|
||||
personal: 'all',
|
||||
owners: ['user:default/guest'],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not fetch when only frontend filters change', async () => {
|
||||
const { result, waitFor } = renderHook(() => usePlaylistList(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.playlists.length).toBe(2);
|
||||
expect(mockPlaylistApi.getAllPlaylists).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
act(() =>
|
||||
result.current.updateFilters({
|
||||
personal: new PersonalListFilter(
|
||||
PersonalListFilterValue.owned,
|
||||
playlist => playlist.name === 'list-1',
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.playlists.length).toBe(1);
|
||||
expect(mockPlaylistApi.getAllPlaylists).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('applies custom sorting', async () => {
|
||||
const { result, waitFor } = renderHook(() => usePlaylistList(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.playlists.length).toBe(2);
|
||||
expect(result.current.playlists[0].name).toEqual('list-1');
|
||||
expect(result.current.playlists[1].name).toEqual('list-2');
|
||||
});
|
||||
|
||||
act(() =>
|
||||
result.current.updateSort(
|
||||
DefaultSortCompareFunctions[DefaultPlaylistSortTypes.numEntities],
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.playlists.length).toBe(2);
|
||||
expect(result.current.playlists[0].name).toEqual('list-2');
|
||||
expect(result.current.playlists[1].name).toEqual('list-1');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an error on playlistApi failure', async () => {
|
||||
mockPlaylistApi.getAllPlaylists = jest.fn().mockRejectedValue('error');
|
||||
const { result, waitFor } = renderHook(() => usePlaylistList(), {
|
||||
wrapper,
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
/*
|
||||
* Copyright 2022 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 { useApi } from '@backstage/core-plugin-api';
|
||||
import { Playlist } from '@backstage/plugin-playlist-common';
|
||||
import { compact, isEqual } from 'lodash';
|
||||
import qs from 'qs';
|
||||
import React, {
|
||||
createContext,
|
||||
PropsWithChildren,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useLocation } from 'react-router';
|
||||
import useAsyncFn from 'react-use/lib/useAsyncFn';
|
||||
import useDebounce from 'react-use/lib/useDebounce';
|
||||
import useMountedState from 'react-use/lib/useMountedState';
|
||||
|
||||
import { playlistApiRef } from '../api';
|
||||
import { PersonalListFilter } from '../components/PersonalListPicker';
|
||||
import { PlaylistOwnerFilter } from '../components/PlaylistOwnerPicker';
|
||||
import { PlaylistTextFilter } from '../components/PlaylistSearchBar';
|
||||
import {
|
||||
DefaultPlaylistSortTypes,
|
||||
DefaultSortCompareFunctions,
|
||||
} from '../components/PlaylistSortPicker';
|
||||
import { PlaylistFilter, PlaylistSortCompareFunction } from '../types';
|
||||
|
||||
class NoopFilter implements PlaylistFilter {
|
||||
getBackendFilters() {
|
||||
return { '': null };
|
||||
}
|
||||
}
|
||||
|
||||
export type DefaultPlaylistFilters = {
|
||||
noop?: NoopFilter;
|
||||
owners?: PlaylistOwnerFilter;
|
||||
personal?: PersonalListFilter;
|
||||
text?: PlaylistTextFilter;
|
||||
};
|
||||
|
||||
export type PlaylistListContextProps<
|
||||
PlaylistFilters extends DefaultPlaylistFilters = DefaultPlaylistFilters,
|
||||
> = {
|
||||
/**
|
||||
* The currently registered filters, adhering to the shape of DefaultPlaylistFilters
|
||||
*/
|
||||
filters: PlaylistFilters;
|
||||
|
||||
/**
|
||||
* The resolved list of playlists, after all filters are applied.
|
||||
*/
|
||||
playlists: Playlist[];
|
||||
|
||||
/**
|
||||
* The resolved list of playlists, after _only playlist-backend_ filters are applied.
|
||||
*/
|
||||
backendPlaylists: Playlist[];
|
||||
|
||||
/**
|
||||
* Update one or more of the registered filters. Optional filters can be set to `undefined` to
|
||||
* reset the filter.
|
||||
*/
|
||||
updateFilters: (
|
||||
filters:
|
||||
| Partial<PlaylistFilters>
|
||||
| ((prevFilters: PlaylistFilters) => Partial<PlaylistFilters>),
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Update the compare function used to sort playlists.
|
||||
*/
|
||||
updateSort: (compareFn: PlaylistSortCompareFunction) => void;
|
||||
|
||||
/**
|
||||
* Filter values from query parameters.
|
||||
*/
|
||||
queryParameters: Partial<Record<keyof PlaylistFilters, string | string[]>>;
|
||||
|
||||
loading: boolean;
|
||||
error?: Error;
|
||||
};
|
||||
|
||||
export const PlaylistListContext = createContext<
|
||||
PlaylistListContextProps<any> | undefined
|
||||
>(undefined);
|
||||
|
||||
const reduceBackendFilters = (
|
||||
filters: PlaylistFilter[],
|
||||
): Record<string, string | string[] | null> => {
|
||||
return filters.reduce((compoundFilter, filter) => {
|
||||
return {
|
||||
...compoundFilter,
|
||||
...(filter.getBackendFilters ? filter.getBackendFilters() : {}),
|
||||
};
|
||||
}, {} as Record<string, string | string[] | null>);
|
||||
};
|
||||
|
||||
type OutputState<PlaylistFilters extends DefaultPlaylistFilters> = {
|
||||
appliedFilters: PlaylistFilters;
|
||||
playlists: Playlist[];
|
||||
backendPlaylists: Playlist[];
|
||||
};
|
||||
|
||||
export const PlaylistListProvider = <
|
||||
PlaylistFilters extends DefaultPlaylistFilters,
|
||||
>({
|
||||
children,
|
||||
}: PropsWithChildren<{}>) => {
|
||||
const isMounted = useMountedState();
|
||||
const playlistApi = useApi(playlistApiRef);
|
||||
const [sortCompareFn, setSortCompareFn] =
|
||||
useState<PlaylistSortCompareFunction>(
|
||||
() => DefaultSortCompareFunctions[DefaultPlaylistSortTypes.popular],
|
||||
);
|
||||
const [requestedFilters, setRequestedFilters] = useState<PlaylistFilters>(
|
||||
{} as PlaylistFilters,
|
||||
);
|
||||
|
||||
// We use react-router's useLocation hook so updates from external sources trigger an update to
|
||||
// the queryParameters in outputState. Updates from this hook use replaceState below and won't
|
||||
// trigger a useLocation change; this would instead come from an external source, such as a manual
|
||||
// update of the URL or two sidebar links with different filters.
|
||||
const location = useLocation();
|
||||
const queryParameters = useMemo(
|
||||
() =>
|
||||
(qs.parse(location.search, {
|
||||
ignoreQueryPrefix: true,
|
||||
}).filters ?? {}) as Record<string, string | string[]>,
|
||||
[location],
|
||||
);
|
||||
|
||||
const [outputState, setOutputState] = useState<OutputState<PlaylistFilters>>({
|
||||
appliedFilters: {
|
||||
noop: new NoopFilter(), // Init with a noop filter to trigger intial request
|
||||
} as PlaylistFilters,
|
||||
playlists: [],
|
||||
backendPlaylists: [],
|
||||
});
|
||||
|
||||
// The main async filter worker. Note that while it has a lot of dependencies
|
||||
// in terms of its implementation, the triggering only happens (debounced)
|
||||
// based on the requested filters/sortCompareFn changing.
|
||||
const [{ loading, error }, refresh] = useAsyncFn(
|
||||
async () => {
|
||||
const compacted: PlaylistFilter[] = compact(
|
||||
Object.values(requestedFilters),
|
||||
);
|
||||
const playlistFilter = (p: Playlist) =>
|
||||
compacted.every(
|
||||
filter => !filter.filterPlaylist || filter.filterPlaylist(p),
|
||||
);
|
||||
const backendFilter = reduceBackendFilters(compacted);
|
||||
const previousBackendFilter = reduceBackendFilters(
|
||||
compact(Object.values(outputState.appliedFilters)),
|
||||
);
|
||||
|
||||
const queryParams = Object.keys(requestedFilters).reduce(
|
||||
(params, key) => {
|
||||
const filter: PlaylistFilter | undefined =
|
||||
requestedFilters[key as keyof PlaylistFilters];
|
||||
if (filter?.toQueryValue) {
|
||||
params[key] = filter.toQueryValue();
|
||||
}
|
||||
return params;
|
||||
},
|
||||
{} as Record<string, string | string[]>,
|
||||
);
|
||||
|
||||
if (!isEqual(previousBackendFilter, backendFilter)) {
|
||||
const response = await playlistApi.getAllPlaylists({
|
||||
filter: backendFilter,
|
||||
});
|
||||
setOutputState({
|
||||
appliedFilters: requestedFilters,
|
||||
backendPlaylists: response,
|
||||
playlists: response.filter(playlistFilter).sort(sortCompareFn),
|
||||
});
|
||||
} else {
|
||||
setOutputState({
|
||||
appliedFilters: requestedFilters,
|
||||
backendPlaylists: outputState.backendPlaylists,
|
||||
playlists: outputState.backendPlaylists
|
||||
.filter(playlistFilter)
|
||||
.sort(sortCompareFn),
|
||||
});
|
||||
}
|
||||
|
||||
if (isMounted()) {
|
||||
const oldParams = qs.parse(location.search, {
|
||||
ignoreQueryPrefix: true,
|
||||
});
|
||||
const newParams = qs.stringify(
|
||||
{ ...oldParams, filters: queryParams },
|
||||
{ addQueryPrefix: true, arrayFormat: 'repeat' },
|
||||
);
|
||||
const newUrl = `${window.location.pathname}${newParams}`;
|
||||
// We use direct history manipulation since useSearchParams and
|
||||
// useNavigate in react-router-dom cause unnecessary extra rerenders.
|
||||
// Also make sure to replace the state rather than pushing, since we
|
||||
// don't want there to be back/forward slots for every single filter
|
||||
// change.
|
||||
window.history?.replaceState(null, document.title, newUrl);
|
||||
}
|
||||
},
|
||||
[
|
||||
playlistApi,
|
||||
queryParameters,
|
||||
requestedFilters,
|
||||
sortCompareFn,
|
||||
outputState,
|
||||
],
|
||||
{ loading: true },
|
||||
);
|
||||
|
||||
// Slight debounce on the refresh, since (especially on page load) several
|
||||
// filters will be calling this in rapid succession.
|
||||
useDebounce(refresh, 10, [requestedFilters, sortCompareFn]);
|
||||
|
||||
const updateFilters = useCallback(
|
||||
(
|
||||
update:
|
||||
| Partial<PlaylistFilters>
|
||||
| ((prevFilters: PlaylistFilters) => Partial<PlaylistFilters>),
|
||||
) => {
|
||||
setRequestedFilters(prevFilters => {
|
||||
const newFilters =
|
||||
typeof update === 'function' ? update(prevFilters) : update;
|
||||
return { ...prevFilters, ...newFilters };
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const updateSort = useCallback(
|
||||
(compareFn: PlaylistSortCompareFunction) =>
|
||||
setSortCompareFn(() => compareFn),
|
||||
[],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
filters: outputState.appliedFilters,
|
||||
playlists: outputState.playlists,
|
||||
backendPlaylists: outputState.backendPlaylists,
|
||||
updateFilters,
|
||||
updateSort,
|
||||
queryParameters,
|
||||
loading,
|
||||
error,
|
||||
}),
|
||||
[outputState, updateFilters, updateSort, queryParameters, loading, error],
|
||||
);
|
||||
|
||||
return (
|
||||
<PlaylistListContext.Provider value={value}>
|
||||
{children}
|
||||
</PlaylistListContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function usePlaylistList<
|
||||
PlaylistFilters extends DefaultPlaylistFilters = DefaultPlaylistFilters,
|
||||
>(): PlaylistListContextProps<PlaylistFilters> {
|
||||
const context = useContext(PlaylistListContext);
|
||||
if (!context)
|
||||
throw new Error('usePlaylistList must be used within PlaylistListProvider');
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright 2022 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Playlist frontend plugin
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
export type { EntityPlaylistDialogProps } from './components';
|
||||
export {
|
||||
EntityPlaylistDialog,
|
||||
playlistPlugin,
|
||||
PlaylistIndexPage,
|
||||
} from './plugin';
|
||||
export * from './api';
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright 2022 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 { playlistPlugin } from './plugin';
|
||||
|
||||
describe('playlist', () => {
|
||||
it('should export plugin', () => {
|
||||
expect(playlistPlugin).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright 2022 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 {
|
||||
createApiFactory,
|
||||
createComponentExtension,
|
||||
createPlugin,
|
||||
createRoutableExtension,
|
||||
discoveryApiRef,
|
||||
fetchApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
|
||||
import { playlistApiRef, PlaylistClient } from './api';
|
||||
import { rootRouteRef } from './routes';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const playlistPlugin = createPlugin({
|
||||
id: 'playlist',
|
||||
routes: {
|
||||
root: rootRouteRef,
|
||||
},
|
||||
apis: [
|
||||
createApiFactory({
|
||||
api: playlistApiRef,
|
||||
deps: {
|
||||
discoveryApi: discoveryApiRef,
|
||||
fetchApi: fetchApiRef,
|
||||
},
|
||||
factory: ({ discoveryApi, fetchApi }) =>
|
||||
new PlaylistClient({ discoveryApi, fetchApi }),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const PlaylistIndexPage = playlistPlugin.provide(
|
||||
createRoutableExtension({
|
||||
name: 'PlaylistIndexPage',
|
||||
component: () => import('./components/Router').then(m => m.Router),
|
||||
mountPoint: rootRouteRef,
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const EntityPlaylistDialog = playlistPlugin.provide(
|
||||
createComponentExtension({
|
||||
name: 'EntityPlaylistDialog',
|
||||
component: {
|
||||
lazy: () =>
|
||||
import('./components/EntityPlaylistDialog').then(
|
||||
m => m.EntityPlaylistDialog,
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright 2022 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 { createRouteRef, createSubRouteRef } from '@backstage/core-plugin-api';
|
||||
|
||||
export const rootRouteRef = createRouteRef({
|
||||
id: 'playlist:index-page',
|
||||
});
|
||||
|
||||
export const playlistRouteRef = createSubRouteRef({
|
||||
id: 'playlist:playlist-page',
|
||||
parent: rootRouteRef,
|
||||
path: '/:playlistId',
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2022 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 '@testing-library/jest-dom';
|
||||
import 'cross-fetch/polyfill';
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Copyright 2022 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, {
|
||||
PropsWithChildren,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
DefaultPlaylistSortTypes,
|
||||
DefaultSortCompareFunctions,
|
||||
} from '../components';
|
||||
import {
|
||||
DefaultPlaylistFilters,
|
||||
PlaylistListContext,
|
||||
PlaylistListContextProps,
|
||||
} from '../hooks';
|
||||
import { PlaylistSortCompareFunction } from '../types';
|
||||
|
||||
export const MockPlaylistListProvider = ({
|
||||
children,
|
||||
value,
|
||||
}: PropsWithChildren<{
|
||||
value?: Partial<PlaylistListContextProps>;
|
||||
}>) => {
|
||||
const [filters, setFilters] = useState<DefaultPlaylistFilters>(
|
||||
value?.filters ?? {},
|
||||
);
|
||||
|
||||
const [_, setSortCompareFn] = useState<PlaylistSortCompareFunction>(
|
||||
() => DefaultSortCompareFunctions[DefaultPlaylistSortTypes.popular],
|
||||
);
|
||||
|
||||
const updateFilters = useCallback(
|
||||
(
|
||||
update:
|
||||
| Partial<DefaultPlaylistFilters>
|
||||
| ((
|
||||
prevFilters: DefaultPlaylistFilters,
|
||||
) => Partial<DefaultPlaylistFilters>),
|
||||
) => {
|
||||
setFilters(prevFilters => {
|
||||
const newFilters =
|
||||
typeof update === 'function' ? update(prevFilters) : update;
|
||||
return { ...prevFilters, ...newFilters };
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const updateSort = useCallback(
|
||||
(compareFn: PlaylistSortCompareFunction) =>
|
||||
setSortCompareFn(() => compareFn),
|
||||
[],
|
||||
);
|
||||
|
||||
// Memoize the default values since pickers have useEffect triggers on these; naively defaulting
|
||||
// below with `?? <X>` breaks referential equality on subsequent updates.
|
||||
const defaultValues = useMemo(
|
||||
() => ({
|
||||
playlists: [],
|
||||
backendPlaylists: [],
|
||||
queryParameters: {},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const resolvedValue: PlaylistListContextProps = useMemo(
|
||||
() => ({
|
||||
playlists: value?.playlists ?? defaultValues.playlists,
|
||||
backendPlaylists:
|
||||
value?.backendPlaylists ?? defaultValues.backendPlaylists,
|
||||
updateFilters: value?.updateFilters ?? updateFilters,
|
||||
filters,
|
||||
updateSort: value?.updateSort ?? updateSort,
|
||||
loading: value?.loading ?? false,
|
||||
queryParameters: value?.queryParameters ?? defaultValues.queryParameters,
|
||||
error: value?.error,
|
||||
}),
|
||||
[value, defaultValues, filters, updateFilters, updateSort],
|
||||
);
|
||||
|
||||
return (
|
||||
<PlaylistListContext.Provider value={resolvedValue}>
|
||||
{children}
|
||||
</PlaylistListContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2022 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 './MockPlaylistListProvider';
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2022 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 { Playlist } from '@backstage/plugin-playlist-common';
|
||||
|
||||
export type PlaylistFilter = {
|
||||
getBackendFilters?: () => Record<string, string | string[] | null>;
|
||||
|
||||
filterPlaylist?: (playlist: Playlist) => boolean;
|
||||
|
||||
toQueryValue?: () => string | string[];
|
||||
};
|
||||
|
||||
export type PlaylistSortCompareFunction = (a: Playlist, b: Playlist) => number;
|
||||
@@ -40,6 +40,7 @@
|
||||
"@material-ui/core": "^4.12.2",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.57",
|
||||
"lodash": "^4.17.21",
|
||||
"qs": "^6.9.4",
|
||||
"react-use": "^17.3.2"
|
||||
},
|
||||
|
||||
@@ -160,6 +160,60 @@ describe('SearchContext', () => {
|
||||
|
||||
expect(result.current.pageCursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it('When filters are cleared', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => useSearch(), {
|
||||
wrapper,
|
||||
initialProps: {
|
||||
initialState: {
|
||||
...initialState,
|
||||
filters: { foo: 'bar' },
|
||||
term: 'first term',
|
||||
pageCursor: 'SOMEPAGE',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.filters).toEqual({ foo: 'bar' });
|
||||
expect(result.current.pageCursor).toEqual('SOMEPAGE');
|
||||
|
||||
act(() => {
|
||||
result.current.setFilters({});
|
||||
});
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.pageCursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it('When filters are set (and different from previous)', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => useSearch(), {
|
||||
wrapper,
|
||||
initialProps: {
|
||||
initialState: {
|
||||
...initialState,
|
||||
filters: { foo: 'bar' },
|
||||
term: 'first term',
|
||||
pageCursor: 'SOMEPAGE',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.filters).toEqual({ foo: 'bar' });
|
||||
expect(result.current.pageCursor).toEqual('SOMEPAGE');
|
||||
|
||||
act(() => {
|
||||
result.current.setFilters({ foo: 'test' });
|
||||
});
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.pageCursor).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Performs search (and sets results)', () => {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { isEqual } from 'lodash';
|
||||
import React, {
|
||||
PropsWithChildren,
|
||||
useCallback,
|
||||
@@ -115,6 +116,7 @@ const useSearchContextValue = (
|
||||
);
|
||||
|
||||
const prevTerm = usePrevious(term);
|
||||
const prevFilters = usePrevious(filters);
|
||||
|
||||
const result = useAsync(
|
||||
() =>
|
||||
@@ -146,6 +148,14 @@ const useSearchContextValue = (
|
||||
}
|
||||
}, [term, prevTerm, setPageCursor]);
|
||||
|
||||
useEffect(() => {
|
||||
// Any time filters is reset, we want to start from page 0.
|
||||
// Only reset the page if it has been modified by the user at least once, the initial state must not reset the page.
|
||||
if (prevFilters !== undefined && !isEqual(filters, prevFilters)) {
|
||||
setPageCursor(undefined);
|
||||
}
|
||||
}, [filters, prevFilters, setPageCursor]);
|
||||
|
||||
const value: SearchContextValue = {
|
||||
result,
|
||||
filters,
|
||||
|
||||
Reference in New Issue
Block a user