← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/snap-edit-views into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/snap-edit-views into lp:launchpad.

Commit message:
Add Snap:+edit, Snap:+admin, and Snap:+delete views.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1476405 in Launchpad itself: "Add support for building snaps"
  https://bugs.launchpad.net/launchpad/+bug/1476405

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/snap-edit-views/+merge/270193

Add Snap:+edit, Snap:+admin, and Snap:+delete views.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/snap-edit-views into lp:launchpad.
=== modified file 'lib/lp/snappy/browser/configure.zcml'
--- lib/lp/snappy/browser/configure.zcml	2015-08-07 10:12:38 +0000
+++ lib/lp/snappy/browser/configure.zcml	2015-09-04 16:25:33 +0000
@@ -22,9 +22,30 @@
             permission="launchpad.View"
             name="+index"
             template="../templates/snap-index.pt" />
+        <browser:menus
+            module="lp.snappy.browser.snap"
+            classes="SnapNavigationMenu" />
         <browser:navigation
             module="lp.snappy.browser.snap"
             classes="SnapNavigation" />
+        <browser:page
+            for="lp.snappy.interfaces.snap.ISnap"
+            class="lp.snappy.browser.snap.SnapAdminView"
+            permission="launchpad.Admin"
+            name="+admin"
+            template="../../app/templates/generic-edit.pt" />
+        <browser:page
+            for="lp.snappy.interfaces.snap.ISnap"
+            class="lp.snappy.browser.snap.SnapEditView"
+            permission="launchpad.Edit"
+            name="+edit"
+            template="../templates/snap-edit.pt" />
+        <browser:page
+            for="lp.snappy.interfaces.snap.ISnap"
+            class="lp.snappy.browser.snap.SnapDeleteView"
+            permission="launchpad.Edit"
+            name="+delete"
+            template="../templates/snap-delete.pt" />
         <adapter
             provides="lp.services.webapp.interfaces.IBreadcrumb"
             for="lp.snappy.interfaces.snap.ISnap"

=== modified file 'lib/lp/snappy/browser/snap.py'
--- lib/lp/snappy/browser/snap.py	2015-08-27 16:06:41 +0000
+++ lib/lp/snappy/browser/snap.py	2015-09-04 16:25:33 +0000
@@ -5,14 +5,41 @@
 
 __metaclass__ = type
 __all__ = [
+    'SnapDeleteView',
+    'SnapEditView',
     'SnapNavigation',
+    'SnapNavigationMenu',
     'SnapView',
     ]
 
+from lazr.restful.interface import (
+    copy_field,
+    use_template,
+    )
+from zope.component import getUtility
+from zope.interface import Interface
+from zope.schema import Choice
+
+from lp.app.browser.launchpadform import (
+    action,
+    custom_widget,
+    LaunchpadEditFormView,
+    render_radio_widget_part,
+    )
+from lp.app.browser.lazrjs import (
+    InlinePersonEditPickerWidget,
+    TextLineEditorWidget,
+    )
+from lp.app.browser.tales import format_link
+from lp.app.widgets.itemswidgets import LaunchpadRadioWidget
+from lp.registry.enums import VCSType
 from lp.services.webapp import (
     canonical_url,
+    enabled_with_permission,
     LaunchpadView,
+    Link,
     Navigation,
+    NavigationMenu,
     stepthrough,
     )
 from lp.services.webapp.authorization import check_permission
@@ -20,7 +47,11 @@
     Breadcrumb,
     NameBreadcrumb,
     )
-from lp.snappy.interfaces.snap import ISnap
+from lp.snappy.interfaces.snap import (
+    ISnap,
+    ISnapSet,
+    NoSuchSnap,
+    )
 from lp.snappy.interfaces.snapbuild import ISnapBuildSet
 from lp.soyuz.browser.build import get_build_by_id_str
 
@@ -46,6 +77,28 @@
             text="Snap packages", inside=self.context.owner)
 
 
+class SnapNavigationMenu(NavigationMenu):
+    """Navigation menu for snap packages."""
+
+    usedfor = ISnap
+
+    facet = 'overview'
+
+    links = ('admin', 'delete', 'edit')
+
+    @enabled_with_permission('launchpad.Admin')
+    def admin(self):
+        return Link('+admin', 'Administer snap package', icon='edit')
+
+    @enabled_with_permission('launchpad.Edit')
+    def edit(self):
+        return Link('+edit', 'Edit snap package', icon='edit')
+
+    @enabled_with_permission('launchpad.Edit')
+    def delete(self):
+        return Link('+delete', 'Delete snap package', icon='trash-icon')
+
+
 class SnapView(LaunchpadView):
     """Default view of a Snap."""
 
