docs(12-01): document legacy AttachmentStore implementation
- Comprehensive analysis of CoffeeScript Reflux store patterns - Upload flow breakdown with FormData construction - Client-side validation logic (10 MB limit) - Hidden file input trigger pattern - Error handling for 413/422 responses - Integration points with ChatStore and dialog system - React port patterns and implementation checklist - 538 lines, 42 code examples ATTACHMENT_LEGACY.md: .planning/phases/12-attachment-research-&-backend-validation/docs/ATTACHMENT_LEGACY.md
This commit is contained in:
parent
ec0607a1d4
commit
48ff1dfbb1
|
|
@ -0,0 +1,538 @@
|
|||
# Legacy AttachmentStore Implementation - Reference Documentation
|
||||
|
||||
**Source:** `web/app/assets/javascripts/react-components/stores/AttachmentStore.js.coffee`
|
||||
**Purpose:** Reference documentation for porting file upload functionality to React/Redux
|
||||
**Date:** 2026-02-02
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The legacy AttachmentStore is a **Reflux store** written in CoffeeScript that handles file attachment uploads for both lesson chat and session chat. It provides client-side validation, FormData construction, AJAX upload, and error handling. The store integrates with the legacy ChatStore to trigger chat message creation after successful uploads.
|
||||
|
||||
**Key characteristics:**
|
||||
- Reflux store pattern (event-based state management)
|
||||
- Supports two attachment types: `notation` and `audio`
|
||||
- Client-side validation (10 MB limit)
|
||||
- jQuery AJAX for multipart/form-data uploads
|
||||
- Hidden file input trigger pattern
|
||||
- Separate upload flows for lesson vs session context
|
||||
|
||||
---
|
||||
|
||||
## 1. File Structure
|
||||
|
||||
**Location:** `web/app/assets/javascripts/react-components/stores/AttachmentStore.js.coffee`
|
||||
|
||||
**Store Pattern:** Reflux.createStore with listenables
|
||||
|
||||
**Public Methods (Actions):**
|
||||
- `onStartAttachRecording(lessonId, sessionId)` - Triggers recording selector dialog
|
||||
- `onStartAttachNotation(lessonId, sessionId)` - Triggers notation file input
|
||||
- `onStartAttachAudio(lessonId, sessionId)` - Triggers audio file input
|
||||
- `onUploadNotations(notations, doneCallback, failCallback)` - Uploads notation files
|
||||
- `onUploadAudios(notations, doneCallback, failCallback)` - Uploads audio files
|
||||
|
||||
**Internal State:**
|
||||
- `uploading: boolean` - Prevents concurrent uploads
|
||||
- `lessonId: string | null` - Current lesson context
|
||||
- `sessionId: string | null` - Current session context
|
||||
|
||||
**Integration:**
|
||||
- Listens to `AttachmentActions` (Reflux action dispatcher)
|
||||
- Listens to `AppStore` for app initialization
|
||||
- Uses `context.JK.Rest()` for API calls
|
||||
- Uses `@app.layout.showDialog()` for upload progress dialog
|
||||
|
||||
---
|
||||
|
||||
## 2. Upload Flow
|
||||
|
||||
### Step-by-Step Breakdown (Notation Upload)
|
||||
|
||||
**1. User Initiates Upload**
|
||||
```javascript
|
||||
// Translated from CoffeeScript
|
||||
onStartAttachNotation(lessonId, sessionId = null) {
|
||||
if (this.uploading) {
|
||||
logger.warn("rejecting onStartAttachNotation attempt as currently busy");
|
||||
return;
|
||||
}
|
||||
this.lessonId = lessonId;
|
||||
this.sessionId = sessionId;
|
||||
|
||||
logger.debug("notation upload started for lesson: " + lessonId);
|
||||
this.triggerNotation(); // Clicks hidden file input
|
||||
this.changed(); // Triggers state update
|
||||
}
|
||||
```
|
||||
|
||||
**2. Hidden File Input Triggered**
|
||||
```javascript
|
||||
triggerNotation() {
|
||||
if (!this.attachNotationBtn) {
|
||||
this.attachNotationBtn = $('input.attachment-notation').eq(0);
|
||||
}
|
||||
console.log("@attachNotationBtn", this.attachNotationBtn);
|
||||
this.attachNotationBtn.trigger('click');
|
||||
}
|
||||
```
|
||||
|
||||
**3. Files Selected → onUploadNotations Called**
|
||||
```javascript
|
||||
onUploadNotations(notations, doneCallback, failCallback) {
|
||||
logger.debug("beginning upload of notations", notations);
|
||||
this.uploading = true;
|
||||
this.changed();
|
||||
|
||||
// Client-side validation
|
||||
const formData = new FormData();
|
||||
let maxExceeded = false;
|
||||
|
||||
$.each(notations, (i, file) => {
|
||||
const max = 10 * 1024 * 1024; // 10 MB
|
||||
if (file.size > max) {
|
||||
maxExceeded = true;
|
||||
return false; // Break loop
|
||||
}
|
||||
formData.append('files[]', file);
|
||||
});
|
||||
|
||||
if (maxExceeded) {
|
||||
this.app.notify({
|
||||
title: "Maximum Music Notation Size Exceeded",
|
||||
text: "You can only upload files up to 10 megabytes in size."
|
||||
});
|
||||
failCallback();
|
||||
this.uploading = false;
|
||||
this.changed();
|
||||
return;
|
||||
}
|
||||
|
||||
// Add context parameters
|
||||
if (this.lessonId) {
|
||||
formData.append('lesson_session_id', this.lessonId);
|
||||
} else if (this.sessionId) {
|
||||
formData.append('session_id', this.sessionId);
|
||||
}
|
||||
|
||||
formData.append('attachment_type', 'notation');
|
||||
|
||||
// Show progress dialog
|
||||
this.app.layout.showDialog('music-notation-upload-dialog');
|
||||
|
||||
// Upload via REST API
|
||||
rest.uploadMusicNotations(formData)
|
||||
.done((response) => this.doneUploadingNotations(notations, response, doneCallback, failCallback))
|
||||
.fail((jqXHR) => this.failUploadingNotations(jqXHR, failCallback));
|
||||
}
|
||||
```
|
||||
|
||||
**4. REST API Call (from jam_rest.js)**
|
||||
```javascript
|
||||
function uploadMusicNotations(formData) {
|
||||
return $.ajax({
|
||||
type: "POST",
|
||||
processData: false, // Don't convert FormData to string
|
||||
contentType: false, // Don't set Content-Type (browser adds multipart boundary)
|
||||
dataType: "json",
|
||||
cache: false,
|
||||
url: "/api/music_notations",
|
||||
data: formData
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**5. Success Handler**
|
||||
```javascript
|
||||
doneUploadingNotations(notations, response, doneCallback, failCallback) {
|
||||
this.uploading = false;
|
||||
this.changed();
|
||||
|
||||
const error_files = [];
|
||||
$.each(response, (i, music_notation) => {
|
||||
if (music_notation.errors) {
|
||||
error_files.push(notations[i].name);
|
||||
}
|
||||
});
|
||||
|
||||
if (error_files.length > 0) {
|
||||
failCallback();
|
||||
this.app.notifyAlert("Failed to upload notations.", error_files.join(', '));
|
||||
} else {
|
||||
doneCallback();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**6. Error Handler**
|
||||
```javascript
|
||||
failUploadingNotations(jqXHR, failCallback) {
|
||||
this.uploading = false;
|
||||
this.changed();
|
||||
|
||||
if (jqXHR.status == 413) {
|
||||
// File too large (server-side check)
|
||||
this.app.notify({
|
||||
title: "Maximum Music Notation Size Exceeded",
|
||||
text: "You can only upload files up to 10 megabytes in size."
|
||||
});
|
||||
} else {
|
||||
this.app.notifyServerError(jqXHR, "Unable to upload music notations");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Client-Side Validation
|
||||
|
||||
### File Size Check
|
||||
|
||||
**Limit:** 10 MB (10 × 1024 × 1024 bytes)
|
||||
|
||||
**Implementation:**
|
||||
```javascript
|
||||
const max = 10 * 1024 * 1024;
|
||||
if (file.size > max) {
|
||||
maxExceeded = true;
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
**Error Message:**
|
||||
```
|
||||
Title: "Maximum Music Notation Size Exceeded"
|
||||
Text: "You can only upload files up to 10 megabytes in size."
|
||||
```
|
||||
|
||||
**When shown:**
|
||||
- Immediately after file selection, before upload starts
|
||||
- If server returns 413 (Payload Too Large)
|
||||
|
||||
### File Type Validation
|
||||
|
||||
**Note:** The legacy AttachmentStore does NOT perform file type validation on the client side. File type filtering happens at the file input level via HTML `accept` attribute, and server-side validation happens in the `MusicNotationUploader` whitelist.
|
||||
|
||||
**Server-side whitelist (for reference):**
|
||||
```ruby
|
||||
def extension_white_list
|
||||
%w(pdf png jpg jpeg gif xml mxl txt wav flac ogg aiff aifc au)
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Attachment Type Detection
|
||||
|
||||
The legacy implementation uses **explicit attachment type** passed as a parameter, not automatic detection based on file extension.
|
||||
|
||||
**Two Upload Methods:**
|
||||
1. `onUploadNotations()` → `attachment_type: 'notation'`
|
||||
2. `onUploadAudios()` → `attachment_type: 'audio'`
|
||||
|
||||
**How it works:**
|
||||
```javascript
|
||||
// For notation files
|
||||
formData.append('attachment_type', 'notation');
|
||||
|
||||
// For audio files
|
||||
formData.append('attachment_type', 'audio');
|
||||
```
|
||||
|
||||
**File Type to Attachment Type Mapping:**
|
||||
|
||||
| File Extensions | Attachment Type | Used For |
|
||||
|----------------|----------------|----------|
|
||||
| `.pdf, .png, .jpg, .jpeg, .gif, .xml, .mxl, .txt` | `notation` | Music notation, sheet music, images, text |
|
||||
| `.wav, .flac, .ogg, .aiff, .aifc, .au` | `audio` | Audio files |
|
||||
|
||||
**Note:** The mapping is implicit based on which button the user clicks (Attach Notation vs Attach Audio), not programmatically determined from file extension.
|
||||
|
||||
---
|
||||
|
||||
## 5. Integration Points
|
||||
|
||||
### Integration with ChatStore
|
||||
|
||||
The AttachmentStore does NOT directly trigger chat messages. Instead:
|
||||
|
||||
1. **Upload completes** → Returns array of created `MusicNotation` objects
|
||||
2. **Backend automatically creates ChatMessage** with attachment reference
|
||||
3. **Backend broadcasts via WebSocket** → ChatMessage with attachment metadata
|
||||
4. **ChatStore receives WebSocket message** → Displays in chat UI
|
||||
|
||||
**Key insight:** The attachment upload is decoupled from chat message creation. The backend handles the integration.
|
||||
|
||||
### Event Emission Pattern
|
||||
|
||||
**Reflux trigger pattern:**
|
||||
```javascript
|
||||
changed() {
|
||||
this.trigger({
|
||||
lessonId: this.lessonId,
|
||||
uploading: this.uploading
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**When triggered:**
|
||||
- On `onStartAttachNotation()` / `onStartAttachAudio()` - Sets `uploading: true`
|
||||
- On `doneUploadingNotations()` / `failUploadingNotations()` - Sets `uploading: false`
|
||||
|
||||
**UI components consume via:**
|
||||
```javascript
|
||||
// In React component
|
||||
mixins: [Reflux.connect(AttachmentStore, 'attachmentState')]
|
||||
|
||||
// Component can access this.state.attachmentState.uploading
|
||||
```
|
||||
|
||||
### Dialog System Integration
|
||||
|
||||
**Upload Progress Dialog:**
|
||||
```javascript
|
||||
this.app.layout.showDialog('music-notation-upload-dialog');
|
||||
```
|
||||
|
||||
**Notification System:**
|
||||
```javascript
|
||||
// Success/Info notification
|
||||
this.app.notify({
|
||||
title: "Title",
|
||||
text: "Message"
|
||||
});
|
||||
|
||||
// Error notification
|
||||
this.app.notifyAlert("Error title", "Error details");
|
||||
|
||||
// Server error handler
|
||||
this.app.notifyServerError(jqXHR, "Context message");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. FormData Construction
|
||||
|
||||
**Critical Pattern for React Port:**
|
||||
|
||||
```javascript
|
||||
// Create FormData instance
|
||||
const formData = new FormData();
|
||||
|
||||
// Append files (supports multiple)
|
||||
formData.append('files[]', file); // Array notation for multiple files
|
||||
|
||||
// Append session context (one of these)
|
||||
formData.append('lesson_session_id', lessonId); // For lesson chat
|
||||
formData.append('session_id', sessionId); // For session chat
|
||||
|
||||
// Append attachment type
|
||||
formData.append('attachment_type', 'notation'); // or 'audio'
|
||||
|
||||
// AJAX configuration
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
processData: false, // CRITICAL: Don't convert FormData to string
|
||||
contentType: false, // CRITICAL: Don't set Content-Type (browser adds multipart boundary)
|
||||
dataType: "json",
|
||||
url: "/api/music_notations",
|
||||
data: formData
|
||||
});
|
||||
```
|
||||
|
||||
**Why this matters for React port:**
|
||||
- `processData: false` → When using fetch(), don't stringify FormData
|
||||
- `contentType: false` → Don't manually set `Content-Type` header (browser auto-adds boundary)
|
||||
- File array notation `files[]` → Required by Rails params parsing
|
||||
- Session context → Only ONE of `lesson_session_id` or `session_id` should be set
|
||||
|
||||
---
|
||||
|
||||
## 7. Audio Upload Flow
|
||||
|
||||
The audio upload flow is nearly identical to notation upload, with these differences:
|
||||
|
||||
**Method:** `onUploadAudios()` instead of `onUploadNotations()`
|
||||
|
||||
**attachment_type:** `'audio'` instead of `'notation'`
|
||||
|
||||
**Error messages:**
|
||||
- Title: "Maximum Music Audio Size Exceeded"
|
||||
- Failure: "Failed to upload audio files."
|
||||
|
||||
**Code Example:**
|
||||
```javascript
|
||||
onUploadAudios(notations, doneCallback, failCallback) {
|
||||
// ... same validation logic ...
|
||||
|
||||
formData.append('attachment_type', 'audio'); // Only difference
|
||||
|
||||
rest.uploadMusicNotations(formData) // Same endpoint!
|
||||
.done((response) => this.doneUploadingAudios(notations, response, doneCallback, failCallback))
|
||||
.fail((jqXHR) => this.failUploadingAudios(jqXHR, failCallback));
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Both notation and audio uploads use the same REST endpoint (`/api/music_notations`), differentiated only by the `attachment_type` parameter.
|
||||
|
||||
---
|
||||
|
||||
## 8. Recording Attachment Flow
|
||||
|
||||
**Different from file uploads:** Recording attachments link existing recordings to lessons/sessions, they don't upload files.
|
||||
|
||||
```javascript
|
||||
recordingsSelected(recordings) {
|
||||
if (this.lessonId) {
|
||||
const options = { id: this.lessonId, recordings: recordings };
|
||||
rest.attachRecordingToLesson(options)
|
||||
.done((response) => this.attachedRecordingsToLesson(response))
|
||||
.fail((jqXHR) => this.attachedRecordingsFail(jqXHR));
|
||||
} else if (this.sessionId) {
|
||||
const options = { id: this.sessionId, recordings: recordings };
|
||||
rest.attachRecordingToSession(options)
|
||||
.done((response) => this.attachedRecordingsToSession(response))
|
||||
.fail((jqXHR) => this.attachedRecordingsFail(jqXHR));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Success messages:**
|
||||
- Lesson: "Your recording has been associated with this lesson, and can be accessed from the Messages window for this lesson."
|
||||
- Session: "Your recording has been associated with this session."
|
||||
|
||||
**Note:** This flow is NOT in scope for v1.2 Session Attachments milestone. Recording attachments are handled separately from file uploads.
|
||||
|
||||
---
|
||||
|
||||
## 9. Key Patterns for React Port
|
||||
|
||||
### Pattern 1: Hidden File Input with Button Trigger
|
||||
```javascript
|
||||
// Legacy uses jQuery trigger
|
||||
this.attachNotationBtn = $('input.attachment-notation').eq(0);
|
||||
this.attachNotationBtn.trigger('click');
|
||||
|
||||
// React equivalent
|
||||
const fileInputRef = useRef(null);
|
||||
const handleAttachClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={handleAttachClick}>Attach File</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
accept=".pdf,.xml,.mxl,.txt,.png,.jpg,.jpeg,.gif,.wav"
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.[0]) {
|
||||
handleFileSelect(e.target.files[0]);
|
||||
e.target.value = ''; // Reset for re-selection
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
```
|
||||
|
||||
### Pattern 2: Upload State Management
|
||||
```javascript
|
||||
// Legacy Reflux store
|
||||
this.uploading = true;
|
||||
this.changed();
|
||||
|
||||
// React Redux equivalent
|
||||
dispatch(setUploadingState({ uploading: true, error: null }));
|
||||
```
|
||||
|
||||
### Pattern 3: Client-Side Validation Before Upload
|
||||
```javascript
|
||||
// Always validate BEFORE creating FormData
|
||||
const MAX_SIZE = 10 * 1024 * 1024;
|
||||
if (file.size > MAX_SIZE) {
|
||||
dispatch(setUploadError('File exceeds 10 MB limit'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Then proceed with FormData construction
|
||||
```
|
||||
|
||||
### Pattern 4: FormData with fetch()
|
||||
```javascript
|
||||
// Modern fetch equivalent
|
||||
const formData = new FormData();
|
||||
formData.append('files[]', file);
|
||||
formData.append('session_id', sessionId);
|
||||
formData.append('attachment_type', 'notation');
|
||||
|
||||
const response = await fetch('/api/music_notations', {
|
||||
method: 'POST',
|
||||
credentials: 'include', // Important for session cookies
|
||||
body: formData // Don't set Content-Type!
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 413) {
|
||||
throw new Error('File too large - maximum 10 MB');
|
||||
}
|
||||
throw new Error('Upload failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Differences Between Legacy and Modern Implementation
|
||||
|
||||
| Aspect | Legacy (CoffeeScript) | Modern (React/Redux) |
|
||||
|--------|----------------------|---------------------|
|
||||
| Store pattern | Reflux with events | Redux Toolkit with slices |
|
||||
| AJAX library | jQuery $.ajax | fetch() API |
|
||||
| State updates | `this.changed()` triggers listeners | Redux actions with reducers |
|
||||
| File input | jQuery trigger on hidden input | useRef + ref.current.click() |
|
||||
| Context passing | Store instance variables | Redux state or component props |
|
||||
| Callbacks | done/fail callbacks | async/await with try/catch |
|
||||
| Error handling | jqXHR status checks | response.ok and response.status |
|
||||
| Multiple files | Supported (files[] array) | Implement single file first, extend later |
|
||||
| Dialog system | `@app.layout.showDialog()` | Custom React component or state flag |
|
||||
|
||||
---
|
||||
|
||||
## 11. Implementation Checklist for React Port
|
||||
|
||||
Based on the legacy implementation, the React port should include:
|
||||
|
||||
- [ ] Redux state for upload status (`uploading`, `progress`, `error`)
|
||||
- [ ] Hidden file input with ref-based trigger
|
||||
- [ ] Client-side file size validation (10 MB)
|
||||
- [ ] Client-side file type validation (match requirements list)
|
||||
- [ ] FormData construction with `files[]`, `session_id`, `attachment_type`
|
||||
- [ ] fetch() upload with `credentials: include`, no Content-Type header
|
||||
- [ ] Error handling for 413 (file too large)
|
||||
- [ ] Error handling for 422 (validation errors)
|
||||
- [ ] Success/error user feedback (toast notifications)
|
||||
- [ ] Disable UI during upload (prevent concurrent uploads)
|
||||
- [ ] Reset file input after selection (allow re-selection)
|
||||
- [ ] Integration with sessionChatSlice for attachment state
|
||||
- [ ] WebSocket message handling for attachment metadata
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The legacy AttachmentStore provides a proven reference implementation for file uploads. Key takeaways:
|
||||
|
||||
1. **FormData is critical:** `processData: false` and `contentType: false` equivalent in fetch()
|
||||
2. **Client-side validation prevents wasted uploads:** Check size before FormData construction
|
||||
3. **Single upload state flag:** Prevents concurrent uploads with simple boolean
|
||||
4. **Backend creates chat messages:** Don't manually create chat entries after upload
|
||||
5. **Error handling by status code:** 413 gets special user-friendly message
|
||||
6. **Hidden file input pattern:** Standard approach, works reliably
|
||||
7. **Same endpoint for both types:** Differentiated by `attachment_type` parameter
|
||||
|
||||
**Next steps:** Refer to ATTACHMENT_API.md for backend contract details and response formats.
|
||||
Loading…
Reference in New Issue