← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:charm-recipe-create-views into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:charm-recipe-create-views into launchpad:master with ~cjwatson/launchpad:charm-recipe-edit-views as a prerequisite.

Commit message:
Add views for creating charm recipes

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/403968
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:charm-recipe-create-views into launchpad:master.
diff --git a/lib/lp/charms/browser/charmrecipe.py b/lib/lp/charms/browser/charmrecipe.py
index 6511424..2345ec1 100644
--- a/lib/lp/charms/browser/charmrecipe.py
+++ b/lib/lp/charms/browser/charmrecipe.py
@@ -7,6 +7,7 @@ from __future__ import absolute_import, print_function, unicode_literals
 
 __metaclass__ = type
 __all__ = [
+    "CharmRecipeAddView",
     "CharmRecipeAdminView",
     "CharmRecipeDeleteView",
     "CharmRecipeEditView",
@@ -30,6 +31,7 @@ from zope.security.interfaces import Unauthorized
 from lp.app.browser.launchpadform import (
     action,
     LaunchpadEditFormView,
+    LaunchpadFormView,
     )
 from lp.app.browser.lazrjs import InlinePersonEditPickerWidget
 from lp.app.browser.tales import format_link
@@ -43,7 +45,9 @@ from lp.charms.interfaces.charmrecipe import (
     )
 from lp.charms.interfaces.charmrecipebuild import ICharmRecipeBuildSet
 from lp.code.browser.widgets.gitref import GitRefWidget
+from lp.code.interfaces.gitref import IGitRef
 from lp.registry.interfaces.personproduct import IPersonProductFactory
+from lp.registry.interfaces.product import IProduct
 from lp.services.propertycache import cachedproperty
 from lp.services.utils import seconds_since_epoch
 from lp.services.webapp import (
@@ -240,6 +244,96 @@ class ICharmRecipeEditSchema(Interface):
     store_channels = copy_field(ICharmRecipe["store_channels"], required=True)
 
 
+class CharmRecipeAddView(LaunchpadFormView):
+    """View for creating charm recipes."""
+
+    page_title = label = "Create a new charm recipe"
+
+    schema = ICharmRecipeEditSchema
+
+    custom_widget_git_ref = GitRefWidget
+    custom_widget_auto_build_channels = CharmRecipeBuildChannelsWidget
+    custom_widget_store_channels = StoreChannelsWidget
+
+    @property
+    def field_names(self):
+        fields = ["owner", "name"]
+        if self.is_project_context:
+            fields += ["git_ref"]
+        else:
+            fields += ["project"]
+        return fields + [
+            "auto_build",
+            "auto_build_channels",
+            "store_upload",
+            "store_name",
+            "store_channels",
+            ]
+
+    @property
+    def is_project_context(self):
+        return IProduct.providedBy(self.context)
+
+    @property
+    def cancel_url(self):
+        return canonical_url(self.context)
+
+    @property
+    def initial_values(self):
+        initial_values = {"owner": self.user}
+        if (IGitRef.providedBy(self.context) and
+                IProduct.providedBy(self.context.target)):
+            initial_values["project"] = self.context.target
+        return initial_values
+
+    def validate_widgets(self, data, names=None):
+        """See `LaunchpadFormView`."""
+        if self.widgets.get("store_upload") is not None:
+            # Set widgets as required or optional depending on the
+            # store_upload field.
+            super(CharmRecipeAddView, self).validate_widgets(
+                data, ["store_upload"])
+            store_upload = data.get("store_upload", False)
+            self.widgets["store_name"].context.required = store_upload
+            self.widgets["store_channels"].context.required = store_upload
+        super(CharmRecipeAddView, self).validate_widgets(data, names=names)
+
+    @action("Create charm recipe", name="create")
+    def create_action(self, action, data):
+        if IGitRef.providedBy(self.context):
+            project = data["project"]
+            git_ref = self.context
+        elif self.is_project_context:
+            project = self.context
+            git_ref = data["git_ref"]
+        else:
+            raise NotImplementedError(
+                "Unknown context for charm recipe creation.")
+        recipe = getUtility(ICharmRecipeSet).new(
+            self.user, data["owner"], project, data["name"], git_ref=git_ref,
+            auto_build=data["auto_build"],
+            auto_build_channels=data["auto_build_channels"],
+            store_upload=data["store_upload"],
+            store_name=data["store_name"],
+            store_channels=data.get("store_channels"))
+        self.next_url = canonical_url(recipe)
+
+    def validate(self, data):
+        super(CharmRecipeAddView, self).validate(data)
+        owner = data.get("owner", None)
+        if self.is_project_context:
+            project = self.context
+        else:
+            project = data.get("project", None)
+        name = data.get("name", None)
+        if owner and project and name:
+            if getUtility(ICharmRecipeSet).exists(owner, project, name):
+                self.setFieldError(
+                    "name",
+                    "There is already a charm recipe owned by %s in %s with "
+                    "this name." % (owner.display_name, project.display_name))
+
+
 class BaseCharmRecipeEditView(LaunchpadEditFormView):
 
     schema = ICharmRecipeEditSchema
diff --git a/lib/lp/charms/browser/configure.zcml b/lib/lp/charms/browser/configure.zcml
index 60e3dc4..72be851 100644
--- a/lib/lp/charms/browser/configure.zcml
+++ b/lib/lp/charms/browser/configure.zcml
@@ -50,6 +50,19 @@
             factory="lp.charms.browser.charmrecipe.CharmRecipeBreadcrumb"
             permission="zope.Public" />
 
+        <browser:page
+            for="lp.code.interfaces.gitref.IGitRef"
+            class="lp.charms.browser.charmrecipe.CharmRecipeAddView"
+            permission="launchpad.AnyPerson"
+            name="+new-charm-recipe"
+            template="../templates/charmrecipe-new.pt" />
+        <browser:page
+            for="lp.registry.interfaces.product.IProduct"
+            class="lp.charms.browser.charmrecipe.CharmRecipeAddView"
+            permission="launchpad.AnyPerson"
+            name="+new-charm-recipe"
+            template="../templates/charmrecipe-new.pt" />
+
         <browser:url
             for="lp.charms.interfaces.charmrecipe.ICharmRecipeBuildRequest"
             path_expression="string:+build-request/${id}"
diff --git a/lib/lp/charms/browser/hascharmrecipes.py b/lib/lp/charms/browser/hascharmrecipes.py
index 5c395b5..16f6f4c 100644
--- a/lib/lp/charms/browser/hascharmrecipes.py
+++ b/lib/lp/charms/browser/hascharmrecipes.py
@@ -13,8 +13,13 @@ __all__ = [
 
 from zope.component import getUtility
 
-from lp.charms.interfaces.charmrecipe import ICharmRecipeSet
+from lp.charms.interfaces.charmrecipe import (
+    CHARM_RECIPE_ALLOW_CREATE,
+    CHARM_RECIPE_PRIVATE_FEATURE_FLAG,
+    ICharmRecipeSet,
+    )
 from lp.code.interfaces.gitrepository import IGitRepository
+from lp.services.features import getFeatureFlag
 from lp.services.webapp import (
     canonical_url,
     Link,
@@ -31,6 +36,17 @@ class HasCharmRecipesMenuMixin:
             self.context, visible_by_user=self.user).is_empty()
         return Link("+charm-recipes", text, icon="info", enabled=enabled)
 
+    def create_charm_recipe(self):
+        # Only enabled for private contexts if the
+        # charm.recipe.allow_private flag is enabled.
+        enabled = (
+            bool(getFeatureFlag(CHARM_RECIPE_ALLOW_CREATE)) and (
+                not self.context.private or
+                bool(getFeatureFlag(CHARM_RECIPE_PRIVATE_FEATURE_FLAG))))
+
+        text = "Create charm recipe"
+        return Link("+new-charm-recipe", text, enabled=enabled, icon="add")
+
 
 class HasCharmRecipesViewMixin:
     """A view mixin for objects that have charm recipes."""
diff --git a/lib/lp/charms/browser/tests/test_charmrecipe.py b/lib/lp/charms/browser/tests/test_charmrecipe.py
index fcc7f7b..c6130cb 100644
--- a/lib/lp/charms/browser/tests/test_charmrecipe.py
+++ b/lib/lp/charms/browser/tests/test_charmrecipe.py
@@ -66,6 +66,7 @@ from lp.testing.pages import (
     extract_text,
     find_main_content,
     find_tags_by_class,
+    find_tag_by_id,
     )
 from lp.testing.publication import test_traverse
 from lp.testing.views import (
@@ -111,6 +112,155 @@ class BaseTestCharmRecipeView(BrowserTestCase):
             name="test-person", displayname="Test Person")
 
 
+class TestCharmRecipeAddView(BaseTestCharmRecipeView):
+
+    def test_create_new_recipe_not_logged_in(self):
+        [git_ref] = self.factory.makeGitRefs()
+        self.assertRaises(
+            Unauthorized, self.getViewBrowser, git_ref,
+            view_name="+new-charm-recipe", no_login=True)
+
+    def test_create_new_recipe_git(self):
+        self.factory.makeProduct(
+            name="test-project", displayname="Test Project")
+        [git_ref] = self.factory.makeGitRefs(
+            owner=self.person, target=self.person)
+        source_display = git_ref.display_name
+        browser = self.getViewBrowser(
+            git_ref, view_name="+new-charm-recipe", user=self.person)
+        browser.getControl(name="field.name").value = "charm-name"
+        self.assertEqual("", browser.getControl(name="field.project").value)
+        browser.getControl(name="field.project").value = "test-project"
+        browser.getControl("Create charm recipe").click()
+
+        content = find_main_content(browser.contents)
+        self.assertEqual("charm-name", extract_text(content.h1))
+        self.assertThat(
+            "Test Person", MatchesPickerText(content, "edit-owner"))
+        self.assertThat(
+            "Project:\nTest Project\nEdit charm recipe",
+            MatchesTagText(content, "project"))
+        self.assertThat(
+            "Source:\n%s\nEdit charm recipe" % source_display,
+            MatchesTagText(content, "source"))
+        self.assertThat(
+            "Build schedule:\n(?)\nBuilt on request\nEdit charm recipe\n",
+            MatchesTagText(content, "auto_build"))
+        self.assertIsNone(find_tag_by_id(content, "auto_build_channels"))
+        self.assertThat(
+            "Builds of this charm recipe are not automatically uploaded to "
+            "the store.\nEdit charm recipe",
+            MatchesTagText(content, "store_upload"))
+
+    def test_create_new_recipe_git_project_namespace(self):
+        # If the Git repository is already in a project namespace, then that
+        # project is the default for the new recipe.
+        project = self.factory.makeProduct(
+            name="test-project", displayname="Test Project")
+        [git_ref] = self.factory.makeGitRefs(target=project)
+        source_display = git_ref.display_name
+        browser = self.getViewBrowser(
+            git_ref, view_name="+new-charm-recipe", user=self.person)
+        browser.getControl(name="field.name").value = "charm-name"
+        self.assertEqual(
+            "test-project", browser.getControl(name="field.project").value)
+        browser.getControl("Create charm recipe").click()
+
+        content = find_main_content(browser.contents)
+        self.assertEqual("charm-name", extract_text(content.h1))
+        self.assertThat(
+            "Test Person", MatchesPickerText(content, "edit-owner"))
+        self.assertThat(
+            "Project:\nTest Project\nEdit charm recipe",
+            MatchesTagText(content, "project"))
+        self.assertThat(
+            "Source:\n%s\nEdit charm recipe" % source_display,
+            MatchesTagText(content, "source"))
+        self.assertThat(
+            "Build schedule:\n(?)\nBuilt on request\nEdit charm recipe\n",
+            MatchesTagText(content, "auto_build"))
+        self.assertIsNone(find_tag_by_id(content, "auto_build_channels"))
+        self.assertThat(
+            "Builds of this charm recipe are not automatically uploaded to "
+            "the store.\nEdit charm recipe",
+            MatchesTagText(content, "store_upload"))
+
+    def test_create_new_recipe_project(self):
+        project = self.factory.makeProduct(displayname="Test Project")
+        [git_ref] = self.factory.makeGitRefs()
+        source_display = git_ref.display_name
+        browser = self.getViewBrowser(
+            project, view_name="+new-charm-recipe", user=self.person)
+        browser.getControl(name="field.name").value = "charm-name"
+        browser.getControl(name="field.git_ref.repository").value = (
+            git_ref.repository.shortened_path)
+        browser.getControl(name="field.git_ref.path").value = git_ref.path
+        browser.getControl("Create charm recipe").click()
+
+        content = find_main_content(browser.contents)
+        self.assertEqual("charm-name", extract_text(content.h1))
+        self.assertThat(
+            "Test Person", MatchesPickerText(content, "edit-owner"))
+        self.assertThat(
+            "Project:\nTest Project\nEdit charm recipe",
+            MatchesTagText(content, "project"))
+        self.assertThat(
+            "Source:\n%s\nEdit charm recipe" % source_display,
+            MatchesTagText(content, "source"))
+        self.assertThat(
+            "Build schedule:\n(?)\nBuilt on request\nEdit charm recipe\n",
+            MatchesTagText(content, "auto_build"))
+        self.assertIsNone(find_tag_by_id(content, "auto_build_channels"))
+        self.assertThat(
+            "Builds of this charm recipe are not automatically uploaded to "
+            "the store.\nEdit charm recipe",
+            MatchesTagText(content, "store_upload"))
+
+    def test_create_new_recipe_users_teams_as_owner_options(self):
+        # Teams that the user is in are options for the charm recipe owner.
+        self.factory.makeTeam(
+            name="test-team", displayname="Test Team", members=[self.person])
+        [git_ref] = self.factory.makeGitRefs()
+        browser = self.getViewBrowser(
+            git_ref, view_name="+new-charm-recipe", user=self.person)
+        options = browser.getControl("Owner").displayOptions
+        self.assertEqual(
+            ["Test Person (test-person)", "Test Team (test-team)"],
+            sorted(str(option) for option in options))
+
+    def test_create_new_recipe_auto_build(self):
+        # Creating a new recipe and asking for it to be automatically built
+        # sets all the appropriate fields.
+        self.factory.makeProduct(
+            name="test-project", displayname="Test Project")
+        [git_ref] = self.factory.makeGitRefs()
+        browser = self.getViewBrowser(
+            git_ref, view_name="+new-charm-recipe", user=self.person)
+        browser.getControl(name="field.name").value = "charm-name"
+        browser.getControl(name="field.project").value = "test-project"
+        browser.getControl(
+            "Automatically build when branch changes").selected = True
+        browser.getControl(
+            name="field.auto_build_channels.charmcraft").value = "edge"
+        browser.getControl(
+            name="field.auto_build_channels.core").value = "stable"
+        browser.getControl(
+            name="field.auto_build_channels.core18").value = "beta"
+        browser.getControl(
+            name="field.auto_build_channels.core20").value = "edge/feature"
+        browser.getControl("Create charm recipe").click()
+
+        content = find_main_content(browser.contents)
+        self.assertThat(
+            "Build schedule:\n(?)\nBuilt automatically\nEdit charm recipe\n",
+            MatchesTagText(content, "auto_build"))
+        self.assertThat(
+            "Source snap channels for automatic builds:\nEdit charm recipe\n"
+            "charmcraft\nedge\ncore\nstable\ncore18\nbeta\n"
+            "core20\nedge/feature\n",
+            MatchesTagText(content, "auto_build_channels"))
+
+
 class TestCharmRecipeAdminView(BaseTestCharmRecipeView):
 
     def test_unauthorized(self):
diff --git a/lib/lp/charms/browser/tests/test_hascharmrecipes.py b/lib/lp/charms/browser/tests/test_hascharmrecipes.py
index 6374893..6cc7bf7 100644
--- a/lib/lp/charms/browser/tests/test_hascharmrecipes.py
+++ b/lib/lp/charms/browser/tests/test_hascharmrecipes.py
@@ -7,13 +7,16 @@ from __future__ import absolute_import, print_function, unicode_literals
 
 __metaclass__ = type
 
+import soupmatchers
 from testscenarios import (
     load_tests_apply_scenarios,
     WithScenarios,
     )
+from testtools.matchers import Not
 
 from lp.charms.interfaces.charmrecipe import CHARM_RECIPE_ALLOW_CREATE
 from lp.code.interfaces.gitrepository import IGitRepository
+from lp.code.tests.helpers import GitHostingFixture
 from lp.services.features.testing import FeatureFixture
 from lp.services.webapp import canonical_url
 from lp.testing import TestCaseWithFactory
@@ -83,4 +86,57 @@ class TestHasCharmRecipesView(WithScenarios, TestCaseWithFactory):
         self.assertEqual(expected_link, view.charm_recipes_link)
 
 
+class TestHasCharmRecipesMenu(WithScenarios, TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    scenarios = [
+        ("GitRef", {"context_factory": make_git_ref}),
+        ]
+
+    def setUp(self):
+        super(TestHasCharmRecipesMenu, self).setUp()
+        self.useFixture(GitHostingFixture())
+
+    def makeCharmRecipe(self, context):
+        return self.factory.makeCharmRecipe(git_ref=context)
+
+    def test_feature_flag_disabled(self):
+        # If the feature flag to allow charm recipe creation is disabled, we
+        # don't show a creation link.
+        context = self.context_factory(self)
+        view = create_initialized_view(context, "+index")
+        new_charm_recipe_url = canonical_url(
+            context, view_name="+new-charm-recipe")
+        self.assertThat(view(), Not(soupmatchers.HTMLContains(
+            soupmatchers.Tag(
+                "creation link", "a", attrs={"href": new_charm_recipe_url},
+                text="Create charm recipe"))))
+
+    def test_creation_link_no_recipes(self):
+        # An object with no charm recipes shows a creation link.
+        self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}))
+        context = self.context_factory(self)
+        view = create_initialized_view(context, "+index")
+        new_charm_recipe_url = canonical_url(
+            context, view_name="+new-charm-recipe")
+        self.assertThat(view(), soupmatchers.HTMLContains(
+            soupmatchers.Tag(
+                "creation link", "a", attrs={"href": new_charm_recipe_url},
+                text="Create charm recipe")))
+
+    def test_creation_link_recipes(self):
+        # An object with charm recipes shows a creation link.
+        self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}))
+        context = self.context_factory(self)
+        self.makeCharmRecipe(context)
+        view = create_initialized_view(context, "+index")
+        new_charm_recipe_url = canonical_url(
+            context, view_name="+new-charm-recipe")
+        self.assertThat(view(), soupmatchers.HTMLContains(
+            soupmatchers.Tag(
+                "creation link", "a", attrs={"href": new_charm_recipe_url},
+                text="Create charm recipe")))
+
+
 load_tests = load_tests_apply_scenarios