@@ -54,6 +107,22 @@
         return builds_for_snap(self.context)
 
     @property
+    def name_widget(self):
+        name = ISnap['name']
+        title = "Edit the snap package name"
+        return TextLineEditorWidget(
+            self.context, name, title, 'h1', max_width='95%', truncate_lines=1)
+
+    @property
+    def person_picker(self):
+        field = copy_field(
+            ISnap['owner'],
+            vocabularyName='UserTeamsParticipationPlusSelfSimpleDisplay')
+        return InlinePersonEditPickerWidget(
+            self.context, field, format_link(self.context.owner),
+            header='Change owner', step_title='Select a new owner')
+
+    @property
     def source(self):
         if self.context.branch is not None:
             return self.context.branch
@@ -86,3 +155,161 @@
         if len(builds) >= 10:
             break
     return builds
+
+
+class ISnapEditSchema(Interface):
+    """Schema for adding or editing a snap package."""
+
+    use_template(ISnap, include=[
+        'owner',
+        'name',
+        'require_virtualized',
+        ])
+    distro_series = Choice(
+        vocabulary='BuildableDistroSeries', title=u'Distribution series')
+    vcs = Choice(vocabulary=VCSType, required=True, title=u'VCS')
+
+    # Each of these is only required if vcs has an appropriate value.  Later
+    # validation takes care of adjusting the required attribute.
+    branch = copy_field(ISnap['branch'], required=True)
+    git_repository = copy_field(ISnap['git_repository'], required=True)
+    git_path = copy_field(ISnap['git_path'], required=True)
+
+
+class BaseSnapAddEditView(LaunchpadEditFormView):
+
+    schema = ISnapEditSchema
+
+    @property
+    def cancel_url(self):
+        return canonical_url(self.context)
+
+    def setUpWidgets(self):
+        """See `LaunchpadFormView`."""
+        super(BaseSnapAddEditView, self).setUpWidgets()
+        widget = self.widgets.get('vcs')
+        if widget is not None:
+            current_value = widget._getFormValue()
+            self.vcs_bzr, self.vcs_git = [
+                render_radio_widget_part(widget, value, current_value)
+                for value in (VCSType.BZR, VCSType.GIT)]
+
+    def validate_widgets(self, data, names=None):
+        """See `LaunchpadFormView`."""
+        if 'vcs' in self.widgets:
+            # Set widgets as required or optional depending on the vcs
+            # field.
+            super(BaseSnapAddEditView, self).validate_widgets(data, ['vcs'])
+            vcs = data.get('vcs')
+            if vcs == VCSType.BZR:
+                self.widgets['branch'].context.required = True
+                self.widgets['git_repository'].context.required = False
+                self.widgets['git_path'].context.required = False
+            elif vcs == VCSType.GIT:
+                self.widgets['branch'].context.required = False
+                self.widgets['git_repository'].context.required = True
+                self.widgets['git_path'].context.required = True
+            else:
+                raise AssertionError("Unknown branch type %s" % vcs)
+        super(BaseSnapAddEditView, self).validate_widgets(data, names=names)
+
+
+class BaseSnapEditView(BaseSnapAddEditView):
+
+    @action('Update snap package', name='update')
+    def request_action(self, action, data):
+        vcs = data.pop('vcs', None)
+        if vcs == VCSType.BZR:
+            data['git_repository'] = None
+            data['git_path'] = None
+        elif vcs == VCSType.GIT:
+            data['branch'] = None
+        self.updateContextFromData(data)
+        self.next_url = canonical_url(self.context)
+
+    @property
+    def adapters(self):
+        """See `LaunchpadFormView`."""
+        return {ISnapEditSchema: self.context}
+
+
+class SnapAdminView(BaseSnapEditView):
+    """View for administering snap packages."""
+
+    @property
+    def title(self):
+        return 'Administer %s snap package' % self.context.name
+
+    label = title
+
+    field_names = ['require_virtualized']
+
+    @property
+    def initial_values(self):
+        return {'require_virtualized': self.context.require_virtualized}
+
+
+class SnapEditView(BaseSnapEditView):
+    """View for editing snap packages."""
+
+    @property
+    def title(self):
+        return 'Edit %s snap package' % self.context.name
+
+    label = title
+
+    field_names = [
+        'owner', 'name', 'distro_series', 'vcs', 'branch', 'git_repository',
+        'git_path']
+    custom_widget('distro_series', LaunchpadRadioWidget)
+    custom_widget('vcs', LaunchpadRadioWidget)
+
+    @property
+    def initial_values(self):
+        if self.context.git_repository is not None:
+            vcs = VCSType.GIT
+        else:
+            vcs = VCSType.BZR
+        return {
+            'distro_series': self.context.distro_series,
+            'vcs': vcs,
+            }
+
+    def validate(self, data):
+        super(SnapEditView, self).validate(data)
+        owner = data.get('owner', None)
+        name = data.get('name', None)
+        if owner and name:
+            try:
+                snap = getUtility(ISnapSet).getByName(owner, name)
+                if snap != self.context:
+                    self.setFieldError(
+                        'name',
+                        'There is already a snap package owned by %s with '
+                        'this name.' % owner.displayname)
+            except NoSuchSnap:
+                pass
+
+
+class SnapDeleteView(BaseSnapEditView):
+    """View for deleting snap packages."""
+
+    @property
+    def title(self):
+        return 'Delete %s snap package' % self.context.name
+
+    label = title
+
+    field_names = []
+
+    @property
+    def has_builds(self):
+        return not self.context.builds.is_empty()
+
+    @action('Delete snap package', name='delete')
+    def delete_action(self, action, data):
+        owner = self.context.owner
+        self.context.destroySelf()
+        # XXX cjwatson 2015-07-17: This should go to Person:+snaps or
+        # similar (or something on SnapSet?) once that exists.
+        self.next_url = canonical_url(owner)

