Skip to content
This repository has been archived by the owner on Apr 17, 2023. It is now read-only.

Commit

Permalink
js: added inline validation for namespace's new form
Browse files Browse the repository at this point in the history
The validation so far was fully done on the server side and
the feedback was through the float alert that disappears in 5 seconds.
Not the best experience for the users.

Now, for each field (name and team), we have an inline feedback validation.
User will only see a validation message on the float alert in unexpected
situations.

Fixes #1376
  • Loading branch information
vitoravelino committed Aug 22, 2017
1 parent 82be2ae commit 9c223b7
Show file tree
Hide file tree
Showing 10 changed files with 235 additions and 46 deletions.
93 changes: 90 additions & 3 deletions app/assets/javascripts/modules/namespaces/components/new-form.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import Vue from 'vue';

import EventBus from '~/utils/eventbus';
import { required } from 'vuelidate/lib/validators';

import { setTypeahead } from '~/utils/typeahead';

import EventBus from '~/utils/eventbus';
import Alert from '~/shared/components/alert';
import FormMixin from '~/shared/mixins/form';

Expand All @@ -24,9 +25,14 @@ export default {
return {
namespace: {
namespace: {
name: '',
team: this.teamName || '',
},
},
timeout: {
name: null,
team: null,
},
};
},

Expand All @@ -37,6 +43,7 @@ export default {
const name = namespace.attributes.clean_name;

this.toggleForm();
this.$v.$reset();
set(this.namespace, 'namespace', {});

Alert.show(`Namespace '${name}' was created successfully`);
Expand All @@ -53,12 +60,92 @@ export default {
},
},

validations: {
namespace: {
namespace: {
name: {
required,
format(value) {
// extracted from models/namespace.rb
const regexp = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;

// required already taking care of this
if (value === '') {
return true;
}

return regexp.test(value);
},
available(value) {
clearTimeout(this.timeout.name);

// required already taking care of this
if (value === '') {
return true;
}

return new Promise((resolve) => {
const searchName = () => {
const promise = NamespacesService.existsByName(value);

promise.then((exists) => {
// leave it for the back-end
if (exists === null) {
resolve(true);
}

// if exists, invalid
resolve(!exists);
});
};

this.timeout.name = setTimeout(searchName, 1000);
});
},
},
team: {
required,
available(value) {
clearTimeout(this.timeout.team);

// required already taking care of this
if (value === '') {
return true;
}

return new Promise((resolve) => {
const searchTeam = () => {
const promise = NamespacesService.teamExists(value);

promise.then((exists) => {
// leave it for the back-end
if (exists === null) {
resolve(true);
}

// if exists, valid
resolve(exists);
});
};

this.timeout.team = setTimeout(searchTeam, 1000);
});
},
},
},
},
},

mounted() {
const $team = setTypeahead(TYPEAHEAD_INPUT, '/namespaces/typeahead/%QUERY');

// workaround because of typeahead
$team.on('change', () => {
const updateTeam = () => {
set(this.namespace.namespace, 'team', $team.val());
});
};

$team.on('typeahead:selected', updateTeam);
$team.on('typeahead:autocompleted', updateTeam);
$team.on('change', updateTeam);
},
};
43 changes: 43 additions & 0 deletions app/assets/javascripts/modules/namespaces/services/namespaces.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ const customActions = {
method: 'PUT',
url: '/namespaces/{id}/change_visibility',
},
teamTypeahead: {
method: 'GET',
url: '/namespaces/typeahead/{teamName}',
},
existsByName: {
method: 'HEAD',
url: '/namespaces',
},
};

const resource = Vue.resource('/namespaces{/id}.json', {}, customActions);
Expand All @@ -20,6 +28,22 @@ function changeVisibility(id, params = {}) {
return resource.changeVisibility({ id }, params);
}

function searchTeam(teamName) {
return resource.teamTypeahead({ teamName });
}

function existsByName(name) {
return resource.existsByName({ name })
.then(() => true)
.catch((response) => {
if (response.status === 404) {
return false;
}

return null;
});
}

function get(id) {
return resource.get({ id });
}
Expand All @@ -28,9 +52,28 @@ function save(namespace) {
return resource.save({}, namespace);
}

function teamExists(value) {
return searchTeam(value)
.then((response) => {
const collection = response.data;

if (Array.isArray(collection)) {
return collection.some(e => e.name === value);
}

// some unexpected response from the api,
// leave it for the back-end validation
return null;
})
.catch(() => null);
}

export default {
get,
all,
save,
changeVisibility,
searchTeam,
teamExists,
existsByName,
};
2 changes: 2 additions & 0 deletions app/assets/javascripts/vue-shared.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import Vue from 'vue';
import VueResource from 'vue-resource';
import Vuelidate from 'vuelidate';

Vue.use(Vuelidate);
Vue.use(VueResource);