diff --git a/lib/lp/charms/browser/widgets/charmrecipebuildchannels.py b/lib/lp/charms/browser/widgets/charmrecipebuildchannels.py
index ef76294..b1512ed 100644
--- a/lib/lp/charms/browser/widgets/charmrecipebuildchannels.py
+++ b/lib/lp/charms/browser/widgets/charmrecipebuildchannels.py
@@ -34,7 +34,7 @@ class CharmRecipeBuildChannelsWidget(BrowserWidget, InputWidget):
 
     template = ViewPageTemplateFile("templates/charmrecipebuildchannels.pt")
     hint = False
-    snap_names = ["core", "core18", "core20", "charmcraft"]
+    snap_names = ["charmcraft", "core", "core18", "core20"]
     _widgets_set_up = False
 
     def __init__(self, context, request):
diff --git a/lib/lp/charms/browser/widgets/templates/charmrecipebuildchannels.pt b/lib/lp/charms/browser/widgets/templates/charmrecipebuildchannels.pt
index cf0052a..0430f4a 100644
--- a/lib/lp/charms/browser/widgets/templates/charmrecipebuildchannels.pt
+++ b/lib/lp/charms/browser/widgets/templates/charmrecipebuildchannels.pt
@@ -4,6 +4,10 @@
 
 <table class="subordinate">
   <tr>