=== modified file 'lib/lp/snappy/browser/tests/test_snap.py'
--- lib/lp/snappy/browser/tests/test_snap.py	2015-08-27 16:06:41 +0000
+++ lib/lp/snappy/browser/tests/test_snap.py	2015-09-04 16:25:33 +0000
@@ -10,18 +10,31 @@
     timedelta,
     )
 
+from fixtures import FakeLogger
+from mechanize import LinkNotFoundError
 import pytz
 from zope.component import getUtility
+from zope.publisher.interfaces import NotFound
+from zope.security.interfaces import Unauthorized
 
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
 from lp.buildmaster.enums import BuildStatus
 from lp.buildmaster.interfaces.processor import IProcessorSet
+from lp.registry.interfaces.series import SeriesStatus
+from lp.services.database.constants import UTC_NOW
 from lp.services.features.testing import FeatureFixture
 from lp.services.webapp import canonical_url
-from lp.snappy.browser.snap import SnapView
+from lp.services.webapp.servers import LaunchpadTestRequest
+from lp.snappy.browser.snap import (
+    SnapAdminView,
+    SnapEditView,
+    SnapView,
+    )
 from lp.snappy.interfaces.snap import SNAP_FEATURE_FLAG
 from lp.testing import (
     BrowserTestCase,
+    login,
+    login_person,
     person_logged_in,
     TestCaseWithFactory,
     time_counter,
@@ -30,6 +43,15 @@
     DatabaseFunctionalLayer,
     LaunchpadFunctionalLayer,
     )
+from lp.testing.matchers import (
+    MatchesPickerText,
+    MatchesTagText,
+    )
+from lp.testing.pages import (
+    extract_text,
+    find_main_content,
+    find_tags_by_class,
+    )
 from lp.testing.publication import test_traverse
 
 
@@ -55,6 +77,184 @@
         self.assertEqual(snap, obj)
 
 
