Commit 6a23428f authored by Robert Hunt's avatar Robert Hunt Committed by Mike Greiling

Use the slug as the project name if empty

Added on change events to the project path so only once the slug has
been created will we generate the project name (since we don't want
to change the project name if the slug changes again).

On the git URL importer the slug/project names are more dynamic
(based on the URL given).

Updated some old-style jQuery event references and updated the
humanize method to accept a separator.

Fixed gitlab importer so it actually copies between fields.
Updated to convert the slug to title case and added test cases.

Fixed slug edge-cases with emoji's in the project name.

Updated documentation.

Added changelog
parent d6d4ebed
...@@ -21,12 +21,17 @@ export const addDelimiter = text => ...@@ -21,12 +21,17 @@ export const addDelimiter = text =>
export const highCountTrim = count => (count > 99 ? '99+' : count); export const highCountTrim = count => (count > 99 ? '99+' : count);
/** /**
* Converts first char to uppercase and replaces undercores with spaces * Converts first char to uppercase and replaces the given separator with spaces
* @param {String} string * @param {String} string - The string to humanize
* @param {String} separator - The separator used to separate words (defaults to "_")
* @requires {String} * @requires {String}
* @returns {String}
*/ */
export const humanize = string => export const humanize = (string, separator = '_') => {
string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1); const replaceRegex = new RegExp(separator, 'g');
return string.charAt(0).toUpperCase() + string.replace(replaceRegex, ' ').slice(1);
};
/** /**
* Replaces underscores with dashes * Replaces underscores with dashes
...@@ -45,7 +50,11 @@ export const slugify = (str, separator = '-') => { ...@@ -45,7 +50,11 @@ export const slugify = (str, separator = '-') => {
const slug = str const slug = str
.trim() .trim()
.toLowerCase() .toLowerCase()
.replace(/[^a-zA-Z0-9_.-]+/g, separator); .replace(/[^a-zA-Z0-9_.-]+/g, separator)
// Remove any duplicate separators or separator prefixes/suffixes
.split(separator)
.filter(Boolean)
.join(separator);
return slug === separator ? '' : slug; return slug === separator ? '' : slug;
}; };
...@@ -159,6 +168,15 @@ export const convertToSentenceCase = string => { ...@@ -159,6 +168,15 @@ export const convertToSentenceCase = string => {
return splitWord.join(' '); return splitWord.join(' ');
}; };
/**
* Converts a sentence to title case
* e.g. Hello world => Hello World
*
* @param {String} string
* @returns {String}
*/
export const convertToTitleCase = string => string.replace(/\b[a-z]/g, s => s.toUpperCase());
/** /**
* Splits camelCase or PascalCase words * Splits camelCase or PascalCase words
* e.g. HelloWorld => Hello World * e.g. HelloWorld => Hello World
......
import $ from 'jquery'; import $ from 'jquery';
import { convertToTitleCase, humanize, slugify } from '../lib/utils/text_utility';
import { getParameterValues } from '../lib/utils/url_utility'; import { getParameterValues } from '../lib/utils/url_utility';
import projectNew from './project_new'; import projectNew from './project_new';
const prepareParameters = () => {
const name = getParameterValues('name')[0];
const path = getParameterValues('path')[0];
// If the name param exists but the path doesn't then generate it from the name
if (name && !path) {
return { name, path: slugify(name) };
}
// If the path param exists but the name doesn't then generate it from the path
if (path && !name) {
return { name: convertToTitleCase(humanize(path, '-')), path };
}
return { name, path };
};
export default () => { export default () => {
const pathParam = getParameterValues('path')[0]; let hasUserDefinedProjectName = false;
const nameParam = getParameterValues('name')[0];
const $projectPath = $('.js-path-name');
const $projectName = $('.js-project-name'); const $projectName = $('.js-project-name');
const $projectPath = $('.js-path-name');
// get the path url and append it in the input const { name, path } = prepareParameters();
$projectPath.val(pathParam);
// get the project name from the URL and set it as input value // get the project name from the URL and set it as input value
$projectName.val(nameParam); $projectName.val(name);
// get the path url and append it in the input
$projectPath.val(path);
// generate slug when project name changes // generate slug when project name changes
$projectName.keyup(() => projectNew.onProjectNameChange($projectName, $projectPath)); $projectName.on('keyup', () => {
projectNew.onProjectNameChange($projectName, $projectPath);
hasUserDefinedProjectName = $projectName.val().trim().length > 0;
});
// generate project name from the slug if one isn't set
$projectPath.on('keyup', () =>
projectNew.onProjectPathChange($projectName, $projectPath, hasUserDefinedProjectName),
);
}; };
import $ from 'jquery'; import $ from 'jquery';
import { addSelectOnFocusBehaviour } from '../lib/utils/common_utils'; import { addSelectOnFocusBehaviour } from '../lib/utils/common_utils';
import { slugify } from '../lib/utils/text_utility'; import { convertToTitleCase, humanize, slugify } from '../lib/utils/text_utility';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
let hasUserDefinedProjectPath = false; let hasUserDefinedProjectPath = false;
let hasUserDefinedProjectName = false;
const onProjectNameChange = ($projectNameInput, $projectPathInput) => {
const slug = slugify($projectNameInput.val());
$projectPathInput.val(slug);
};
const onProjectPathChange = ($projectNameInput, $projectPathInput, hasExistingProjectName) => {
const slug = $projectPathInput.val();
if (!hasExistingProjectName) {
$projectNameInput.val(convertToTitleCase(humanize(slug, '[-_]')));
}
};
const setProjectNamePathHandlers = ($projectNameInput, $projectPathInput) => {
$projectNameInput.off('keyup change').on('keyup change', () => {
onProjectNameChange($projectNameInput, $projectPathInput);
hasUserDefinedProjectName = $projectNameInput.val().trim().length > 0;
hasUserDefinedProjectPath = $projectPathInput.val().trim().length > 0;
});
$projectPathInput.off('keyup change').on('keyup change', () => {
onProjectPathChange($projectNameInput, $projectPathInput, hasUserDefinedProjectName);
hasUserDefinedProjectPath = $projectPathInput.val().trim().length > 0;
});
};
const deriveProjectPathFromUrl = $projectImportUrl => { const deriveProjectPathFromUrl = $projectImportUrl => {
const $currentProjectName = $projectImportUrl
.parents('.toggle-import-form')
.find('#project_name');
const $currentProjectPath = $projectImportUrl const $currentProjectPath = $projectImportUrl
.parents('.toggle-import-form') .parents('.toggle-import-form')
.find('#project_path'); .find('#project_path');
if (hasUserDefinedProjectPath) { if (hasUserDefinedProjectPath) {
return; return;
} }
...@@ -30,14 +61,10 @@ const deriveProjectPathFromUrl = $projectImportUrl => { ...@@ -30,14 +61,10 @@ const deriveProjectPathFromUrl = $projectImportUrl => {
const pathMatch = /\/([^/]+)$/.exec(importUrl); const pathMatch = /\/([^/]+)$/.exec(importUrl);
if (pathMatch) { if (pathMatch) {
$currentProjectPath.val(pathMatch[1]); $currentProjectPath.val(pathMatch[1]);
onProjectPathChange($currentProjectName, $currentProjectPath, false);
} }
}; };
const onProjectNameChange = ($projectNameInput, $projectPathInput) => {
const slug = slugify($projectNameInput.val());
$projectPathInput.val(slug);
};
const bindEvents = () => { const bindEvents = () => {
const $newProjectForm = $('#new_project'); const $newProjectForm = $('#new_project');
const $projectImportUrl = $('#project_import_url'); const $projectImportUrl = $('#project_import_url');
...@@ -202,10 +229,7 @@ const bindEvents = () => { ...@@ -202,10 +229,7 @@ const bindEvents = () => {
const $activeTabProjectName = $('.tab-pane.active #project_name'); const $activeTabProjectName = $('.tab-pane.active #project_name');
const $activeTabProjectPath = $('.tab-pane.active #project_path'); const $activeTabProjectPath = $('.tab-pane.active #project_path');
$activeTabProjectName.focus(); $activeTabProjectName.focus();
$activeTabProjectName.keyup(() => { setProjectNamePathHandlers($activeTabProjectName, $activeTabProjectPath);
onProjectNameChange($activeTabProjectName, $activeTabProjectPath);
hasUserDefinedProjectPath = $activeTabProjectPath.val().trim().length > 0;
});
} }
$useTemplateBtn.on('change', chooseTemplate); $useTemplateBtn.on('change', chooseTemplate);
...@@ -220,26 +244,24 @@ const bindEvents = () => { ...@@ -220,26 +244,24 @@ const bindEvents = () => {
$projectPath.val($projectPath.val().trim()); $projectPath.val($projectPath.val().trim());
}); });
$projectPath.on('keyup', () => {
hasUserDefinedProjectPath = $projectPath.val().trim().length > 0;
});
$projectImportUrl.keyup(() => deriveProjectPathFromUrl($projectImportUrl)); $projectImportUrl.keyup(() => deriveProjectPathFromUrl($projectImportUrl));
$('.js-import-git-toggle-button').on('click', () => { $('.js-import-git-toggle-button').on('click', () => {
const $projectMirror = $('#project_mirror'); const $projectMirror = $('#project_mirror');
$projectMirror.attr('disabled', !$projectMirror.attr('disabled')); $projectMirror.attr('disabled', !$projectMirror.attr('disabled'));
setProjectNamePathHandlers(
$('.tab-pane.active #project_name'),
$('.tab-pane.active #project_path'),
);
}); });
$projectName.on('keyup change', () => { setProjectNamePathHandlers($projectName, $projectPath);
onProjectNameChange($projectName, $projectPath);
hasUserDefinedProjectPath = $projectPath.val().trim().length > 0;
});
}; };
export default { export default {
bindEvents, bindEvents,
deriveProjectPathFromUrl, deriveProjectPathFromUrl,
onProjectNameChange, onProjectNameChange,
onProjectPathChange,
}; };
---
title: 'Resolve Create new project: Auto-populate project slug string to project name
if name is empty'
merge_request: 22627
author:
type: changed
...@@ -31,7 +31,14 @@ To create a new blank project on the **New project** page: ...@@ -31,7 +31,14 @@ To create a new blank project on the **New project** page:
1. On the **Blank project** tab, provide the following information: 1. On the **Blank project** tab, provide the following information:
- The name of your project in the **Project name** field. You can't use - The name of your project in the **Project name** field. You can't use
special characters, but you can use spaces, hyphens, underscores or even special characters, but you can use spaces, hyphens, underscores or even
emoji. emoji. When adding the name, the **Project slug** will auto populate.
The slug is what the GitLab instance will use as the URL path to the project.
If you want a different slug, input the project name first,
then change the slug after.
- The path to your project in the **Project slug** field. This is the URL
path for your project that the GitLab instance will use. If the
**Project name** is blank, it will auto populate when you fill in
the **Project slug**.
- The **Project description (optional)** field enables you to enter a - The **Project description (optional)** field enables you to enter a
description for your project's dashboard, which will help others description for your project's dashboard, which will help others
understand what your project is about. Though it's not required, it's a good understand what your project is about. Though it's not required, it's a good
......
...@@ -13,6 +13,7 @@ const bindEvents = () => { ...@@ -13,6 +13,7 @@ const bindEvents = () => {
const $projectFieldsFormInput = $('.project-fields-form input#project_use_custom_template'); const $projectFieldsFormInput = $('.project-fields-form input#project_use_custom_template');
const $subgroupWithTemplatesIdInput = $('.js-project-group-with-project-templates-id'); const $subgroupWithTemplatesIdInput = $('.js-project-group-with-project-templates-id');
const $namespaceSelect = $projectFieldsForm.find('.js-select-namespace'); const $namespaceSelect = $projectFieldsForm.find('.js-select-namespace');
let hasUserDefinedProjectName = false;
if ($newProjectForm.length !== 1 || $useCustomTemplateBtn.length === 0) { if ($newProjectForm.length !== 1 || $useCustomTemplateBtn.length === 0) {
return; return;
...@@ -86,8 +87,16 @@ const bindEvents = () => { ...@@ -86,8 +87,16 @@ const bindEvents = () => {
const $activeTabProjectName = $('.tab-pane.active #project_name'); const $activeTabProjectName = $('.tab-pane.active #project_name');
const $activeTabProjectPath = $('.tab-pane.active #project_path'); const $activeTabProjectPath = $('.tab-pane.active #project_path');
$activeTabProjectName.focus(); $activeTabProjectName.focus();
$activeTabProjectName.keyup(() => $activeTabProjectName.on('keyup', () => {
projectNew.onProjectNameChange($activeTabProjectName, $activeTabProjectPath), projectNew.onProjectNameChange($activeTabProjectName, $activeTabProjectPath);
hasUserDefinedProjectName = $activeTabProjectName.val().trim().length > 0;
});
$activeTabProjectPath.on('keyup', () =>
projectNew.onProjectPathChange(
$activeTabProjectName,
$activeTabProjectPath,
hasUserDefinedProjectName,
),
); );
$projectFieldsForm $projectFieldsForm
......
...@@ -40,15 +40,18 @@ describe 'Project' do ...@@ -40,15 +40,18 @@ describe 'Project' do
end end
it 'allows creation from custom project template', :js do it 'allows creation from custom project template', :js do
new_name = 'example_custom_project_template' new_path = 'example-custom-project-template'
new_name = 'Example Custom Project Template'
find('#create-from-template-tab').click find('#create-from-template-tab').click
find('.project-template .custom-instance-project-templates-tab').click find('.project-template .custom-instance-project-templates-tab').click
find("label[for='#{projects.first.name}']").click find("label[for='#{projects.first.name}']").click
page.within '.project-fields-form' do page.within '.project-fields-form' do
fill_in("project_path", with: new_name)
fill_in("project_name", with: new_name) fill_in("project_name", with: new_name)
# Have to reset it to '' so it overwrites rather than appends
fill_in('project_path', with: '')
fill_in("project_path", with: new_path)
Sidekiq::Testing.inline! do Sidekiq::Testing.inline! do
click_button "Create project" click_button "Create project"
...@@ -57,6 +60,52 @@ describe 'Project' do ...@@ -57,6 +60,52 @@ describe 'Project' do
expect(page).to have_content new_name expect(page).to have_content new_name
expect(Project.last.name).to eq new_name expect(Project.last.name).to eq new_name
expect(page).to have_current_path "/#{user.username}/#{new_path}"
expect(Project.last.path).to eq new_path
end
it 'allows creation from custom project template using only the name', :js do
new_path = 'example-custom-project-template'
new_name = 'Example Custom Project Template'
find('#create-from-template-tab').click
find('.project-template .custom-instance-project-templates-tab').click
find("label[for='#{projects.first.name}']").click
page.within '.project-fields-form' do
fill_in("project_name", with: new_name)
Sidekiq::Testing.inline! do
click_button "Create project"
end
end
expect(page).to have_content new_name
expect(Project.last.name).to eq new_name
expect(page).to have_current_path "/#{user.username}/#{new_path}"
expect(Project.last.path).to eq new_path
end
it 'allows creation from custom project template using only the path', :js do
new_path = 'example-custom-project-template'
new_name = 'Example Custom Project Template'
find('#create-from-template-tab').click
find('.project-template .custom-instance-project-templates-tab').click
find("label[for='#{projects.first.name}']").click
page.within '.project-fields-form' do
fill_in("project_path", with: new_path)
Sidekiq::Testing.inline! do
click_button "Create project"
end
end
expect(page).to have_content new_name
expect(Project.last.name).to eq new_name
expect(page).to have_current_path "/#{user.username}/#{new_path}"
expect(Project.last.path).to eq new_path
end end
it 'has a working pagination', :js do it 'has a working pagination', :js do
......
...@@ -27,6 +27,9 @@ describe('text_utility', () => { ...@@ -27,6 +27,9 @@ describe('text_utility', () => {
it('should remove underscores and uppercase the first letter', () => { it('should remove underscores and uppercase the first letter', () => {
expect(textUtils.humanize('foo_bar')).toEqual('Foo bar'); expect(textUtils.humanize('foo_bar')).toEqual('Foo bar');
}); });
it('should remove underscores and dashes and uppercase the first letter', () => {
expect(textUtils.humanize('foo_bar-foo', '[_-]')).toEqual('Foo bar foo');
});
}); });
describe('dasherize', () => { describe('dasherize', () => {
...@@ -52,14 +55,20 @@ describe('text_utility', () => { ...@@ -52,14 +55,20 @@ describe('text_utility', () => {
expect(textUtils.slugify(' a new project ')).toEqual('a-new-project'); expect(textUtils.slugify(' a new project ')).toEqual('a-new-project');
}); });
it('should only remove non-allowed special characters', () => { it('should only remove non-allowed special characters', () => {
expect(textUtils.slugify('test!_pro-ject~')).toEqual('test-_pro-ject-'); expect(textUtils.slugify('test!_pro-ject~')).toEqual('test-_pro-ject');
}); });
it('should squash multiple hypens', () => { it('should squash multiple hypens', () => {
expect(textUtils.slugify('test!!!!_pro-ject~')).toEqual('test-_pro-ject-'); expect(textUtils.slugify('test!!!!_pro-ject~')).toEqual('test-_pro-ject');
}); });
it('should return empty string if only non-allowed characters', () => { it('should return empty string if only non-allowed characters', () => {
expect(textUtils.slugify('здрасти')).toEqual(''); expect(textUtils.slugify('здрасти')).toEqual('');
}); });
it('should squash multiple separators', () => {
expect(textUtils.slugify('Test:-)')).toEqual('test');
});
it('should trim any separators from the beginning and end of the slug', () => {
expect(textUtils.slugify('-Test:-)-')).toEqual('test');
});
}); });
describe('stripHtml', () => { describe('stripHtml', () => {
...@@ -109,6 +118,12 @@ describe('text_utility', () => { ...@@ -109,6 +118,12 @@ describe('text_utility', () => {
}); });
}); });
describe('convertToTitleCase', () => {
it('converts sentence case to Sentence Case', () => {
expect(textUtils.convertToTitleCase('hello world')).toBe('Hello World');
});
});
describe('truncateSha', () => { describe('truncateSha', () => {
it('shortens SHAs to 8 characters', () => { it('shortens SHAs to 8 characters', () => {
expect(textUtils.truncateSha('verylongsha')).toBe('verylong'); expect(textUtils.truncateSha('verylongsha')).toBe('verylong');
......
...@@ -29,8 +29,8 @@ describe('Monitoring mutations', () => { ...@@ -29,8 +29,8 @@ describe('Monitoring mutations', () => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, payload); mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, payload);
const groups = getGroups(); const groups = getGroups();
expect(groups[0].key).toBe('response-metrics-nginx-ingress-vts--0'); expect(groups[0].key).toBe('response-metrics-nginx-ingress-vts-0');
expect(groups[1].key).toBe('system-metrics-kubernetes--1'); expect(groups[1].key).toBe('system-metrics-kubernetes-1');
}); });
it('normalizes values', () => { it('normalizes values', () => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, payload); mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, payload);
......
...@@ -169,7 +169,7 @@ describe('Release block', () => { ...@@ -169,7 +169,7 @@ describe('Release block', () => {
releaseClone.tag_name = 'a dangerous tag name <script>alert("hello")</script>'; releaseClone.tag_name = 'a dangerous tag name <script>alert("hello")</script>';
return factory(releaseClone).then(() => { return factory(releaseClone).then(() => {
expect(wrapper.attributes().id).toBe('a-dangerous-tag-name-script-alert-hello-script-'); expect(wrapper.attributes().id).toBe('a-dangerous-tag-name-script-alert-hello-script');
}); });
}); });
......
import projectImportGitlab from '~/projects/project_import_gitlab_project'; import projectImportGitlab from '~/projects/project_import_gitlab_project';
describe('Import Gitlab project', () => { describe('Import Gitlab project', () => {
let projectName; const pathName = 'my-project';
beforeEach(() => { const projectName = 'My Project';
projectName = 'project';
window.history.pushState({}, null, `?path=${projectName}`); const setTestFixtures = url => {
window.history.pushState({}, null, url);
setFixtures(` setFixtures(`
<input class="js-path-name" /> <input class="js-path-name" />
<input class="js-project-name" />
`); `);
projectImportGitlab(); projectImportGitlab();
};
beforeEach(() => {
setTestFixtures(`?name=${projectName}&path=${pathName}`);
}); });
afterEach(() => { afterEach(() => {
window.history.pushState({}, null, ''); window.history.pushState({}, null, '');
}); });
describe('path name', () => { describe('project name', () => {
it('should fill in the project name derived from the previously filled project name', () => { it('should fill in the project name derived from the previously filled project name', () => {
expect(document.querySelector('.js-path-name').value).toEqual(projectName); expect(document.querySelector('.js-project-name').value).toEqual(projectName);
});
describe('empty path name', () => {
it('derives the path name from the previously filled project name', () => {
const alternateProjectName = 'My Alt Project';
const alternatePathName = 'my-alt-project';
setTestFixtures(`?name=${alternateProjectName}`);
expect(document.querySelector('.js-path-name').value).toEqual(alternatePathName);
});
});
});
describe('path name', () => {
it('should fill in the path name derived from the previously filled path name', () => {
expect(document.querySelector('.js-path-name').value).toEqual(pathName);
});
describe('empty project name', () => {
it('derives the project name from the previously filled path name', () => {
const alternateProjectName = 'My Alt Project';
const alternatePathName = 'my-alt-project';
setTestFixtures(`?path=${alternatePathName}`);
expect(document.querySelector('.js-project-name').value).toEqual(alternateProjectName);
});
}); });
}); });
}); });
...@@ -172,4 +172,34 @@ describe('New Project', () => { ...@@ -172,4 +172,34 @@ describe('New Project', () => {
expect($projectPath.val()).toEqual('my-dash-delimited-awesome-project'); expect($projectPath.val()).toEqual('my-dash-delimited-awesome-project');
}); });
}); });
describe('derivesProjectNameFromSlug', () => {
const dummyProjectPath = 'my-awesome-project';
const dummyProjectName = 'Original Awesome Project';
beforeEach(() => {
projectNew.bindEvents();
$projectPath.val('').change();
});
it('converts slug to humanized project name', () => {
$projectPath.val(dummyProjectPath);
projectNew.onProjectPathChange($projectName, $projectPath);
expect($projectName.val()).toEqual('My Awesome Project');
});
it('does not convert slug to humanized project name if a project name already exists', () => {
$projectName.val(dummyProjectName);
$projectPath.val(dummyProjectPath);
projectNew.onProjectPathChange(
$projectName,
$projectPath,
$projectName.val().trim().length > 0,
);
expect($projectName.val()).toEqual(dummyProjectName);
});
});
}); });
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment