Merge pull request #16955 from antoniobergas/feat/playlist-dynamic-group-noun
[Playlist] feat: add dynamic group noun through config
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-playlist': patch
|
||||
---
|
||||
|
||||
Added config properties to change dynamically the group noun for all the components in the UI
|
||||
@@ -131,6 +131,15 @@ const defaultEntityPage = (
|
||||
|
||||
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)
|
||||
|
||||
## Custom Title
|
||||
|
||||
You can define a custom title to be shown in all the components of this plugin to replace the default term "playlist" in the UI. To do this you just need to add some config in your **app-config.yaml**, here's an example:
|
||||
|
||||
```yaml
|
||||
playlist:
|
||||
title: Collection
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### View All Playlists
|
||||
|
||||
Vendored
+25
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2023 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 interface Config {
|
||||
playlist?: {
|
||||
/**
|
||||
* (Optional) The title that will shown in the UI; Defaults to`Playlist` if undefined.
|
||||
* @visibility frontend
|
||||
*/
|
||||
title?: string | undefined;
|
||||
};
|
||||
}
|
||||
@@ -42,6 +42,7 @@
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.61",
|
||||
"lodash": "^4.17.21",
|
||||
"pluralize": "^8.0.0",
|
||||
"qs": "^6.9.4",
|
||||
"react-hook-form": "^7.13.0",
|
||||
"react-use": "^17.2.4"
|
||||
@@ -68,6 +69,8 @@
|
||||
"swr": "^2.0.0"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
"dist",
|
||||
"config.d.ts"
|
||||
],
|
||||
"configSchema": "config.d.ts"
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { playlistApiRef } from '../../api';
|
||||
import { playlistRouteRef } from '../../routes';
|
||||
import { PlaylistEditDialog } from '../PlaylistEditDialog';
|
||||
import { useTitle } from '../../hooks';
|
||||
|
||||
export const CreatePlaylistButton = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -55,13 +56,18 @@ export const CreatePlaylistButton = () => {
|
||||
[errorApi, navigate, playlistApi, playlistRoute],
|
||||
);
|
||||
|
||||
const singularTitle = useTitle({
|
||||
pluralize: false,
|
||||
lowerCase: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{isXSScreen ? (
|
||||
<IconButton
|
||||
disabled={!allowed}
|
||||
color="primary"
|
||||
title="Create Playlist"
|
||||
title={`Create ${singularTitle}`}
|
||||
size="small"
|
||||
onClick={() => setOpenDialog(true)}
|
||||
>
|
||||
@@ -74,7 +80,7 @@ export const CreatePlaylistButton = () => {
|
||||
color="primary"
|
||||
onClick={() => setOpenDialog(true)}
|
||||
>
|
||||
Create Playlist
|
||||
Create {singularTitle}
|
||||
</Button>
|
||||
)}
|
||||
<PlaylistEditDialog
|
||||
|
||||
@@ -51,6 +51,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import useAsyncFn from 'react-use/lib/useAsyncFn';
|
||||
|
||||
import { playlistApiRef } from '../../api';
|
||||
import { useTitle } from '../../hooks';
|
||||
import { playlistRouteRef } from '../../routes';
|
||||
import { PlaylistEditDialog } from '../PlaylistEditDialog';
|
||||
|
||||
@@ -104,6 +105,19 @@ export const EntityPlaylistDialog = (props: EntityPlaylistDialogProps) => {
|
||||
[playlistApi],
|
||||
);
|
||||
|
||||
const singularTitle = useTitle({
|
||||
pluralize: true,
|
||||
lowerCase: false,
|
||||
});
|
||||
const singularTitleLowerCase = useTitle({
|
||||
pluralize: false,
|
||||
lowerCase: true,
|
||||
});
|
||||
const pluralTitleLowerCase = useTitle({
|
||||
pluralize: true,
|
||||
lowerCase: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadPlaylists();
|
||||
@@ -120,12 +134,19 @@ export const EntityPlaylistDialog = (props: EntityPlaylistDialogProps) => {
|
||||
navigate(playlistRoute({ playlistId }));
|
||||
} catch (e) {
|
||||
alertApi.post({
|
||||
message: `Failed to add entity to playlist: ${e}`,
|
||||
message: `Failed to add entity to ${singularTitleLowerCase}: ${e}`,
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
},
|
||||
[alertApi, entity, navigate, playlistApi, playlistRoute],
|
||||
[
|
||||
alertApi,
|
||||
entity,
|
||||
navigate,
|
||||
playlistApi,
|
||||
playlistRoute,
|
||||
singularTitleLowerCase,
|
||||
],
|
||||
);
|
||||
|
||||
const [{ loading: addEntityLoading }, addToPlaylist] = useAsyncFn(
|
||||
@@ -141,12 +162,12 @@ export const EntityPlaylistDialog = (props: EntityPlaylistDialogProps) => {
|
||||
});
|
||||
} catch (e) {
|
||||
alertApi.post({
|
||||
message: `Failed to add entity to playlist: ${e}`,
|
||||
message: `Failed to add entity to ${singularTitleLowerCase}: ${e}`,
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
},
|
||||
[alertApi, closeDialog, entity, playlistApi],
|
||||
[alertApi, closeDialog, entity, playlistApi, singularTitleLowerCase],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -160,7 +181,7 @@ export const EntityPlaylistDialog = (props: EntityPlaylistDialogProps) => {
|
||||
>
|
||||
{(loading || addEntityLoading) && <LinearProgress />}
|
||||
<DialogTitle className={classes.dialogTitle}>
|
||||
Add to Playlist
|
||||
Add to {singularTitle}
|
||||
<TextField
|
||||
fullWidth
|
||||
data-testid="entity-playlist-dialog-search"
|
||||
@@ -191,7 +212,10 @@ export const EntityPlaylistDialog = (props: EntityPlaylistDialogProps) => {
|
||||
</DialogTitle>
|
||||
<DialogContent className={classes.dialogContent}>
|
||||
{error && (
|
||||
<ResponseErrorPanel title="Error loading playlists" error={error} />
|
||||
<ResponseErrorPanel
|
||||
title={`Error loading ${pluralTitleLowerCase}`}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
{playlists && entity && (
|
||||
<List>
|
||||
@@ -205,7 +229,9 @@ export const EntityPlaylistDialog = (props: EntityPlaylistDialogProps) => {
|
||||
<ListItemIcon>
|
||||
<PlaylistAddIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Create new playlist" />
|
||||
<ListItemText
|
||||
primary={`Create new ${singularTitleLowerCase}`}
|
||||
/>
|
||||
</ListItem>
|
||||
)}
|
||||
{playlists
|
||||
|
||||
@@ -39,6 +39,7 @@ 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';
|
||||
import { useTitle } from '../../hooks';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
buttonWrapper: {
|
||||
@@ -105,6 +106,11 @@ export const PlaylistEditDialog = ({
|
||||
}
|
||||
};
|
||||
|
||||
const titleSingularLowerCase = useTitle({
|
||||
pluralize: false,
|
||||
lowerCase: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog fullWidth maxWidth="xs" onClose={closeDialog} open={open}>
|
||||
<DialogContent>
|
||||
@@ -121,7 +127,7 @@ export const PlaylistEditDialog = ({
|
||||
fullWidth
|
||||
label="Name"
|
||||
margin="dense"
|
||||
placeholder="Give your playlist name"
|
||||
placeholder={`Give your ${titleSingularLowerCase} a name`}
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
@@ -139,7 +145,7 @@ export const PlaylistEditDialog = ({
|
||||
label="Description"
|
||||
margin="dense"
|
||||
multiline
|
||||
placeholder="Describe your playlist"
|
||||
placeholder={`Describe your ${titleSingularLowerCase}`}
|
||||
type="text"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
} from '@backstage/core-components';
|
||||
import { CatalogFilterLayout } from '@backstage/plugin-catalog-react';
|
||||
|
||||
import { PlaylistListProvider } from '../../hooks';
|
||||
import { PlaylistListProvider, useTitle } from '../../hooks';
|
||||
import { CreatePlaylistButton } from '../CreatePlaylistButton';
|
||||
import { PersonalListPicker } from '../PersonalListPicker';
|
||||
import { PlaylistList } from '../PlaylistList';
|
||||
@@ -31,26 +31,33 @@ 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>
|
||||
);
|
||||
export const PlaylistIndexPage = () => {
|
||||
const pluralTitle = useTitle({
|
||||
pluralize: true,
|
||||
lowerCase: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<PageWithHeader themeId="home" title={pluralTitle}>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,13 +14,19 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ConfigApi, configApiRef } from '@backstage/core-plugin-api';
|
||||
import { Playlist } from '@backstage/plugin-playlist-common';
|
||||
import { TestApiProvider } from '@backstage/test-utils';
|
||||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { MockPlaylistListProvider } from '../../testUtils';
|
||||
import { PlaylistList } from './PlaylistList';
|
||||
|
||||
const mockConfigApi = {
|
||||
getOptionalString: () => undefined,
|
||||
} as Partial<ConfigApi>;
|
||||
|
||||
jest.mock('../PlaylistCard', () => ({
|
||||
PlaylistCard: ({ playlist }: { playlist: Playlist }) => (
|
||||
<div>{playlist.name}</div>
|
||||
@@ -30,9 +36,11 @@ jest.mock('../PlaylistCard', () => ({
|
||||
describe('<PlaylistList/>', () => {
|
||||
it('renders error on error', () => {
|
||||
const rendered = render(
|
||||
<MockPlaylistListProvider value={{ error: new Error('Test Error') }}>
|
||||
<PlaylistList />
|
||||
</MockPlaylistListProvider>,
|
||||
<TestApiProvider apis={[[configApiRef, mockConfigApi]]}>
|
||||
<MockPlaylistListProvider value={{ error: new Error('Test Error') }}>
|
||||
<PlaylistList />
|
||||
</MockPlaylistListProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
expect(rendered.getByText('Test Error')).toBeInTheDocument();
|
||||
@@ -40,9 +48,11 @@ describe('<PlaylistList/>', () => {
|
||||
|
||||
it('handles no playlists', () => {
|
||||
const rendered = render(
|
||||
<MockPlaylistListProvider value={{ playlists: [] }}>
|
||||
<PlaylistList />
|
||||
</MockPlaylistListProvider>,
|
||||
<TestApiProvider apis={[[configApiRef, mockConfigApi]]}>
|
||||
<MockPlaylistListProvider value={{ playlists: [] }}>
|
||||
<PlaylistList />
|
||||
</MockPlaylistListProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
expect(
|
||||
@@ -52,32 +62,34 @@ describe('<PlaylistList/>', () => {
|
||||
|
||||
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>,
|
||||
<TestApiProvider apis={[[configApiRef, mockConfigApi]]}>
|
||||
<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>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
expect(rendered.getByText('playlist-1')).toBeInTheDocument();
|
||||
|
||||
@@ -23,18 +23,24 @@ import {
|
||||
} from '@backstage/core-components';
|
||||
import { Typography } from '@material-ui/core';
|
||||
|
||||
import { usePlaylistList } from '../../hooks';
|
||||
import { useTitle, usePlaylistList } from '../../hooks';
|
||||
import { PlaylistCard } from '../PlaylistCard';
|
||||
|
||||
export const PlaylistList = () => {
|
||||
const { loading, error, playlists } = usePlaylistList();
|
||||
const pluralTitleLowerCase = useTitle({
|
||||
pluralize: true,
|
||||
lowerCase: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading && <Progress />}
|
||||
|
||||
{error && (
|
||||
<WarningPanel title="Oops! Something went wrong loading playlists">
|
||||
<WarningPanel
|
||||
title={`Oops! Something went wrong loading ${pluralTitleLowerCase}`}
|
||||
>
|
||||
{error.message}
|
||||
</WarningPanel>
|
||||
)}
|
||||
|
||||
@@ -32,6 +32,7 @@ import React, { forwardRef, useCallback, useEffect, useState } from 'react';
|
||||
import useAsyncFn from 'react-use/lib/useAsyncFn';
|
||||
|
||||
import { playlistApiRef } from '../../api';
|
||||
import { useTitle } from '../../hooks';
|
||||
import { AddEntitiesDrawer } from './AddEntitiesDrawer';
|
||||
|
||||
export const PlaylistEntitiesTable = ({
|
||||
@@ -84,16 +85,21 @@ export const PlaylistEntitiesTable = ({
|
||||
[errorApi, loadEntities, playlistApi, playlistId],
|
||||
);
|
||||
|
||||
const singularTitleLowerCase = useTitle({
|
||||
pluralize: false,
|
||||
lowerCase: true,
|
||||
});
|
||||
|
||||
const actions = editAllowed
|
||||
? [
|
||||
{
|
||||
icon: DeleteIcon,
|
||||
tooltip: 'Remove from playlist',
|
||||
tooltip: `Remove from ${singularTitleLowerCase}`,
|
||||
onClick: removeEntity,
|
||||
},
|
||||
{
|
||||
icon: AddBoxIcon,
|
||||
tooltip: 'Add entities to playlist',
|
||||
tooltip: `Add entities to ${singularTitleLowerCase}`,
|
||||
isFreeAction: true,
|
||||
onClick: () => setOpenAddEntitiesDrawer(true),
|
||||
},
|
||||
|
||||
@@ -45,6 +45,7 @@ import useAsyncFn from 'react-use/lib/useAsyncFn';
|
||||
import { playlistApiRef } from '../../api';
|
||||
import { rootRouteRef } from '../../routes';
|
||||
import { PlaylistEditDialog } from '../PlaylistEditDialog';
|
||||
import { useTitle } from '../../hooks';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
buttonWrapper: {
|
||||
@@ -109,6 +110,11 @@ export const PlaylistHeader = ({ playlist, onUpdate }: PlaylistHeaderProps) => {
|
||||
}
|
||||
}, [playlistApi]);
|
||||
|
||||
const singularTitle = useTitle({
|
||||
pluralize: false,
|
||||
lowerCase: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<Header
|
||||
type={!playlist.public ? 'private' : undefined}
|
||||
@@ -143,7 +149,7 @@ export const PlaylistHeader = ({ playlist, onUpdate }: PlaylistHeaderProps) => {
|
||||
onClick: () => setOpenEditDialog(true),
|
||||
},
|
||||
{
|
||||
label: 'Delete Playlist',
|
||||
label: `Delete ${singularTitle}`,
|
||||
icon: <DeleteIcon />,
|
||||
disabled: !deleteAllowed,
|
||||
onClick: () => setOpenDeleteDialog(true),
|
||||
|
||||
@@ -15,3 +15,4 @@
|
||||
*/
|
||||
|
||||
export * from './usePlaylistList';
|
||||
export * from './useTitle';
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2023 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, useApi } from '@backstage/core-plugin-api';
|
||||
import pluralize from 'pluralize';
|
||||
|
||||
interface UseTitleOptions {
|
||||
pluralize?: boolean;
|
||||
lowerCase?: boolean;
|
||||
}
|
||||
|
||||
export function useTitle(opts: UseTitleOptions) {
|
||||
const configApi = useApi(configApiRef);
|
||||
let title = configApi.getOptionalString('playlist.title') ?? 'Playlist';
|
||||
|
||||
if (opts.pluralize) {
|
||||
title = pluralize(title);
|
||||
}
|
||||
|
||||
return opts.lowerCase ? title.toLocaleLowerCase('en-US') : title;
|
||||
}
|
||||
Reference in New Issue
Block a user