diff --git a/.changeset/swift-frogs-drop.md b/.changeset/swift-frogs-drop.md new file mode 100644 index 0000000000..3edff21ca2 --- /dev/null +++ b/.changeset/swift-frogs-drop.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-playlist': patch +--- + +Limit the use of the same playlist name when adding a playlist diff --git a/plugins/playlist/src/components/PlaylistEditDialog/PlaylistEditDialog.test.tsx b/plugins/playlist/src/components/PlaylistEditDialog/PlaylistEditDialog.test.tsx index 9f30d7608c..b01b5acbf2 100644 --- a/plugins/playlist/src/components/PlaylistEditDialog/PlaylistEditDialog.test.tsx +++ b/plugins/playlist/src/components/PlaylistEditDialog/PlaylistEditDialog.test.tsx @@ -18,11 +18,32 @@ 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 { PlaylistApi, playlistApiRef } from '../../api'; import React from 'react'; import { PlaylistEditDialog } from './PlaylistEditDialog'; describe('', () => { + 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, + }, + ]; it('handle saving with an edited playlist', async () => { const identityApi: Partial = { getBackstageIdentity: async () => ({ @@ -33,8 +54,18 @@ describe('', () => { }; const mockOnSave = jest.fn().mockImplementation(async () => {}); + const playlistApi: Partial = { + getAllPlaylists: jest + .fn() + .mockImplementation(async () => samplePlaylists), + }; const rendered = await renderInTestApp( - + , ); @@ -83,4 +114,53 @@ describe('', () => { }); }); }); + + it('displays duplicate validation message for playlist name', async () => { + const identityApi: Partial = { + getBackstageIdentity: async () => ({ + type: 'user', + userEntityRef: 'user:default/me', + ownershipEntityRefs: ['group:default/test-owner', 'user:default/me'], + }), + }; + + const mockOnSave = jest.fn().mockImplementation(async () => {}); + const playlistApi: Partial = { + getAllPlaylists: jest + .fn() + .mockImplementation(async () => [...samplePlaylists]), + }; + + const rendered = await renderInTestApp( + + + , + ); + + act(() => { + fireEvent.input( + getByRole(rendered.getByTestId('edit-dialog-name-input'), 'textbox'), + { + target: { + value: 'playlist-1', + }, + }, + ); + + fireEvent.click(rendered.getByTestId('edit-dialog-save-button')); + }); + + await waitFor(() => { + expect( + rendered.getByText('A playlist with this name already exists'), + ).toBeInTheDocument(); + }); + + expect(mockOnSave).not.toHaveBeenCalled(); + }); }); diff --git a/plugins/playlist/src/components/PlaylistEditDialog/PlaylistEditDialog.tsx b/plugins/playlist/src/components/PlaylistEditDialog/PlaylistEditDialog.tsx index a213c7c51c..11f972afbe 100644 --- a/plugins/playlist/src/components/PlaylistEditDialog/PlaylistEditDialog.tsx +++ b/plugins/playlist/src/components/PlaylistEditDialog/PlaylistEditDialog.tsx @@ -18,6 +18,7 @@ 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 { playlistApiRef } from '../../api'; import { Button, CircularProgress, @@ -35,7 +36,7 @@ import { Select, TextField, } from '@material-ui/core'; -import React from 'react'; +import React, { useRef } from 'react'; import { useForm, Controller } from 'react-hook-form'; import useAsync from 'react-use/lib/useAsync'; import useAsyncFn from 'react-use/lib/useAsyncFn'; @@ -74,13 +75,27 @@ export const PlaylistEditDialog = ({ }: PlaylistEditDialogProps) => { const classes = useStyles(); const identityApi = useApi(identityApiRef); - + const playlistApi = useApi(playlistApiRef); + const playlistPromise = useRef(playlistApi.getAllPlaylists({})); const { loading: loadingOwnership, value: ownershipRefs } = useAsync(async () => { const { ownershipEntityRefs } = await identityApi.getBackstageIdentity(); return ownershipEntityRefs; }, []); + const nameIsUnique = async (name: string) => { + const playlists = await playlistPromise.current; + + if (name !== playlist.name) { + return ( + !playlists.some(p => p.name === name) || + 'A playlist with this name already exists' + ); + } + + return true; + }; + const defaultValues = { ...playlist, public: playlist.public.toString(), @@ -117,13 +132,17 @@ export const PlaylistEditDialog = ({ nameIsUnique(value), + }} render={({ field }) => ( { const playlistApi = useApi(playlistApiRef); const navigate = useNavigate(); const rootRoute = useRouteRef(rootRouteRef); + const [openEditDialog, setOpenEditDialog] = useState(false); const [openDeleteDialog, setOpenDeleteDialog] = useState(false); @@ -99,12 +100,17 @@ export const PlaylistHeader = ({ playlist, onUpdate }: PlaylistHeaderProps) => { try { await playlistApi.updatePlaylist({ ...update, id: playlist.id }); setOpenEditDialog(false); - onUpdate(); + let message = `Updated playlist '${playlist.name}'`; + if (update.name !== playlist.name) { + message = `Updated playlist name '${playlist.name}' to '${update.name}'`; + } + alertApi.post({ - message: `Updated playlist '${playlist.name}'`, + message, severity: 'success', display: 'transient', }); + onUpdate(); } catch (e) { errorApi.post(e); } @@ -116,15 +122,16 @@ export const PlaylistHeader = ({ playlist, onUpdate }: PlaylistHeaderProps) => { try { await playlistApi.deletePlaylist(playlist.id); navigate(rootRoute()); + const message = `Deleted playlist '${playlist.name}'`; alertApi.post({ - message: `Deleted playlist '${playlist.name}'`, + message, severity: 'success', display: 'transient', }); } catch (e) { errorApi.post(e); } - }, [playlistApi]); + }, [playlistApi, alertApi]); const singularTitle = useTitle({ pluralize: false,