+    <td>charmcraft</td>
+    <td><div tal:content="structure view/charmcraft_widget" /></td>
+  </tr>
+  <tr>
     <td>core</td>
     <td><div tal:content="structure view/core_widget" /></td>
   </tr>
@@ -15,10 +19,6 @@
     <td>core20</td>
     <td><div tal:content="structure view/core20_widget" /></td>
   </tr>
-  <tr>
-    <td>charmcraft</td>
-    <td><div tal:content="structure view/charmcraft_widget" /></td>
-  </tr>
 </table>
 
 </tal:root>
diff --git a/lib/lp/charms/browser/widgets/tests/test_charmrecipebuildchannelswidget.py b/lib/lp/charms/browser/widgets/tests/test_charmrecipebuildchannelswidget.py
index 6ab1907..67b2040 100644
--- a/lib/lp/charms/browser/widgets/tests/test_charmrecipebuildchannelswidget.py
+++ b/lib/lp/charms/browser/widgets/tests/test_charmrecipebuildchannelswidget.py
@@ -62,53 +62,53 @@ class TestCharmRecipeBuildChannelsWidget(TestCaseWithFactory):
         # The subwidgets are set up and a flag is set.
         self.widget.setUpSubWidgets()
         self.assertTrue(self.widget._widgets_set_up)
