diff --git a/readthedocs/core/forms.py b/readthedocs/core/forms.py index bb5eb8322af..b853f9f07e6 100644 --- a/readthedocs/core/forms.py +++ b/readthedocs/core/forms.py @@ -191,8 +191,10 @@ class RichChoice: text: str #: Choice input value value: str - #: Right floated content for dropdown item + #: Optional content beneath/to the side for dropdown item description: str + #: Extra content below text and description + extra: str = None #: Optional image URL for item image_url: str = None #: Optional image alt text @@ -217,8 +219,21 @@ class RichSelect(forms.Select): widget=RichSelect(), choices=[(choice.value, choice)] ) + + Attributes used by templates: + + use_data_binding + Set up Knockout data-bindings, disable this if using a web component instead. """ + use_data_binding = True + + def __init__(self, attrs=None): + if attrs is None: + attrs = {} + attrs.setdefault("use_data_binding", self.use_data_binding) + super().__init__(attrs) + class FacetField(forms.MultipleChoiceField): """ diff --git a/readthedocs/projects/forms.py b/readthedocs/projects/forms.py index 2637e56007f..be9482de62e 100644 --- a/readthedocs/projects/forms.py +++ b/readthedocs/projects/forms.py @@ -8,6 +8,10 @@ import dns.name import dns.resolver from allauth.socialaccount.models import SocialAccount +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Field +from crispy_forms.layout import Layout +from crispy_forms.layout import MultiField from django import forms from django.conf import settings from django.contrib.auth.models import User @@ -17,6 +21,8 @@ from readthedocs.builds.constants import INTERNAL from readthedocs.core.forms import PrevalidatedForm +from readthedocs.core.forms import RichChoice +from readthedocs.core.forms import RichSelect from readthedocs.core.forms import RichValidationError from readthedocs.core.history import SimpleHistoryModelForm from readthedocs.core.permissions import AdminPermission @@ -43,6 +49,22 @@ from readthedocs.redirects.models import Redirect +class ProjectRemoteRepositorySelect(RichSelect): + """ + Rich select for user's remote repository listing + + :param can_connect_remote_repository: Is the current user able to connect a remote repository + """ + + can_connect_remote_repository = False + + def __init__(self, attrs=None): + if attrs is None: + attrs = {} + attrs.setdefault("can_connect_remote_repository", self.can_connect_remote_repository) + super().__init__(attrs) + + class ProjectForm(SimpleHistoryModelForm): """ Project form. @@ -69,14 +91,9 @@ def __init__(self, *args, **kwargs): empty_value=None, help_text=self.fields["remote_repository"].help_text, label=self.fields["remote_repository"].label, + widget=ProjectRemoteRepositorySelect(attrs={"use_data_binding": False}), ) - # The clone URL will be set from the remote repository. - if self.instance.remote_repository and not self.instance.has_feature( - Feature.DONT_SYNC_WITH_REMOTE_REPO - ): - self.fields["repo"].disabled = True - def _get_remote_repository_choices(self): """ Get valid choices for the remote repository field. @@ -94,14 +111,39 @@ def _get_remote_repository_choices(self): """ queryset = RemoteRepository.objects.for_project_linking(self.user) current_remote_repo = self.instance.remote_repository if self.instance.pk else None - options = [ - (None, _("No connected repository")), + choices = [ + ( + None, + RichChoice( + text=_("No connected repository"), + description=_("This project uses a manually configured repository URL"), + value=None, + ), + ), ] + # Show currently connected remote repository instance even if the + # maintainer does not control this remote repository through their + # connected account. if current_remote_repo and current_remote_repo not in queryset: - options.append((current_remote_repo.pk, str(current_remote_repo))) + choice = RichChoice( + text=current_remote_repo.full_name, + description=current_remote_repo.clone_url, + extra=_("This repository is connected through another user's account"), + value=current_remote_repo.pk, + image_url=current_remote_repo.avatar_url, + ) + choices.append((choice.value, choice)) + + for repo in queryset: + choice = RichChoice( + text=repo.full_name, + description=repo.clone_url, + value=repo.pk, + image_url=repo.avatar_url, + ) + choices.append((choice.value, choice)) - options.extend((repo.pk, repo.clone_url) for repo in queryset) - return options + return choices def save(self, commit=True): project = super().save(commit) @@ -510,6 +552,34 @@ def __init__(self, *args, **kwargs): self.setup_external_builds_option() + # We use crispy layout here strictly for multifield and field ordering, + # There's no HTML in Python, it's all in templates and web components. + self.helper = FormHelper() + # Let templates close form tag and add submit button + self.helper.form_tag = False + + multifield_attrs = {} + if not SocialAccount.objects.filter(user=self.user).exists(): + multifield_attrs["show-connected-service-warning"] = True + if self.instance.has_feature(Feature.DONT_SYNC_WITH_REMOTE_REPO): + multifield_attrs["dont-sync"] = True + multifield = MultiField( + _("Repository"), + Field("remote_repository"), + Field("repo"), + template="projects/includes/crispy/repository.html", + **multifield_attrs, + ) + + # We only care about the order of the first fields, the rest of the + # fields are dictated by Meta class configuration. + fields_other = [ + field + for field in self.fields.keys() + if field not in ["name", "repo", "remote_repository"] + ] + self.helper.layout = Layout("name", multifield, *fields_other) + def clean_readthedocs_yaml_path(self): """ Validate user input to help user. diff --git a/readthedocs/rtd_tests/tests/test_project_forms.py b/readthedocs/rtd_tests/tests/test_project_forms.py index 030bdbf3ec9..a17dc8d51c4 100644 --- a/readthedocs/rtd_tests/tests/test_project_forms.py +++ b/readthedocs/rtd_tests/tests/test_project_forms.py @@ -402,9 +402,10 @@ def test_set_remote_repository(self): self.assertEqual(self.project.remote_repository, remote_repository) self.assertEqual(self.project.repo, remote_repository.clone_url) - # Since a remote repository is attached, the repo field should be disabled. + # Since a remote repository is attached, we used to disable the repo + # field. This is now handled more on the front end instead. form = UpdateProjectForm(data, instance=self.project, user=self.user) - self.assertTrue(form.fields["repo"].disabled) + self.assertFalse(form.fields["repo"].disabled) # This project has the don't sync with remote repository feature enabled, # so the repo field should be enabled.