+class TestSnapAdminView(BrowserTestCase):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestSnapAdminView, self).setUp()
+        self.useFixture(FeatureFixture({SNAP_FEATURE_FLAG: u"on"}))
+        self.useFixture(FakeLogger())
+        self.person = self.factory.makePerson(
+            name="test-person", displayname="Test Person")
+
+    def test_unauthorized(self):
+        # A non-admin user cannot administer a snap package.
+        login_person(self.person)
+        snap = self.factory.makeSnap(registrant=self.person)
+        snap_url = canonical_url(snap)
+        browser = self.getViewBrowser(snap, user=self.person)
+        self.assertRaises(
+            LinkNotFoundError, browser.getLink, "Administer snap package")
+        self.assertRaises(
+            Unauthorized, self.getUserBrowser, snap_url + "/+admin",
+            user=self.person)
+
+    def test_admin_snap(self):
+        # Admins can change require_virtualized.
+        login("admin@xxxxxxxxxxxxx")
+        commercial_admin = self.factory.makePerson(
+            member_of=[getUtility(ILaunchpadCelebrities).commercial_admin])
+        login_person(self.person)
+        snap = self.factory.makeSnap(registrant=self.person)
+        self.assertTrue(snap.require_virtualized)
+        browser = self.getViewBrowser(snap, user=commercial_admin)
+        browser.getLink("Administer snap package").click()
+        browser.getControl("Require virtualized builders").selected = False
+        browser.getControl("Update snap package").click()
+        login_person(self.person)
+        self.assertFalse(snap.require_virtualized)
+
+    def test_admin_snap_sets_date_last_modified(self):
+        # Administering a snap package sets the date_last_modified property.
+        login("admin@xxxxxxxxxxxxx")
+        commercial_admin = self.factory.makePerson(
+            member_of=[getUtility(ILaunchpadCelebrities).commercial_admin])
+        login_person(self.person)
+        date_created = datetime(2000, 1, 1, tzinfo=pytz.UTC)
+        snap = self.factory.makeSnap(
+            registrant=self.person, date_created=date_created)
+        login_person(commercial_admin)
+        view = SnapAdminView(snap, LaunchpadTestRequest())
+        view.initialize()
+        view.request_action.success({"require_virtualized": False})
+        self.assertSqlAttributeEqualsDate(snap, "date_last_modified", UTC_NOW)
+
+
+class TestSnapEditView(BrowserTestCase):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestSnapEditView, self).setUp()
+        self.useFixture(FeatureFixture({SNAP_FEATURE_FLAG: u"on"}))
+        self.useFixture(FakeLogger())
+        self.person = self.factory.makePerson(
+            name="test-person", displayname="Test Person")
+
+    def test_edit_snap(self):
+        archive = self.factory.makeArchive()
+        old_series = self.factory.makeDistroSeries(
+            distribution=archive.distribution, status=SeriesStatus.CURRENT)
+        old_branch = self.factory.makeAnyBranch()
+        snap = self.factory.makeSnap(
+            registrant=self.person, owner=self.person, distroseries=old_series,
+            branch=old_branch)
+        self.factory.makeTeam(
+            name="new-team", displayname="New Team", members=[self.person])
+        new_series = self.factory.makeDistroSeries(
+            distribution=archive.distribution, status=SeriesStatus.DEVELOPMENT)
+        [new_git_ref] = self.factory.makeGitRefs()
+
+        browser = self.getViewBrowser(snap, user=self.person)
+        browser.getLink("Edit snap package").click()
+        browser.getControl("Owner").value = ["new-team"]
+        browser.getControl("Name").value = "new-name"
+        browser.getControl(name="field.distro_series").value = [
+            str(new_series.id)]
+        browser.getControl("Git", index=0).click()
+        browser.getControl("Git repository").value = (
+            new_git_ref.repository.identity)
+        browser.getControl("Git branch path").value = new_git_ref.path
+        browser.getControl("Update snap package").click()
+
+        content = find_main_content(browser.contents)
+        self.assertEqual("new-name\nEdit", extract_text(content.h1))
+        self.assertThat("New Team", MatchesPickerText(content, "edit-owner"))
+        self.assertThat(
+            "Distribution series:\n%s\nEdit snap package" %
+            new_series.fullseriesname,
+            MatchesTagText(content, "distro_series"))
+        self.assertThat(
+            "Source:\n%s\nEdit snap package" % new_git_ref.display_name,
+            MatchesTagText(content, "source"))
+
+    def test_edit_snap_sets_date_last_modified(self):
+        # Editing a snap package sets the date_last_modified property.
+        date_created = datetime(2000, 1, 1, tzinfo=pytz.UTC)
+        snap = self.factory.makeSnap(
+            registrant=self.person, date_created=date_created)
+        with person_logged_in(self.person):
+            view = SnapEditView(snap, LaunchpadTestRequest())
+            view.initialize()
+            view.request_action.success({
+                "owner": snap.owner,
+                "name": u"changed",
+                "distro_series": snap.distro_series,
+                })
+        self.assertSqlAttributeEqualsDate(snap, "date_last_modified", UTC_NOW)
+
+    def test_edit_snap_already_exists(self):
+        snap = self.factory.makeSnap(
+            registrant=self.person, owner=self.person, name=u"one")
+        self.factory.makeSnap(
+            registrant=self.person, owner=self.person, name=u"two")
+        browser = self.getViewBrowser(snap, user=self.person)
+        browser.getLink("Edit snap package").click()
+        browser.getControl("Name").value = "two"
+        browser.getControl("Update snap package").click()
+        self.assertEqual(
+            "There is already a snap package owned by Test Person with this "
+            "name.",
+            extract_text(find_tags_by_class(browser.contents, "message")[1]))
+
+
+class TestSnapDeleteView(BrowserTestCase):
+
+    layer = LaunchpadFunctionalLayer
+
+    def setUp(self):
+        super(TestSnapDeleteView, self).setUp()
+        self.useFixture(FeatureFixture({SNAP_FEATURE_FLAG: u"on"}))
+        self.person = self.factory.makePerson(
+            name="test-person", displayname="Test Person")
+
+    def test_unauthorized(self):
+        # A user without edit access cannot delete a snap package.
+        self.useFixture(FakeLogger())
+        snap = self.factory.makeSnap(registrant=self.person, owner=self.person)
+        snap_url = canonical_url(snap)
+        other_person = self.factory.makePerson()
+        browser = self.getViewBrowser(snap, user=other_person)
+        self.assertRaises(
+            LinkNotFoundError, browser.getLink, "Delete snap package")
+        self.assertRaises(
+            Unauthorized, self.getUserBrowser, snap_url + "/+delete",
+            user=other_person)
+
+    def test_delete_snap_without_builds(self):
+        # A snap package without builds can be deleted.
+        self.useFixture(FakeLogger())
+        snap = self.factory.makeSnap(registrant=self.person, owner=self.person)
+        snap_url = canonical_url(snap)
+        owner_url = canonical_url(self.person)
+        browser = self.getViewBrowser(snap, user=self.person)
+        browser.getLink("Delete snap package").click()
+        browser.getControl("Delete snap package").click()
+        self.assertEqual(owner_url, browser.url)
+        self.assertRaises(NotFound, browser.open, snap_url)
+
+    def test_delete_snap_with_builds(self):
+        # A snap package with builds cannot be deleted.
+        snap = self.factory.makeSnap(registrant=self.person, owner=self.person)
+        self.factory.makeSnapBuild(snap=snap)
+        browser = self.getViewBrowser(snap, user=self.person)
+        browser.getLink("Delete snap package").click()
+        self.assertIn("This snap package cannot be deleted", browser.contents)
+        self.assertRaises(
+            LookupError, browser.getControl, "Delete snap package")
+
+
 class TestSnapView(BrowserTestCase):
 
     layer = LaunchpadFunctionalLayer

