Repository: DanielMoraDC/festwrap-ui
Pull request #26: feat: FW-74 display and select existing playlists in the generator form
Author: login: barroro
Created: 2025-03-07T07:43:38Z | Merged: 2025-03-22T09:43:54Z

- Create a new context called ServiceProvider to provide services using dependency inversion.
- Create `SelectSearch` component using `Command` and `Popover` components.
- Create `FetchService` and `PlaylistService` to retrieve existing playlists from the UI.


Diff:
diff --git a/__tests__/generate.test.tsx b/__tests__/generate.test.tsx
index 150a92fa..cdf096eb 100644
--- a/__tests__/generate.test.tsx
+++ b/__tests__/generate.test.tsx
@@ -8,6 +8,8 @@ import {
 } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import GeneratePlaylistPage, { GenerateProps } from '@/pages/generate';
+import { ReactNode } from 'react';
+import { ServiceProvider } from '@/contexts/ServiceContext';
 
 vi.mock('next/image', () => ({
   __esModule: true,
@@ -42,9 +44,23 @@ const clickToNextButton = async () => {
   await userEvent.click(nextButton);
 };
 
+const playlistsService = {
+  searchPlaylists: vi.fn(),
+};
+
+const MockServiceProvider = ({ children }: { children: ReactNode }) => {
+  return (
+    <ServiceProvider value={{ playlistsService }}>{children}</ServiceProvider>
+  );
+};
+
+const customRenderWithProviders = (ui: ReactNode) => {
+  return render(ui, { wrapper: MockServiceProvider });
+};
+
 describe('GeneratePlaylistPage', () => {
   it('should render the form with the first step displayed', async () => {
-    render(<GeneratePlaylistPage {...staticTranslations} />);
+    customRenderWithProviders(<GeneratePlaylistPage {...staticTranslations} />);
 
     const firstNavigationStepButton = screen.getByRole('button', {
       name: /steps.step1.title/i,
@@ -74,7 +90,7 @@ describe('GeneratePlaylistPage', () => {
   });
 
   it('should display the error message when the "Create new playlist" option is selected and the playlist name is not filled', async () => {
-    render(<GeneratePlaylistPage {...staticTranslations} />);
+    customRenderWithProviders(<GeneratePlaylistPage {...staticTranslations} />);
 
     const createNewPlaylistRadio = screen.getByRole('radio', {
       name: /steps.step1.form.createNewPlaylist.title/i,
@@ -96,7 +112,7 @@ describe('GeneratePlaylistPage', () => {
   });
 
   it('should display the error message when the "Use existing playlist" option is selected and the playlist is not selected', async () => {
-    render(<GeneratePlaylistPage {...staticTranslations} />);
+    customRenderWithProviders(<GeneratePlaylistPage {...staticTranslations} />);
 
     const useExistingPlaylistRadio = screen.getByRole('radio', {
       name: /steps.step1.form.useExistingPlaylist.title/i,
@@ -113,7 +129,7 @@ describe('GeneratePlaylistPage', () => {
   });
 
   it('should fill the form and navigate to the next step when clicking the next button', async () => {
-    render(<GeneratePlaylistPage {...staticTranslations} />);
+    customRenderWithProviders(<GeneratePlaylistPage {...staticTranslations} />);
 
     const createNewPlaylistRadio = screen.getByRole('radio', {
       name: /steps.step1.form.createNewPlaylist.title/i,
@@ -141,7 +157,7 @@ describe('GeneratePlaylistPage', () => {
   });
 
   it('should navigate to the previous step when clicking the previous button', async () => {
-    render(<GeneratePlaylistPage {...staticTranslations} />);
+    customRenderWithProviders(<GeneratePlaylistPage {...staticTranslations} />);
 
     const playlistNameInput = screen.getByLabelText(
       /steps.step1.form.createNewPlaylist.giveAName/i
@@ -169,7 +185,7 @@ describe('GeneratePlaylistPage', () => {
   });
 
   it('should navigate to the last step when filling the form and clicking the next button', async () => {
-    render(<GeneratePlaylistPage {...staticTranslations} />);
+    customRenderWithProviders(<GeneratePlaylistPage {...staticTranslations} />);
 
     const playlistNameInput = screen.getByLabelText(
       /steps.step1.form.createNewPlaylist.giveAName/i
@@ -220,4 +236,42 @@ describe('GeneratePlaylistPage', () => {
     });
     expect(copyURLButton).toBeInTheDocument();
   });
+
+  it('should select a existing playlist when the "Use existing playlist" option is selected and the playlist is selected', async () => {
+    playlistsService.searchPlaylists.mockResolvedValue({
+      playlists: [
+        {
+          id: '1',
+          name: 'Holding Absence',
+          description: 'Holding Absence description',
+          isPublic: true,
+        },
+      ],
+    });
+    customRenderWithProviders(<GeneratePlaylistPage {...staticTranslations} />);
+
+    const useExistingPlaylistRadio = screen.getByRole('radio', {
+      name: /steps.step1.form.useExistingPlaylist.title/i,
+    });
+    await userEvent.click(useExistingPlaylistRadio);
+
+    const triggerInput = screen.getByRole('combobox');
+    await userEvent.click(triggerInput);
+
+    const searchInput = screen.getByPlaceholderText(
+      /steps.step1.form.useExistingPlaylist.playlistSelector.placeholder/i
+    );
+    await userEvent.type(searchInput, 'Holding');
+
+    await waitFor(() => {
+      const itemOption = screen.getByRole('option', {
+        name: /holding absence/i,
+      });
+      expect(itemOption).toBeInTheDocument();
+      userEvent.click(itemOption);
+    });
+
+    const selectedItem = screen.getByText(/Holding Absence/i);
+    expect(selectedItem).toBeInTheDocument();
+  });
 });
diff --git a/locales/ca/generate.json b/locales/ca/generate.json
index 33d61c57..31340d7b 100644
--- a/locales/ca/generate.json
+++ b/locales/ca/generate.json
@@ -37,7 +37,13 @@
                 },
                 "useExistingPlaylist": {
                     "description": "Utilitza aquesta opció per afegir cançons a una de les teves llistes de reproducció ja existent",
-                    "selectPlaylist": "Selecciona una llista de reproducció",
+                    "playlistSelector": {
+                        "loading": "Carregant llistes de reproducció...",
+                        "noResults": "No s'han trobat llistes de reproducció",
+                        "placeholder": "Selecciona una llista de reproducció",
+                        "searchPlaceholder": "Cerca llistes de reproducció",
+                        "title": "Selecciona una llista de reproducció"
+                    },
                     "title": "Utilitzar una llista existent"
                 }
             },
diff --git a/locales/en/generate.json b/locales/en/generate.json
index e3aaf7a5..f4557e10 100644
--- a/locales/en/generate.json
+++ b/locales/en/generate.json
@@ -37,7 +37,13 @@
                 },
                 "useExistingPlaylist": {
                     "description": "Use this option to add songs to one of your existing playlists.",
-                    "selectPlaylist": "Select a playlist",
+                    "playlistSelector": {
+                        "loading": "Loading playlists...",
+                        "noResults": "No playlists found",
+                        "placeholder": "Select a playlist",
+                        "searchPlaceholder": "Search playlists",
+                        "title": "Select a playlist"
+                    },
                     "title": "Use an existing playlist"
                 }
             },
diff --git a/locales/es/generate.json b/locales/es/generate.json
index 3288fded..7ae37124 100644
--- a/locales/es/generate.json
+++ b/locales/es/generate.json
@@ -37,6 +37,13 @@
                 },
                 "useExistingPlaylist": {
                     "description": "Utiliza esta opción para añadir canciones a una de tus listas de reproducción ya existentes.",
+                    "playlistSelector": {
+                        "loading": "Cargando listas de reproducción...",
+                        "noResults": "No se encontraron listas de reproducción",
+                        "placeholder": "Selecciona una lista de reproducción",
+                        "searchPlaceholder": "Busca listas de reproducción",
+                        "title": "Selecciona una lista de reproducción"
+                    },
                     "selectPlaylist": "Selecciona una lista de reproducción",
                     "title": "Usar una lista de reproducción existente"
                 }
diff --git a/src/components/generate/GeneratePlaylistStepper.tsx b/src/components/generate/GeneratePlaylistStepper.tsx
index 29c3a413..361dc065 100644
--- a/src/components/generate/GeneratePlaylistStepper.tsx
+++ b/src/components/generate/GeneratePlaylistStepper.tsx
@@ -18,7 +18,12 @@ const formSchema = z
   .object({
     playlistType: z.enum([PlaylistType.New, PlaylistType.Existing]),
     name: z.string().optional(),
-    playlistSelected: z.string().optional(),
+    playlistSelected: z
+      .object({
+        id: z.string(),
+        name: z.string(),
+      })
+      .optional(),
     isPrivate: z.boolean(),
     bands: z.array(z.number().min(1)).nonempty('At least one band is required'),
   })
@@ -51,7 +56,7 @@ const GeneratePlaylistStepper = () => {
     defaultValues: {
       playlistType: PlaylistType.New,
       name: '',
-      playlistSelected: '',
+      playlistSelected: undefined,
       isPrivate: false,
       bands: [],
     },
diff --git a/src/components/generate/PlaylistSetupForm/PlaylistSearcher.tsx b/src/components/generate/PlaylistSetupForm/PlaylistSearcher.tsx
new file mode 100644
index 00000000..9d752216
--- /dev/null
+++ b/src/components/generate/PlaylistSetupForm/PlaylistSearcher.tsx
@@ -0,0 +1,135 @@
+import { FormField, FormLabel, FormItem } from '@/components/ui/Form';
+import { useDebouncedCallback } from '@/hooks/useDebounceCallback';
+import useTranslation from 'next-translate/useTranslation';
+import { useFormContext } from 'react-hook-form';
+import { usePlaylistSearch } from './usePlaylistSearch';
+import { useState } from 'react';
+import ErrorMessage from '@/components/ui/ErrorMessage';
+import { Button } from '@/components/ui/Button';
+import { cn } from '@/lib/utils';
+import {
+  Popover,
+  PopoverContent,
+  PopoverTrigger,
+} from '@/components/ui/Popover';
+import { Check, ChevronsUpDown } from 'lucide-react';
+import {
+  Command,
+  CommandEmpty,
+  CommandGroup,
+  CommandInput,
+  CommandItem,
+  CommandList,
+} from '@/components/ui/Command';
+
+const getEmptyMessageTranslationKey = (
+  loading: boolean,
+  playlists: any[],
+  searchValue: string
+) => {
+  if (loading) {
+    return 'steps.step1.form.useExistingPlaylist.playlistSelector.loading';
+  }
+
+  if (playlists.length === 0 && searchValue.trim() !== '') {
+    return 'steps.step1.form.useExistingPlaylist.playlistSelector.noResults';
+  }
+
+  return 'steps.step1.form.useExistingPlaylist.playlistSelector.searchPlaceholder';
+};
+
+const PlaylistSearcher = () => {
+  const { t } = useTranslation('generate');
+  const [searchValue, setSearchValue] = useState('');
+  const [open, setOpen] = useState(false);
+  const { playlists, search, loading } = usePlaylistSearch();
+
+  const { control, formState } = useFormContext();
+  const { errors } = formState;
+
+  const debouncedSearch = useDebouncedCallback((searchTerm: string) => {
+    search(searchTerm);
+    setSearchValue(searchTerm);
+  });
+
+  const emptyMessageKeyTranslation = getEmptyMessageTranslationKey(
+    loading,
+    playlists,
+    searchValue
+  );
+
+  return (
+    <FormField
+      control={control}
+      name="playlistSelected"
+      render={({ field }) => (
+        <FormItem>
+          <FormLabel>
+            {t('steps.step1.form.useExistingPlaylist.playlistSelector.title')}
+          </FormLabel>
+          <Popover open={open} onOpenChange={setOpen}>
+            <PopoverTrigger asChild>
+              <Button
+                variant="outline"
+                role="combobox"
+                aria-expanded={open}
+                className="w-full justify-between"
+              >
+                {field.value
+                  ? field.value.name
+                  : t(
+                      'steps.step1.form.useExistingPlaylist.playlistSelector.placeholder'
+                    )}
+                <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+              </Button>
+            </PopoverTrigger>
+            <PopoverContent className="p-0 w-[--radix-popover-trigger-width]">
+              <Command shouldFilter={false}>
+                <CommandInput
+                  placeholder={t(
+                    'steps.step1.form.useExistingPlaylist.playlistSelector.placeholder'
+                  )}
+                  onValueChange={debouncedSearch}
+                />
+                <CommandList>
+                  <CommandEmpty>{t(emptyMessageKeyTranslation)}</CommandEmpty>
+                  <CommandGroup>
+                    {playlists.map((playlist) => (
+                      <CommandItem
+                        key={playlist.id}
+                        value={playlist.id}
+                        onSelect={(currentValue: string) => {
+                          field.onChange(
+                            playlists.find((item) => item.id === currentValue)
+                          );
+                          setOpen(false);
+                        }}
+                      >
+                        <Check
+                          className={cn(
+                            'mr-2 h-4 w-4',
+                            field.value === playlist.id
+                              ? 'opacity-100'
+                              : 'opacity-0'
+                          )}
+                        />
+                        {playlist.name}
+                      </CommandItem>
+                    ))}
+                  </CommandGroup>
+                </CommandList>
+              </Command>
+            </PopoverContent>
+          </Popover>
+          {errors.playlistSelected?.message && (
+            <ErrorMessage>
+              {t('steps.errors.playlistSelected.required')}
+            </ErrorMessage>
+          )}
+        </FormItem>
+      )}
+    />
+  );
+};
+
+export default PlaylistSearcher;
diff --git a/src/components/generate/PlaylistSetupForm/PlaylistSetupForm.tsx b/src/components/generate/PlaylistSetupForm/PlaylistSetupForm.tsx
index 6e368b89..50e78e82 100644
--- a/src/components/generate/PlaylistSetupForm/PlaylistSetupForm.tsx
+++ b/src/components/generate/PlaylistSetupForm/PlaylistSetupForm.tsx
@@ -8,13 +8,6 @@ import {
 } from '@components/ui/RadioGroupButtons';
 import { Switch } from '@components/ui/Switch';
 import useTranslation from 'next-translate/useTranslation';
-import {
-  Select,
-  SelectContent,
-  SelectItem,
-  SelectTrigger,
-  SelectValue,
-} from '@components/ui/Select';
 import { useFormContext } from 'react-hook-form';
 import {
   FormField,
@@ -24,10 +17,12 @@ import {
 } from '@/components/ui/Form';
 import ErrorMessage from '@/components/ui/ErrorMessage';
 import { PlaylistType } from '@/types/Playlist';
+import PlaylistSearcher from './PlaylistSearcher';
 
 const PlaylistSetupForm = () => {
   const { watch, control, formState } = useFormContext();
   const { errors } = formState;
+
   const { t } = useTranslation('generate');
 
   const playlistSelection = watch('playlistType');
@@ -130,42 +125,7 @@ const PlaylistSetupForm = () => {
         </>
       ) : (
         <div className="space-y-2 mt-6">
-          <FormField
-            control={control}
-            name="playlistSelected"
-            render={({ field }) => (
-              <FormItem>
-                <FormLabel>
-                  {t('steps.step1.form.useExistingPlaylist.selectPlaylist')}
-                </FormLabel>
-                <Select
-                  onValueChange={field.onChange}
-                  defaultValue={field.value}
-                >
-                  <FormControl>
-                    <SelectTrigger className="w-full">
-                      <SelectValue
-                        placeholder={t(
-                          'steps.step1.form.useExistingPlaylist.selectPlaylist'
-                        )}
-                      />
-                    </SelectTrigger>
-                  </FormControl>
-                  <SelectContent>
-                    <SelectItem value="1">Playlist 1</SelectItem>
-                    <SelectItem value="2">Playlist 2</SelectItem>
-                    <SelectItem value="3">Playlist 3</SelectItem>
-                    <SelectItem value="4">Playlist 4</SelectItem>
-                  </SelectContent>
-                </Select>
-                {errors.playlistSelected?.message && (
-                  <ErrorMessage>
-                    {t('steps.errors.playlistSelected.required')}
-                  </ErrorMessage>
-                )}
-              </FormItem>
-            )}
-          />
+          <PlaylistSearcher />
         </div>
       )}
     </>
diff --git a/src/components/generate/PlaylistSetupForm/usePlaylistSearch.ts b/src/components/generate/PlaylistSetupForm/usePlaylistSearch.ts
new file mode 100644
index 00000000..5338e3be
--- /dev/null
+++ b/src/components/generate/PlaylistSetupForm/usePlaylistSearch.ts
@@ -0,0 +1,31 @@
+import { useState, useCallback } from 'react';
+import { Playlist } from '@/services/playlistsService';
+import { useServices } from '@/contexts/ServiceContext';
+
+export function usePlaylistSearch() {
+  const { playlistsService } = useServices();
+  const [playlists, setPlaylists] = useState<Playlist[]>([]);
+  const [loading, setLoading] = useState<boolean>(false);
+  const [error, setError] = useState<string | null>(null);
+
+  const search = useCallback(
+    async (name: string, limit: number = 5) => {
+      try {
+        if (name.trim() === '') {
+          setPlaylists([]);
+          return;
+        }
+        setLoading(true);
+        const data = await playlistsService.searchPlaylists(name, limit);
+        setPlaylists(data.playlists || []);
+      } catch (err: any) {
+        setError(err.message);
+      } finally {
+        setLoading(false);
+      }
+    },
+    [playlistsService]
+  );
+
+  return { playlists, loading, error, search };
+}
diff --git a/src/components/ui/Command.tsx b/src/components/ui/Command.tsx
index c3c5f0e4..e434e698 100644
--- a/src/components/ui/Command.tsx
+++ b/src/components/ui/Command.tsx
@@ -48,7 +48,7 @@ const CommandInput = React.forwardRef<
     <CommandPrimitive.Input
       ref={ref}
       className={cn(
-        'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-slate-500 disabled:cursor-not-allowed disabled:opacity-50 dark:placeholder:text-slate-400',
+        'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-secondary disabled:cursor-not-allowed disabled:opacity-50',
         className
       )}
       {...props}
diff --git a/src/contexts/ServiceContext.tsx b/src/contexts/ServiceContext.tsx
new file mode 100644
index 00000000..d07641e3
--- /dev/null
+++ b/src/contexts/ServiceContext.tsx
@@ -0,0 +1,27 @@
+import { IPlaylistsService } from '@/services/playlistsService';
+import { createContext, useContext, ReactNode } from 'react';
+
+export interface ServiceContextType {
+  playlistsService: IPlaylistsService;
+}
+
+const ServiceContext = createContext<ServiceContextType | undefined>(undefined);
+
+type ServiceProviderProps = {
+  children: ReactNode;
+  value: ServiceContextType;
+};
+
+export const ServiceProvider = ({ children, value }: ServiceProviderProps) => {
+  return (
+    <ServiceContext.Provider value={value}>{children}</ServiceContext.Provider>
+  );
+};
+
+// Custom hook to access services
+export const useServices = () => {
+  const context = useContext(ServiceContext);
+  if (!context)
+    throw new Error('useServices must be used within a ServiceProvider');
+  return context;
+};
diff --git a/src/hooks/useDebounceCallback.ts b/src/hooks/useDebounceCallback.ts
new file mode 100644
index 00000000..ddd62a44
--- /dev/null
+++ b/src/hooks/useDebounceCallback.ts
@@ -0,0 +1,26 @@
+import { useCallback, useRef } from 'react';
+
+/**
+ * Hook to debounce a callback function
+ * @param callback - Function to execute after the debounce
+ * @param delay - Delay in milliseconds
+ */
+export function useDebouncedCallback<T extends (..._args: any[]) => void>(
+  callback: T,
+  delay: number = 500
+) {
+  const timeoutRef = useRef<NodeJS.Timeout | null>(null);
+
+  return useCallback(
+    (...args: Parameters<T>) => {
+      if (timeoutRef.current) {
+        clearTimeout(timeoutRef.current);
+      }
+
+      timeoutRef.current = setTimeout(() => {
+        callback(...args);
+      }, delay);
+    },
+    [callback, delay]
+  );
+}
diff --git a/src/lib/clients/playlists.test.ts b/src/lib/clients/playlists.test.ts
index f5bf8ce7..1d63d11d 100644
--- a/src/lib/clients/playlists.test.ts
+++ b/src/lib/clients/playlists.test.ts
@@ -21,11 +21,17 @@ describe('PlaylistsHTTPClient', () => {
     response = {
       data: [
         {
+          id: '1',
           name: 'Chill Vibes',
           description: 'Some description',
           isPublic: true,
         },
-        { name: 'Chill Hits', description: 'Some description', isPublic: true },
+        {
+          id: '2',
+          name: 'Chill Hits',
+          description: 'Some description',
+          isPublic: true,
+        },
       ],
       status: 200,
     };
@@ -58,8 +64,8 @@ describe('PlaylistsHTTPClient', () => {
     const actual = await client.searchPlaylists(token, name, limit);
 
     const expected = [
-      new Playlist('Chill Vibes', true, 'Some description'),
-      new Playlist('Chill Hits', true, 'Some description'),
+      new Playlist('1', 'Chill Vibes', true, 'Some description'),
+      new Playlist('2', 'Chill Hits', true, 'Some description'),
     ];
     expect(actual).toEqual(expected);
   });
diff --git a/src/lib/clients/playlists.ts b/src/lib/clients/playlists.ts
index 3a85430c..5db52db9 100644
--- a/src/lib/clients/playlists.ts
+++ b/src/lib/clients/playlists.ts
@@ -41,7 +41,12 @@ export class PlaylistsHTTPClient implements PlaylistsClient {
       .then((response) =>
         response.data.map(
           (playlist: any) =>
-            new Playlist(playlist.name, playlist.isPublic, playlist.description)
+            new Playlist(
+              playlist.id,
+              playlist.name,
+              playlist.isPublic,
+              playlist.description
+            )
         )
       );
   }
diff --git a/src/lib/playlists.ts b/src/lib/playlists.ts
index 263fc5f2..c3bb76e3 100644
--- a/src/lib/playlists.ts
+++ b/src/lib/playlists.ts
@@ -1,14 +1,25 @@
 export class Playlist {
+  private id: string;
   private name: string;
   private description: string | undefined = undefined;
   private isPublic: boolean;
 
-  constructor(name: string, isPublic: boolean, description?: string) {
+  constructor(
+    id: string,
+    name: string,
+    isPublic: boolean,
+    description?: string
+  ) {
+    this.id = id;
     this.name = name;
     this.isPublic = isPublic;
     this.description = description;
   }
 
+  getId(): string {
+    return this.id;
+  }
+
   getName(): string {
     return this.name;
   }
@@ -23,6 +34,7 @@ export class Playlist {
 
   toPrimitives() {
     return {
+      id: this.id,
       name: this.name,
       description: this.description,
       isPublic: this.isPublic,
diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx
index 65001ae4..0fdf4b37 100644
--- a/src/pages/_app.tsx
+++ b/src/pages/_app.tsx
@@ -3,20 +3,32 @@ import { Poppins } from 'next/font/google';
 import '../styles/globals.css';
 import SessionWrapper from '@components/SessionWrapper';
 import RootLayout from '@components/layout/RootLayout';
+import { ServiceContextType, ServiceProvider } from '@/contexts/ServiceContext';
+import { FetchService } from '@/services/fetchService';
+import { PlaylistsService } from '@/services/playlistsService';
 
 const poppins = Poppins({
   subsets: ['latin'],
   weight: ['400', '500', '600', '700'],
 });
 
+const fetchService = new FetchService();
+const playlistsService = new PlaylistsService(fetchService);
+
+const servicesValue: ServiceContextType = {
+  playlistsService,
+};
+
 export default function MyApp({ Component, pageProps }: AppProps) {
   return (
     <SessionWrapper>
-      <main className={poppins.className}>
-        <RootLayout>
-          <Component {...pageProps} />
-        </RootLayout>
-      </main>
+      <ServiceProvider value={servicesValue}>
+        <main className={poppins.className}>
+          <RootLayout>
+            <Component {...pageProps} />
+          </RootLayout>
+        </main>
+      </ServiceProvider>
     </SessionWrapper>
   );
 }
diff --git a/src/pages/api/playlists/search.test.ts b/src/pages/api/playlists/search.test.ts
index 7a543922..ef692861 100644
--- a/src/pages/api/playlists/search.test.ts
+++ b/src/pages/api/playlists/search.test.ts
@@ -107,8 +107,8 @@ describe('createSearchPlaylistHandler', () => {
 
   it('should return backend client results', async () => {
     const retrievedPlaylists = [
-      new Playlist('Chill Vibes', true, 'Relaxing music'),
-      new Playlist('Workout Hits', false, 'High energy music'),
+      new Playlist('1', 'Chill Vibes', true, 'Relaxing music'),
+      new Playlist('2', 'Workout Hits', false, 'High energy music'),
     ];
     const client = new PlaylistsClientStub(retrievedPlaylists);
     const response = createMockResponse();
@@ -122,8 +122,14 @@ describe('createSearchPlaylistHandler', () => {
 
     const expected = {
       playlists: [
-        { name: 'Chill Vibes', description: 'Relaxing music', isPublic: true },
         {
+          id: '1',
+          name: 'Chill Vibes',
+          description: 'Relaxing music',
+          isPublic: true,
+        },
+        {
+          id: '2',
           name: 'Workout Hits',
           description: 'High energy music',
           isPublic: false,
diff --git a/src/pages/api/playlists/search.ts b/src/pages/api/playlists/search.ts
index 2c6c2153..43263b48 100644
--- a/src/pages/api/playlists/search.ts
+++ b/src/pages/api/playlists/search.ts
@@ -13,12 +13,13 @@ export type SearchPlaylistHandlerParams = {
 };
 
 type ResponsePlaylist = {
+  id: string;
   name: string;
   description: string | undefined;
   isPublic: boolean;
 };
 
-type ResponseData = {
+export type ResponseData = {
   message: string;
   playlists?: ResponsePlaylist[];
 };
diff --git a/src/services/fetchService.ts b/src/services/fetchService.ts
new file mode 100644
index 00000000..972910f7
--- /dev/null
+++ b/src/services/fetchService.ts
@@ -0,0 +1,13 @@
+export interface IFetchService {
+  fetchData<T>(_url: string, _options?: RequestInit): Promise<T>;
+}
+
+export class FetchService implements IFetchService {
+  async fetchData<T>(url: string, options?: RequestInit): Promise<T> {
+    const response = await fetch(url, options);
+    if (!response.ok) {
+      throw new Error(`Error fetching data: ${response.statusText}`);
+    }
+    return response.json();
+  }
+}
diff --git a/src/services/playlistsService.ts b/src/services/playlistsService.ts
new file mode 100644
index 00000000..d4c577ab
--- /dev/null
+++ b/src/services/playlistsService.ts
@@ -0,0 +1,26 @@
+import { ResponseData } from '@/pages/api/playlists/search';
+import { IFetchService } from './fetchService';
+
+export type Playlist = {
+  id: string;
+  name: string;
+  description?: string;
+  isPublic: boolean;
+};
+
+export interface IPlaylistsService {
+  searchPlaylists(_name: string, _limit: number): Promise<ResponseData>;
+}
+
+export class PlaylistsService implements IPlaylistsService {
+  private fetchService: IFetchService;
+
+  constructor(fetchService: IFetchService) {
+    this.fetchService = fetchService;
+  }
+
+  async searchPlaylists(name: string, limit: number): Promise<ResponseData> {
+    const url = `/api/playlists/search?name=${name}&limit=${limit}`;
+    return this.fetchService.fetchData<ResponseData>(url);
+  }
+}


Reviews:
user: login: DanielMoraDC
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-08T08:53:22Z
user: login: DanielMoraDC
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-08T09:02:43Z
user: login: DanielMoraDC
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-08T09:17:21Z
user: login: DanielMoraDC
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-08T09:21:54Z
user: login: DanielMoraDC
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-08T09:24:42Z
user: login: DanielMoraDC
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-08T09:25:49Z
user: login: DanielMoraDC
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-08T09:37:05Z
user: login: DanielMoraDC
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-08T09:40:56Z
user: login: DanielMoraDC
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-08T09:43:06Z
user: login: barroro
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T18:16:05Z
user: login: barroro
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T18:28:24Z
user: login: barroro
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T18:46:20Z
user: login: barroro
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T18:48:06Z
user: login: barroro
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T18:50:41Z
user: login: DanielMoraDC
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-20T18:58:14Z
user: login: barroro
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-21T07:21:58Z
user: login: DanielMoraDC
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-21T08:37:35Z
user: login: DanielMoraDC
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-22T09:39:38Z

Review comments:
diff_hunk: @@ -0,0 +1,92 @@
+import { FormField, FormLabel, FormItem } from '@/components/ui/Form';
+import {
+  SelectSearch,
+  SelectSearchRoot,
+  SelectSearchTrigger,
+  SelectSearchContent,
+} from '@/components/ui/SelectSearch';
+import { useDebouncedCallback } from '@/hooks/useDebounceCallback';
+import useTranslation from 'next-translate/useTranslation';
+import { useFormContext } from 'react-hook-form';
+import { useExistingPlaylists } from './useExistingPlaylists';
+import { useState } from 'react';
+import ErrorMessage from '@/components/ui/ErrorMessage';
+
+const getEmptyMessageKeyTranslation = (
path: src/components/generate/PlaylistSetupForm/ExistingPlaylistsSelector.tsx
user: login: DanielMoraDC
body: Nitpick: should this be `getEmptyMessageTranslationKey`, instead?
created_at: 2025-03-08T08:53:22Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,13 @@
+export interface IFetchService {
path: src/services/fetchService.ts
user: login: DanielMoraDC
body: SHouldn't this go inside `lib`, too?
created_at: 2025-03-08T09:02:43Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,26 @@
+import { useCallback, useRef } from 'react';
+
+/**
path: src/hooks/useDebounceCallback.ts
user: login: DanielMoraDC
body: Let's keep all comments in English for consistency 🙇 If this has been copied from some public snippet, I would also suggest to add the source.
created_at: 2025-03-08T09:17:21Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,92 @@
+import { FormField, FormLabel, FormItem } from '@/components/ui/Form';
+import {
+  SelectSearch,
+  SelectSearchRoot,
+  SelectSearchTrigger,
+  SelectSearchContent,
+} from '@/components/ui/SelectSearch';
+import { useDebouncedCallback } from '@/hooks/useDebounceCallback';
+import useTranslation from 'next-translate/useTranslation';
+import { useFormContext } from 'react-hook-form';
+import { useExistingPlaylists } from './useExistingPlaylists';
+import { useState } from 'react';
+import ErrorMessage from '@/components/ui/ErrorMessage';
+
+const getEmptyMessageKeyTranslation = (
+  loading: boolean,
+  playlists: any[],
+  searchValue: string
+) => {
+  if (loading) {
+    return 'steps.step1.form.useExistingPlaylist.playlistSelector.loading';
+  }
+
+  if (playlists.length === 0 && searchValue.trim() !== '') {
+    return 'steps.step1.form.useExistingPlaylist.playlistSelector.noResults';
+  }
+
+  return 'steps.step1.form.useExistingPlaylist.playlistSelector.searchPlaceholder';
+};
+
+const ExistingPlaylistsSelector = () => {
path: src/components/generate/PlaylistSetupForm/ExistingPlaylistsSelector.tsx
user: login: DanielMoraDC
body: I would rather name this `PlaylistSearcher`. The word `search` already tells us they must be existing ones and it is consistent with the use of `search` inside the context (instead of select).
created_at: 2025-03-08T09:21:54Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,32 @@
+import { useState, useCallback } from 'react';
+import { Playlist } from '@/services/playlistsService';
+import { useServices } from '@/contexts/ServiceContext';
+
+export function useExistingPlaylists() {
path: src/components/generate/PlaylistSetupForm/useExistingPlaylists.ts
user: login: DanielMoraDC
body: I also think this name can be confusing. I understand keeping the `use` prefix for consistency, but what about `usePlaylistSearch`?
created_at: 2025-03-08T09:24:42Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,92 @@
+import { FormField, FormLabel, FormItem } from '@/components/ui/Form';
+import {
+  SelectSearch,
+  SelectSearchRoot,
+  SelectSearchTrigger,
+  SelectSearchContent,
+} from '@/components/ui/SelectSearch';
+import { useDebouncedCallback } from '@/hooks/useDebounceCallback';
+import useTranslation from 'next-translate/useTranslation';
+import { useFormContext } from 'react-hook-form';
+import { useExistingPlaylists } from './useExistingPlaylists';
+import { useState } from 'react';
+import ErrorMessage from '@/components/ui/ErrorMessage';
+
+const getEmptyMessageKeyTranslation = (
+  loading: boolean,
+  playlists: any[],
+  searchValue: string
+) => {
+  if (loading) {
+    return 'steps.step1.form.useExistingPlaylist.playlistSelector.loading';
+  }
+
+  if (playlists.length === 0 && searchValue.trim() !== '') {
+    return 'steps.step1.form.useExistingPlaylist.playlistSelector.noResults';
+  }
+
+  return 'steps.step1.form.useExistingPlaylist.playlistSelector.searchPlaceholder';
+};
+
+const ExistingPlaylistsSelector = () => {
+  const { t } = useTranslation('generate');
+  const [searchValue, setSearchValue] = useState('');
+  const { playlists, search, loading } = useExistingPlaylists();
+
+  const { control, formState } = useFormContext();
+  const { errors } = formState;
+
+  const debouncedSearch = useDebouncedCallback((searchTerm: string) => {
+    search(searchTerm);
+    setSearchValue(searchTerm);
+  }, 500);
path: src/components/generate/PlaylistSetupForm/ExistingPlaylistsSelector.tsx
user: login: DanielMoraDC
body: Can we keep this as a default parameter so we do not hardcode it?
created_at: 2025-03-08T09:25:49Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,244 @@
+'use client';
+
+import * as React from 'react';
+import { Check, ChevronsUpDown } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { Button } from '@/components/ui/Button';
+import {
+  Command,
+  CommandEmpty,
+  CommandGroup,
+  CommandInput,
+  CommandItem,
+  CommandList,
+} from '@/components/ui/Command';
+import {
+  Popover,
+  PopoverContent,
+  PopoverTrigger,
+} from '@/components/ui/Popover';
+
+export type SelectSearchItem = {
+  value: string;
+  label: string;
+};
+
+type SelectSearchContextValue = {
+  value: string | string[];
+  onChange: (_value: string) => void;
+  items: SelectSearchItem[];
+  isOpen: boolean;
+  setIsOpen: (_open: boolean) => void;
+  multiple?: boolean;
+  onSearch?: (_query: string) => Promise<void> | void;
+};
+
+const SelectSearchContext = React.createContext<
+  SelectSearchContextValue | undefined
+>(undefined);
+
+interface SelectSearchProps {
+  value?: string | string[];
+  defaultValue?: string | string[];
+  onChange?: (_value: string | string[]) => void;
+  onSearch?: (_query: string) => Promise<void> | void;
+  items: SelectSearchItem[];
+  children: React.ReactNode;
+  multiple?: boolean;
+}
+
+export function SelectSearch({
+  value,
+  defaultValue,
+  onChange,
+  onSearch,
+  items,
+  children,
+  multiple = false,
path: src/components/ui/SelectSearch.tsx
user: login: DanielMoraDC
body: Can we just assume we are always going to receive a list, here, so we can keep this simpler?
created_at: 2025-03-08T09:37:05Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,244 @@
+'use client';
+
+import * as React from 'react';
+import { Check, ChevronsUpDown } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { Button } from '@/components/ui/Button';
+import {
+  Command,
+  CommandEmpty,
+  CommandGroup,
+  CommandInput,
+  CommandItem,
+  CommandList,
+} from '@/components/ui/Command';
+import {
+  Popover,
+  PopoverContent,
+  PopoverTrigger,
+} from '@/components/ui/Popover';
+
+export type SelectSearchItem = {
+  value: string;
+  label: string;
+};
+
+type SelectSearchContextValue = {
+  value: string | string[];
+  onChange: (_value: string) => void;
+  items: SelectSearchItem[];
+  isOpen: boolean;
+  setIsOpen: (_open: boolean) => void;
+  multiple?: boolean;
+  onSearch?: (_query: string) => Promise<void> | void;
+};
+
+const SelectSearchContext = React.createContext<
+  SelectSearchContextValue | undefined
+>(undefined);
+
+interface SelectSearchProps {
+  value?: string | string[];
+  defaultValue?: string | string[];
+  onChange?: (_value: string | string[]) => void;
+  onSearch?: (_query: string) => Promise<void> | void;
+  items: SelectSearchItem[];
+  children: React.ReactNode;
+  multiple?: boolean;
+}
+
+export function SelectSearch({
+  value,
+  defaultValue,
+  onChange,
+  onSearch,
+  items,
+  children,
+  multiple = false,
+}: SelectSearchProps) {
+  const [isOpen, setIsOpen] = React.useState(false);
+  const [selectedValue, setSelectedValue] = React.useState<string | string[]>(
+    value || defaultValue || (multiple ? [] : '')
+  );
+
+  React.useEffect(() => {
+    if (value !== undefined) {
+      setSelectedValue(value);
+    }
+  }, [value]);
+
+  const handleChange = React.useCallback(
+    (newValue: string) => {
+      let updatedValue: string | string[];
+
+      if (multiple) {
+        const currentValues = Array.isArray(selectedValue) ? selectedValue : [];
+        updatedValue = currentValues.includes(newValue)
+          ? currentValues.filter((v) => v !== newValue)
+          : [...currentValues, newValue];
+      } else {
+        updatedValue = newValue;
+        setIsOpen(false);
+      }
+
+      setSelectedValue(updatedValue);
+      onChange?.(updatedValue);
path: src/components/ui/SelectSearch.tsx
user: login: DanielMoraDC
body: Shouldn't `onChange` always be provided?
created_at: 2025-03-08T09:40:56Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,244 @@
+'use client';
+
+import * as React from 'react';
+import { Check, ChevronsUpDown } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { Button } from '@/components/ui/Button';
+import {
+  Command,
+  CommandEmpty,
+  CommandGroup,
+  CommandInput,
+  CommandItem,
+  CommandList,
+} from '@/components/ui/Command';
+import {
+  Popover,
+  PopoverContent,
+  PopoverTrigger,
+} from '@/components/ui/Popover';
+
+export type SelectSearchItem = {
+  value: string;
+  label: string;
+};
+
+type SelectSearchContextValue = {
+  value: string | string[];
+  onChange: (_value: string) => void;
+  items: SelectSearchItem[];
+  isOpen: boolean;
+  setIsOpen: (_open: boolean) => void;
+  multiple?: boolean;
+  onSearch?: (_query: string) => Promise<void> | void;
+};
+
+const SelectSearchContext = React.createContext<
+  SelectSearchContextValue | undefined
+>(undefined);
+
+interface SelectSearchProps {
+  value?: string | string[];
+  defaultValue?: string | string[];
+  onChange?: (_value: string | string[]) => void;
+  onSearch?: (_query: string) => Promise<void> | void;
+  items: SelectSearchItem[];
+  children: React.ReactNode;
+  multiple?: boolean;
+}
+
+export function SelectSearch({
path: src/components/ui/SelectSearch.tsx
user: login: DanielMoraDC
body: This part feels a bit confusing:
- We declare more props than the ones actually used.
- Some props are just passed so they are returned. Can't we just let the parent component handle that? Feels like an unnecessary burden for this component.
- We call this `SelectSearch` but its responsabilities are keeping the state of the selected value. Can we reflect that in the naming?
created_at: 2025-03-08T09:43:06Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,92 @@
+import { FormField, FormLabel, FormItem } from '@/components/ui/Form';
+import {
+  SelectSearch,
+  SelectSearchRoot,
+  SelectSearchTrigger,
+  SelectSearchContent,
+} from '@/components/ui/SelectSearch';
+import { useDebouncedCallback } from '@/hooks/useDebounceCallback';
+import useTranslation from 'next-translate/useTranslation';
+import { useFormContext } from 'react-hook-form';
+import { useExistingPlaylists } from './useExistingPlaylists';
+import { useState } from 'react';
+import ErrorMessage from '@/components/ui/ErrorMessage';
+
+const getEmptyMessageKeyTranslation = (
+  loading: boolean,
+  playlists: any[],
+  searchValue: string
+) => {
+  if (loading) {
+    return 'steps.step1.form.useExistingPlaylist.playlistSelector.loading';
+  }
+
+  if (playlists.length === 0 && searchValue.trim() !== '') {
+    return 'steps.step1.form.useExistingPlaylist.playlistSelector.noResults';
+  }
+
+  return 'steps.step1.form.useExistingPlaylist.playlistSelector.searchPlaceholder';
+};
+
+const ExistingPlaylistsSelector = () => {
path: src/components/generate/PlaylistSetupForm/ExistingPlaylistsSelector.tsx
user: login: barroro
body: Yes, it's more consistent `PlaylistSearcher`. I'm going to change it. Good point!
created_at: 2025-03-17T18:16:04Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,244 @@
+'use client';
+
+import * as React from 'react';
+import { Check, ChevronsUpDown } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { Button } from '@/components/ui/Button';
+import {
+  Command,
+  CommandEmpty,
+  CommandGroup,
+  CommandInput,
+  CommandItem,
+  CommandList,
+} from '@/components/ui/Command';
+import {
+  Popover,
+  PopoverContent,
+  PopoverTrigger,
+} from '@/components/ui/Popover';
+
+export type SelectSearchItem = {
+  value: string;
+  label: string;
+};
+
+type SelectSearchContextValue = {
+  value: string | string[];
+  onChange: (_value: string) => void;
+  items: SelectSearchItem[];
+  isOpen: boolean;
+  setIsOpen: (_open: boolean) => void;
+  multiple?: boolean;
+  onSearch?: (_query: string) => Promise<void> | void;
+};
+
+const SelectSearchContext = React.createContext<
+  SelectSearchContextValue | undefined
+>(undefined);
+
+interface SelectSearchProps {
+  value?: string | string[];
+  defaultValue?: string | string[];
+  onChange?: (_value: string | string[]) => void;
+  onSearch?: (_query: string) => Promise<void> | void;
+  items: SelectSearchItem[];
+  children: React.ReactNode;
+  multiple?: boolean;
+}
+
+export function SelectSearch({
+  value,
+  defaultValue,
+  onChange,
+  onSearch,
+  items,
+  children,
+  multiple = false,
path: src/components/ui/SelectSearch.tsx
user: login: barroro
body: What do you mean? If we can remove the `multiple` prop, right?
created_at: 2025-03-17T18:28:24Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,244 @@
+'use client';
+
+import * as React from 'react';
+import { Check, ChevronsUpDown } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { Button } from '@/components/ui/Button';
+import {
+  Command,
+  CommandEmpty,
+  CommandGroup,
+  CommandInput,
+  CommandItem,
+  CommandList,
+} from '@/components/ui/Command';
+import {
+  Popover,
+  PopoverContent,
+  PopoverTrigger,
+} from '@/components/ui/Popover';
+
+export type SelectSearchItem = {
+  value: string;
+  label: string;
+};
+
+type SelectSearchContextValue = {
+  value: string | string[];
+  onChange: (_value: string) => void;
+  items: SelectSearchItem[];
+  isOpen: boolean;
+  setIsOpen: (_open: boolean) => void;
+  multiple?: boolean;
+  onSearch?: (_query: string) => Promise<void> | void;
+};
+
+const SelectSearchContext = React.createContext<
+  SelectSearchContextValue | undefined
+>(undefined);
+
+interface SelectSearchProps {
+  value?: string | string[];
+  defaultValue?: string | string[];
+  onChange?: (_value: string | string[]) => void;
+  onSearch?: (_query: string) => Promise<void> | void;
+  items: SelectSearchItem[];
+  children: React.ReactNode;
+  multiple?: boolean;
+}
+
+export function SelectSearch({
path: src/components/ui/SelectSearch.tsx
user: login: barroro
body: I've simplified the flow by removing the `SelectSearch` component because it was also causing confusion and complexity.
created_at: 2025-03-17T18:46:20Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,32 @@
+import { useState, useCallback } from 'react';
+import { Playlist } from '@/services/playlistsService';
+import { useServices } from '@/contexts/ServiceContext';
+
+export function useExistingPlaylists() {
path: src/components/generate/PlaylistSetupForm/useExistingPlaylists.ts
user: login: barroro
body: Done! The `use` prefix is generally used in the hook functions to distinct between them.
created_at: 2025-03-17T18:48:06Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,13 @@
+export interface IFetchService {
path: src/services/fetchService.ts
user: login: barroro
body: I'm not sure. I've been looking for some NextJS structures to understand the different folders. However, I would like to separate the API and client methods. We could use `/lib` folder just for the API logic. What do you think?
created_at: 2025-03-17T18:50:41Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,13 @@
+export interface IFetchService {
path: src/services/fetchService.ts
user: login: DanielMoraDC
body: I am fine having the clients in its own subfolder, but I do not like to have a `lib` inside `src`, as the name provides little information about what the folder contains.

IMHO I believe everything in the `lib` folder could be moved to the parent folder `src`. What do you think?
created_at: 2025-03-20T18:58:14Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,13 @@
+export interface IFetchService {
path: src/services/fetchService.ts
user: login: barroro
body: Yes, we should have separated the API and the UI parts in different folders. I was thinking in how can we apply this approach but I didn't find the better solution right now. I think the best approach is using App router as I told you sometimes and probably I should spend time to apply this after the MVP. There are some parts in the `lib` folder that for me it makes sense to be in this folder like `utils`or the `motionVariants`. I would discuss this in another PR, we can create specific task to refactor this part to avoid create more changes in this MR. What do you think?
created_at: 2025-03-21T07:21:58Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,13 @@
+export interface IFetchService {
path: src/services/fetchService.ts
user: login: DanielMoraDC
body: Sure, this is not something introduced in this PR, so happy to move the conversation outside. Resolving.
created_at: 2025-03-21T08:37:35Z
author_association: MEMBER

PR comments:
user: login: DanielMoraDC
created_at: 2025-03-08T08:50:37Z
body: Given that there are 3 main changes, I suggest to create 3 separate PRs. It would make review process much lighter.
author_association: MEMBER
user: login: DanielMoraDC
created_at: 2025-03-08T08:52:27Z
body: https://github.com/user-attachments/assets/a0f73492-2a7c-49de-8393-b4999a5bc5d3

I am testing the branch locally and I see the search is buggy. Results of the search only appears once I delete the text.

Also: could we use a text input directly? Having to click the dropdown adds an unnecessary extra step.
author_association: MEMBER
user: login: DanielMoraDC
created_at: 2025-03-20T18:52:50Z
body: Nice updates 🚀 

I see that the playlist is not properly stored in session when going back from the next step. Is that expected?


https://github.com/user-attachments/assets/5eaf045e-aae8-4e5e-b329-e2366efbbf24


author_association: MEMBER