Skip to content

Commit 11a6321

Browse files
committed
make user's job/role also a dropdown option
1 parent d698b6f commit 11a6321

9 files changed

Lines changed: 235 additions & 106 deletions

File tree

packages/divbase-api/src/divbase_api/api_constants.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
DivBase API constants.
33
"""
44

5-
# List of Swedish universities for the dropdown in the registration form.
5+
# List of Swedish universities for the dropdown in the registration/update profile form.
66
SWEDISH_UNIVERSITIES = [
77
"Blekinge Institute of Technology",
88
"Chalmers University of Technology",
@@ -35,3 +35,18 @@
3535
"Uppsala University",
3636
"Örebro University",
3737
]
38+
39+
# List of known job roles for the dropdown in the registration/update profile form.
40+
KNOWN_JOB_ROLES = [
41+
"Bachelor's Student",
42+
"Bioinformatician",
43+
"Infrastructure Support Staff",
44+
"Master's Student",
45+
"PhD Candidate",
46+
"Postdoctoral Researcher",
47+
"Principal Investigator",
48+
"Research Associate",
49+
"Researcher",
50+
"Scientist",
51+
"Technician",
52+
]

packages/divbase-api/src/divbase_api/crud/users.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,3 +119,18 @@ async def update_user_profile(db: AsyncSession, user_data: UserUpdate, user_id:
119119
await db.commit()
120120
await db.refresh(user)
121121
return user
122+
123+
124+
def resolve_dropdown_form_input(dropdown_value: str, other_value: str | None) -> str | None:
125+
"""
126+
Helper to resolve "Other" fields in registration/profile update forms.
127+
These fields have a dropdown with predefined options and an "Other" option that reveals a text input.
128+
If "Other" selected, we take the value from the text input instead. If the text input is empty or too short, we return None to indicate an error (e.g. in the registration/profile update endpoint), otherwise we return the resolved value. If "Other" is not selected, we just return the original value.
129+
130+
If non-resolvable, returns None, so calling function can return an error response.
131+
"""
132+
if dropdown_value != "Other":
133+
return dropdown_value.strip()
134+
if not other_value or len(other_value.strip()) < 3:
135+
return None
136+
return other_value.strip()

packages/divbase-api/src/divbase_api/frontend_routes/auth.py

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from sqlalchemy.ext.asyncio import AsyncSession
1111

1212
from divbase_api.api_config import settings
13-
from divbase_api.api_constants import SWEDISH_UNIVERSITIES
13+
from divbase_api.api_constants import KNOWN_JOB_ROLES, SWEDISH_UNIVERSITIES
1414
from divbase_api.crud.auth import (
1515
authenticate_user,
1616
check_user_email_verified,
@@ -19,7 +19,7 @@
1919
update_user_password,
2020
)
2121
from divbase_api.crud.revoked_tokens import revoke_token_on_logout, revoke_used_password_reset_token, token_is_revoked
22-
from divbase_api.crud.users import create_user, get_user_by_email, get_user_by_id_or_raise
22+
from divbase_api.crud.users import create_user, get_user_by_email, get_user_by_id_or_raise, resolve_dropdown_form_input
2323
from divbase_api.db import get_db
2424
from divbase_api.deps import get_current_user_from_cookie_optional
2525
from divbase_api.exceptions import AuthenticationError
@@ -132,7 +132,11 @@ async def get_register(request: Request, current_user: UserDB | None = Depends(g
132132
return templates.TemplateResponse(
133133
request=request,
134134
name="auth_pages/register.html",
135-
context={"current_user": None, "swedish_universities": SWEDISH_UNIVERSITIES},
135+
context={
136+
"current_user": None,
137+
"swedish_universities": SWEDISH_UNIVERSITIES,
138+
"known_job_roles": KNOWN_JOB_ROLES,
139+
},
136140
)
137141

138142

@@ -144,7 +148,8 @@ async def post_register(
144148
email: str = Form(...),
145149
organisation: str = Form(...),
146150
organisation_other: str | None = Form(None),
147-
organisation_role: str = Form(...),
151+
role: str = Form(...),
152+
role_other: str | None = Form(None),
148153
password: str = Form(...),
149154
confirm_password: str = Form(...),
150155
db: AsyncSession = Depends(get_db),
@@ -162,16 +167,20 @@ def registration_failed_response(error_message: str):
162167
"email": email,
163168
"organisation": organisation,
164169
"organisation_other": organisation_other,
165-
"organisation_role": organisation_role,
170+
"role": role,
171+
"role_other": role_other,
166172
"swedish_universities": SWEDISH_UNIVERSITIES,
173+
"known_job_roles": KNOWN_JOB_ROLES,
167174
},
168175
)
169176

170-
if organisation == "Other":
171-
if not organisation_other or len(organisation_other.strip()) < 3:
172-
return registration_failed_response("Please specify your organisation")
173-
else:
174-
organisation = organisation_other.strip()
177+
resolved_organisation = resolve_dropdown_form_input(dropdown_value=organisation, other_value=organisation_other)
178+
if not resolved_organisation:
179+
return registration_failed_response("Please specify your organisation, it must be at least 3 characters long.")
180+
181+
resolved_role = resolve_dropdown_form_input(dropdown_value=role, other_value=role_other)
182+
if not resolved_role:
183+
return registration_failed_response("Please specify your role, it must be at least 3 characters long.")
175184

176185
existing_user = await get_user_by_email(db=db, email=email.strip())
177186
if existing_user: # Not recommended to specify why failed, just say failed.
@@ -182,8 +191,8 @@ def registration_failed_response(error_message: str):
182191
user_data = UserCreate(
183192
name=name.strip(),
184193
email=email.strip(),
185-
organisation=organisation.strip(),
186-
organisation_role=organisation_role.strip(),
194+
organisation=resolved_organisation.strip(),
195+
organisation_role=resolved_role.strip(),
187196
confirm_password=SecretStr(confirm_password),
188197
password=SecretStr(password),
189198
)

packages/divbase-api/src/divbase_api/frontend_routes/profile.py

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@
1111
from pydantic import ValidationError
1212
from sqlalchemy.ext.asyncio import AsyncSession
1313

14-
from divbase_api.api_constants import SWEDISH_UNIVERSITIES
14+
from divbase_api.api_constants import KNOWN_JOB_ROLES, SWEDISH_UNIVERSITIES
1515
from divbase_api.crud.projects import create_user_project_responses, get_user_projects_with_roles
16-
from divbase_api.crud.users import update_user_profile
16+
from divbase_api.crud.users import resolve_dropdown_form_input, update_user_profile
1717
from divbase_api.db import get_db
1818
from divbase_api.deps import get_current_user_from_cookie
1919
from divbase_api.frontend_routes.core import templates
@@ -52,25 +52,32 @@ async def get_edit_user_profile_endpoint(
5252
current_user: UserDB = Depends(get_current_user_from_cookie),
5353
):
5454
"""Render the edit user's profile page."""
55-
55+
# little awkward, but this logic is needed to handle the "Other" option for
56+
# organisation and roles, and pre-fill each field appropriately on page load.
5657
organisation_other = None
5758
organisation = current_user.organisation
5859
if current_user.organisation not in SWEDISH_UNIVERSITIES:
5960
organisation_other = current_user.organisation
6061
organisation = "Other"
6162