+        self.assertIsNotNone(getattr(self.widget, "charmcraft_widget", None))
         self.assertIsNotNone(getattr(self.widget, "core_widget", None))
         self.assertIsNotNone(getattr(self.widget, "core18_widget", None))
         self.assertIsNotNone(getattr(self.widget, "core20_widget", None))
-        self.assertIsNotNone(getattr(self.widget, "charmcraft_widget", None))
 
     def test_setUpSubWidgets_second_call(self):
         # The setUpSubWidgets method exits early if a flag is set to
         # indicate that the widgets were set up.
         self.widget._widgets_set_up = True
         self.widget.setUpSubWidgets()
+        self.assertIsNone(getattr(self.widget, "charmcraft_widget", None))
         self.assertIsNone(getattr(self.widget, "core_widget", None))
         self.assertIsNone(getattr(self.widget, "core18_widget", None))
         self.assertIsNone(getattr(self.widget, "core20_widget", None))
-        self.assertIsNone(getattr(self.widget, "charmcraft_widget", None))
 
     def test_setRenderedValue_None(self):
         self.widget.setRenderedValue(None)
+        self.assertIsNone(self.widget.charmcraft_widget._getCurrentValue())
         self.assertIsNone(self.widget.core_widget._getCurrentValue())
         self.assertIsNone(self.widget.core18_widget._getCurrentValue())
         self.assertIsNone(self.widget.core20_widget._getCurrentValue())