=== added directory 'lib/lp/snappy/javascript'
=== added file 'lib/lp/snappy/javascript/snap.edit.js'
--- lib/lp/snappy/javascript/snap.edit.js	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/javascript/snap.edit.js	2015-09-04 16:25:33 +0000
@@ -0,0 +1,36 @@
+/* Copyright 2015 Canonical Ltd.  This software is licensed under the
+ * GNU Affero General Public License version 3 (see the file LICENSE).
+ *
+ * Control enabling/disabling form elements on the Snap:+edit page.
+ *
+ * @module Y.lp.snappy.snap.edit
+ * @requires node, DOM
+ */
+YUI.add('lp.snappy.snap.edit', function(Y) {
+    Y.log('loading lp.snappy.snap.edit');
+    var module = Y.namespace('lp.snappy.snap.edit');
+
+    module.set_enabled = function(field_id, is_enabled) {
+        var field = Y.DOM.byId(field_id);
+        field.disabled = !is_enabled;
+    };
+
+    module.onclick_vcs = function(e) {
+        var selected_vcs = null;
+        Y.all('input[name="field.vcs"]').each(function(node) {
+            if (node.get('checked')) {
+                selected_vcs = node.get('value');
+            }
+        });
+        module.set_enabled('field.branch', selected_vcs === 'BZR');
+        module.set_enabled('field.git_repository', selected_vcs === 'GIT');
+        module.set_enabled('field.git_path', selected_vcs === 'GIT');
+    };
+
+    module.setup = function() {
+        Y.all('input[name="field.vcs"]').on('click', module.onclick_vcs);
+
+        // Set the initial state.
+        module.onclick_vcs();
+    };
+}, '0.1', {'requires': ['node', 'DOM']});

