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:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user