Add filtering and sorting to question bank

- Add search, category, and type filters to questions API
- Add clickable sortable table headers with sort indicators
- Add filter controls UI with clear filters button

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
ryan
2026-01-18 13:01:11 -05:00
parent 758e1a18e4
commit ab962725e6
3 changed files with 206 additions and 21 deletions

View File

@@ -7,8 +7,65 @@ bp = Blueprint('questions', __name__, url_prefix='/api/questions')
@bp.route('', methods=['GET'])
def list_questions():
"""Get all questions"""
questions = Question.query.order_by(Question.created_at.desc()).all()
"""Get all questions with optional filtering and sorting
Query parameters:
- search: Search in question content and answer (case-insensitive)
- category: Filter by category name (exact match, or 'none' for uncategorized)
- type: Filter by question type (text, image, youtube_audio)
- sort_by: Field to sort by (created_at, category, type, question_content, answer)
- sort_order: Sort direction (asc, desc) - default: desc
"""
query = Question.query
# Search filter
search = request.args.get('search', '').strip()
if search:
search_pattern = f'%{search}%'
query = query.filter(
db.or_(
Question.question_content.ilike(search_pattern),
Question.answer.ilike(search_pattern)
)
)
# Category filter
category = request.args.get('category', '').strip()
if category:
if category.lower() == 'none':
query = query.filter(Question.category.is_(None))
else:
query = query.filter(Question.category == category)
# Type filter
question_type = request.args.get('type', '').strip()
if question_type:
try:
query = query.filter(Question.type == QuestionType(question_type))
except ValueError:
pass # Invalid type, ignore filter
# Sorting
sort_by = request.args.get('sort_by', 'created_at').strip()
sort_order = request.args.get('sort_order', 'desc').strip().lower()
# Map sort_by to column
sort_columns = {
'created_at': Question.created_at,
'category': Question.category,
'type': Question.type,
'question_content': Question.question_content,
'answer': Question.answer,
}
sort_column = sort_columns.get(sort_by, Question.created_at)
if sort_order == 'asc':
query = query.order_by(sort_column.asc())
else:
query = query.order_by(sort_column.desc())
questions = query.all()
return jsonify([q.to_dict(include_answer=True) for q in questions]), 200

View File

@@ -29,14 +29,29 @@ export default function QuestionBankView() {
const [downloadJob, setDownloadJob] = useState(null);
const [downloadProgress, setDownloadProgress] = useState(0);
// Filter and sort state
const [searchTerm, setSearchTerm] = useState("");
const [filterCategory, setFilterCategory] = useState("");
const [filterType, setFilterType] = useState("");
const [sortBy, setSortBy] = useState("created_at");
const [sortOrder, setSortOrder] = useState("desc");
useEffect(() => {
loadQuestions();
loadCategories();
}, []);
}, [searchTerm, filterCategory, filterType, sortBy, sortOrder]);
const loadQuestions = async () => {
try {
const response = await questionsAPI.getAll();
const params = {
sort_by: sortBy,
sort_order: sortOrder,
};
if (searchTerm) params.search = searchTerm;
if (filterCategory) params.category = filterCategory;
if (filterType) params.type = filterType;
const response = await questionsAPI.getAll(params);
setQuestions(response.data);
} catch (error) {
console.error("Error loading questions:", error);
@@ -295,6 +310,28 @@ export default function QuestionBankView() {
}
};
const handleSort = (column) => {
if (sortBy === column) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy(column);
setSortOrder("asc");
}
};
const SortIndicator = ({ column }) => {
if (sortBy !== column) return <span style={{ opacity: 0.3, marginLeft: "0.25rem" }}></span>;
return <span style={{ marginLeft: "0.25rem" }}>{sortOrder === "asc" ? "↑" : "↓"}</span>;
};
const sortableHeaderStyle = {
padding: "0.75rem",
textAlign: "left",
borderBottom: "2px solid #ddd",
cursor: "pointer",
userSelect: "none",
};
return (
<>
<AdminNavbar />
@@ -581,6 +618,101 @@ export default function QuestionBankView() {
)}
<div>
{/* Filter and Sort Controls */}
<div
style={{
marginBottom: "1rem",
padding: "1rem",
background: "#f9f9f9",
borderRadius: "8px",
display: "flex",
flexWrap: "wrap",
gap: "1rem",
alignItems: "center",
}}
>
{/* Search */}
<div style={{ flex: "1", minWidth: "200px" }}>
<input
type="text"
placeholder="Search questions or answers..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{
width: "100%",
padding: "0.5rem",
border: "1px solid #ddd",
borderRadius: "4px",
}}
/>
</div>
{/* Category Filter */}
<div>
<select
value={filterCategory}
onChange={(e) => setFilterCategory(e.target.value)}
style={{
padding: "0.5rem",
border: "1px solid #ddd",
borderRadius: "4px",
minWidth: "150px",
}}
>
<option value="">All Categories</option>
<option value="none">Uncategorized</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.name}>
{cat.name}
</option>
))}
</select>
</div>
{/* Type Filter */}
<div>
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
style={{
padding: "0.5rem",
border: "1px solid #ddd",
borderRadius: "4px",
minWidth: "130px",
}}
>
<option value="">All Types</option>
<option value="text">Text</option>
<option value="image">Image</option>
<option value="youtube_audio">YouTube Audio</option>
</select>
</div>
{/* Clear Filters */}
{(searchTerm || filterCategory || filterType || sortBy !== "created_at" || sortOrder !== "desc") && (
<button
onClick={() => {
setSearchTerm("");
setFilterCategory("");
setFilterType("");
setSortBy("created_at");
setSortOrder("desc");
}}
style={{
padding: "0.5rem 1rem",
background: "#f44336",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontSize: "0.9rem",
}}
>
Clear Filters
</button>
)}
</div>
<div
style={{
display: "flex",
@@ -715,42 +847,38 @@ export default function QuestionBankView() {
/>
</th>
<th
onClick={() => handleSort("type")}
style={{
padding: "0.75rem",
textAlign: "left",
borderBottom: "2px solid #ddd",
...sortableHeaderStyle,
width: "80px",
}}
>
Type
<SortIndicator column="type" />
</th>
<th
onClick={() => handleSort("category")}
style={{
padding: "0.75rem",
textAlign: "left",
borderBottom: "2px solid #ddd",
...sortableHeaderStyle,
width: "120px",
}}
>
Category
<SortIndicator column="category" />
</th>
<th
style={{
padding: "0.75rem",
textAlign: "left",
borderBottom: "2px solid #ddd",
}}
onClick={() => handleSort("question_content")}
style={sortableHeaderStyle}
>
Question
<SortIndicator column="question_content" />
</th>
<th
style={{
padding: "0.75rem",
textAlign: "left",
borderBottom: "2px solid #ddd",
}}
onClick={() => handleSort("answer")}
style={sortableHeaderStyle}
>
Answer
<SortIndicator column="answer" />
</th>
<th
style={{

View File

@@ -48,7 +48,7 @@ api.interceptors.response.use(
// Questions API
export const questionsAPI = {
getAll: () => api.get("/questions"),
getAll: (params = {}) => api.get("/questions", { params }),
getOne: (id) => api.get(`/questions/${id}`),
create: (data) => api.post("/questions", data),
createWithImage: (formData) =>