=== added directory 'lib/lp/snappy/javascript/tests'
=== added file 'lib/lp/snappy/javascript/tests/test_snap.edit.html'
--- lib/lp/snappy/javascript/tests/test_snap.edit.html	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/javascript/tests/test_snap.edit.html	2015-09-04 16:25:33 +0000
@@ -0,0 +1,146 @@
+<!DOCTYPE html>
+<!--
+Copyright 2015 Canonical Ltd.  This software is licensed under the
+GNU Affero General Public License version 3 (see the file LICENSE).
+-->
+
+<html>
+  <head>
+      <title>snappy.snap.edit Tests</title>
+
+      <!-- YUI and test setup -->
+      <script type="text/javascript"
+              src="../../../../../build/js/yui/yui/yui.js">
+      </script>
+      <link rel="stylesheet"
+      href="../../../../../build/js/yui/console/assets/console-core.css" />
+      <link rel="stylesheet"
+      href="../../../../../build/js/yui/test-console/assets/skins/sam/test-console.css" />
+      <link rel="stylesheet"
+      href="../../../../../build/js/yui/test/assets/skins/sam/test.css" />
+
+      <script type="text/javascript"
+              src="../../../../../build/js/lp/app/testing/testrunner.js"></script>
+
+      <link rel="stylesheet" href="../../../app/javascript/testing/test.css" />
+
+      <!-- Dependencies -->
+      <script type="text/javascript"
+          src="../../../../../build/js/lp/app/lp.js"></script>
+
+      <!-- The module under test. -->
+      <script type="text/javascript" src="../snap.edit.js"></script>
+
+      <!-- The test suite -->
+      <script type="text/javascript" src="test_snap.edit.js"></script>
+
+      <script type="text/javascript">
+        YUI().use('lp.snappy.snap.edit', function(Y) {
+          Y.on('domready', Y.lp.snappy.snap.edit.setup);
+        });
+      </script>
+
+    </head>
+    <body class="yui3-skin-sam">
+        <ul id="suites">
+            <li>lp.snappy.snap.edit.test</li>
+        </ul>
+        <div id="snap.edit">
+        <form action="." name="launchpadform" method="post"
+              enctype="multipart/form-data"
+              accept-charset="UTF-8">
+
+          <table class="form">
+            <tr>
+              <td colspan="2">
+                <div>
+                  <label for="field.vcs">Source:</label>
+                  <table>
+                    <tr>
+                      <td>
+                        <label>
+                          <input class="radioType"
+                                 id="field.vcs.Bazaar"
+                                 name="field.vcs"
+                                 type="radio"
+                                 value="BZR" />
+                          Bazaar
+                        </label>
+                        <table class="subordinate">
+                          <tr>
+                            <td colspan="2">
+                              <div>
+                                <label for="field.branch">Bazaar branch:</label>
+                                <div>
+                                  <input type="text"
+                                         value=""
+                                         id="field.branch"
+                                         name="field.branch"
+                                         size="35"
+                                         class="" />
+                                </div>
+                              </div>
+                            </td>
+                          </tr>
+                        </table>
+                      </td>
+                    </tr>
+                    <tr>
+                      <td>
+                        <label>
+                          <input class="radioType"
+                                 id="field.vcs.Git"
+                                 name="field.vcs"
+                                 type="radio"
+                                 value="GIT" />
+                          Git
+                        </label>
+                        <table class="subordinate">
+                          <tr>
+                            <td colspan="2">
+                              <div>
+                                <label for="field.git_repository">Git repository:</label>
+                                <div>
+                                  <input type="text"
+                                         value=""
+                                         id="field.git_repository"
+                                         name="field.git_repository"
+                                         size="20"
+                                         class="" />
+                                </div>
+                              </div>
+                            </td>
+                          </tr>
+                          <tr>
+                            <td colspan="2">
+                              <div>
+                                <label for="field.git_path">Git path:</label>
+                                <div>
+                                  <input class="textType"
+                                         id="field.git_path"
+                                         name="field.git_path"
+                                         size="20"
+                                         type="text"
+                                         value="" />
+                                </div>
+                              </div>
+                            </td>
+                          </tr>
+                        </table>
+                      </td>
+                    </tr>
+                  </table>
+                </div>
+              </td>
+            </tr>
+          </table>
+
+          <input type="submit" id="field.actions.update"
+                 name="field.actions.update" value="Update snap package"
+                 class="button" />
+          or&nbsp;
+          <a href="https://launchpad.dev/~me/+snap/s";>Cancel</a>
+        </form>
+        </div>
+    </body>
+</html>