-        self.assertIsNone(self.widget.charmcraft_widget._getCurrentValue())
 
     def test_setRenderedValue_empty(self):
         self.widget.setRenderedValue({})
+        self.assertIsNone(self.widget.charmcraft_widget._getCurrentValue())
         self.assertIsNone(self.widget.core_widget._getCurrentValue())
         self.assertIsNone(self.widget.core18_widget._getCurrentValue())
         self.assertIsNone(self.widget.core20_widget._getCurrentValue())
-        self.assertIsNone(self.widget.charmcraft_widget._getCurrentValue())
 
     def test_setRenderedValue_one_channel(self):
         self.widget.setRenderedValue({"charmcraft": "stable"})
+        self.assertEqual(
+            "stable", self.widget.charmcraft_widget._getCurrentValue())
         self.assertIsNone(self.widget.core_widget._getCurrentValue())
         self.assertIsNone(self.widget.core18_widget._getCurrentValue())
         self.assertIsNone(self.widget.core20_widget._getCurrentValue())
-        self.assertEqual(
-            "stable", self.widget.charmcraft_widget._getCurrentValue())
 
     def test_setRenderedValue_all_channels(self):
         self.widget.setRenderedValue(
-            {"core": "candidate", "core18": "beta", "core20": "edge",
-             "charmcraft": "stable"})
+            {"charmcraft": "stable", "core": "candidate", "core18": "beta",
+             "core20": "edge"})
+        self.assertEqual(
+            "stable", self.widget.charmcraft_widget._getCurrentValue())
         self.assertEqual(
             "candidate", self.widget.core_widget._getCurrentValue())
         self.assertEqual("beta", self.widget.core18_widget._getCurrentValue())
         self.assertEqual("edge", self.widget.core20_widget._getCurrentValue())
