Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/components/projects/new_component.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ See COPYRIGHT and LICENSE files for more details.
++#%>

<%=
component_wrapper(target: "_top") do
component_wrapper(target: "_top", data: identifier_suggestion_data) do
settings_primer_form_with(
model: project,
url: workspaces_path,
Expand All @@ -39,6 +39,7 @@ See COPYRIGHT and LICENSE files for more details.
render(
Primer::Forms::FormList.new(
Projects::Settings::NameForm.new(f),
Projects::Settings::EditableIdentifierForm.new(f),
Projects::Settings::DescriptionForm.new(f),
Projects::Settings::RelationsForm.new(f, invisible: params[:parent_id].present?),
Projects::Settings::TypeForm.new(f)
Expand Down
11 changes: 11 additions & 0 deletions app/components/projects/new_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ def step_3_display
{ display: :none } unless step == 3
end

def identifier_suggestion_data
suggestion_mode = Setting::WorkPackageIdentifier.alphanumeric? ? "semantic" : "legacy"

{
controller: "projects--identifier-suggestion",
"projects--identifier-suggestion-mode-value": suggestion_mode,
"projects--identifier-suggestion-url-value": projects_identifier_suggestion_path,
"projects--identifier-suggestion-set-name-first-value": I18n.t("js.projects.identifier_suggestion.set_name_first")
}
end

def workspaces_path
workspace_type = if Project.workspace_types.key?(project.workspace_type)
project.workspace_type
Expand Down
44 changes: 44 additions & 0 deletions app/controllers/projects/identifier_suggestions_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# frozen_string_literal: true

#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

module Projects
class IdentifierSuggestionsController < ApplicationController
before_action :require_login
no_authorization_required! :show
Comment thread
thykel marked this conversation as resolved.

def show
name = params[:name].to_s.strip
return render json: {}, status: :unprocessable_entity if name.blank?

identifier = Project.suggest_identifier(name)
render json: { identifier: }
end
end
end
2 changes: 1 addition & 1 deletion app/models/permitted_params.rb
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ def project

def new_project
params
.expect(project: %i[name description parent_id workspace_type] + [{ custom_comments: {} }])
.expect(project: %i[name description parent_id workspace_type identifier] + [{ custom_comments: {} }])
.merge(custom_field_values(:project))
end

Expand Down
10 changes: 9 additions & 1 deletion app/models/project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ class Project < ApplicationRecord
SEMANTIC_IDENTIFIER_MAX_LENGTH = 10

# reserved identifiers
RESERVED_IDENTIFIERS = %w[new menu queries filters identifier_update_dialog].freeze
RESERVED_IDENTIFIERS = %w[new menu queries filters identifier_update_dialog identifier_suggestion].freeze

enum :workspace_type, {
project: "project",
Expand Down Expand Up @@ -279,6 +279,14 @@ def copy_allowed?
User.current.allowed_in_project?(:copy_projects, self)
end

def self.suggest_identifier(name)
if Setting::WorkPackageIdentifier.alphanumeric?
WorkPackages::IdentifierAutofix::ProjectIdentifierSuggestionGenerator.suggest_identifier(name)
else
name.to_url.first(IDENTIFIER_MAX_LENGTH).presence || "project"
end
end
Comment thread
thykel marked this conversation as resolved.

def self.selectable_projects
Project.visible.select { |p| User.current.member_of? p }.sort_by(&:to_s)
end
Expand Down
6 changes: 3 additions & 3 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -837,7 +837,7 @@ en:
Sub-projects are not affected and have their own settings.
change_identifier: Change identifier
change_identifier_dialog_title: Change project identifier
change_identifier_format_hint_semantic: "Only uppercase letters (A–Z), numbers or underscores. Max 10 characters. The first character has to be a letter."
change_identifier_format_hint_semantic: "Only uppercase letters (A–Z), numbers or underscores. Max 10 characters. Must start with a letter."
change_identifier_format_hint_legacy: "Only lowercase letters (a–z), numbers, dashes or underscores."
change_identifier_warning: >
This will permanently change identifiers and URLs of all work packages in this project.
Expand Down Expand Up @@ -2023,8 +2023,8 @@ en:
types:
in_use_by_work_packages: "still in use by work packages: %{types}"
identifier:
must_start_with_letter: "The first character has to be a letter."
no_special_characters: "Special characters not allowed."
must_start_with_letter: "must start with a letter"
no_special_characters: "may only contain uppercase letters, numbers, and underscores"
enabled_modules:
dependency_missing: "The module '%{dependency}' needs to be enabled as well since the module '%{module}' depends on it."
format: "%{message}"
Expand Down
4 changes: 4 additions & 0 deletions config/locales/js-en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1298,3 +1298,7 @@ en:
waiting_subtitle:
network_off: "There is a network problem."
network_on: "Network is back. We are trying."
projects:
identifier_suggestion:
loading: "Loading suggestion..."
set_name_first: "Please set the name first."
2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,8 @@
resource :filters, only: %i[show]
end

get "projects/identifier_suggestion", to: "projects/identifier_suggestions#show", as: :projects_identifier_suggestion

%w[portfolio project program].each do |workspace_type|
resources workspace_type.pluralize,
only: %i[new create],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/*
* -- copyright
* OpenProject is an open source project management software.
* Copyright (C) the OpenProject GmbH
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License version 3.
*
* OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
* Copyright (C) 2006-2013 Jean-Philippe Lang
* Copyright (C) 2010-2013 the ChiliProject Team
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*
* See COPYRIGHT and LICENSE files for more details.
* ++
*/

import {Controller} from '@hotwired/stimulus';
import {debounce, DebouncedFunc} from 'lodash';
Comment thread
thykel marked this conversation as resolved.

const ALLOWED_CHARS:Record<string, RegExp> = {
semantic: /[^A-Z0-9_]/g,
legacy: /[^a-z0-9\-_]/g,
};

export default class extends Controller {
static values = {
url: String,
debounce: {type: Number, default: 300},
mode: {type: String, default: 'legacy'},
setNameFirst: {type: String, default: ''},
};

declare urlValue:string;
declare debounceValue:number;
declare modeValue:string;
declare setNameFirstValue:string;

private nameInput:HTMLInputElement | null = null;
private identifierInput:HTMLInputElement | null = null;
private debouncedSuggest:DebouncedFunc<(name:string) => Promise<void>> | null = null;
private handleBlur:((event:Event) => void) | null = null;
private handleInput:((event:Event) => void) | null = null;

connect():void {
this.nameInput = this.element.querySelector<HTMLInputElement>('[name="project[name]"]');
this.identifierInput = this.element.querySelector<HTMLInputElement>('[name="project[identifier]"]');
Comment thread
thykel marked this conversation as resolved.

if (!this.nameInput || !this.identifierInput) return;
Comment thread
thykel marked this conversation as resolved.

this.handleInput = () => this.filterInput();
this.identifierInput.addEventListener('input', this.handleInput);
Comment thread
thykel marked this conversation as resolved.

if (this.urlValue) {
if (!this.identifierInput.value) {
this.identifierInput.placeholder = this.setNameFirstValue;
this.identifierInput.readOnly = true;
}

this.debouncedSuggest = debounce(
(name:string) => this.fetchSuggestion(name),
this.debounceValue,
);

this.handleBlur = () => {
const name = this.nameInput!.value.trim();
if (name) void this.debouncedSuggest!(name);
};

this.nameInput.addEventListener('blur', this.handleBlur);
}
}

disconnect():void {
this.debouncedSuggest?.cancel();
if (this.nameInput && this.handleBlur) {
this.nameInput.removeEventListener('blur', this.handleBlur);
}
if (this.identifierInput && this.handleInput) {
this.identifierInput.removeEventListener('input', this.handleInput);
}
Comment thread
thykel marked this conversation as resolved.
}

private filterInput():void {
if (!this.identifierInput) return;
Comment thread
thykel marked this conversation as resolved.

const pattern = ALLOWED_CHARS[this.modeValue] ?? ALLOWED_CHARS.legacy;
const current = this.identifierInput.value;
const filtered = current.replace(pattern, '');

if (filtered !== current) {
const pos = this.identifierInput.selectionStart ?? filtered.length;
this.identifierInput.value = filtered;
const newPos = Math.min(pos, filtered.length);
this.identifierInput.setSelectionRange(newPos, newPos);
}
}

private async fetchSuggestion(name:string):Promise<void> {
if (!this.urlValue) return;

if (this.identifierInput) {
this.identifierInput.readOnly = true;
this.identifierInput.placeholder = I18n.t('js.projects.identifier_suggestion.loading');
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🍊 For loading state- you might want to consider: https://primer.style/product/components/text-input/#in-a-loading-state due to a11ly

}

try {
const url = `${this.urlValue}?name=${encodeURIComponent(name)}`;
const response = await fetch(url, {headers: {Accept: 'application/json'}});
Comment thread
thykel marked this conversation as resolved.

if (!response.ok) return;

const data = await response.json() as { identifier:string };
if (this.identifierInput) {
this.identifierInput.value = data.identifier;
}
} finally {
if (this.identifierInput) {
this.identifierInput.readOnly = false;
this.identifierInput.placeholder = '';
}
}
}
}
2 changes: 2 additions & 0 deletions frontend/src/stimulus/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import StemsController from './controllers/dynamic/work-packages/activities-tab/
import EditorController from './controllers/dynamic/work-packages/activities-tab/editor.controller';
import LazyPageController from './controllers/dynamic/work-packages/activities-tab/lazy-page.controller';
import EditablePageHeaderTitleController from './controllers/dynamic/editable-page-header-title.controller';
import IdentifierSuggestionController from './controllers/dynamic/projects/identifier-suggestion.controller';

import AutoSubmit from '@stimulus-components/auto-submit';
import RevealController from '@stimulus-components/reveal';
Expand Down Expand Up @@ -82,6 +83,7 @@ OpenProjectStimulusApplication.preregister('external-links', ExternalLinksContro
OpenProjectStimulusApplication.preregister('highlight-target-element', HighlightTargetElementController);
OpenProjectStimulusApplication.preregister('select-autosize', SelectAutosizeController);
OpenProjectStimulusApplication.preregister('editable-page-header-title', EditablePageHeaderTitleController);
OpenProjectStimulusApplication.preregister('projects--identifier-suggestion', IdentifierSuggestionController);
Comment thread
thykel marked this conversation as resolved.
OpenProjectStimulusApplication.preregister('check-all', CheckAllController);
OpenProjectStimulusApplication.preregister('checkable', CheckableController);
OpenProjectStimulusApplication.preregister('truncation', TruncationController);
Expand Down
31 changes: 31 additions & 0 deletions spec/components/projects/new_component_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,35 @@
expect(Projects::Settings::CustomFieldsForm).to have_received(:new)
end
end

describe "#identifier_suggestion_data" do
subject(:rendered) { render_inline(described_class.new(project:)) }

it "mounts the Stimulus controller on the wrapper" do
expect(rendered).to have_css("[data-controller='projects--identifier-suggestion']")
end

it "includes the suggestion URL" do
expect(rendered).to have_css("[data-projects--identifier-suggestion-url-value='/projects/identifier_suggestion']")
end

it "includes the set_name_first translation" do
translation = I18n.t("js.projects.identifier_suggestion.set_name_first")
expect(rendered).to have_css(
"[data-projects--identifier-suggestion-set-name-first-value='#{translation}']"
)
end

context "with alphanumeric identifiers", with_settings: { work_packages_identifier: "alphanumeric" } do
it "sets mode to semantic" do
expect(rendered).to have_css("[data-projects--identifier-suggestion-mode-value='semantic']")
end
end

context "with numeric identifiers", with_settings: { work_packages_identifier: "numeric" } do
it "sets mode to legacy" do
expect(rendered).to have_css("[data-projects--identifier-suggestion-mode-value='legacy']")
end
end
end
end
40 changes: 40 additions & 0 deletions spec/features/projects/create_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,46 @@
end
end

context "with alphanumeric identifiers", with_settings: { work_packages_identifier: "alphanumeric" } do
it "auto-suggests an identifier when the name field is blurred" do
projects_page.create_new_workspace
click_on "Continue"

fill_in "Name", with: "Flight Planning Algorithm"
find("body").click # blur the name field

expect(page).to have_field "Identifier", with: "FPA"
end

it "allows overriding the auto-suggested identifier" do
projects_page.create_new_workspace
click_on "Continue"

fill_in "Name", with: "Flight Planning Algorithm"
find("body").click
expect(page).to have_field "Identifier", with: "FPA"

fill_in "Identifier", with: "MYIDENT"
click_on "Complete"

expect_and_dismiss_flash type: :success, message: "Successful creation."
expect(page).to have_current_path %r{/projects/MYIDENT/?}
end

it "shows a validation error for identifiers not starting with a letter" do
projects_page.create_new_workspace
click_on "Continue"

fill_in "Name", with: "Flight Planning Algorithm"
find("body").click

fill_in "Identifier", with: "3INVALID"
click_on "Complete"

expect(page).to have_text "Identifier must start with a letter"
end
end

context "with workspace type badges in parent field", with_flag: { portfolio_models: true } do
include_context "ng-select-autocomplete helpers"

Expand Down
Loading
Loading