=== added file 'lib/lp/snappy/javascript/tests/test_snap.edit.js'
--- lib/lp/snappy/javascript/tests/test_snap.edit.js	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/javascript/tests/test_snap.edit.js	2015-09-04 16:25:33 +0000
@@ -0,0 +1,80 @@
+/* Copyright 2015 Canonical Ltd.  This software is licensed under the
+ * GNU Affero General Public License version 3 (see the file LICENSE).
+ *
+ * Test driver for snap.edit.js.
+ */
+YUI.add('lp.snappy.snap.edit.test', function(Y) {
+    var tests = Y.namespace('lp.snappy.snap.edit.test');
+    var module = Y.lp.snappy.snap.edit;
+    tests.suite = new Y.Test.Suite('snappy.snap.edit Tests');
+
+    tests.suite.add(new Y.Test.Case({
+        name: 'snappy.snap.edit_tests',
+
+        setUp: function() {
+            this.tbody = Y.one('#snap.edit');
+
+            // Get the individual VCS type radio buttons.
+            this.vcs_bzr = Y.DOM.byId('field.vcs.Bazaar');
+            this.vcs_git = Y.DOM.byId('field.vcs.Git');
+
+            // Get the input widgets.
+            this.input_branch = Y.DOM.byId('field.branch');
+            this.input_git_repository = Y.DOM.byId('field.git_repository');
+            this.input_git_path = Y.DOM.byId('field.git_path');
+        },
+
+        tearDown: function() {
+            delete this.tbody;
+        },
+
+        test_handlers_connected: function() {
+            // Manually invoke the setup function to ensure the handlers are
+            // set.
+            module.setup();
+
+            var check_handler = function(field, expected) {
+                var custom_events = Y.Event.getListeners(field, 'click');
+                var click_event = custom_events[0];
+                var subscribers = click_event.subscribers;
+                Y.each(subscribers, function(sub) {
+                    Y.Assert.isTrue(sub.contains(expected),
+                                    'handler not set up');
+                });
+            };
+
+            check_handler(this.vcs_bzr, module.onclick_vcs);
+            check_handler(this.vcs_git, module.onclick_vcs);
+        },
+
+        test_select_vcs_bzr: function() {
+            this.vcs_bzr.checked = true;
+            module.onclick_vcs();
+            // The branch input field is enabled.
+            Y.Assert.isFalse(this.input_branch.disabled,
+                             'branch field disabled');
+            // The git_repository and git_path input fields are disabled.
+            Y.Assert.isTrue(this.input_git_repository.disabled,
+                            'git_repository field not disabled');
+            Y.Assert.isTrue(this.input_git_path.disabled,
+                            'git_path field not disabled');
+        },
+
+        test_select_vcs_git: function() {
+            this.vcs_git.checked = true;
+            module.onclick_vcs();
+            // The branch input field is disabled.
+            Y.Assert.isTrue(this.input_branch.disabled,
+                            'branch field not disabled');
+            // The git_repository and git_path input fields are enabled.
+            Y.Assert.isFalse(this.input_git_repository.disabled,
+                             'git_repository field disabled');
+            Y.Assert.isFalse(this.input_git_path.disabled,
+                             'git_path field disabled');
+        }
+    }));
+}, '0.1', {
+    requires: ['lp.testing.runner', 'test', 'test-console',
+               'Event', 'node-event-simulate',
+               'lp.snappy.snap.edit']
+});

=== added file 'lib/lp/snappy/templates/snap-delete.pt'
--- lib/lp/snappy/templates/snap-delete.pt	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/templates/snap-delete.pt	2015-09-04 16:25:33 +0000
@@ -0,0 +1,21 @@
+<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_only"
+  i18n:domain="launchpad">
+<body>
+
+  <div metal:fill-slot="main">
+    <tal:has-builds condition="view/has_builds">
+      This snap package cannot be deleted as builds have been created for it.
+    </tal:has-builds>
+
+    <tal:has-no-builds condition="not: view/has_builds">
+      <div metal:use-macro="context/@@launchpad_form/form"/>
+    </tal:has-no-builds>
+  </div>
+
+</body>
+</html>

