Skip to content

Commit 12b702f

Browse files
Campaign question management page (#644)
* basic questions editing ui basic questions editing ui basic option editing functionality * frontend question crud setup * question update and persist * add new questions * delete questions * add existing questions
1 parent 89b7711 commit 12b702f

File tree

14 files changed

+997
-23
lines changed

14 files changed

+997
-23
lines changed

backend/database-seeding/src/seeder.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,72 @@ pub async fn seed_database(dev_email: String, mut seeder: Seeder) {
252252
)
253253
.await.expect("Failed seeding Question 3");
254254

255+
let question_id_4 = Question::create(
256+
campaign_id,
257+
"Where did you hear about DevSoc from?".to_string(),
258+
Some("This is a general question for all roles".to_string()),
259+
true,
260+
None,
261+
true,
262+
QuestionData::MultiChoice(
263+
MultiOptionData {
264+
options: vec![
265+
MultiOptionQuestionOption {
266+
id: 0,
267+
text: "Email".to_string(),
268+
display_order: 1,
269+
},
270+
MultiOptionQuestionOption {
271+
id: 0,
272+
text: "Word of Mouth".to_string(),
273+
display_order: 2,
274+
},
275+
MultiOptionQuestionOption {
276+
id: 0,
277+
text: "Social Media".to_string(),
278+
display_order: 3,
279+
},
280+
]
281+
}
282+
),
283+
&mut seeder.app_state.snowflake_generator,
284+
&mut tx,
285+
)
286+
.await.expect("Failed seeding Question 4");
287+
288+
let question_id_5 = Question::create(
289+
campaign_id,
290+
"Rank these programming languages?".to_string(),
291+
Some("This is a general question for all technical roles".to_string()),
292+
false,
293+
Some(vec![role_id_1, role_id_2]),
294+
true,
295+
QuestionData::Ranking(
296+
MultiOptionData {
297+
options: vec![
298+
MultiOptionQuestionOption {
299+
id: 0,
300+
text: "Rust".to_string(),
301+
display_order: 1,
302+
},
303+
MultiOptionQuestionOption {
304+
id: 0,
305+
text: "JavaScript".to_string(),
306+
display_order: 2,
307+
},
308+
MultiOptionQuestionOption {
309+
id: 0,
310+
text: "Python".to_string(),
311+
display_order: 3,
312+
},
313+
]
314+
}
315+
),
316+
&mut seeder.app_state.snowflake_generator,
317+
&mut tx,
318+
)
319+
.await.expect("Failed seeding Question 4");
320+
255321
let application_id_1 = Application::create(
256322
campaign_id,
257323
2,

backend/server/src/models/question.rs

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -131,17 +131,26 @@ impl Question {
131131
.await?;
132132

133133
if !common {
134-
for role in roles.unwrap_or_default() {
135-
sqlx::query!(
134+
if let Some(roles) = roles {
135+
if roles.len() == 0 {
136+
return Err(ChaosError::BadRequestWithMessage("Question must either be common or assigned to at least one role".to_string()));
137+
}
138+
139+
for role in roles {
140+
sqlx::query!(
136141
"
137142
INSERT INTO question_roles (question_id, role_id) VALUES ($1, $2)
138143
",
139144
id,
140145
role
141146
)
142-
.execute(transaction.deref_mut())
143-
.await?;
147+
.execute(transaction.deref_mut())
148+
.await?;
149+
}
150+
} else {
151+
return Err(ChaosError::BadRequestWithMessage("Question must either be common or assigned to at least one role".to_string()));
144152
}
153+
145154
}
146155

147156
Ok(id)
@@ -159,7 +168,7 @@ impl Question {
159168
q.title,
160169
q.description,
161170
q.common,
162-
COALESCE(array_remove(array_agg(qr.role_id), NULL), '{}') AS "roles!: Vec<i64>",
171+
COALESCE(array_remove(array_agg(DISTINCT qr.role_id), NULL), '{}') AS "roles!: Vec<i64>",
163172
q.required,
164173
q.question_type AS "question_type: QuestionType",
165174
q.created_at,
@@ -220,7 +229,7 @@ impl Question {
220229
q.title,
221230
q.description,
222231
q.common,
223-
COALESCE(array_remove(array_agg(qr.role_id), NULL), '{}') AS "roles!: Vec<i64>",
232+
COALESCE(array_remove(array_agg(DISTINCT qr.role_id), NULL), '{}') AS "roles!: Vec<i64>",
224233
q.required,
225234
q.question_type AS "question_type: QuestionType",
226235
q.created_at,
@@ -288,19 +297,22 @@ impl Question {
288297
q.title,
289298
q.description,
290299
q.common,
291-
COALESCE(array_remove(array_agg(qr.role_id), NULL), '{}') AS "roles!: Vec<i64>",
300+
COALESCE(
301+
(SELECT array_agg(qr.role_id) FROM question_roles qr WHERE qr.question_id = q.id),
302+
'{}'
303+
) AS "roles!: Vec<i64>",
292304
q.required,
293305
q.question_type AS "question_type: QuestionType",
294306
q.created_at,
295307
q.updated_at,
296-
to_jsonb(
297-
array_agg(
298-
jsonb_build_object(
299-
'id', mod.id,
300-
'display_order', mod.display_order,
301-
'text', mod.text
302-
) ORDER BY mod.display_order
303-
) FILTER (WHERE mod.id IS NOT NULL)
308+
(
309+
SELECT to_jsonb(array_agg(jsonb_build_object(
310+
'id', mod.id,
311+
'display_order', mod.display_order,
312+
'text', mod.text
313+
) ORDER BY mod.display_order))
314+
FROM multi_option_question_options mod
315+
WHERE mod.question_id = q.id
304316
) AS "multi_option_data: Json<Vec<MultiOptionQuestionOption>>"
305317
FROM
306318
questions q
@@ -309,7 +321,9 @@ impl Question {
309321
LEFT JOIN
310322
multi_option_question_options mod ON q.id = mod.question_id
311323
AND q.question_type IN ('MultiChoice', 'MultiSelect', 'DropDown', 'Ranking')
312-
WHERE q.campaign_id = $1 AND q.common = false AND qr.role_id = $2
324+
WHERE q.campaign_id = $1 AND q.common = false AND EXISTS (
325+
SELECT 1 FROM question_roles qr_check WHERE qr_check.question_id = q.id AND qr_check.role_id = $2
326+
)
313327
GROUP BY
314328
q.id
315329
"#,
@@ -356,7 +370,7 @@ impl Question {
356370
q.title,
357371
q.description,
358372
q.common,
359-
COALESCE(array_remove(array_agg(qr.role_id), NULL), '{}') AS "roles!: Vec<i64>",
373+
COALESCE(array_remove(array_agg(DISTINCT qr.role_id), NULL), '{}') AS "roles!: Vec<i64>",
360374
q.required,
361375
q.question_type AS "question_type: QuestionType",
362376
q.created_at,
@@ -540,6 +554,7 @@ pub struct MultiOptionData {
540554
#[derive(Deserialize, Serialize)]
541555
pub struct MultiOptionQuestionOption {
542556
#[serde(serialize_with = "crate::models::serde_string::serialize")]
557+
#[serde(deserialize_with = "crate::models::serde_string::deserialize")]
543558
pub id: i64,
544559
pub display_order: i32,
545560
pub text: String,

backend/server/src/models/serde_string.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,13 @@ pub fn deserialize<'de, D>(deserializer: D) -> Result<i64, D::Error>
2323
where
2424
D: Deserializer<'de>,
2525
{
26-
let s = String::deserialize(deserializer)?;
27-
s.parse::<i64>().map_err(Error::custom)
26+
match serde_json::Value::deserialize(deserializer)? {
27+
serde_json::Value::String(s) => s.parse::<i64>().map_err(Error::custom),
28+
serde_json::Value::Number(n) => n
29+
.as_i64()
30+
.ok_or_else(|| Error::custom("number out of range for i64")),
31+
_ => Err(Error::custom("expected string or number")),
32+
}
2833
}
2934

3035
pub fn deserialize_vec<'de, D>(deserializer: D) -> Result<Vec<i64>, D::Error>

frontend-nextjs/bun.lock

Lines changed: 25 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend-nextjs/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"start": "next start"
99
},
1010
"dependencies": {
11+
"@hello-pangea/dnd": "^18.0.1",
1112
"@lexical/react": "^0.39.0",
1213
"@radix-ui/react-alert-dialog": "^1.1.15",
1314
"@radix-ui/react-collapsible": "^1.1.12",
@@ -21,10 +22,12 @@
2122
"@radix-ui/react-slot": "^1.2.4",
2223
"@radix-ui/react-tabs": "^1.1.13",
2324
"@radix-ui/react-tooltip": "^1.2.8",
25+
"@sapphire/snowflake": "^3.5.5",
2426
"@tanstack/react-query": "^5.90.13",
2527
"@tanstack/react-table": "^8.21.3",
2628
"class-variance-authority": "^0.7.1",
2729
"clsx": "^2.1.1",
30+
"cmdk": "^1.1.1",
2831
"date-fns": "^4.1.0",
2932
"lexical": "^0.39.0",
3033
"lucide-react": "^0.562.0",

0 commit comments

Comments
 (0)