-        self.assertEqual(
-            "stable", self.widget.charmcraft_widget._getCurrentValue())
 
     def test_hasInput_false(self):
         # hasInput is false when there are no channels in the form data.
@@ -126,41 +126,40 @@ class TestCharmRecipeBuildChannelsWidget(TestCaseWithFactory):
         # (At the moment, individual channel names are not validated, so
         # there is no "false" counterpart to this test.)
         form = {
+            "field.auto_build_channels.charmcraft": "stable",
             "field.auto_build_channels.core": "",
             "field.auto_build_channels.core18": "beta",
             "field.auto_build_channels.core20": "edge",
-            "field.auto_build_channels.charmcraft": "stable",
             }
         self.widget.request = LaunchpadTestRequest(form=form)
         self.assertTrue(self.widget.hasValidInput())
 
     def test_getInputValue(self):
         form = {
+            "field.auto_build_channels.charmcraft": "stable",
             "field.auto_build_channels.core": "",
             "field.auto_build_channels.core18": "beta",
             "field.auto_build_channels.core20": "edge",
-            "field.auto_build_channels.charmcraft": "stable",
             }
         self.widget.request = LaunchpadTestRequest(form=form)
         self.assertEqual(
-            {"core18": "beta", "core20": "edge",
-             "charmcraft": "stable"},
+            {"charmcraft": "stable", "core18": "beta", "core20": "edge"},
             self.widget.getInputValue())
 
     def test_call(self):
         # The __call__ method sets up the widgets.
         markup = self.widget()
+        self.assertIsNotNone(self.widget.charmcraft_widget)
         self.assertIsNotNone(self.widget.core_widget)
         self.assertIsNotNone(self.widget.core18_widget)
         self.assertIsNotNone(self.widget.core20_widget)
-        self.assertIsNotNone(self.widget.charmcraft_widget)
         soup = BeautifulSoup(markup)
         fields = soup.find_all(["input"], {"id": re.compile(".*")})
         expected_ids = [
+            "field.auto_build_channels.charmcraft",
             "field.auto_build_channels.core",
             "field.auto_build_channels.core18",
             "field.auto_build_channels.core20",
-            "field.auto_build_channels.charmcraft",
             ]
         ids = [field["id"] for field in fields]
         self.assertContentEqual(expected_ids, ids)
