diff --git a/server/crashmanager/forms.py b/server/crashmanager/forms.py
index 145ce4fd..31ff02f9 100644
--- a/server/crashmanager/forms.py
+++ b/server/crashmanager/forms.py
@@ -1,20 +1,10 @@
from crispy_forms.helper import FormHelper
from crispy_forms.layout import HTML, Div, Field, Layout, Submit
from django.conf import settings
-from django.forms import (
- CharField,
- CheckboxSelectMultiple,
- ChoiceField,
- EmailField,
- ModelChoiceField,
- ModelForm,
- ModelMultipleChoiceField,
- Textarea,
- TextInput,
-)
+from django.forms import CharField, EmailField, ModelForm, Textarea, TextInput
from rest_framework.exceptions import ValidationError
-from .models import BugProvider, BugzillaTemplate, Tool, User
+from .models import BugzillaTemplate, User
class Row(Div):
@@ -178,31 +168,6 @@ class Meta:
class UserSettingsForm(ModelForm):
- helper = FormHelper()
- helper.layout = Layout(
- "defaultToolsFilter",
- Row(
- Field("defaultProviderId", wrapper_class="col-md-6"),
- Field("defaultTemplateId", wrapper_class="col-md-6"),
- ),
- "email",
- HTML("""
Subscribe to notifications:
"""),
- "inaccessible_bug",
- "coverage_drop",
- "bucket_hit",
- "tasks_failed",
- Submit("submit", "Save settings", css_class="btn btn-danger"),
- )
- defaultToolsFilter = ModelMultipleChoiceField(
- queryset=Tool.objects.all(),
- label="Select the tools you would like to include in your default views:",
- widget=CheckboxSelectMultiple(),
- required=False,
- )
- defaultProviderId = ModelChoiceField(
- queryset=BugProvider.objects.all(), label="Default Provider:", empty_label=None
- )
- defaultTemplateId = ChoiceField(label="Default Template:")
email = EmailField(label="Email:")
class Meta:
@@ -220,17 +185,6 @@ class Meta:
def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user", None)
super().__init__(*args, **kwargs)
-
- self.fields["defaultTemplateId"].choices = list(
- dict.fromkeys(
- [
- (t.pk, f"{p.classname}: {t}")
- for p in BugProvider.objects.all()
- for t in p.getInstance().getTemplateList()
- ]
- )
- )
-
instance = kwargs.get("instance", None)
if instance:
self.initial["email"] = instance.user.email
@@ -239,8 +193,10 @@ def __init__(self, *args, **kwargs):
self.fields["email"].required = False
self.fields["email"].widget.attrs["readonly"] = True
+ self.fields["defaultToolsFilter"].required = False
+
def clean_defaultToolsFilter(self):
- data = self.cleaned_data["defaultToolsFilter"]
+ data = self.cleaned_data.get("defaultToolsFilter", None)
if (
self.user
and list(self.user.defaultToolsFilter.all()) != list(data)
@@ -252,7 +208,7 @@ def clean_defaultToolsFilter(self):
return data
def clean_defaultProviderId(self):
- data = self.cleaned_data["defaultProviderId"].id
+ data = self.cleaned_data["defaultProviderId"]
return data
def save(self, *args, **kwargs):
diff --git a/server/crashmanager/templates/usersettings.html b/server/crashmanager/templates/usersettings.html
index 1b480fc8..2fee40d4 100644
--- a/server/crashmanager/templates/usersettings.html
+++ b/server/crashmanager/templates/usersettings.html
@@ -1,5 +1,4 @@
{% extends 'layouts/layout_base.html' %}
-{% load crispy_forms_tags %}
{% block title %}User Settings{% endblock title %}
@@ -8,19 +7,53 @@
+
{% if bugzilla_providers %}
Bugzilla Providers Settings
Provide API Keys to authenticate calls to your Bugzilla Providers on this browser.
{% for p in bugzilla_providers %}
-
+
{% endfor %}
diff --git a/server/crashmanager/tests/test_user_settings.py b/server/crashmanager/tests/test_user_settings.py
index 994e632b..a46918e8 100644
--- a/server/crashmanager/tests/test_user_settings.py
+++ b/server/crashmanager/tests/test_user_settings.py
@@ -35,7 +35,7 @@ def test_user_settings_simple_get(client):
response = client.get(path)
assert response.status_code == requests.codes["ok"]
assert list(response.context["bugzilla_providers"]) == []
- assert response.context["user"] == User.objects.get(user__username="test").user
+ assert response.context["user"] == User.objects.get(user__username="test")
def test_user_settings_edit(client, cm):
diff --git a/server/crashmanager/views.py b/server/crashmanager/views.py
index 5f0db463..e0560ecb 100644
--- a/server/crashmanager/views.py
+++ b/server/crashmanager/views.py
@@ -5,6 +5,7 @@
from wsgiref.util import FileWrapper
from django.conf import settings as django_settings
+from django.conf import settings as djangosettings
from django.core.exceptions import FieldError, PermissionDenied, SuspiciousOperation
from django.db.models import F, Q
from django.db.models.aggregates import Count, Min
@@ -1666,10 +1667,55 @@ def get_object(self):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
- context["bugzilla_providers"] = BugProvider.objects.filter(
- classname="BugzillaProvider"
+ user_object = self.get_object()
+
+ # Prepare form errors if any
+ form = self.get_form()
+ form_errors = {field: errors for field, errors in form.errors.items()}
+
+ # Prepare all form-related data
+ context.update(
+ {
+ "defaultToolsFilter": user_object.defaultToolsFilter.all(),
+ "defaultToolsFilterChoices": Tool.objects.all(),
+ "defaultProviderChoices": BugProvider.objects.all(),
+ "defaultTemplateChoices": [
+ (t.pk, f"{p.classname}: {t}")
+ for p in BugProvider.objects.all()
+ for t in p.getInstance().getTemplateList()
+ ],
+ "bugzilla_providers": BugProvider.objects.filter(
+ classname="BugzillaProvider"
+ ),
+ "user": user_object,
+ "email": self.request.user.email,
+ "allow_email_edit": djangosettings.ALLOW_EMAIL_EDITION,
+ "form_errors": form_errors,
+ "notificationChoices": [
+ {
+ "id": "inaccessible_bug",
+ "label": "Inaccessible Bug",
+ "initial": user_object.inaccessible_bug,
+ },
+ {
+ "id": "coverage_drop",
+ "label": "Coverage Drop",
+ "initial": user_object.coverage_drop,
+ },
+ {
+ "id": "bucket_hit",
+ "label": "Bucket Hit",
+ "initial": user_object.bucket_hit,
+ },
+ {
+ "id": "tasks_failed",
+ "label": "Tasks Failed",
+ "initial": user_object.tasks_failed,
+ },
+ ],
+ }
)
- context["user"] = self.request.user
+
return context
diff --git a/server/frontend/package-lock.json b/server/frontend/package-lock.json
index d0699eca..3b82fd82 100644
--- a/server/frontend/package-lock.json
+++ b/server/frontend/package-lock.json
@@ -17,6 +17,7 @@
"sweetalert": "^2.1.2",
"vue": "^3.4.21",
"vue-loading-overlay": "^6.0.3",
+ "vue-multiselect": "^3.1.0",
"vue-router": "^4.3.0"
},
"devDependencies": {
@@ -10651,6 +10652,16 @@
"vue": "^3.2.0"
}
},
+ "node_modules/vue-multiselect": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/vue-multiselect/-/vue-multiselect-3.1.0.tgz",
+ "integrity": "sha512-+i/fjTqFBpaay9NP+lU7obBeNaw2DdFDFs4mqhsM0aEtKRdvIf7CfREAx2o2B4XDmPrBt1r7x1YCM3BOMLaUgQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.18.1",
+ "npm": ">= 6.14.15"
+ }
+ },
"node_modules/vue-resize": {
"version": "2.0.0-alpha.1",
"resolved": "https://registry.npmjs.org/vue-resize/-/vue-resize-2.0.0-alpha.1.tgz",
diff --git a/server/frontend/package.json b/server/frontend/package.json
index 91b824a7..c906d53f 100644
--- a/server/frontend/package.json
+++ b/server/frontend/package.json
@@ -22,6 +22,7 @@
"sweetalert": "^2.1.2",
"vue": "^3.4.21",
"vue-loading-overlay": "^6.0.3",
+ "vue-multiselect": "^3.1.0",
"vue-router": "^4.3.0"
},
"devDependencies": {
diff --git a/server/frontend/src/components/ProviderKey.vue b/server/frontend/src/components/ProviderKey.vue
index d011a19d..71981ab5 100644
--- a/server/frontend/src/components/ProviderKey.vue
+++ b/server/frontend/src/components/ProviderKey.vue
@@ -156,4 +156,7 @@ export default defineComponent({
.mt-light {
margin-top: 0.5rem;
}
+.btn {
+ margin-left: 0.8rem;
+}
diff --git a/server/frontend/src/components/UserSettingsForm.vue b/server/frontend/src/components/UserSettingsForm.vue
new file mode 100644
index 00000000..d7112551
--- /dev/null
+++ b/server/frontend/src/components/UserSettingsForm.vue
@@ -0,0 +1,188 @@
+
+
+
+
+
+
+
+
+ {{ formErrors.defaultProviderId.join(", ") }}
+
+
+
+
+
+
+
+
+ {{ formErrors.defaultTemplateId.join(", ") }}
+
+
+
+
+
+
+
+
+
+ {{ formErrors.email.join(", ") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/server/frontend/src/main.js b/server/frontend/src/main.js
index c9d96a76..e531edf6 100644
--- a/server/frontend/src/main.js
+++ b/server/frontend/src/main.js
@@ -16,6 +16,7 @@ import Inbox from "./components/Notifications/Inbox.vue";
import PoolsList from "./components/Pools/List.vue";
import PoolView from "./components/Pools/View.vue";
import ProviderKey from "./components/ProviderKey.vue";
+import UserSettingsForm from "./components/UserSettingsForm.vue";
import AssignBtn from "./components/Signatures/AssignBtn.vue";
import CreateOrEdit from "./components/Signatures/CreateOrEdit.vue";
import SignaturesList from "./components/Signatures/List.vue";
@@ -37,6 +38,7 @@ const app = createApp({
poolview: PoolView,
ppcselect: FullPPCSelect,
providerkey: ProviderKey,
+ usersettingsform: UserSettingsForm,
signatureslist: SignaturesList,
signatureview: SignatureView,
},
diff --git a/server/frontend/tests/user_settings_form.test.js b/server/frontend/tests/user_settings_form.test.js
new file mode 100644
index 00000000..11d9bf21
--- /dev/null
+++ b/server/frontend/tests/user_settings_form.test.js
@@ -0,0 +1,233 @@
+import { fireEvent, render } from "@testing-library/vue";
+import UserSettingsForm from "../src/components/UserSettingsForm.vue";
+import Multiselect from "vue-multiselect";
+
+// Mock vue-multiselect component
+jest.mock("vue-multiselect", () => ({
+ name: "Multiselect",
+ template: `
+
+ `,
+ props: [
+ "modelValue",
+ "options",
+ "trackBy",
+ "label",
+ "multiple",
+ "placeholder",
+ ],
+ emits: ["update:modelValue"],
+}));
+
+describe("UserSettingsForm Component", () => {
+ const defaultProps = {
+ defaultToolsOptions: [
+ { code: "tool1", name: "Tool 1" },
+ { code: "tool2", name: "Tool 2" },
+ ],
+ defaultToolsSelected: [{ code: "tool1", name: "Tool 1" }],
+ defaultProviderOptions: [
+ { id: 1, name: "Provider 1" },
+ { id: 2, name: "Provider 2" },
+ ],
+ defaultProviderSelected: 1,
+ defaultTemplateOptions: [
+ { id: 1, name: "Template 1" },
+ { id: 2, name: "Template 2" },
+ ],
+ defaultTemplateSelected: 1,
+ initialEmail: "test@example.com",
+ allowEmailEdit: true,
+ subscribeNotificationOptions: [
+ { code: "notify1", name: "Notification 1", selected: true },
+ { code: "notify2", name: "Notification 2", selected: false },
+ ],
+ };
+
+ test("submits form with correct values", async () => {
+ // Create a wrapper component that includes a form
+ const handleSubmit = jest.fn();
+
+ const WrapperComponent = {
+ components: { UserSettingsForm },
+ props: {
+ onSubmit: {
+ type: Function,
+ required: true,
+ },
+ },
+ template: `
+
+ `,
+ setup(props) {
+ return {
+ formProps: defaultProps,
+ onSubmit: props.onSubmit,
+ };
+ },
+ };
+
+ const { getByTestId } = await render(WrapperComponent, {
+ props: {
+ onSubmit: handleSubmit,
+ },
+ global: {
+ components: { Multiselect },
+ },
+ });
+
+ const form = getByTestId("settings-form");
+ await fireEvent.submit(form);
+
+ expect(handleSubmit).toHaveBeenCalled();
+ });
+
+ test("renders with initial values", async () => {
+ const { container, getAllByTestId } = await render(UserSettingsForm, {
+ props: defaultProps,
+ global: {
+ components: { Multiselect },
+ },
+ });
+
+ const toolsSelect = getAllByTestId("multiselect")[0];
+ expect(toolsSelect).toBeTruthy();
+
+ const providerSelect = container.querySelector("#defaultProviderId");
+ expect(providerSelect.value).toBe("1");
+
+ const templateSelect = container.querySelector("#defaultTemplateId");
+ expect(templateSelect.value).toBe("1");
+
+ const emailInput = container.querySelector("#email");
+ expect(emailInput.value).toBe("test@example.com");
+ expect(emailInput.disabled).toBe(false);
+ });
+
+ test("generates hidden inputs for selected tools", async () => {
+ const { container } = await render(UserSettingsForm, {
+ props: defaultProps,
+ global: {
+ components: { Multiselect },
+ },
+ });
+
+ const hiddenInputs = container.querySelectorAll(
+ 'input[type="hidden"][name="defaultToolsFilter"]',
+ );
+ expect(hiddenInputs.length).toBe(1);
+ expect(hiddenInputs[0].value).toBe("tool1");
+ });
+
+ test("generates hidden inputs for selected notifications", async () => {
+ const { container } = await render(UserSettingsForm, {
+ props: defaultProps,
+ global: {
+ components: { Multiselect },
+ },
+ });
+
+ const hiddenInputs = container.querySelectorAll(
+ 'input[type="hidden"][name="notify1"]',
+ );
+ expect(hiddenInputs.length).toBe(1);
+ expect(hiddenInputs[0].value).toBe("on");
+ });
+
+ test("updates provider selection", async () => {
+ const { container } = await render(UserSettingsForm, {
+ props: defaultProps,
+ global: {
+ components: { Multiselect },
+ },
+ });
+
+ const select = container.querySelector("#defaultProviderId");
+ await fireEvent.update(select, "2");
+ expect(select.value).toBe("2");
+ });
+
+ test("updates template selection", async () => {
+ const { container } = await render(UserSettingsForm, {
+ props: defaultProps,
+ global: {
+ components: { Multiselect },
+ },
+ });
+
+ const select = container.querySelector("#defaultTemplateId");
+ await fireEvent.update(select, "2");
+ expect(select.value).toBe("2");
+ });
+
+ test("updates email value", async () => {
+ const { container } = await render(UserSettingsForm, {
+ props: defaultProps,
+ global: {
+ components: { Multiselect },
+ },
+ });
+
+ const input = container.querySelector("#email");
+ await fireEvent.update(input, "newemail@example.com");
+ expect(input.value).toBe("newemail@example.com");
+ });
+
+ test("renders email input as disabled when allowEmailEdit is false", async () => {
+ const props = {
+ ...defaultProps,
+ allowEmailEdit: false,
+ };
+
+ const { container } = await render(UserSettingsForm, {
+ props,
+ global: {
+ components: { Multiselect },
+ },
+ });
+
+ const emailInput = container.querySelector("#email");
+ expect(emailInput.disabled).toBe(true);
+ });
+
+ test("renders all provider options", async () => {
+ const { container } = await render(UserSettingsForm, {
+ props: defaultProps,
+ global: {
+ components: { Multiselect },
+ },
+ });
+
+ const select = container.querySelector("#defaultProviderId");
+ const options = Array.from(select.options);
+ expect(options.length).toBe(2);
+ expect(options[0].textContent.trim()).toBe("Provider 1");
+ expect(options[1].textContent.trim()).toBe("Provider 2");
+ });
+
+ test("renders all template options", async () => {
+ const { container } = await render(UserSettingsForm, {
+ props: defaultProps,
+ global: {
+ components: { Multiselect },
+ },
+ });
+
+ const select = container.querySelector("#defaultTemplateId");
+ const options = Array.from(select.options);
+ expect(options.length).toBe(2);
+ expect(options[0].textContent.trim()).toBe("Template 1");
+ expect(options[1].textContent.trim()).toBe("Template 2");
+ });
+});