diff --git a/jam-ui/cypress/e2e/sessions/leave-session-modal.cy.js b/jam-ui/cypress/e2e/sessions/leave-session-modal.cy.js new file mode 100644 index 000000000..63592a272 --- /dev/null +++ b/jam-ui/cypress/e2e/sessions/leave-session-modal.cy.js @@ -0,0 +1,219 @@ +/// + +import makeFakeUser from '../../factories/user'; + +describe('Leave Session Modal', () => { + const currentUser = makeFakeUser(); + + beforeEach(() => { + cy.stubAuthenticate({ id: currentUser.id }); + cy.stubGonSubscriptionCodes(); + }); + + describe('Leave Session Button', () => { + it('should display the Leave Session button with correct styling', () => { + // Mock the session context and other dependencies + cy.window().then((win) => { + // Mock the necessary context providers + win.JK = win.JK || {}; + win.JK.GA = win.JK.GA || { trackSessionMusicians: cy.stub() }; + }); + + // Visit a mock session page (this would need to be set up properly in a real scenario) + cy.visit('/client#/session/test-session-id', { + onBeforeLoad: (win) => { + // Mock React context and hooks + win.React = { useContext: cy.stub(), useState: cy.stub(), useEffect: cy.stub() }; + } + }); + + // The button should be visible and have primary color + cy.contains('Leave Session').should('be.visible'); + cy.contains('Leave Session').should('have.class', 'btn-primary'); + }); + }); + + describe('Leave Session Modal', () => { + beforeEach(() => { + // Mock the modal opening by simulating the button click + cy.window().then((win) => { + win.JK = win.JK || {}; + win.JK.GA = win.JK.GA || { trackSessionMusicians: cy.stub() }; + }); + + cy.visit('/client#/session/test-session-id'); + }); + + it('should open modal when Leave Session button is clicked', () => { + // Mock the sessionModel.handleLeaveSession to resolve immediately + cy.window().then((win) => { + if (win.sessionModel && win.sessionModel.handleLeaveSession) { + cy.stub(win.sessionModel, 'handleLeaveSession').resolves(); + } + }); + + cy.contains('Leave Session').click(); + + // Modal should appear + cy.contains('Rate Your Session').should('be.visible'); + cy.contains('Thank you for using JamKazam!').should('be.visible'); + }); + + it('should display rating buttons', () => { + cy.contains('Leave Session').click(); + + // Check thumbs up and thumbs down buttons are present + cy.get('[data-cy=thumbs-up-btn]').should('be.visible'); + cy.get('[data-cy=thumbs-down-btn]').should('be.visible'); + }); + + it('should allow selecting thumbs up rating', () => { + cy.contains('Leave Session').click(); + + // Click thumbs up + cy.get('[data-cy=thumbs-up-btn]').click(); + + // Should be selected (success color) + cy.get('[data-cy=thumbs-up-btn]').should('have.class', 'btn-success'); + cy.get('[data-cy=thumbs-down-btn]').should('not.have.class', 'btn-danger'); + }); + + it('should allow selecting thumbs down rating', () => { + cy.contains('Leave Session').click(); + + // Click thumbs down + cy.get('[data-cy=thumbs-down-btn]').click(); + + // Should be selected (danger color) + cy.get('[data-cy=thumbs-down-btn]').should('have.class', 'btn-danger'); + cy.get('[data-cy=thumbs-up-btn]').should('not.have.class', 'btn-success'); + }); + + it('should allow toggling rating selection', () => { + cy.contains('Leave Session').click(); + + // Click thumbs up + cy.get('[data-cy=thumbs-up-btn]').click(); + cy.get('[data-cy=thumbs-up-btn]').should('have.class', 'btn-success'); + + // Click again to deselect + cy.get('[data-cy=thumbs-up-btn]').click(); + cy.get('[data-cy=thumbs-up-btn]').should('not.have.class', 'btn-success'); + }); + + it('should display comments textarea', () => { + cy.contains('Leave Session').click(); + + cy.get('[data-cy=comments-textarea]').should('be.visible'); + cy.get('[data-cy=comments-textarea]').should('have.attr', 'placeholder', 'Optional feedback...'); + }); + + it('should allow typing in comments', () => { + cy.contains('Leave Session').click(); + + const testComment = 'This is a test comment for the session feedback.'; + cy.get('[data-cy=comments-textarea]').type(testComment); + cy.get('[data-cy=comments-textarea]').should('have.value', testComment); + }); + + it('should disable Send Feedback button when no rating or comments provided', () => { + cy.contains('Leave Session').click(); + + cy.get('[data-cy=send-feedback-btn]').should('be.disabled'); + }); + + it('should enable Send Feedback button when rating is selected', () => { + cy.contains('Leave Session').click(); + + cy.get('[data-cy=thumbs-up-btn]').click(); + cy.get('[data-cy=send-feedback-btn]').should('not.be.disabled'); + }); + + it('should enable Send Feedback button when comments are provided', () => { + cy.contains('Leave Session').click(); + + cy.get('[data-cy=comments-textarea]').type('Test comment'); + cy.get('[data-cy=send-feedback-btn]').should('not.be.disabled'); + }); + + it('should submit feedback successfully', () => { + // Mock the API call + cy.intercept('POST', '**/participant_histories/*/rating', { + statusCode: 200, + body: { success: true } + }).as('submitFeedback'); + + cy.contains('Leave Session').click(); + + // Fill out the form + cy.get('[data-cy=thumbs-up-btn]').click(); + cy.get('[data-cy=comments-textarea]').type('Great session!'); + + // Submit + cy.get('[data-cy=send-feedback-btn]').click(); + + // Should show success toast + cy.contains('Thank you for your feedback!').should('be.visible'); + + // Modal should close + cy.contains('Rate Your Session').should('not.exist'); + }); + + it('should handle feedback submission error', () => { + // Mock API error + cy.intercept('POST', '**/participant_histories/*/rating', { + statusCode: 500, + body: { error: 'Server error' } + }).as('submitFeedbackError'); + + cy.contains('Leave Session').click(); + + cy.get('[data-cy=thumbs-down-btn]').click(); + cy.get('[data-cy=send-feedback-btn]').click(); + + // Should show error toast + cy.contains('Failed to submit feedback').should('be.visible'); + }); + + it('should close modal when Cancel button is clicked', () => { + cy.contains('Leave Session').click(); + + cy.get('[data-cy=cancel-btn]').click(); + + // Modal should close + cy.contains('Rate Your Session').should('not.exist'); + }); + + it('should close modal when X button is clicked', () => { + cy.contains('Leave Session').click(); + + // Click the modal close button (usually has class 'close' or 'btn-close') + cy.get('.modal-header .close').click(); + + // Modal should close + cy.contains('Rate Your Session').should('not.exist'); + }); + }); + + describe('Form Validation', () => { + it('should require at least rating or comments to submit', () => { + cy.contains('Leave Session').click(); + + // Button should be disabled initially + cy.get('[data-cy=send-feedback-btn]').should('be.disabled'); + + // Add rating - should enable + cy.get('[data-cy=thumbs-up-btn]').click(); + cy.get('[data-cy=send-feedback-btn]').should('not.be.disabled'); + + // Remove rating, add comments - should enable + cy.get('[data-cy=thumbs-up-btn]').click(); // deselect + cy.get('[data-cy=comments-textarea]').type('Test comment'); + cy.get('[data-cy=send-feedback-btn]').should('not.be.disabled'); + + // Remove comments - should disable + cy.get('[data-cy=comments-textarea]').clear(); + cy.get('[data-cy=send-feedback-btn]').should('be.disabled'); + }); + }); +}); diff --git a/jam-ui/src/components/client/JKSessionLeaveModal.js b/jam-ui/src/components/client/JKSessionLeaveModal.js new file mode 100644 index 000000000..9659f038c --- /dev/null +++ b/jam-ui/src/components/client/JKSessionLeaveModal.js @@ -0,0 +1,124 @@ +import React, { useState, useEffect } from 'react'; +import { + Modal, + ModalHeader, + ModalBody, + ModalFooter, + Button, + Row, + Col, + Input, + Label +} from 'reactstrap'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faThumbsUp, faThumbsDown, faSpinner } from '@fortawesome/free-solid-svg-icons'; + +const JKSessionLeaveModal = ({ isOpen, toggle, onSubmit, loading = false }) => { + const [rating, setRating] = useState(null); // null, 'thumbsUp', 'thumbsDown' + const [comments, setComments] = useState(''); + + // Reset form when modal opens + useEffect(() => { + if (isOpen) { + setRating(null); + setComments(''); + } + }, [isOpen]); + + const handleRatingClick = (newRating) => { + setRating(rating === newRating ? null : newRating); + }; + + const handleSubmit = () => { + // Convert rating to backend format + let ratingValue = 0; // No rating + if (rating === 'thumbsUp') ratingValue = 1; + else if (rating === 'thumbsDown') ratingValue = -1; + + onSubmit({ + rating: ratingValue, + comments: comments.trim() + }); + }; + + const canSubmit = rating !== null || comments.trim().length > 0; + + return ( + + + Rate Your Session + + +
+