diff --git a/lib/lp/charms/interfaces/charmrecipe.py b/lib/lp/charms/interfaces/charmrecipe.py
index 9c76cf4..80e4572 100644
--- a/lib/lp/charms/interfaces/charmrecipe.py
+++ b/lib/lp/charms/interfaces/charmrecipe.py
@@ -445,8 +445,8 @@ class ICharmRecipeEditableAttributes(Interface):
         key_type=TextLine(), required=False, readonly=False,
         description=_(
             "A dictionary mapping snap names to channels to use when building "
-            "this charm recipe.  Currently only 'core', 'core18', 'core20', "
-            "and 'charmcraft' keys are supported."))
+            "this charm recipe.  Currently only 'charmcraft', 'core', "
+            "'core18', and 'core20' keys are supported."))
 
     is_stale = Bool(
         title=_("Charm recipe is stale and is due to be rebuilt."),
diff --git a/lib/lp/charms/interfaces/charmrecipebuild.py b/lib/lp/charms/interfaces/charmrecipebuild.py
index 284566f..077ccce 100644
--- a/lib/lp/charms/interfaces/charmrecipebuild.py
+++ b/lib/lp/charms/interfaces/charmrecipebuild.py
@@ -65,8 +65,8 @@ class ICharmRecipeBuildView(IPackageBuild):
         title=_("Source snap channels to use for this build."),
         description=_(
             "A dictionary mapping snap names to channels to use for this "
-            "build.  Currently only 'core', 'core18', 'core20', "
-            "and 'charmcraft' keys are supported."),
+            "build.  Currently only 'charmcraft', 'core', 'core18', and "
+            "'core20' keys are supported."),
         key_type=TextLine())
 
     virtualized = Bool(
diff --git a/lib/lp/charms/interfaces/charmrecipejob.py b/lib/lp/charms/interfaces/charmrecipejob.py
index 9bdc9e5..1094e5a 100644
--- a/lib/lp/charms/interfaces/charmrecipejob.py
+++ b/lib/lp/charms/interfaces/charmrecipejob.py
@@ -64,8 +64,8 @@ class ICharmRecipeRequestBuildsJob(IRunnableJob):
         title=_("Source snap channels to use for these builds."),
         description=_(
             "A dictionary mapping snap names to channels to use for these "
-            "builds.  Currently only 'core', 'core18', 'core20', and "
-            "'charmcraft' keys are supported."),
+            "builds.  Currently only 'charmcraft', 'core', 'core18', and "
+            "'core20' keys are supported."),
         key_type=TextLine(), required=False, readonly=True)
 
     architectures = Set(
diff --git a/lib/lp/charms/templates/charmrecipe-macros.pt b/lib/lp/charms/templates/charmrecipe-macros.pt
index 93b4d69..fe314e1 100644
--- a/lib/lp/charms/templates/charmrecipe-macros.pt
+++ b/lib/lp/charms/templates/charmrecipe-macros.pt
@@ -17,6 +17,12 @@
     </div>
   </div>
 
+  <span
+    tal:define="link context_menu/create_charm_recipe|nothing"
+    tal:condition="python: link and link.enabled"
+    tal:replace="structure link/render"
+    />
+
 </div>
 
 </tal:root>
diff --git a/lib/lp/charms/templates/charmrecipe-new.pt b/lib/lp/charms/templates/charmrecipe-new.pt
new file mode 100644
index 0000000..bde870d
--- /dev/null
+++ b/lib/lp/charms/templates/charmrecipe-new.pt
@@ -0,0 +1,87 @@
+<html
+  xmlns="http://www.w3.org/1999/xhtml";
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";
+  metal:use-macro="view/macro:page/main_side"
+  i18n:domain="launchpad">
+<body>
+
+<div metal:fill-slot="main">
+  <div>
+    <p>
+      A <a href="https://juju.is/docs";>charmed operator</a> (packaged as a
+      "charm") encapsulates a single application and all the code and
+      know-how it takes to operate it, such as how to combine and work with
+      other related applications or how to upgrade it.  Launchpad can build
+      charms using <a href="https://juju.is/docs/sdk";>charmcraft</a>, from
+      any Git branch on Launchpad that has a <tt>charmcraft.yaml</tt> file.
+    </p>
+  </div>
+
+  <div metal:use-macro="context/@@launchpad_form/form">
+    <metal:formbody fill-slot="widgets">
+      <table class="form">
+        <tal:widget define="widget nocall:view/widgets/name">
+          <metal:block use-macro="context/@@launchpad_form/widget_row" />
+        </tal:widget>
+        <tal:widget define="widget nocall:view/widgets/owner">
+          <metal:block use-macro="context/@@launchpad_form/widget_row" />
+        </tal:widget>
+
+        <tal:not-project-context condition="not: view/is_project_context">
+          <tal:widget define="widget nocall:view/widgets/project">
+            <metal:block use-macro="context/@@launchpad_form/widget_row" />
+          </tal:widget>
+        </tal:not-project-context>
+
+        <tal:project-context condition="view/is_project_context">
+          <tal:widget define="widget nocall:view/widgets/git_ref">
+            <metal:block use-macro="context/@@launchpad_form/widget_row" />
+          </tal:widget>
+        </tal:project-context>
+
+        <tal:widget define="widget nocall:view/widgets/auto_build">
+          <metal:block use-macro="context/@@launchpad_form/widget_row" />
+        </tal:widget>
+        <tr>
+          <td>
+            <table class="subordinate">
+              <tal:widget define="widget nocall:view/widgets/auto_build_channels">
+                <metal:block use-macro="context/@@launchpad_form/widget_row" />
+              </tal:widget>
+            </table>
+          </td>
+        </tr>
+
+        <tal:widget define="widget nocall:view/widgets/store_upload">
+          <metal:block use-macro="context/@@launchpad_form/widget_row" />
+        </tal:widget>
+        <tr>
+          <td>
+            <table class="subordinate">
+              <tal:widget define="widget nocall:view/widgets/store_name">
+                <metal:block use-macro="context/@@launchpad_form/widget_row" />
+              </tal:widget>
+              <tal:widget define="widget nocall:view/widgets/store_channels"
+                          condition="widget/has_risks_vocabulary">
+                <metal:block use-macro="context/@@launchpad_form/widget_row" />
+              </tal:widget>
+            </table>
+          </td>
+        </tr>
+      </table>
+    </metal:formbody>
+  </div>
+
+  <script type="text/javascript">
+    LPJS.use('lp.charms.charmrecipe.edit', function(Y) {
+      Y.on('domready', function(e) {
+        Y.lp.charms.charmrecipe.edit.setup();
+      }, window);
+    });
+  </script>
+</div>
+
+</body>
+</html>
diff --git a/lib/lp/code/browser/gitref.py b/lib/lp/code/browser/gitref.py
index c53f8f1..95abbd9 100644
--- a/lib/lp/code/browser/gitref.py
+++ b/lib/lp/code/browser/gitref.py
@@ -84,6 +84,7 @@ class GitRefContextMenu(
     facet = 'branches'
     links = [
         'browse_commits',
+        'create_charm_recipe',
         'create_recipe',
         'create_snap',
         'register_merge',
diff --git a/lib/lp/registry/browser/product.py b/lib/lp/registry/browser/product.py
index 628fe0e..63724fd 100644
--- a/lib/lp/registry/browser/product.py
+++ b/lib/lp/registry/browser/product.py
@@ -588,6 +588,7 @@ class ProductOverviewMenu(ApplicationMenu, ProductEditLinksMixin,
         'view_charm_recipes',
         'view_recipes',
         'view_snaps',
+        'create_charm_recipe',
         'create_snap',
         ]
 
diff --git a/lib/lp/registry/templates/product-index.pt b/lib/lp/registry/templates/product-index.pt
index 242483d..fc3c274 100644
--- a/lib/lp/registry/templates/product-index.pt
+++ b/lib/lp/registry/templates/product-index.pt
@@ -170,25 +170,35 @@
             </p>
 
             <ul class="horizontal" id="project-link-info">
-              <li tal:condition="overview_menu/series_add/enabled">
+              <li class="nowrap"
+                  tal:condition="overview_menu/series_add/enabled">
                 <a tal:replace="structure overview_menu/series_add/fmt:link" />
               </li>
-              <li>
+              <li class="nowrap">
                 <a tal:replace="structure overview_menu/milestones/fmt:link" />
               </li>
-              <li tal:define="link context/menu:overview/view_recipes"
+              <li class="nowrap"
+                  tal:define="link context/menu:overview/view_recipes"
                   tal:condition="link/enabled">
                 <a tal:replace="structure link/fmt:link" />
               </li>
-              <li tal:define="link context/menu:overview/view_snaps"
+              <li class="nowrap"
+                  tal:define="link context/menu:overview/view_snaps"
                   tal:condition="link/enabled">
                 <a tal:replace="structure link/fmt:link" />
               </li>
-              <li tal:define="link context/menu:overview/create_snap"
+              <li class="nowrap"
+                  tal:define="link context/menu:overview/create_snap"
                   tal:condition="link/enabled">
                 <a tal:replace="structure link/fmt:link" />
               </li>
-              <li tal:define="link context/menu:overview/view_charm_recipes"
+              <li class="nowrap"
+                  tal:define="link context/menu:overview/view_charm_recipes"
+                  tal:condition="link/enabled">
+                <a tal:replace="structure link/fmt:link" />
+              </li>
+              <li class="nowrap"
+                  tal:define="link context/menu:overview/create_charm_recipe"
                   tal:condition="link/enabled">
                 <a tal:replace="structure link/fmt:link" />
               </li>