=== added file 'lib/lp/snappy/templates/snap-edit.pt'
--- lib/lp/snappy/templates/snap-edit.pt	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/templates/snap-edit.pt	2015-09-04 16:25:33 +0000
@@ -0,0 +1,79 @@
+<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_only"
+  i18n:domain="launchpad">
+<body>
+
+<metal:block fill-slot="head_epilogue">
+  <style type="text/css">
+    .subordinate {
+      margin: 0.5em 0 0.5em 4em;
+    }
+  </style>
+</metal:block>
+
+<div metal:fill-slot="main">
+  <div metal:use-macro="context/@@launchpad_form/form">
+    <metal:formbody fill-slot="widgets">
+      <table class="form">
+        <tal:widget define="widget nocall:view/widgets/owner">
+          <metal:block use-macro="context/@@launchpad_form/widget_row" />
+        </tal:widget>
+        <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/distro_series">
+          <metal:block use-macro="context/@@launchpad_form/widget_row" />
+        </tal:widget>
+
+        <tr>
+          <td>
+            <div>
+              <label for="field.vcs">Source:</label>
+              <table>
+                <tr>
+                  <td>
+                    <label tal:replace="structure view/vcs_bzr" />
+                    <table class="subordinate">
+                      <tal:widget define="widget nocall:view/widgets/branch">
+                        <metal:block use-macro="context/@@launchpad_form/widget_row" />
+                      </tal:widget>
+                    </table>
+                  </td>
+                </tr>
+
+                <tr>
+                  <td>
+                    <label tal:replace="structure view/vcs_git" />
+                    <table class="subordinate">
+                      <tal:widget define="widget nocall:view/widgets/git_repository">
+                        <metal:block use-macro="context/@@launchpad_form/widget_row" />
+                      </tal:widget>
+                      <tal:widget define="widget nocall:view/widgets/git_path">
+                        <metal:block use-macro="context/@@launchpad_form/widget_row" />
+                      </tal:widget>
+                    </table>
+                  </td>
+                </tr>
+              </table>
+            </div>
+          </td>
+        </tr>
+      </table>
+    </metal:formbody>
+  </div>
+
+  <script type="text/javascript">
+    LPJS.use('lp.snappy.snap.edit', function(Y) {
+      Y.on('domready', function(e) {
+        Y.lp.snappy.snap.edit.setup();
+      }, window);
+    });
+  </script>
+</div>
+
+</body>
+</html>

=== modified file 'lib/lp/snappy/templates/snap-index.pt'
--- lib/lp/snappy/templates/snap-index.pt	2015-08-27 16:06:41 +0000
+++ lib/lp/snappy/templates/snap-index.pt	2015-09-04 16:25:33 +0000
@@ -22,7 +22,7 @@
   </metal:side>
 
   <metal:heading fill-slot="heading">
-    <h1 tal:content="context/name"/>
+    <h1 tal:replace="structure view/name_widget"/>
   </metal:heading>
 
   <div metal:fill-slot="main">
@@ -30,18 +30,22 @@
     <div class="two-column-list">
       <dl id="owner">
         <dt>Owner:</dt>
-        <dd tal:content="structure context/owner/fmt:link"/>
+        <dd tal:content="structure view/person_picker"/>
       </dl>
       <dl id="distro_series">
         <dt>Distribution series:</dt>
         <dd tal:define="distro_series context/distro_series">
           <a tal:attributes="href distro_series/fmt:url"
              tal:content="distro_series/fullseriesname"/>
+          <a tal:replace="structure view/menu:overview/edit/fmt:icon"/>
         </dd>
       </dl>
       <dl id="source" tal:define="source view/source" tal:condition="source">
         <dt>Source:</dt>
-        <dd tal:content="structure source/fmt:link"/>
+        <dd>
+          <a tal:replace="structure source/fmt:link"/>
+          <a tal:replace="structure view/menu:overview/edit/fmt:icon"/>
+        </dd>
       </dl>
     </div>
 

=== added file 'lib/lp/snappy/tests/test_yuitests.py'
--- lib/lp/snappy/tests/test_yuitests.py	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/tests/test_yuitests.py	2015-09-04 16:25:33 +0000
@@ -0,0 +1,24 @@
+# Copyright 2010-2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Run YUI.test tests."""
+
+__metaclass__ = type
+__all__ = []
+
+from lp.testing import (
+    build_yui_unittest_suite,
+    YUIUnitTestCase,
+    )
+from lp.testing.layers import YUITestLayer
+
+
+class SnappyYUIUnitTestCase(YUIUnitTestCase):
+
+    layer = YUITestLayer
+    suite_name = 'SnappyYUIUnitTests'
+
+
+def test_suite():
+    app_testing_path = 'lp/snappy'
+    return build_yui_unittest_suite(app_testing_path, SnappyYUIUnitTestCase)


Follow ups