leave session feature

This commit is contained in:
Nuwan 2025-12-11 15:38:05 +05:30
parent 8676210d23
commit 782bf3a76b
5 changed files with 445 additions and 29 deletions

View File

@ -0,0 +1,219 @@
/// <reference types="cypress" />
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');
});
});
});

View File

@ -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 (
<Modal isOpen={isOpen} toggle={toggle} modalClassName="theme-modal" contentClassName="border" size="md">
<ModalHeader toggle={toggle} className="bg-light d-flex flex-between-center border-bottom-0">
Rate Your Session
</ModalHeader>
<ModalBody>
<div className="mb-3">
<p className="mb-4">Thank you for using JamKazam! Your feedback helps us improve the experience for everyone.</p>
<Row className="mb-3">
<Col xs="3">
<Label className="form-label">Rating:</Label>
</Col>
<Col>
<div className="d-flex gap-3">
<Button
color={rating === 'thumbsUp' ? 'success' : 'outline-success'}
onClick={() => handleRatingClick('thumbsUp')}
className="d-flex align-items-center justify-content-center"
style={{ width: '50px', height: '50px' }}
data-cy="thumbs-up-btn"
>
<FontAwesomeIcon icon={faThumbsUp} size="lg" />
</Button>
<Button
color={rating === 'thumbsDown' ? 'danger' : 'outline-danger'}
onClick={() => handleRatingClick('thumbsDown')}
className="d-flex align-items-center justify-content-center"
style={{ width: '50px', height: '50px' }}
data-cy="thumbs-down-btn"
>
<FontAwesomeIcon icon={faThumbsDown} size="lg" />
</Button>
</div>
</Col>
</Row>
<Row>
<Col xs="3">
<Label className="form-label">Comments:</Label>
</Col>
<Col>
<Input
type="textarea"
placeholder="Optional feedback..."
value={comments}
onChange={(e) => setComments(e.target.value)}
rows={4}
data-cy="comments-textarea"
/>
</Col>
</Row>
</div>
</ModalBody>
<ModalFooter>
<Button color="secondary" onClick={toggle} data-cy="cancel-btn">
Cancel
</Button>
<Button
color="primary"
onClick={handleSubmit}
disabled={!canSubmit || loading}
data-cy="send-feedback-btn"
>
{loading ? (
<>
<FontAwesomeIcon icon={faSpinner} spin className="me-2" />
Sending...
</>
) : (
'Send Feedback'
)}
</Button>
</ModalFooter>
</Modal>
);
};
export default JKSessionLeaveModal;

View File

@ -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 (
<Card>
{!isConnected && <div className='d-flex align-items-center'>Connecting to backend...</div>}
<FalconCardHeader title="Session" titleClass="font-weight-bold">
<Button color="primary" size="md" onClick={handleLeaveSession}>Leave Session</Button>
</FalconCardHeader>
<CardHeader className="bg-light border-bottom border-top py-2 border-3">
@ -570,6 +614,7 @@ const JKSessionScreen = () => {
<Button className='btn-custom-outline' outline size="md">Chat</Button>
<Button className='btn-custom-outline' outline size="md">Attach</Button>
<Button className='btn-custom-outline' outline size="md">Resync</Button>
</div>
</CardHeader>
@ -797,6 +842,13 @@ const JKSessionScreen = () => {
onSubmit={handleRecordingSubmit}
/>
<JKSessionLeaveModal
isOpen={showLeaveModal}
toggle={() => setShowLeaveModal(false)} // Just close modal, don't navigate since session not left yet
onSubmit={handleLeaveSubmit}
loading={leaveLoading}
/>
<UncontrolledTooltip target="audioInputsTooltip" trigger="hover click">
<div>
<p>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.</p>

View File

@ -896,4 +896,15 @@ export const updateSessionSettings = (options = {}) => {
.then(response => resolve(response))
.catch(error => reject(error));
});
}
}
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));
});
}

View File

@ -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);