63+
role_other = None
64+
role = current_user.organisation_role
65+
if current_user.organisation_role not in KNOWN_JOB_ROLES:
66+
role_other = current_user.organisation_role
67+
role = "Other"
68+
6269
return templates.TemplateResponse(
6370
request=request,
6471
name="profile_pages/edit_profile.html",
6572
context={
6673
"request": request,
6774
"current_user": current_user,
6875
"swedish_universities": SWEDISH_UNIVERSITIES,
69-
# Pre-fill the form with the user's current information.
70-
# little awkward, but this logic is needed to handle the "Other" option for organisation.
76+
"known_job_roles": KNOWN_JOB_ROLES,
7177
"organisation": organisation,
7278
"organisation_other": organisation_other,
73-
"organisation_role": current_user.organisation_role,
79+
"role": role,
80+
"role_other": role_other,
7481
},
7582
)
7683

@@ -82,7 +89,8 @@ async def post_edit_user_profile_endpoint(
8289
current_user: UserDB = Depends(get_current_user_from_cookie),
8390
organisation: str = Form(...),
8491
organisation_other: str | None = Form(None),
85-
organisation_role: str = Form(...),
92+
role: str = Form(...),
93+
role_other: str | None = Form(None),
8694
db: AsyncSession = Depends(get_db),
8795
):
8896
"""
@@ -106,30 +114,33 @@ def edit_profile_failed_response(error_message: str):
106114
"name": name,
107115
"organisation": organisation,
108116
"organisation_other": organisation_other,
109-
"organisation_role": organisation_role,
117+
"role": role,
118+
"role_other": role_other,
110119
},
111120
)
112121

113-
if organisation == "Other":
114-
if not organisation_other or len(organisation_other.strip()) < 3:
115-
return edit_profile_failed_response(
116-
"Please specify your organisation, it must be at least 3 characters long."
117-
)
118-
else:
119-
organisation = organisation_other.strip()
122+
resolved_organisation = resolve_dropdown_form_input(dropdown_value=organisation, other_value=organisation_other)
123+
if not resolved_organisation:
124+
return edit_profile_failed_response("Please specify your organisation, it must be at least 3 characters long.")
125+
126+
resolved_role = resolve_dropdown_form_input(dropdown_value=role, other_value=role_other)
127+
if not resolved_role:
128+
return edit_profile_failed_response("Please specify your role, it must be at least 3 characters long.")
120129

121130
try:
122131
user_data = UserUpdate(
123132
name=name.strip(),
124-
organisation=organisation.strip(),
125-
organisation_role=organisation_role.strip(),
133+
organisation=resolved_organisation.strip(),
134+
organisation_role=resolved_role.strip(),
126135
)
127136
except ValidationError as e:
128137
# This "should" not have happened, either:
129138
# Some mismatch in backend vs frontend validation logic or
130139
# someone bypassing client side validation (could be accidently or intentionally).
131140
logger.warning(f"User profile update failed backend validation for user_id: {current_user.id} - {e.errors()}")
132-
return edit_profile_failed_response("Invalid input, please check your data and try again.")
141+
return edit_profile_failed_response(
142+
"Invalid input, please check your inputs match the required formats and try again."
143+
)
133144

134145
_ = await update_user_profile(db=db, user_id=current_user.id, user_data=user_data)
135146
return RedirectResponse(url="/profile", status_code=status.HTTP_303_SEE_OTHER)

packages/divbase-api/src/divbase_api/static/js/organisation_dropdown.js

Lines changed: 0 additions & 57 deletions
This file was deleted.
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/*
2+
For both the organisation and role dropdowns in the registration and edit profile forms,
3+
we want to allow users to select "Other" and then specify their organisation/role if it's not listed.
4+
5+
This handles toggling the new text input for the organisation when "Other" is selected in the organisation dropdown,
6+
and the new text input for the role when "Other" is selected in the role dropdown.
7+
8+
Alongside validation of the inputs on the client side. (Pydantic used for server side validation.)
9+
*/
10+
11+
document.addEventListener("DOMContentLoaded", function () {
12+
const organisationDropdown = document.getElementById("organisation_dropdown");
13+
const otherOrganisationDiv = document.getElementById("other_organisation_div");
14+
const otherOrganisationInput = document.getElementById("organisation_other");
15+
16+
const roleDropdown = document.getElementById("role_dropdown");
17+
const otherRoleDiv = document.getElementById("role_other_div");
18+
const otherRoleInput = document.getElementById("role_other");
19+
20+
const form = document.getElementById("registerForm") || document.getElementById("editProfileForm");
21+
if (!form) {
22+
return;
23+
}
24+
25+
// Generic validator for "Other" fields
26+
function validateOtherInput(dropdown, input, message) {
27+
const value = input.value.trim();
28+
if (dropdown.value === "Other" && value.length < 3) {
29+
input.setCustomValidity(message);
30+
} else {
31+
input.setCustomValidity("");
32+
}
33+
}
34+
35+
// Specific validator for organisation
36+
function validateOtherOrganisationInput() {
37+
validateOtherInput(
38+
organisationDropdown,
39+
otherOrganisationInput,
40+
"Please specify your organisation (at least 3 characters)."
41+
);
42+
}
43+
44+
// Specific validator for role
45+
function validateOtherRoleInput() {
46+
validateOtherInput(
47+
roleDropdown,
48+
otherRoleInput,
49+
"Please specify your role (at least 3 characters)."
50+
);
51+
}
52+
53+
// listners for organisation dropdown
54+
organisationDropdown.addEventListener("change", function () {
55+
if (this.value === "Other") {
56+
otherOrganisationDiv.style.display = "block";
57+
otherOrganisationInput.required = true;
58+
} else {
59+
otherOrganisationDiv.style.display = "none";
60+
otherOrganisationInput.required = false;
61+
otherOrganisationInput.value = "";
62+
otherOrganisationInput.setCustomValidity("");
63+
}
64+
validateOtherOrganisationInput();
65+
});
66+
67+
otherOrganisationInput.addEventListener("input", function () {
68+
validateOtherOrganisationInput();
69+
});
70+
71+
// listners for role dropdown
72+
roleDropdown.addEventListener("change", function () {
73+
if (this.value === "Other") {
74+
otherRoleDiv.style.display = "block";
75+
otherRoleInput.required = true;
76+
} else {
77+
otherRoleDiv.style.display = "none";
78+
otherRoleInput.required = false;
79+
otherRoleInput.value = "";
80+
otherRoleInput.setCustomValidity("");
81+
}
82+
validateOtherRoleInput();
83+
});
84+
85+
otherRoleInput.addEventListener("input", function () {
86+
validateOtherRoleInput();
87+
});
88+
89+
90+
// Form submission, prevent submission if any validation failures.
91+
form.addEventListener("submit", function (event) {
92+
validateOtherOrganisationInput();
93+
validateOtherRoleInput();
94+
95+
if (!form.checkValidity()) {
96+
form.reportValidity();
97+
event.preventDefault();
98+
}
99+
});
100+
101+
// Run on page load to ensure form is in correct state if form submission fails
102+
// and page/form is re-rendered with previous values.
103+
// e.g. password don't match etc...
104+
organisationDropdown.dispatchEvent(new Event("change"));
105+
roleDropdown.dispatchEvent(new Event("change"));
106+
});

0 commit comments

Comments
 (0)