Skip to content

LB-1759: Added a checkbox to make multiple listens or albums keeping the modal open #3269

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
May 20, 2025
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 30 additions & 5 deletions frontend/js/src/user/components/AddListenModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,17 @@ export default NiceModal.create(() => {
const [customTimestamp, setCustomTimestamp] = useState(false);
const [selectedDate, setSelectedDate] = useState(new Date());
const [invertOrder, setInvertOrder] = useState(false);
const [keepModalOpen, setKeepModalOpen] = useState(false);
// Used for the automatic switching and search trigger if pasting URL for another entity type
const [textToSearch, setTextToSearch] = useState<string>();

const closeModal = useCallback(() => {
modal.hide();
document?.body?.classList?.remove("modal-open");
const backdrop = document?.querySelector(".modal-backdrop");
if (backdrop) {
backdrop.remove();
}
setTimeout(modal.remove, 200);
}, [modal]);

Expand Down Expand Up @@ -186,7 +191,14 @@ export default NiceModal.create(() => {
/>,
{ toastId: "added-listens-success" }
);
closeModal();

if (!keepModalOpen) {
closeModal();
} else {
// Reset the form state but keep the modal open
setSelectedListens([]);
setTextToSearch("");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have the impression that this is not working as expected?

At least in the video in the PR description it shows two selected listens one under the other, when I would expect clicking the 'submit' button to clear the current selection
image

I think this is possibly because some of the logic is in the child components AddSingleListen and AddAlbumListens ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MonkeyDo Thanks for catching this ! You are right the selected listens are not being fully cleared after submitting, which is why multiple listens are showing up in the modal. I think this is because, even though I’m resetting the selectedListens state in the parent, the child components AddSingleListen and AddAlbumListens might not be resetting their own state or syncing properly.

I’ll dig into how the child components manage their state and make sure everything resets as expected when “Add another” is checked. If you have any suggestions for the best way to reset the child components, I would love to hear them! Otherwise, I will update the PR soon with a fix. Thanks again for your helpful feedback

Copy link
Member

@MonkeyDo MonkeyDo May 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At a quick glance I didn't find a good way to clear the child components' state.

The two ideas I have in mind:

  • simple one: sometimes you can reset a child component's state by adding a key prop like we do here for example. I'm not sure that'll work or be efficient, but it might be worth a try?
  • Export a ref object from the AddSingleListen and AddAlbumListens components so that the parent can call a specific 'reset' method on the children. See for example how we use that in AddAlbumListens itself: follow the breadcrumbs for the searchInputRef, defined here in the child with forwardRef and useImperativeHandle, assigned here in the parent and used to trigger an internal method triggerSearch here:
    const searchInputRef = useRef<{
    focus(): void;
    triggerSearch(newText: string): void;
    }>(null);
    const initialTextRef = useRef(initialText);
    React.useEffect(() => {
    // Trigger search manually if auto-switching from album to recording search
    if (initialText && initialTextRef.current !== initialText) {
    searchInputRef.current?.triggerSearch(initialText);

Also see the react docs page for context: https://react.dev/reference/react/useImperativeHandle

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forgot to paste this link about the key prop: https://react.dev/reference/react/useState#resetting-state-with-a-key

Copy link
Contributor Author

@RayyanSeliya RayyanSeliya May 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hey @MonkeyDo I tried the first option to reset the form state by passing a key prop for child components AddSingleListen and AddAlbumListens and it is not working !! , so now I will go ahead with the second option !!!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MonkeyDo So After proceeding with the second option and doing required changes to related files this is the conclusion you can see in the video below :

Your.Listens.-.ListenBrainz.and.10.more.pages.-.Personal.-.Microsoft_.Edge.2025-05-16.04-19-33.online-video-cutter.com.mp4

So can I go ahead to merge the commit for this pr ?
And thankyou for your efforts you put in this to guide me step by step I really appreciate it !!

Copy link
Member

@MonkeyDo MonkeyDo May 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes you can push the commit, I'll do another review.
nice work!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For sure !!

}
} catch (error) {
handleError(
error,
Expand All @@ -211,6 +223,7 @@ export default NiceModal.create(() => {
APIService,
closeModal,
handleError,
keepModalOpen,
]);

const switchMode = React.useCallback(
Expand Down Expand Up @@ -369,19 +382,31 @@ export default NiceModal.create(() => {
</div>
</div>
</div>
<div className="modal-footer">
<div
className="modal-footer"
style={{ display: "flex", alignItems: "center", gap: "1rem" }}
>
<div style={{ flex: 1 }}>
<label style={{ userSelect: "none", cursor: "pointer" }}>
<input
type="checkbox"
checked={keepModalOpen}
onChange={(e) => setKeepModalOpen(e.target.checked)}
style={{ marginRight: "0.5em" }}
/>
Add another
</label>
</div>
<button
type="button"
className="btn btn-default"
data-dismiss="modal"
onClick={closeModal}
>
Close
</button>
<button
type="submit"
type="button"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this change required? Just out of curiosity.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this change is needed. I changed it from type="submit" to type="button" because we don’t want the button to submit the form in the usual way, which could reload the page. Instead, the button just runs our own JavaScript function to handle everything. Using type="button" makes sure the button only does what we want and doesn’t accidentally submit the form or refresh the page. Also I am new to making pull requests ,so please let me know if there is anything i should improve

Copy link
Member

@MonkeyDo MonkeyDo May 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see! The way to ensure the page is not reloaded is to add use the event's preventDefault method in the submit callback.
A random example from the codebase:

const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();

Generally, keeping the type:submit is important for accessibility (for example for screen readers for visually impaired users).
The literature on accessibility guidelines is quite dry, but if you want more context here it is :) https://www.w3.org/WAI/WCAG21/Techniques/html/H32

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @MonkeyDo , I just wanted to confirm a few things about my AddListenModal.tsx file after making the changes you suggested:

  • I set type="submit" for the submit button to make it more accessible.
  • I used e.preventDefault() in the form handler to stop the page from reloading.
  • The actual submit logic is now inside the submitListens function.

See my changed AddListenModal.tsx file :

import React, { useCallback, useContext, useState } from "react";
import DateTimePicker from "react-datetime-picker/dist/entry.nostyle";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconProp } from "@fortawesome/fontawesome-svg-core";
import { faCalendar } from "@fortawesome/free-regular-svg-icons";
import NiceModal, { useModal } from "@ebay/nice-modal-react";
import { toast } from "react-toastify";
import { Link } from "react-router-dom";
import { add } from "date-fns";
import { get } from "lodash";
import GlobalAppContext from "../../utils/GlobalAppContext";
import { convertDateToUnixTimestamp } from "../../utils/utils";
import { ToastMsg } from "../../notifications/Notifications";
import AddSingleListen from "./AddSingleListen";
import AddAlbumListens from "./AddAlbumListens";
import Pill from "../../components/Pill";

enum SubmitListenType {
  "track",
  "album",
}
export type MBTrackWithAC = MusicBrainzTrack & WithArtistCredits;

export function getListenFromRecording(
  recording: MusicBrainzRecordingWithReleasesAndRGs,
  date?: Date,
  release?: MusicBrainzRelease & WithReleaseGroup
): Listen {
  const releaseOrCanonical = release ?? recording.releases[0];
  const listen: Listen = {
    listened_at: date ? convertDateToUnixTimestamp(date) : 0,
    track_metadata: {
      artist_name:
        recording["artist-credit"]
          ?.map((artist) => `${artist.name}${artist.joinphrase}`)
          .join("") ?? "",
      track_name: recording.title,
      release_name: releaseOrCanonical?.title,
      additional_info: {
        release_mbid: releaseOrCanonical?.id,
        release_group_mbid: releaseOrCanonical?.["release-group"]?.id,
        recording_mbid: recording.id,
        submission_client: "listenbrainz web",
        artist_mbids: recording["artist-credit"].map((ac) => ac.artist.id),
      },
    },
  };
  if (recording.length) {
    // Cannot send a `null` duration
    listen.track_metadata.additional_info!.duration_ms = recording.length;
  }
  return listen;
}

export const getListenFromTrack = (
  track: MusicBrainzTrack & WithArtistCredits,
  date: Date,
  release?: MusicBrainzRelease
): Listen => {
  const listen: Listen = {
    listened_at: convertDateToUnixTimestamp(date),
    track_metadata: {
      track_name: track.title,
      artist_name:
        track["artist-credit"]
          ?.map((artist) => `${artist.name}${artist.joinphrase}`)
          .join("") ?? "",
      additional_info: {
        recording_mbid: track.recording.id,
        submission_client: "listenbrainz web",
        track_mbid: track.id,
        tracknumber: track.number,
        artist_mbids: track["artist-credit"].map((ac) => ac.artist.id),
      },
    },
  };
  if (track.length) {
    // Cannot send a `null` duration
    listen.track_metadata.additional_info!.duration_ms = track.length;
  } else if (track.recording.length) {
    // Cannot send a `null` duration
    listen.track_metadata.additional_info!.duration_ms = track.recording.length;
  }
  if (release) {
    listen.track_metadata.additional_info!.release_mbid = release.id;
    listen.track_metadata.release_name = release.title;
  }
  return listen;
};
// Use a default of 1 minute for the track length if not available in metadata
export const DEFAULT_TRACK_LENGTH_SECONDS = 60;

export default NiceModal.create(() => {
  const modal = useModal();
  const { APIService, currentUser } = useContext(GlobalAppContext);
  const { auth_token } = currentUser;
  const [listenOption, setListenOption] = useState<SubmitListenType>(
    SubmitListenType.track
  );
  const [selectedListens, setSelectedListens] = useState<Listen[]>([]);
  const [customTimestamp, setCustomTimestamp] = useState(false);
  const [selectedDate, setSelectedDate] = useState(new Date());
  const [invertOrder, setInvertOrder] = useState(false);
  const [keepModalOpen, setKeepModalOpen] = useState(false);
  // Used for the automatic switching and search trigger if pasting URL for another entity type
  const [textToSearch, setTextToSearch] = useState<string>();

  const closeModal = useCallback(() => {
    modal.hide();
    document?.body?.classList?.remove("modal-open");
    const backdrop = document?.querySelector(".modal-backdrop");
    if (backdrop) {
      backdrop.remove();
    }
    setTimeout(modal.remove, 200);
  }, [modal]);

  const handleError = useCallback(
    (error: string | Error, title?: string): void => {
      if (!error) {
        return;
      }
      toast.error(
        <ToastMsg
          title={title || "Error"}
          message={typeof error === "object" ? error.message : error.toString()}
        />,
        { toastId: "add-listen-error" }
      );
    },
    []
  );

  const submitListens = useCallback(async () => {
    if (auth_token) {
      let payload: Listen[] = [];
      const listenType: ListenType =
        selectedListens?.length <= 1 ? "single" : "import";
      // Use the user-selected date, default to "now"
      const date = customTimestamp ? selectedDate : new Date();
      if (selectedListens?.length) {
        let orderedSelectedListens = [...selectedListens];
        if (invertOrder) {
          orderedSelectedListens = orderedSelectedListens.reverse();
        }
        let cumulativeDateTime = date;
        payload = orderedSelectedListens.map((listen) => {
          // Now we need to set the listening time for each listen
          const modifiedListen = { ...listen };
          const durationMS = get(
            modifiedListen,
            "track_metadata.additional_info.duration_ms"
          );
          const timeToAdd =
            (durationMS && durationMS / 1000) ??
            modifiedListen.track_metadata.additional_info?.duration ??
            DEFAULT_TRACK_LENGTH_SECONDS;
          // We either use the previous value of cumulativeDateTime,
          // or if inverting listening order directly use the new value newTime
          const newTime = add(cumulativeDateTime, {
            seconds: invertOrder ? -timeToAdd : timeToAdd,
          });
          if (invertOrder) {
            modifiedListen.listened_at = convertDateToUnixTimestamp(newTime);
          } else {
            modifiedListen.listened_at = convertDateToUnixTimestamp(
              cumulativeDateTime
            );
          }
          // Then we assign the new time value for the next iteration
          cumulativeDateTime = newTime;
          return modifiedListen;
        });
      }
      if (!payload?.length) {
        return;
      }
      const listenOrListens = payload.length > 1 ? "listens" : "listen";
      try {
        const response = await APIService.submitListens(
          auth_token,
          listenType,
          payload
        );
        await APIService.checkStatus(response);

        toast.success(
          <ToastMsg
            title="Success"
            message={`You added ${payload.length} ${listenOrListens}`}
          />,
          { toastId: "added-listens-success" }
        );

        if (!keepModalOpen) {
          closeModal();
        } else {
          // Reset the form state but keep the modal open
          setSelectedListens([]);
          setTextToSearch("");
        }
      } catch (error) {
        handleError(
          error,
          `Error while submitting ${payload.length} ${listenOrListens}`
        );
      }
    } else {
      toast.error(
        <ToastMsg
          title="You need to be logged in to submit listens"
          message={<Link to="/login/">Log in here</Link>}
        />,
        { toastId: "auth-error" }
      );
    }
  }, [
    auth_token,
    selectedListens,
    customTimestamp,
    selectedDate,
    invertOrder,
    APIService,
    closeModal,
    handleError,
    keepModalOpen,
  ]);

  const switchMode = React.useCallback(
    (pastedURL: string) => {
      if (listenOption === SubmitListenType.track) {
        setListenOption(SubmitListenType.album);
      } else if (listenOption === SubmitListenType.album) {
        setListenOption(SubmitListenType.track);
      }
      setTimeout(() => {
        // Trigger search in the inner (grandchild) search input component by modifying the textToSearch prop in child component
        // Give it some time to allow re-render and trigger search in the correct child component
        setTextToSearch(pastedURL);
      }, 200);
      setTimeout(() => {
        // Reset text trigger
        setTextToSearch("");
      }, 500);
    },
    [listenOption]
  );

  const userLocale = navigator.languages?.length
    ? navigator.languages[0]
    : navigator.language;
  const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    submitListens();
  };

  return (
    <div
      className={`modal fade ${modal.visible ? "in" : ""}`}
      id="AddListenModal"
      tabIndex={-1}
      role="dialog"
      aria-labelledby="AddListenModalLabel"
      data-backdrop="static"
    >
      <div className="modal-dialog" role="document">
        <form className="modal-content" onSubmit={handleFormSubmit}>
          <div className="modal-header">
            <button
              type="button"
              className="close"
              data-dismiss="modal"
              aria-label="Close"
              onClick={closeModal}
            >
              <span aria-hidden="true">&times;</span>
            </button>
            <h4 className="modal-title" id="AddListenModalLabel">
              Add Listens
            </h4>
          </div>
          <div className="modal-body">
            <div className="add-listen-header">
              <Pill
                active={listenOption === SubmitListenType.track}
                onClick={() => {
                  setListenOption(SubmitListenType.track);
                }}
                type="secondary"
              >
                Add track
              </Pill>
              <Pill
                active={listenOption === SubmitListenType.album}
                onClick={() => {
                  setListenOption(SubmitListenType.album);
                }}
                type="secondary"
              >
                Add album
              </Pill>
            </div>
            {listenOption === SubmitListenType.track && (
              <AddSingleListen
                onPayloadChange={setSelectedListens}
                switchMode={switchMode}
                initialText={textToSearch}
              />
            )}
            {listenOption === SubmitListenType.album && (
              <AddAlbumListens
                onPayloadChange={setSelectedListens}
                switchMode={switchMode}
                initialText={textToSearch}
              />
            )}
            <hr />
            <div className="timestamp">
              <h5>Timestamp</h5>
              <div className="timestamp-entities">
                <Pill
                  active={customTimestamp === false}
                  onClick={() => {
                    setCustomTimestamp(false);
                    setSelectedDate(new Date());
                  }}
                  type="secondary"
                >
                  Now
                </Pill>
                <Pill
                  active={customTimestamp === true}
                  onClick={() => {
                    setCustomTimestamp(true);
                    setSelectedDate(new Date());
                  }}
                  type="secondary"
                >
                  Custom
                </Pill>
                <div className="timestamp-date-picker">
                  <div>
                    <label htmlFor="starts-at">
                      <input
                        name="invert-timestamp"
                        type="radio"
                        checked={invertOrder === false}
                        id="starts-at"
                        aria-label="Set the time of the beginning of the album"
                        onChange={() => {
                          setInvertOrder(false);
                        }}
                      />
                      &nbsp;Starts at:
                    </label>
                    <label htmlFor="ends-at">
                      <input
                        name="invert-timestamp"
                        type="radio"
                        checked={invertOrder === true}
                        id="ends-at"
                        aria-label="Set the time of the end of the album"
                        onChange={() => {
                          setInvertOrder(true);
                        }}
                      />
                      &nbsp;Finishes at:
                    </label>
                  </div>
                  <DateTimePicker
                    value={selectedDate}
                    onChange={(newDateTimePickerValue: Date) => {
                      setSelectedDate(newDateTimePickerValue);
                    }}
                    calendarIcon={
                      <FontAwesomeIcon icon={faCalendar as IconProp} />
                    }
                    maxDate={new Date()}
                    clearIcon={null}
                    format={userLocale ? undefined : "yyyy-MM-dd hh:mm:ss"}
                    locale={userLocale}
                    maxDetail="second"
                    disabled={!customTimestamp}
                  />
                </div>
              </div>
            </div>
          </div>
          <div
            className="modal-footer"
            style={{ display: "flex", alignItems: "center", gap: "1rem" }}
          >
            <div style={{ flex: 1 }}>
              <label style={{ userSelect: "none", cursor: "pointer" }}>
                <input
                  type="checkbox"
                  checked={keepModalOpen}
                  onChange={(e) => setKeepModalOpen(e.target.checked)}
                  style={{ marginRight: "0.5em" }}
                />
                Add another
              </label>
            </div>
            <button
              type="button"
              className="btn btn-default"
              onClick={closeModal}
            >
              Close
            </button>
            <button
              type="submit"
              className="btn btn-success"
              disabled={!selectedListens?.length}
            >
              Submit {selectedListens.length ?? 0} listen
              {selectedListens.length > 1 ? "s" : ""}
            </button>
          </div>
        </form>
      </div>
    </div>
  );
});

But I ran into a small issue with browser validation:

When I select tracks and try to submit, the browser still asks for the input field to be filled in (because it’s marked as required), even though I’ve already picked tracks.
see in the screenshot
Screenshot 2025-05-15 171708

One More thing it is running fine with Add Album see below :
image

To fix that, I made the input field required only if no tracks are selected.

In AddSingleListen, I added this:

requiredInput={selectedRecordings.length === 0}

And in SearchTrackOrMBID, I used that value like this:

<input required={props.requiredInput} />

After that, TypeScript gave me an error saying:

“Property 'requiredInput' is missing…”

I Think this is happening because now all places where I use SearchTrackOrMBID need to include the requiredInput prop.

So now I’m wondering:

  • Should I go and update every usage of SearchTrackOrMBID and add requiredInput={true} or false?
  • Or is it better to make requiredInput optional and give it a default value?
  • Which approach is the best practice?

Thank You for the help !!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work :)
This should all work fine, but I suggest a small stylistic change:

For the form submission, you don't need a new handleFormSubmit function, you can directly pass the submitListens function like so:

This was exactly correct, no need to change it:

        <form className="modal-content" onSubmit={submitListens}>

Then modify the submitListens function based on the changes you already made:

const submitListens = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
      e.preventDefault();
      if (auth_token) {
      [...]

All I did was, like you, define the expected event type, and add the prevenDefault call right before the rest of the function.
It's essentially the same as what you did, but combining it into submitListens instead of defining a new handleFormSubmit function in-between.


Regarding the required input, looks like I didn't think everything through ;)
But I still think this is a good accessibility change overall, so as you suggested the second option is the way to go:
"make requiredInput optional and give it a default value"
If you look in the SearchTrackOrMBID component this is how we deal with the autofocus prop:

type SearchTrackOrMBIDProps = {
autofocus?: boolean;
defaultValue?: string;
expectedPayload: PayloadType;
switchMode?: (text: string) => void;
} & ConditionalReturnValue;
const SearchTrackOrMBID = forwardRef(function SearchTrackOrMBID(
{
onSelectRecording,
expectedPayload,
defaultValue,
autofocus = true,

className="btn btn-success"
data-dismiss="modal"
disabled={!selectedListens?.length}
onClick={submitListens}
>
Expand Down
Loading