Thank you for using JamKazam! Your feedback helps us improve the experience for everyone.

+ + + + + + +
+ + +
+ +
+ + + + + + + setComments(e.target.value)} + rows={4} + data-cy="comments-textarea" + /> + + +
+
+ + + + +
+ ); +}; + +export default JKSessionLeaveModal; diff --git a/jam-ui/src/components/client/JKSessionScreen.js b/jam-ui/src/components/client/JKSessionScreen.js index 350bead7f..4dfc3fb58 100644 --- a/jam-ui/src/components/client/JKSessionScreen.js +++ b/jam-ui/src/components/client/JKSessionScreen.js @@ -1,6 +1,6 @@ // jam-ui/src/components/client/JKSessionScreen.js import React, { useEffect, useContext, useState, memo, useMemo } from 'react' -import { useParams } from 'react-router-dom'; +import { useParams, useHistory } from 'react-router-dom'; //import useJamServer, { ConnectionStatus } from '../../hooks/useJamServer' import useGearUtils from '../../hooks/useGearUtils' @@ -19,7 +19,7 @@ import { useAuth } from '../../context/UserAuth'; import { dkeys } from '../../helpers/utils.js'; -import { getSessionHistory, getSession, joinSession as joinSessionRest, updateSessionSettings, getFriends, startRecording, stopRecording } from '../../helpers/rest'; +import { getSessionHistory, getSession, joinSession as joinSessionRest, updateSessionSettings, getFriends, startRecording, stopRecording, submitSessionFeedback } from '../../helpers/rest'; import { CLIENT_ROLE, RECORD_TYPE_AUDIO, RECORD_TYPE_BOTH } from '../../helpers/globals'; import { MessageType } from '../../helpers/MessageFactory.js'; @@ -34,6 +34,7 @@ import JKSessionSettingsModal from './JKSessionSettingsModal.js'; import JKSessionInviteModal from './JKSessionInviteModal.js'; import JKSessionVolumeModal from './JKSessionVolumeModal.js'; import JKSessionRecordingModal from './JKSessionRecordingModal.js'; +import JKSessionLeaveModal from './JKSessionLeaveModal.js'; import { SESSION_PRIVACY_MAP } from '../../helpers/globals.js'; import { toast } from 'react-toastify'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -68,6 +69,7 @@ const JKSessionScreen = () => { const sessionModel = useSessionModel(app, server, null); // sessionScreen is null for now const sessionHelper = useSessionHelper(); const { id: sessionId } = useParams(); + const history = useHistory(); // State to hold session data const [userTracks, setUserTracks] = useState([]); @@ -100,6 +102,12 @@ const JKSessionScreen = () => { //state for recording modal const [showRecordingModal, setShowRecordingModal] = useState(false); + //state for leave modal + const [showLeaveModal, setShowLeaveModal] = useState(false); + const [leaveRating, setLeaveRating] = useState(null); // null, 'thumbsUp', 'thumbsDown' + const [leaveComments, setLeaveComments] = useState(''); + const [leaveLoading, setLeaveLoading] = useState(false); + useEffect(() => { if (!isConnected || !jamClient) return; console.debug("JKSessionScreen: -DEBUG- isConnected changed to true"); @@ -552,10 +560,46 @@ const JKSessionScreen = () => { }); } + const handleLeaveSession = () => { + // Just show the modal - no leave operations yet + setShowLeaveModal(true); + }; + + const handleLeaveSubmit = async (feedbackData) => { + try { + setLeaveLoading(true); + + // Submit feedback to backend first + const clientId = server.clientId; + const backendDetails = jamClient.getAllClientsStateMap ? jamClient.getAllClientsStateMap() : {}; + + await submitSessionFeedback(clientId, { + rating: feedbackData.rating, + comment: feedbackData.comments, + backend_details: backendDetails + }); + + // Then perform leave operations + await sessionModel.handleLeaveSession(); + + setShowLeaveModal(false); + toast.success('Thank you for your feedback!'); + + // Navigate to sessions page using React Router + history.push('/sessions'); + } catch (error) { + console.error('Error submitting feedback or leaving session:', error); + toast.error('Failed to submit feedback or leave session'); + } finally { + setLeaveLoading(false); + } + }; + return ( {!isConnected &&
Connecting to backend...
} + @@ -570,6 +614,7 @@ const JKSessionScreen = () => { + @@ -797,6 +842,13 @@ const JKSessionScreen = () => { onSubmit={handleRecordingSubmit} /> + setShowLeaveModal(false)} // Just close modal, don't navigate since session not left yet + onSubmit={handleLeaveSubmit} + loading={leaveLoading} + /> +

Set the input level of your Audio Inputs for each of your tracks to a healthy level. It's important to set your input level correctly. If your level is set too high, you'll get distortion or clipping of your audio. If set too low, your audio signal will be too weak, which can cause noise and degrade your audio quality when you and others use the session mix to increase your volume in the mix.

diff --git a/jam-ui/src/helpers/rest.js b/jam-ui/src/helpers/rest.js index 256145263..21c0fc705 100644 --- a/jam-ui/src/helpers/rest.js +++ b/jam-ui/src/helpers/rest.js @@ -896,4 +896,15 @@ export const updateSessionSettings = (options = {}) => { .then(response => resolve(response)) .catch(error => reject(error)); }); -} \ No newline at end of file +} + +export const submitSessionFeedback = (clientId, options = {}) => { + return new Promise((resolve, reject) => { + apiFetch(`/participant_histories/${clientId}/rating`, { + method: 'POST', + body: JSON.stringify(options) + }) + .then(response => resolve(response)) + .catch(error => reject(error)); + }); +} diff --git a/jam-ui/src/hooks/useSessionModel.js b/jam-ui/src/hooks/useSessionModel.js index 1dc859c44..ee50f9bbd 100644 --- a/jam-ui/src/hooks/useSessionModel.js +++ b/jam-ui/src/hooks/useSessionModel.js @@ -1,4 +1,5 @@ import { useState, useCallback, useRef, useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; import { debounce } from 'lodash'; // Add lodash for debouncing import { useJamClient } from '../context/JamClientContext'; import { useCurrentSessionContext } from '../context/CurrentSessionContext'; @@ -34,6 +35,7 @@ export default function useSessionModel(app, server, sessionScreen) { const { getTrackInfo, getUserTracks } = useTrackHelpers(); const { getCurrentRecordingState, reset: resetRecordingState } = useRecordingHelpers(); const { currentSession, setCurrentSession, currentSessionIdRef, setCurrentSessionId, inSession } = useCurrentSessionContext(); + const history = useHistory(); // State variables from original SessionModel const [userTracks, setUserTracks] = useState(null); @@ -395,6 +397,7 @@ export default function useSessionModel(app, server, sessionScreen) { // Perform the actual leave session (from useSessionLeave) const performLeaveSession = useCallback(async () => { + if (isLeavingRef.current) { logger.debug("Leave session already in progress"); return; @@ -416,6 +419,8 @@ export default function useSessionModel(app, server, sessionScreen) { } } + + // Leave the session via jamClient (don't wait for REST) logger.debug("Leaving session via jamClient for sessionId:", sessionId); try { @@ -426,7 +431,7 @@ export default function useSessionModel(app, server, sessionScreen) { } // Make REST call to server (fire and forget for faster UX) - leaveSessionRest(sessionId); + await leaveSessionRest(sessionId); // Unregister callbacks try { @@ -437,6 +442,8 @@ export default function useSessionModel(app, server, sessionScreen) { logger.error("Error unregistering callbacks:", error); } + + // Call session page leave try { // Note: SessionPageLeave would need to be imported from useSessionUtils @@ -448,12 +455,13 @@ export default function useSessionModel(app, server, sessionScreen) { // Trigger session ended event // TODO: Re-implement this with React context or state management - if (document && window.$) { - // $(document).trigger('SESSION_ENDED', { session: { id: sessionId } }); - logger.debug("Session ended event triggered"); - } + // if (document && window.$) { + // // $(document).trigger('SESSION_ENDED', { session: { id: sessionId } }); + // logger.debug("Session ended event triggered"); + // } logger.debug("Session leave completed successfully"); + } catch (error) { logger.error("Unexpected error during session leave:", error); @@ -468,7 +476,7 @@ export default function useSessionModel(app, server, sessionScreen) { // Main leave session function (from useSessionLeave) const leaveSession = useCallback(async () => { - return performLeaveSession(); + await performLeaveSession(); }, [performLeaveSession]); // Handle leave session with behavior (navigation, notifications, etc.) (from useSessionLeave) @@ -477,36 +485,38 @@ export default function useSessionModel(app, server, sessionScreen) { try { // Handle notifications - if (behavior.notify && window.JK?.app?.layout) { - window.JK.app.layout.notify(behavior.notify); - } + // if (behavior.notify && window.JK?.app?.layout) { + // window.JK.app.layout.notify(behavior.notify); + // } - // Allow leave session (trigger any leave confirmation logic) - if (window.SessionActions?.allowLeaveSession) { - window.SessionActions.allowLeaveSession.trigger(); - } + // // Allow leave session (trigger any leave confirmation logic) + // if (window.SessionActions?.allowLeaveSession) { + // window.SessionActions.allowLeaveSession.trigger(); + // } // Perform the leave operation await leaveSession(); // Handle navigation after successful leave - if (behavior.location) { - if (typeof behavior.location === 'number') { - window.history.go(behavior.location); - } else { - window.location = behavior.location; - } - } else if (behavior.hash) { - window.location.hash = behavior.hash; - } else { - logger.warn("No location specified in leaveSession action, defaulting to home", behavior); - window.location = '/client#/home'; - } + // if (behavior.location) { + // if (typeof behavior.location === 'number') { + // window.history.go(behavior.location); + // } else { + // window.location = behavior.location; + // } + // } else if (behavior.hash) { + // window.location.hash = behavior.hash; + // } else { + // logger.warn("No location specified in leaveSession action, defaulting to home", behavior); + // window.location = '/client#/home'; + // } // Handle lesson session rating if applicable // Note: This would need additional context/state to implement fully // For now, just log that rating logic would go here - logger.debug("Lesson session rating logic would be handled here"); + //logger.debug("Lesson session rating logic would be handled here"); + + } catch (error) { logger.error("Error handling leave session:", error);