diff --git a/assets/images/ui/repo-settings.png b/assets/images/ui/repo-settings.png new file mode 100644 index 00000000..96fe3fa5 Binary files /dev/null and b/assets/images/ui/repo-settings.png differ diff --git a/assets/scripts/app/auth.coffee b/assets/scripts/app/auth.coffee index 40a47c79..46dda80f 100644 --- a/assets/scripts/app/auth.coffee +++ b/assets/scripts/app/auth.coffee @@ -71,7 +71,7 @@ try router.send('afterSignIn') catch e - throw e unless e =~ /There are no active handlers/ + throw e unless e =~ /There are no active handlers/ || e =~ /Can't trigger action "afterSignIn/ @refreshUserData(data.user) refreshUserData: (user) -> diff --git a/assets/scripts/app/components.coffee b/assets/scripts/app/components.coffee index da423edd..09a177fb 100644 --- a/assets/scripts/app/components.coffee +++ b/assets/scripts/app/components.coffee @@ -1,9 +1,19 @@ Travis.TravisSwitchComponent = Ember.Component.extend tagName: 'a' classNames: ['travis-switch'] - classNameBindings: ['active'] + classNameBindings: ['_active:active'] - activeBinding: 'target.active' + # TODO: how to handle overriding properties to + # avoid naming it _action? + _active: (-> + @get('target.active') || @get('active') + ).property('target.active', 'active') click: -> - @sendAction('action', @get('target')) + if target = @get('target') + @set('target.active', !@get('target.active')) + else + @set('active', !@get('active')) + # allow for bindings to propagate + Ember.run.next this, -> + @sendAction('action', target) diff --git a/assets/scripts/app/controllers.coffee b/assets/scripts/app/controllers.coffee index c073461a..3257b5d4 100644 --- a/assets/scripts/app/controllers.coffee +++ b/assets/scripts/app/controllers.coffee @@ -50,6 +50,22 @@ Travis.FirstSyncController = Em.Controller.extend Travis.IndexErrorController = Em.Controller.extend() +Travis.RepoSettingsTabController = Em.ObjectController.extend() +Travis.RepoSettingsController = Em.ObjectController.extend + needs: ['repoSettingsTab'] + tab: Ember.computed.alias('controllers.repoSettingsTab.model.tab') + settings: Ember.computed.alias('model.settings') + + save: -> + @set('saving', true) + self = this + @get('model').saveSettings(@get('settings')).then -> + self.set('saving', false) + Travis.flash(success: 'Settings were saved successfully') + , -> + self.set('saving', false) + Travis.flash(error: 'There was an error while saving settings. Please try again.') + require 'controllers/accounts' require 'controllers/build' require 'controllers/builds' diff --git a/assets/scripts/app/helpers/handlebars.coffee b/assets/scripts/app/helpers/handlebars.coffee index 8dfda033..1074da90 100644 --- a/assets/scripts/app/helpers/handlebars.coffee +++ b/assets/scripts/app/helpers/handlebars.coffee @@ -3,6 +3,293 @@ require 'ext/ember/bound_helper' safe = (string) -> new Handlebars.SafeString(string) +Travis.Tab = Ember.Object.extend + show: -> + @get('tabs').forEach( (t) -> t.hide() ) + @set('visible', true) + + hide: -> + @set('visible', false) + +Travis.TabsView = Ember.View.extend + tabBinding: 'controller.tab' + tabsBinding: 'controller.tabs' + + tabDidChange: (-> + @activateTab(@get('tab')) + ).observes('tab') + + tabsDidChange: (-> + tab = @get('tab') + if tab + @activateTab(tab) + else if @get('tabs.length') + @activateTab(@get('tabs.firstObject.id')) + ).observes('tabs.length', 'tabs') + + activateTab: (tabId) -> + tab = @get('tabs').findBy('id', tabId) + + return unless tab + + tab.show() unless tab.get('visible') + + # TODO: remove hardcoded link + layout: Ember.Handlebars.compile( + '' + + '{{yield}}') + +Travis.TabView = Ember.View.extend + attributeBindings: ['style'] + + style: (-> + if !@get('tab.visible') + 'display: none' + ).property('tab.visible') + +Ember.Handlebars.registerHelper('travis-tab', (id, name, options) -> + controller = this + controller.set('tabs', []) unless controller.get('tabs') + + tab = Travis.Tab.create(id: id, name: name, tabs: controller.get('tabs')) + + view = Travis.TabView.create( + controller: this + tab: tab + ) + + controller = this + Ember.run.schedule('afterRender', -> + if controller.get('tabs.length') == 0 + tab.show() + controller.get('tabs').pushObject(tab) + ) + + Ember.Handlebars.helpers.view.call(this, view, options) +) + + +Ember.Handlebars.registerHelper('travis-tabs', (options) -> + template = options.fn + delete options.fn + + @set('tabs', []) + + view = Travis.TabsView.create( + controller: this + template: template + ) + + Ember.Handlebars.helpers.view.call(this, view, options) +) + +Travis.SettingsMultiplierView = Ember.CollectionView.extend() + +createObjects = (path, offset) -> + segments = path.split('.') + if segments.length > offset + for i in [1..(segments.length - offset)] + path = segments.slice(0, i).join('.') + if Ember.isNone(Ember.get(this, path)) + Ember.set(this, path, {}) + + return segments + +Ember.Handlebars.registerHelper('settings-multiplier', (path, options) -> + template = options.fn + delete options.fn + + parentsPath = getSettingsPath(options.data.view) + if parentsPath && parentsPath != '' + path = parentsPath + '.' + path + + createObjects.call(this, path, 1) + + if Ember.isNone(@get(path)) + collection = [{}] + @set(path, collection) + + + itemViewClass = Ember.View.extend( + template: template, + controller: this, + tagName: 'li', + multiplier: true + ) + + view = Travis.SettingsMultiplierView.create( + contentBinding: 'controller.' + path + controller: this + tagName: 'ul' + itemViewClass: itemViewClass + fields: [] + settingsPath: path + ) + + view.addObserver('content.length', -> + if @get('content.length') == 0 + @get('content').pushObject({}) + ) + + Ember.Handlebars.helpers.view.call(this, view, options) +) + +Travis.FormSettingsView = Ember.View.extend Ember.TargetActionSupport, + target: Ember.computed.alias('controller') + actionContext: Ember.computed.alias('context'), + action: 'submit' + tagName: 'form' + submit: (event) -> + event.preventDefault() + @triggerAction() + + +Ember.Handlebars.registerHelper('settings-form', (path, options) -> + if arguments.length == 1 + options = path + path = 'settings' + + view = Travis.FormSettingsView.create( + template: options.fn + controller: this + settingsPath: path + ) + + delete options.fn + + Ember.Handlebars.helpers.view.call(this, view, options) +) + +Ember.Handlebars.helper('settings-select', (options) -> + view = options.data.view + optionValues = options.hash.options + delete options.hash.options + + originalPath = options.hash.value + + parentsPath = getSettingsPath(view) + #TODO: such checks should also check parents, not only current context view + if !view.get('multiplier') && parentsPath && parentsPath != '' + originalPath = parentsPath + '.' + originalPath + + fullPath = originalPath + + if view.get('multiplier') + fullPath = 'view.content.' + fullPath + + createObjects.call(this, fullPath, 1) + + # TODO: setting a value here does not work and we still need + # a valueBinding in the view, I'm not sure why + options.hash.value = fullPath + + selectView = Ember.Select.create( + content: [''].pushObjects(optionValues.split(',')) + controller: this + valueBinding: 'controller.' + fullPath + ) + + Ember.Handlebars.helpers.view.call(this, selectView, options) +) + + + +Ember.Handlebars.helper('settings-remove-link', (options) -> + view = Ember.View.extend( + tagName: 'a' + attributeBindings: ['href', 'style'] + href: '#' + style: (-> + # TODO: do not assume that we're direct child + if @get('parentView.parentView.content.length') == 1 + 'display: none' + ).property('parentView.parentView.content.length') + template: Ember.Handlebars.compile('remove') + controller: this + click: (event) -> + event.preventDefault() + + if content = @get('parentView.content') + @get('parentView.parentView.content').removeObject(content) + ).create() + + Ember.Handlebars.helpers.view.call(this, view, options) +) + +Ember.Handlebars.registerHelper('settings-add-link', (path, options) -> + parentsPath = getSettingsPath(options.data.view) + if parentsPath && parentsPath != '' + path = parentsPath + '.' + path + + view = Ember.View.create( + tagName: 'a' + attributeBindings: ['href'] + href: '#' + template: options.fn + controller: this + click: (event) -> + event.preventDefault() + + if collection = @get('controller.' + path) + collection.pushObject({}) + ) + + Ember.Handlebars.helpers.view.call(this, view, options) +) + +getSettingsPath = (view) -> + settingsPaths = [] + if settingsPath = view.get('settingsPath') + settingsPaths.pushObject settingsPath + + parent = view + while parent = parent.get('parentView') + if settingsPath = parent.get('settingsPath') + settingsPaths.pushObject settingsPath + + return settingsPaths.reverse().join('.') + +Ember.Handlebars.helper('settings-input', (options) -> + view = options.data.view + + if options.hash.type == 'checkbox' + originalPath = options.hash.checked + else + originalPath = options.hash.value + + parentsPath = getSettingsPath(view) + #TODO: such checks should also check parents, not only current context view + if !view.get('multiplier') && parentsPath && parentsPath != '' + originalPath = parentsPath + '.' + originalPath + + fullPath = originalPath + + if view.get('multiplier') + fullPath = 'view.content.' + fullPath + + if options.hash.type != 'password' + createObjects.call(this, fullPath, 1) + else + createObjects.call(view, fullPath, 2) + content = view.get('content') + fullPath += ".value" + if Ember.isNone(Ember.get(content, originalPath)) + Ember.set(content, originalPath, {}) + Ember.set(content, originalPath + ".type", 'password') + + if options.hash.type == 'checkbox' + options.hash.checked = fullPath + else + options.hash.value = fullPath + Ember.Handlebars.helpers.input.call(this, options) +) + Handlebars.registerHelper 'tipsy', (text, tip) -> safe '' + text + '' diff --git a/assets/scripts/app/models/hook.coffee b/assets/scripts/app/models/hook.coffee index 677708d1..4126292b 100644 --- a/assets/scripts/app/models/hook.coffee +++ b/assets/scripts/app/models/hook.coffee @@ -27,3 +27,13 @@ require 'travis/model' return if @get('isSaving') @set 'active', !@get('active') @save() + + repo: (-> + # I don't want to make an ajax request for each repository showed in profile, + # especially, because most of them does not have any builds anyway. That's why + # I add an info which we have here to the store - this will allow to display + # a link to the repo and if more info is needed, it will be requested when the + # link is used + Travis.loadOrMerge(Travis.Repo, @getProperties('id', 'slug', 'name', 'ownerName'), skipIfExists: true) + Travis.Repo.find(@get('id')) + ).property('id') diff --git a/assets/scripts/app/models/repo.coffee b/assets/scripts/app/models/repo.coffee index e1b835de..3f94fc93 100644 --- a/assets/scripts/app/models/repo.coffee +++ b/assets/scripts/app/models/repo.coffee @@ -105,6 +105,13 @@ require 'travis/model' regenerateKey: (options) -> Travis.ajax.ajax '/repos/' + @get('id') + '/key', 'post', options + fetchSettings: -> + Travis.ajax.ajax('/repos/' + @get('id') + '/settings', 'get', forceAuth: true).then (data) -> + data['settings'] + + saveSettings: (settings) -> + Travis.ajax.ajax('/repos/' + @get('id') + '/settings', 'patch', data: { settings: settings }) + @Travis.Repo.reopenClass recent: -> @find() diff --git a/assets/scripts/app/routes.coffee b/assets/scripts/app/routes.coffee index 523e5f96..f277b20d 100644 --- a/assets/scripts/app/routes.coffee +++ b/assets/scripts/app/routes.coffee @@ -71,6 +71,12 @@ Travis.ApplicationRoute = Travis.Route.extend afterSignOut: -> @afterSignOut() +Travis.Router.reopen + transitionTo: -> + this.container.lookup('controller:repo').set('lineNumber', null) + + @_super.apply this, arguments + Travis.Router.map -> @resource 'index', path: '/', -> @route 'current', path: '/' @@ -82,6 +88,11 @@ Travis.Router.map -> @resource 'pullRequests', path: '/pull_requests' @resource 'branches', path: '/branches' + # this can't be nested in repo, because we want a set of different + # templates rendered for settings (for example no "current", "builds", ... tabs) + @resource 'repo.settings', path: '/:owner/:name/settings', -> + @route 'tab', path: ':tab' + @route 'getting_started' @route 'first_sync' @route 'stats', path: '/stats' @@ -105,7 +116,7 @@ Travis.SetupLastBuild = Ember.Mixin.create repo = @controllerFor('repo').get('repo') if repo && repo.get('isLoaded') && !repo.get('lastBuildId') Ember.run.next => - @render('builds/not_found', outlet: 'pane', into: 'repo') + @render('builds/not_found', into: 'repo', outlet: 'pane') Travis.GettingStartedRoute = Travis.Route.extend setupController: -> @@ -138,7 +149,7 @@ Travis.FirstSyncRoute = Travis.Route.extend Travis.IndexCurrentRoute = Travis.Route.extend Travis.SetupLastBuild, renderTemplate: -> @render 'repo' - @render 'build', outlet: 'pane', into: 'repo' + @render 'build', into: 'repo', outlet: 'pane' setupController: -> @_super.apply this, arguments @@ -155,7 +166,7 @@ Travis.IndexCurrentRoute = Travis.Route.extend Travis.SetupLastBuild, Travis.AbstractBuildsRoute = Travis.Route.extend renderTemplate: -> - @render 'builds', outlet: 'pane', into: 'repo' + @render 'builds', into: 'repo', outlet: 'pane' setupController: -> @controllerFor('repo').activate(@get('contentType')) @@ -180,7 +191,7 @@ Travis.BranchesRoute = Travis.AbstractBuildsRoute.extend(contentType: 'branches' Travis.BuildRoute = Travis.Route.extend renderTemplate: -> - @render 'build', outlet: 'pane', into: 'repo' + @render 'build', into: 'repo', outlet: 'pane' serialize: (model, params) -> id = if model.get then model.get('id') else model @@ -201,7 +212,7 @@ Travis.BuildRoute = Travis.Route.extend Travis.JobRoute = Travis.Route.extend renderTemplate: -> - @render 'job', outlet: 'pane', into: 'repo' + @render 'job', into: 'repo', outlet: 'pane' serialize: (model, params) -> id = if model.get then model.get('id') else model @@ -228,7 +239,7 @@ Travis.RepoIndexRoute = Travis.Route.extend Travis.SetupLastBuild, @controllerFor('repo').activate('current') renderTemplate: -> - @render 'build', outlet: 'pane', into: 'repo' + @render 'build', into: 'repo', outlet: 'pane' Travis.RepoRoute = Travis.Route.extend renderTemplate: -> @@ -247,7 +258,6 @@ Travis.RepoRoute = Travis.Route.extend model: (params) -> slug = "#{params.owner}/#{params.name}" - Travis.Repo.fetchBySlug(slug) actions: @@ -306,7 +316,7 @@ Travis.ProfileRoute = Travis.Route.extend @render 'top', outlet: 'top' @render 'accounts', outlet: 'left' @render 'flash', outlet: 'flash' - @render 'profile' + @_super.apply(this, arguments) Travis.ProfileIndexRoute = Travis.Route.extend setupController: -> @@ -374,3 +384,25 @@ Travis.AuthRoute = Travis.Route.extend deactivate: -> @controllerFor('auth').set('redirected', false) + +Travis.RepoSettingsRoute = Travis.Route.extend + setupController: (controller, model) -> + # TODO: if repo is just a data hash with id and slug load it + # as incomplete record + model = Travis.Repo.find(model.id) if model && !model.get + @_super(controller, model) + + serialize: (repo) -> + slug = if repo.get then repo.get('slug') else repo.slug + [owner, name] = slug.split('/') + { owner: owner, name: name } + + model: (params) -> + slug = "#{params.owner}/#{params.name}" + Travis.Repo.fetchBySlug(slug) + + afterModel: (repo) -> + # I'm using afterModel to fetch settings, because model is not always called. + # If link-to already provides a model, it will be just set as a route context. + repo.fetchSettings().then (settings) -> + repo.set('settings', settings) diff --git a/assets/scripts/app/templates/components/travis-switch.hbs b/assets/scripts/app/templates/components/travis-switch.hbs index e7b20837..18617602 100644 --- a/assets/scripts/app/templates/components/travis-switch.hbs +++ b/assets/scripts/app/templates/components/travis-switch.hbs @@ -1,4 +1,4 @@ -{{#if active}} +{{#if _active}} ON {{else}} OFF diff --git a/assets/scripts/app/templates/profile/repo.hbs b/assets/scripts/app/templates/profile/repo.hbs new file mode 100644 index 00000000..43d6cf6c --- /dev/null +++ b/assets/scripts/app/templates/profile/repo.hbs @@ -0,0 +1,3 @@ +

Settings: {{slug}}

+ +{{outlet}} diff --git a/assets/scripts/app/templates/profile/tabs/hooks.hbs b/assets/scripts/app/templates/profile/tabs/hooks.hbs index a937db01..8be19160 100644 --- a/assets/scripts/app/templates/profile/tabs/hooks.hbs +++ b/assets/scripts/app/templates/profile/tabs/hooks.hbs @@ -23,7 +23,7 @@

{{hook.description}}

- + {{#link-to "repo.settings" hook.repo class="repo-settings-icon tool-tip" title="Repository settings"}}{{/link-to}} {{travis-switch action="toggle" target=hook}}
diff --git a/assets/scripts/app/templates/repo/settings.hbs b/assets/scripts/app/templates/repo/settings.hbs new file mode 100644 index 00000000..1e515d4d --- /dev/null +++ b/assets/scripts/app/templates/repo/settings.hbs @@ -0,0 +1,24 @@ +
+

Settings: {{#linkTo "repo" this}}{{slug}}{{/linkTo}}

+ + {{#travis-tabs}} + {{#travis-tab "general" "General Settings"}} + {{#settings-form}} +

+ Build only commits with .travis.yml file + {{travis-switch action="save" active=settings.builds_only_with_travis_yml}} +

+ +

+ Build pushes + {{travis-switch action="save" active=settings.build_pushes}} +

+ +

+ Build pull requests + {{travis-switch action="save" active=settings.build_pull_requests}} +

+ {{/settings-form}} + {{/travis-tab}} + {{/travis-tabs}} +
diff --git a/assets/scripts/app/templates/repos/show.hbs b/assets/scripts/app/templates/repos/show.hbs index c1bf53b9..9b80786a 100644 --- a/assets/scripts/app/templates/repos/show.hbs +++ b/assets/scripts/app/templates/repos/show.hbs @@ -24,4 +24,3 @@ {{/if}} {{/if}} - diff --git a/assets/scripts/app/templates/repos/show/tools.hbs b/assets/scripts/app/templates/repos/show/tools.hbs index 5fc4135e..65f865b7 100644 --- a/assets/scripts/app/templates/repos/show/tools.hbs +++ b/assets/scripts/app/templates/repos/show/tools.hbs @@ -11,6 +11,12 @@ {{/if}} + {{#if view.displaySettingsLink}} +
  • + {{#linkTo "repo.settings" view.repo}}Settings{{/linkTo}} +
  • + {{/if}} + diff --git a/assets/scripts/app/views.coffee b/assets/scripts/app/views.coffee index c03a0b65..4fa13d04 100644 --- a/assets/scripts/app/views.coffee +++ b/assets/scripts/app/views.coffee @@ -45,6 +45,7 @@ Travis.FirstSyncView = Travis.View.extend ) , Travis.config.syncingPageRedirectionTime + require 'views/accounts' require 'views/annotation' require 'views/application' diff --git a/assets/scripts/app/views/repo/show.coffee b/assets/scripts/app/views/repo/show.coffee index 0adaf3d9..bab9fd3b 100644 --- a/assets/scripts/app/views/repo/show.coffee +++ b/assets/scripts/app/views/repo/show.coffee @@ -115,6 +115,11 @@ Travis.reopen permissions.contains parseInt(@get('repo.id')) ).property('currentUser.permissions.length', 'repo.id') + hasPushPermission: (-> + if permissions = @get('currentUser.pushPermissions') + permissions.contains parseInt(@get('repo.id')) + ).property('currentUser.pushPermissions.length', 'repo.id') + hasAdminPermission: (-> if permissions = @get('currentUser.adminPermissions') permissions.contains parseInt(@get('repo.id')) @@ -124,6 +129,14 @@ Travis.reopen Travis.Urls.statusImage(@get('slug')) ).property('slug') + displaySettingsLink: (-> + @get('hasPushPermission') + ).property('hasPushPermission') + + displayStatusImages: (-> + @get('hasPermission') + ).property('hasPermission') + statusImages: -> @popupCloseAll() view = Travis.StatusImagesView.create(toolsView: this) diff --git a/assets/scripts/lib/travis/ajax.coffee b/assets/scripts/lib/travis/ajax.coffee index 00c32f01..fcd82eed 100644 --- a/assets/scripts/lib/travis/ajax.coffee +++ b/assets/scripts/lib/travis/ajax.coffee @@ -23,13 +23,14 @@ Travis.ajax = Em.Object.create !result ajax: (url, method, options) -> + method = method || "GET" method = method.toUpperCase() endpoint = Travis.config.api_endpoint || '' options = options || {} token = Travis.sessionStorage.getItem('travis.token') - if token && Travis.ajax.needsAuth(method, url) + if token && (Travis.ajax.needsAuth(method, url) || options.forceAuth) options.headers ||= {} options.headers['Authorization'] ||= "token #{token}" diff --git a/assets/scripts/spec/unit/settings_helpers_spec.coffee b/assets/scripts/spec/unit/settings_helpers_spec.coffee new file mode 100644 index 00000000..bea181a1 --- /dev/null +++ b/assets/scripts/spec/unit/settings_helpers_spec.coffee @@ -0,0 +1,28 @@ +view = null + +module "Handlebars helpers - settings-input", + setup: -> + Ember.run -> Travis.advanceReadiness() + +test "settings input allows to bind to nested objects", -> + controller = Ember.Object.create() + view = Ember.View.create( + controller: controller + template: Ember.Handlebars.compile("{{settings-input value=foo.bar.baz}}") + ) + + Ember.run -> + view.appendTo($("#ember-testing")[0]) + + + input = view.$('input') + + Ember.run -> + input.val('a value').change() + + equal(controller.get('foo.bar.baz'), 'a value') + + Ember.run -> + controller.set('foo.bar.baz', 'a new value') + + equal(input.val(), 'a new value') diff --git a/assets/styles/app/loading.sass b/assets/styles/app/loading.sass index 8278162e..2f2a2584 100644 --- a/assets/styles/app/loading.sass +++ b/assets/styles/app/loading.sass @@ -11,7 +11,7 @@ .loading display: none -span.loading +span.loading, span.saving padding: 0 25px 0 0 font-size: $font-size-small color: $color-text-lighter diff --git a/assets/styles/components/travis-switch.sass b/assets/styles/components/travis-switch.sass index 342aaac6..5b7ea12e 100644 --- a/assets/styles/components/travis-switch.sass +++ b/assets/styles/components/travis-switch.sass @@ -1,6 +1,10 @@ +.settings-row + margin-top: 20px + .travis-switch position: relative - display: block + display: inline-block + float: left width: 60px height: 18px margin: 0 10px 0 15px diff --git a/assets/styles/profile/hooks.sass b/assets/styles/profile/hooks.sass index cc639438..2411c8ee 100644 --- a/assets/styles/profile/hooks.sass +++ b/assets/styles/profile/hooks.sass @@ -57,14 +57,14 @@ float: left display: block - .github-admin + .repo-settings-icon // Overriding an earlier definition above, which is probably // obsolete. TODO: Remove if so. position: relative height: 20px width: 20px padding-right: 0 - background: inline-image('ui/github-admin.png') no-repeat 3px 4px + background: inline-image('ui/repo-settings.png') no-repeat 3px 4px &:hover > a