632e66e21d
- Add configurable profile questions with conditional visibility, admin-only fields, user answers, and seeded onboarding/volunteer questions
- Add admin UI for managing profile questions and member profile answers
- Add volunteer level/profile data support across backend schemas, models, API, and migration
- Update dashboard/profile UI, super admin menu, membership service types, and related styling
- Add privacy policy, terms of service, cookie notice, and footer links
- Add frontend Vitest coverage for profile question logic
- Add backend pytest coverage for profile answer normalization and validation
- Update restart.sh to build, run frontend/backend unit tests, and restart only after tests pass
- Refresh README, quickstart, project structure, instructions, and Square docs to match current app features
- Protect feature flag reload behind super-admin access
- Restrict admin-triggered password resets so admins can only reset member accounts
- Replace email template HTML preview rendering with escaped text preview
- Update docs for feature flag reload access, password reset scope, and email template preview safety
-- test user questions are also made by AI and not very useful. but i didn't know what to put there so its good enough for a test
411 lines
16 KiB
TypeScript
411 lines
16 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
|
import {
|
|
ProfileQuestion,
|
|
ProfileQuestionInputType,
|
|
ProfileQuestionOption,
|
|
ProfileQuestionUpsertData,
|
|
userService
|
|
} from '../services/membershipService';
|
|
|
|
interface AdminProfileQuestionManagerProps {
|
|
onQuestionsChanged?: () => void;
|
|
}
|
|
|
|
const INPUT_TYPES: ProfileQuestionInputType[] = ['text', 'number', 'boolean', 'date', 'select'];
|
|
|
|
const optionsToText = (options: ProfileQuestionOption[] | null | undefined): string => {
|
|
if (!options || options.length === 0) {
|
|
return '';
|
|
}
|
|
return options.map((option) => `${option.label}|${option.value}`).join('\n');
|
|
};
|
|
|
|
const textToOptions = (value: string): ProfileQuestionOption[] => {
|
|
return value
|
|
.split('\n')
|
|
.map((line) => line.trim())
|
|
.filter(Boolean)
|
|
.map((line) => {
|
|
const [labelPart, valuePart] = line.split('|');
|
|
const label = (labelPart || '').trim();
|
|
const optionValue = (valuePart || labelPart || '').trim();
|
|
return { label, value: optionValue };
|
|
})
|
|
.filter((option) => option.label.length > 0 && option.value.length > 0);
|
|
};
|
|
|
|
const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> = ({ onQuestionsChanged }) => {
|
|
const [questions, setQuestions] = useState<ProfileQuestion[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [editingQuestionId, setEditingQuestionId] = useState<number | null>(null);
|
|
const [listSearch, setListSearch] = useState('');
|
|
|
|
const emptyForm: ProfileQuestionUpsertData = {
|
|
key: '',
|
|
label: '',
|
|
help_text: '',
|
|
input_type: 'text',
|
|
placeholder: '',
|
|
options: null,
|
|
is_required: false,
|
|
is_active: true,
|
|
admin_only_edit: false,
|
|
display_order: 0,
|
|
depends_on_question_id: null,
|
|
depends_on_value: null
|
|
};
|
|
|
|
const [formData, setFormData] = useState<ProfileQuestionUpsertData>(emptyForm);
|
|
const [optionsText, setOptionsText] = useState('');
|
|
|
|
const loadQuestions = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const data = await userService.getAdminProfileQuestions(true);
|
|
setQuestions(data);
|
|
setError(null);
|
|
} catch (err: any) {
|
|
setError(err.response?.data?.detail || err.message || 'Failed to load questions');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadQuestions();
|
|
}, []);
|
|
|
|
const dependencyCandidates = useMemo(() => {
|
|
return questions.filter((question) => question.id !== editingQuestionId);
|
|
}, [questions, editingQuestionId]);
|
|
|
|
const selectedDependencyQuestion = useMemo(() => {
|
|
if (!formData.depends_on_question_id) {
|
|
return null;
|
|
}
|
|
return questions.find((question) => question.id === formData.depends_on_question_id) || null;
|
|
}, [questions, formData.depends_on_question_id]);
|
|
|
|
const filteredQuestions = useMemo(() => {
|
|
const term = listSearch.trim().toLowerCase();
|
|
if (!term) {
|
|
return questions;
|
|
}
|
|
return questions.filter((question) =>
|
|
question.label.toLowerCase().includes(term) ||
|
|
question.key.toLowerCase().includes(term)
|
|
);
|
|
}, [questions, listSearch]);
|
|
|
|
const resetForm = () => {
|
|
setFormData(emptyForm);
|
|
setOptionsText('');
|
|
setEditingQuestionId(null);
|
|
};
|
|
|
|
const handleEdit = (question: ProfileQuestion) => {
|
|
setEditingQuestionId(question.id);
|
|
setFormData({
|
|
key: question.key,
|
|
label: question.label,
|
|
help_text: question.help_text,
|
|
input_type: question.input_type,
|
|
placeholder: question.placeholder,
|
|
options: question.options,
|
|
is_required: question.is_required,
|
|
is_active: question.is_active,
|
|
admin_only_edit: question.admin_only_edit,
|
|
display_order: question.display_order,
|
|
depends_on_question_id: question.depends_on_question_id,
|
|
depends_on_value: question.depends_on_value
|
|
});
|
|
setOptionsText(optionsToText(question.options));
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
setSaving(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const payload: ProfileQuestionUpsertData = {
|
|
...formData,
|
|
key: formData.key.trim(),
|
|
label: formData.label.trim(),
|
|
help_text: formData.help_text?.trim() || null,
|
|
placeholder: formData.placeholder?.trim() || null,
|
|
depends_on_value: formData.depends_on_value?.trim() || null,
|
|
options: formData.input_type === 'select' ? textToOptions(optionsText) : null,
|
|
};
|
|
|
|
if (editingQuestionId) {
|
|
await userService.updateAdminProfileQuestion(editingQuestionId, payload);
|
|
} else {
|
|
await userService.createAdminProfileQuestion(payload);
|
|
}
|
|
|
|
await loadQuestions();
|
|
resetForm();
|
|
onQuestionsChanged?.();
|
|
} catch (err: any) {
|
|
setError(err.response?.data?.detail || err.message || 'Failed to save question');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDeactivate = async (questionId: number) => {
|
|
if (!window.confirm('Deactivate this question? Existing answers are kept.')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await userService.deactivateAdminProfileQuestion(questionId);
|
|
await loadQuestions();
|
|
onQuestionsChanged?.();
|
|
} catch (err: any) {
|
|
setError(err.response?.data?.detail || err.message || 'Failed to deactivate question');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="card" style={{ marginTop: '20px' }}>
|
|
<h3 style={{ marginBottom: '10px' }}>Profile Questions (Admin)</h3>
|
|
<p style={{ marginBottom: '16px', color: '#555' }}>
|
|
Manage the set of profile questions users can answer. You can add follow-up questions with dependencies.
|
|
</p>
|
|
|
|
{error && <div className="alert alert-error">{error}</div>}
|
|
|
|
<div style={{ display: 'grid', gap: '10px', marginBottom: '20px' }}>
|
|
<input
|
|
type="text"
|
|
placeholder="Question key (e.g. pilot_license_type)"
|
|
value={formData.key}
|
|
onChange={(event) => setFormData((prev) => ({ ...prev, key: event.target.value }))}
|
|
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
|
/>
|
|
<input
|
|
type="text"
|
|
placeholder="Question label"
|
|
value={formData.label}
|
|
onChange={(event) => setFormData((prev) => ({ ...prev, label: event.target.value }))}
|
|
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
|
/>
|
|
<textarea
|
|
placeholder="Help text (optional)"
|
|
value={formData.help_text || ''}
|
|
onChange={(event) => setFormData((prev) => ({ ...prev, help_text: event.target.value }))}
|
|
rows={2}
|
|
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
|
/>
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '10px' }}>
|
|
<select
|
|
value={formData.input_type}
|
|
onChange={(event) => setFormData((prev) => ({ ...prev, input_type: event.target.value as ProfileQuestionInputType }))}
|
|
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
|
>
|
|
{INPUT_TYPES.map((type) => (
|
|
<option key={type} value={type}>{type}</option>
|
|
))}
|
|
</select>
|
|
|
|
<input
|
|
type="number"
|
|
placeholder="Display order"
|
|
value={formData.display_order ?? 0}
|
|
onChange={(event) => setFormData((prev) => ({ ...prev, display_order: Number(event.target.value) }))}
|
|
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
|
/>
|
|
|
|
<input
|
|
type="text"
|
|
placeholder="Placeholder"
|
|
value={formData.placeholder || ''}
|
|
onChange={(event) => setFormData((prev) => ({ ...prev, placeholder: event.target.value }))}
|
|
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
|
/>
|
|
</div>
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '10px' }}>
|
|
<select
|
|
value={formData.depends_on_question_id ?? ''}
|
|
onChange={(event) => {
|
|
const nextValue = event.target.value;
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
depends_on_question_id: nextValue ? Number(nextValue) : null,
|
|
depends_on_value: null
|
|
}));
|
|
}}
|
|
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
|
>
|
|
<option value="">No dependency</option>
|
|
{dependencyCandidates.map((question) => (
|
|
<option key={question.id} value={question.id}>{question.label}</option>
|
|
))}
|
|
</select>
|
|
|
|
{!selectedDependencyQuestion && (
|
|
<input
|
|
type="text"
|
|
placeholder="Choose a dependency question first"
|
|
value=""
|
|
disabled
|
|
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px', background: '#f5f7fa' }}
|
|
/>
|
|
)}
|
|
|
|
{selectedDependencyQuestion?.input_type === 'select' && (
|
|
<select
|
|
value={formData.depends_on_value || ''}
|
|
onChange={(event) => setFormData((prev) => ({ ...prev, depends_on_value: event.target.value || null }))}
|
|
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
|
>
|
|
<option value="">Any answered value</option>
|
|
{selectedDependencyQuestion.options.map((option) => (
|
|
<option key={option.value} value={option.value}>{option.label}</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
|
|
{selectedDependencyQuestion?.input_type === 'boolean' && (
|
|
<select
|
|
value={formData.depends_on_value || ''}
|
|
onChange={(event) => setFormData((prev) => ({ ...prev, depends_on_value: event.target.value || null }))}
|
|
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
|
>
|
|
<option value="">Any answered value</option>
|
|
<option value="true">Yes</option>
|
|
<option value="false">No</option>
|
|
</select>
|
|
)}
|
|
|
|
{selectedDependencyQuestion && !['select', 'boolean'].includes(selectedDependencyQuestion.input_type) && (
|
|
<input
|
|
type={selectedDependencyQuestion.input_type === 'number' ? 'number' : selectedDependencyQuestion.input_type === 'date' ? 'date' : 'text'}
|
|
placeholder="Show when parent answer equals..."
|
|
value={formData.depends_on_value || ''}
|
|
onChange={(event) => setFormData((prev) => ({ ...prev, depends_on_value: event.target.value || null }))}
|
|
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{formData.input_type === 'select' && (
|
|
<textarea
|
|
value={optionsText}
|
|
onChange={(event) => setOptionsText(event.target.value)}
|
|
rows={4}
|
|
placeholder={'Options (one per line):\nNo|none\nPrivate Pilot|ppl'}
|
|
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
|
/>
|
|
)}
|
|
|
|
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
checked={Boolean(formData.is_required)}
|
|
onChange={(event) => setFormData((prev) => ({ ...prev, is_required: event.target.checked }))}
|
|
style={{ marginRight: '6px' }}
|
|
/>
|
|
Required
|
|
</label>
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
checked={Boolean(formData.admin_only_edit)}
|
|
onChange={(event) => setFormData((prev) => ({ ...prev, admin_only_edit: event.target.checked }))}
|
|
style={{ marginRight: '6px' }}
|
|
/>
|
|
Admin-only edits
|
|
</label>
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
checked={Boolean(formData.is_active)}
|
|
onChange={(event) => setFormData((prev) => ({ ...prev, is_active: event.target.checked }))}
|
|
style={{ marginRight: '6px' }}
|
|
/>
|
|
Active
|
|
</label>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', gap: '10px' }}>
|
|
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !formData.key || !formData.label}>
|
|
{saving ? 'Saving...' : editingQuestionId ? 'Update Question' : 'Create Question'}
|
|
</button>
|
|
{editingQuestionId && (
|
|
<button className="btn btn-secondary" onClick={resetForm}>
|
|
Cancel Edit
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<h4 style={{ marginBottom: '10px' }}>Existing Questions</h4>
|
|
<input
|
|
type="text"
|
|
placeholder="Search by label or key..."
|
|
value={listSearch}
|
|
onChange={(event) => setListSearch(event.target.value)}
|
|
style={{ marginBottom: '10px', width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
|
/>
|
|
{loading ? (
|
|
<p>Loading questions...</p>
|
|
) : (
|
|
<div className="table-container">
|
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
|
<thead>
|
|
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
|
<th style={{ padding: '8px', textAlign: 'left' }}>Order</th>
|
|
<th style={{ padding: '8px', textAlign: 'left' }}>Label</th>
|
|
<th style={{ padding: '8px', textAlign: 'left' }}>Type</th>
|
|
<th style={{ padding: '8px', textAlign: 'left' }}>Key</th>
|
|
<th style={{ padding: '8px', textAlign: 'left' }}>Status</th>
|
|
<th style={{ padding: '8px', textAlign: 'left' }}>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredQuestions.map((question) => (
|
|
<tr key={question.id} style={{ borderBottom: '1px solid #eee' }}>
|
|
<td style={{ padding: '8px' }}>{question.display_order}</td>
|
|
<td style={{ padding: '8px' }}>
|
|
{question.label}
|
|
{question.admin_only_edit && (
|
|
<span style={{ backgroundColor: '#eef2ff', color: '#3730a3', marginLeft: '8px', padding: '2px 7px', borderRadius: '999px', fontWeight: 600, fontSize: '12px' }}>
|
|
Admin Managed
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td style={{ padding: '8px' }}>{question.input_type}</td>
|
|
<td style={{ padding: '8px' }}>{question.key}</td>
|
|
<td style={{ padding: '8px' }}>{question.is_active ? 'Active' : 'Inactive'}</td>
|
|
<td style={{ padding: '8px' }}>
|
|
<button className="btn btn-secondary" style={{ fontSize: '12px', padding: '4px 8px', marginRight: '6px' }} onClick={() => handleEdit(question)}>
|
|
Edit
|
|
</button>
|
|
{question.is_active && (
|
|
<button className="btn btn-danger" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => handleDeactivate(question.id)}>
|
|
Deactivate
|
|
</button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
{filteredQuestions.length === 0 && (
|
|
<p style={{ padding: '10px', color: '#666' }}>No questions match your search.</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AdminProfileQuestionManager;
|