Merge pull request #12870 from kuangp/feat/playlists

feat(playlists): implement playlist plugin
This commit is contained in:
Patrik Oldsberg
2022-09-19 16:49:44 +02:00
committed by GitHub
118 changed files with 9232 additions and 34 deletions
+1
View File
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+88
View File
@@ -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(),
...
```
+96
View File
@@ -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');
};
+54
View File
@@ -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}"
]
}
+28
View File
@@ -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 };
+33
View File
@@ -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 {};
+1
View File
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+3
View File
@@ -0,0 +1,3 @@
# Playlist Common
Common functionalities, types, and permissions for the playlist plugin.
+36
View File
@@ -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;
};
```
+34
View File
@@ -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"
]
}
+24
View File
@@ -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,
};
+16
View File
@@ -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 {};
+35
View File
@@ -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;
};
+1
View File
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+128
View File
@@ -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.
+106
View File
@@ -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>;
},
{},
{}
>;
```
+27
View File
@@ -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();
+66
View File
@@ -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"
]
}
+76
View File
@@ -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');
});
});
+199
View File
@@ -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);
}
}
}
+18
View File
@@ -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';
+27
View File
@@ -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';
+17
View File
@@ -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;
}
+28
View File
@@ -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';
+22
View File
@@ -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();
});
});
+74
View File
@@ -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,
),
},
}),
);
+26
View File
@@ -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',
});
+17
View File
@@ -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>
);
};
+17
View File
@@ -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';
+27
View File
@@ -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;
+1
View File
@@ -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,