Vue.http.interceptors.push((_request, next) => {
Expand Down
15 changes: 15 additions & 0 deletions app/assets/stylesheets/includes/forms.scss
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,18 @@ textarea.fixed-size {
.twitter-typeahead, .tt-dropdown-menu {
width: 100%;
}

// help block
.form-group {
.help-block {
display: none;
margin-bottom: 0;
}
}
.has-success,
.has-warning,
.has-error {
.help-block {
display: block;
}
}
45 changes: 31 additions & 14 deletions app/controllers/namespaces_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,24 @@ class NamespacesController < ApplicationController
# GET /namespaces
# GET /namespaces.json
def index
respond_to do |format|
format.html { skip_policy_scope }
format.json do
@special_namespaces = Namespace.where(
"global = ? OR namespaces.name = ?", true, current_user.username
).order("created_at ASC")
@namespaces = policy_scope(Namespace).order(created_at: :asc)

accessible_json = serialize_as_json(@namespaces)
special_json = serialize_as_json(@special_namespaces)
render json: {
accessible: accessible_json,
special: special_json
}
if request.head?
check_namespace_by_name if params[:name]
else
respond_to do |format|
format.html { skip_policy_scope }
format.json do
@special_namespaces = Namespace.where(
"global = ? OR namespaces.name = ?", true, current_user.username
).order("created_at ASC")
@namespaces = policy_scope(Namespace).order(created_at: :asc)

accessible_json = serialize_as_json(@namespaces)
special_json = serialize_as_json(@special_namespaces)
render json: {
accessible: accessible_json,
special: special_json
}
end
end
end
end
Expand Down Expand Up @@ -115,6 +119,19 @@ def change_visibility

private

# Checks if namespaces exists based on the name parameter.
# Renders an empty response with 200 if exists or 404 otherwise.
def check_namespace_by_name
skip_policy_scope
namespace = Namespace.find_by(name: params[:name])

if namespace
head :ok
else
head :not_found
end
end

# Fetch the namespace to be created from the given parameters. Note that this
# method assumes that the @team instance object has already been set.
def fetch_namespace
Expand Down
2 changes: 1 addition & 1 deletion app/views/layouts/application.html.slim
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ html
meta content="/favicon/browserconfig.xml" name="msapplication-config"
meta content="#205683" name="theme-color"

script src="//cdn.polyfill.io/v2/polyfill.js?features=Array.prototype.findIndex,Array.from"
script src="//cdn.polyfill.io/v2/polyfill.js?features=Array.prototype.some,Array.prototype.findIndex,Array.from"
= javascript_include_tag(*webpack_asset_paths("application"))
= yield :js_header

Expand Down
23 changes: 18 additions & 5 deletions app/views/namespaces/components/_form.html.slim
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,30 @@ div ref="form" class="collapse"
= form_for :namespace, url: namespaces_path, html: {id: "new-namespace-form", class: "form-horizontal", role: "form", name: "form", "@submit.prevent" => "onSubmit", novalidate: true} do |f|
= f.hidden_field(:team, "v-model" => "namespace.namespace.team", "v-if" => "teamName")

.form-group
.form-group :class=="{ 'has-error': $v.namespace.namespace.name.$error }"
= f.label :name, {class: "control-label col-md-2"}
.col-md-7
= f.text_field(:name, class: "form-control", required: true, placeholder: "New namespace's name".html_safe, ref: "firstField", "v-model" => "namespace.namespace.name")
= f.text_field(:name, class: "form-control", placeholder: "New namespace's name".html_safe, ref: "firstField", "@input" => "$v.namespace.namespace.name.$touch()", "v-model.trim" => "namespace.namespace.name")
span.help-block
span v-if="!$v.namespace.namespace.name.required"
| Name can't be blank
span v-if="!$v.namespace.namespace.name.format"
| Name can only contain lower case alphanumeric characters, with optional underscores and dashes in the middle
span v-if="!$v.namespace.namespace.name.available"
| Name has already been taken

.form-group.has-feedback v-if="!teamName"
.form-group.has-feedback :class=="{ 'has-error': $v.namespace.namespace.team.$error }" v-if="!teamName"
= f.label :team, {class: "control-label col-md-2"}
.col-md-7
.remote
= f.text_field(:team, class: "form-control typeahead", required: true, placeholder: "Name of the team", "v-model" => "namespace.namespace.team")
= f.text_field(:team, class: "form-control typeahead", required: true, placeholder: "Name of the team", "@input" => "$v.namespace.namespace.team.$touch()", "v-model.trim" => "namespace.namespace.team")
span.fa.fa-search.form-control-feedback
span.help-block
span v-if="!$v.namespace.namespace.team.required"
| Team can't be blank
br
span v-if="!$v.namespace.namespace.team.available"
| Selected team does not exist

.form-group
= f.label :description, {class: "control-label col-md-2"}
Expand All @@ -21,4 +34,4 @@ div ref="form" class="collapse"

.form-group
.col-md-offset-2.col-md-7
= f.submit("Create", class: "btn btn-primary")
= f.submit("Create", class: "btn btn-primary", ":disabled" => "$v.$invalid")
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"vue-loader": "^12.0.3",
"vue-resource": "^1.3.1",
"vue-template-compiler": "^2.3.3",
"vuelidate": "^0.5.0",
"webpack": "^2.2.1"
},
"babel": {
Expand Down
Loading

0 comments on commit 9c223b7

Please sign in to comment.