diff --git a/server/crashmanager/forms.py b/server/crashmanager/forms.py index 145ce4fd..3de90a94 100644 --- a/server/crashmanager/forms.py +++ b/server/crashmanager/forms.py @@ -178,31 +178,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 +195,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 +203,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 +218,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 @@
User Settings
-
+ {% csrf_token %} - {% crispy form %} + + +
+ {% 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/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 @@ + + + + + + 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"); + }); +});