diff --git a/app/assets/images/templates/example.svg b/app/assets/images/templates/example.svg
new file mode 100644
index 000000000000..617121d8c130
--- /dev/null
+++ b/app/assets/images/templates/example.svg
@@ -0,0 +1,151 @@
+
diff --git a/app/assets/images/templates/pmflexone.svg b/app/assets/images/templates/pmflexone.svg
new file mode 100644
index 000000000000..288d54127b24
--- /dev/null
+++ b/app/assets/images/templates/pmflexone.svg
@@ -0,0 +1,4 @@
+
diff --git a/app/components/projects/settings/general/show_component.html.erb b/app/components/projects/settings/general/show_component.html.erb
index ce1c10009958..d38e3854fb98 100644
--- a/app/components/projects/settings/general/show_component.html.erb
+++ b/app/components/projects/settings/general/show_component.html.erb
@@ -84,5 +84,24 @@ See COPYRIGHT and LICENSE files for more details.
)
end
end
+
+ container.with_row(mb: 3) do
+ settings_primer_form_with(model: project, url: project_settings_general_path(project)) do |f|
+ concat(
+ render(Primer::Beta::Subhead.new) do |component|
+ component.with_heading(tag: :h3, size: :medium) { I18n.t("projects.settings.header_icon") }
+ end
+ )
+
+ concat(
+ render(
+ Primer::Forms::FormList.new(
+ Projects::Settings::IconForm.new(f),
+ Projects::Settings::Submit.new(f, label: I18n.t("projects.settings.button_update_icon"))
+ )
+ )
+ )
+ end
+ end
end
%>
diff --git a/app/contracts/projects/base_contract.rb b/app/contracts/projects/base_contract.rb
index f36431cf7738..505beeeb602e 100644
--- a/app/contracts/projects/base_contract.rb
+++ b/app/contracts/projects/base_contract.rb
@@ -52,6 +52,9 @@ class BaseContract < ::ModelContract
attribute :templated do
validate_templated_set_by_admin
end
+ attribute :icon do
+ validate_templated_present
+ end
validate :validate_user_allowed_to_manage
@@ -115,6 +118,12 @@ def validate_status_code_included
errors.add :status, :inclusion if model.status_code && Project.status_codes.keys.exclude?(model.status_code.to_s)
end
+ def validate_templated_present
+ if model.templated.nil?
+ errors.add(:templated, :blank)
+ end
+ end
+
def validate_templated_set_by_admin
if model.templated_changed? && !user.admin?
errors.add :templated, :error_unauthorized
diff --git a/app/forms/projects/settings/icon_form.rb b/app/forms/projects/settings/icon_form.rb
new file mode 100644
index 000000000000..3515d7e5ed9f
--- /dev/null
+++ b/app/forms/projects/settings/icon_form.rb
@@ -0,0 +1,38 @@
+# 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
+ module Settings
+ class IconForm < ApplicationForm
+ form do |f|
+ f.file_field name: :icon, label: attribute_name(:icon), accept: "foo", size: "15"
+ end
+ end
+ end
+end
diff --git a/app/forms/projects/template_select_form.rb b/app/forms/projects/template_select_form.rb
index dd02d6d96649..3eba34d88f02 100644
--- a/app/forms/projects/template_select_form.rb
+++ b/app/forms/projects/template_select_form.rb
@@ -66,7 +66,7 @@ class TemplateSelectForm < ApplicationForm
value: template.id,
label: template.name,
caption: format_caption(template.description),
- icon: agile_image, # TODO: support customizable icons (OP #69068)
+ icon: template.icon,
checked: template.id == template_id
)
end
diff --git a/app/models/concerns/has_icon.rb b/app/models/concerns/has_icon.rb
new file mode 100644
index 000000000000..b1f0671f2396
--- /dev/null
+++ b/app/models/concerns/has_icon.rb
@@ -0,0 +1,37 @@
+# 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 HasIcon
+ extend ActiveSupport::Concern
+
+ included do
+ has_one :icon, as: :iconifiable, dependent: :destroy
+ end
+end
diff --git a/app/models/custom_icon.rb b/app/models/custom_icon.rb
new file mode 100644
index 000000000000..ad9c4815eb1b
--- /dev/null
+++ b/app/models/custom_icon.rb
@@ -0,0 +1,31 @@
+# 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.
+#++
+class CustomIcon < Icon
+end
diff --git a/app/models/icon.rb b/app/models/icon.rb
new file mode 100644
index 000000000000..d9710370aab4
--- /dev/null
+++ b/app/models/icon.rb
@@ -0,0 +1,4 @@
+# frozen_string_literal: true
+
+class Icon < ApplicationRecord
+end
diff --git a/app/models/octicon_icon.rb b/app/models/octicon_icon.rb
new file mode 100644
index 000000000000..959be74541f8
--- /dev/null
+++ b/app/models/octicon_icon.rb
@@ -0,0 +1,31 @@
+# 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.
+#++
+class OcticonIcon < Icon
+end
diff --git a/app/models/permitted_params.rb b/app/models/permitted_params.rb
index fd7f517eab8c..20f11a740899 100644
--- a/app/models/permitted_params.rb
+++ b/app/models/permitted_params.rb
@@ -292,6 +292,7 @@ def project
:templated,
:status_code,
:status_explanation,
+ :icon,
work_package_custom_field_ids: [],
type_ids: [],
enabled_module_names: [])
diff --git a/app/models/project.rb b/app/models/project.rb
index c68893d6a250..26a99843dd7a 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -43,6 +43,8 @@ class Project < ApplicationRecord
include ::Scopes::Scoped
+ include HasIcon
+
# Maximum length for project identifiers
IDENTIFIER_MAX_LENGTH = 100
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 4a9cfdd5ba19..6345fdd8575f 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -506,9 +506,11 @@ en:
header_details: Basic details
header_status: Project status
header_relations: Project relations
+ header_icon: Template icon
button_update_details: Update details
button_update_status_description: Update status description
button_update_parent_project: Update parent project
+ button_update_icon: Update icon
public_warning: >
This project is public.
Anyone who has access to this instance will be able to view and interact with this project depending on their role and associated permissions.
@@ -1995,6 +1997,7 @@ en:
group: "Group"
groups: "Groups"
hexcode: "Hex code"
+ icon: "Icon"
id: "ID"
is_default: "Default value"
is_for_all: "For all projects"
diff --git a/db/migrate/20251111183800_create_icons.rb b/db/migrate/20251111183800_create_icons.rb
new file mode 100644
index 000000000000..68e8ad56c4e0
--- /dev/null
+++ b/db/migrate/20251111183800_create_icons.rb
@@ -0,0 +1,46 @@
+# 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.
+#++
+class CreateIcons < ActiveRecord::Migration[8.0]
+ def change
+ create_table :icons do |t|
+ t.string :type, null: false
+
+ t.string :octicon_name
+ t.string :custom_file
+
+ t.references :iconifiable, polymorphic: true, null: false
+ t.timestamps
+ end
+
+ add_check_constraint :icons,
+ "(octicon_name IS NOT NULL) <> (custom_file IS NOT NULL)",
+ name: "only_one_icon_source"
+ end
+end
diff --git a/frontend/src/custom-elements/index.ts b/frontend/src/custom-elements/index.ts
new file mode 100644
index 000000000000..94d296775131
--- /dev/null
+++ b/frontend/src/custom-elements/index.ts
@@ -0,0 +1 @@
+import './primer-file-field';
diff --git a/frontend/src/custom-elements/primer-file-field.ts b/frontend/src/custom-elements/primer-file-field.ts
new file mode 100644
index 000000000000..822c969e0476
--- /dev/null
+++ b/frontend/src/custom-elements/primer-file-field.ts
@@ -0,0 +1,40 @@
+import { controller, target } from '@github/catalyst';
+
+@controller
+class PrimerFileFieldElement extends HTMLElement {
+ @target inputElement:HTMLInputElement;
+
+ #abortController:AbortController | null;
+
+ @target button:HTMLElement;
+ @target label:HTMLElement;
+
+ connectedCallback() {
+ this.#abortController?.abort();
+ const {signal} = (this.#abortController = new AbortController());
+
+ this.inputElement.addEventListener('change', () => {
+ const files = this.inputElement.files!;
+ if (files.length === 0) {
+ this.label.textContent = 'no file selected';
+ } else {
+ this.label.textContent = files[0].name;
+ }
+ }, { signal });
+ }
+
+ disconnectedCallback() {
+ this.#abortController?.abort();
+ }
+}
+
+// if (!window.customElements.get('primer-file-field')) {
+// window.PrimerFileFieldElement = PrimerFileFieldElement
+// window.customElements.define('primer-file-field', PrimerFileFieldElement)
+// }
+
+declare global {
+ interface Window {
+ PrimerFileFieldElement:typeof PrimerFileFieldElement
+ }
+}
diff --git a/frontend/src/global_styles/openproject/_forms.sass b/frontend/src/global_styles/openproject/_forms.sass
index 2ad2073b2b27..5dcb5dc8cd09 100644
--- a/frontend/src/global_styles/openproject/_forms.sass
+++ b/frontend/src/global_styles/openproject/_forms.sass
@@ -222,3 +222,37 @@ input[type="number"]
.FormControl-advanced-checkbox-icon,
.FormControl-advanced-radio-icon
fill: var(--fgColor-accent)
+
+.FormControl-file-wrap
+ position: relative
+ display: inline-flex
+ align-items: center
+ gap: 0.5rem
+ border: 1px solid #ccc
+ border-radius: 4px
+ padding: 0.4rem 0.5rem 0.4rem 0.5rem
+ width: 280px
+ box-sizing: border-box
+
+.FormControl-file
+ position: absolute
+ inset: 0
+ opacity: 0
+ cursor: pointer
+
+.file-label
+ flex: 1
+ font-size: 0.875rem
+ color: #666
+ white-space: nowrap
+ overflow: hidden
+ text-overflow: ellipsis
+
+.file-button
+ padding: 0.35rem 0.75rem
+ border-radius: 4px
+ border: 1px solid #ccc
+ background: #f5f5f5
+ font-size: 0.85rem
+ cursor: pointer
+ white-space: nowrap
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index d723df2c3b1a..c76b29680e89 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -11,6 +11,9 @@ import { getMetaElement } from 'core-app/core/setup/globals/global-helpers';
import 'core-app/core/setup/init-vendors';
import 'core-app/core/setup/init-globals';
+
+import './custom-elements';
+
import './stimulus/setup';
import './turbo/setup';
import { platformBrowser } from '@angular/platform-browser';
diff --git a/lib/primer/open_project/forms/dsl/file_field_input.rb b/lib/primer/open_project/forms/dsl/file_field_input.rb
new file mode 100644
index 000000000000..5d362b429416
--- /dev/null
+++ b/lib/primer/open_project/forms/dsl/file_field_input.rb
@@ -0,0 +1,67 @@
+# 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 Primer
+ module OpenProject
+ module Forms
+ module Dsl
+ # :nodoc:
+ class FileFieldInput < Primer::Forms::Dsl::Input
+ attr_reader :name, :label
+
+ def initialize(name:, label:, **system_arguments)
+ @name = name
+ @label = label
+
+ super(**system_arguments)
+
+ add_input_data(:target, "primer-file-field-element2.inputElement #{system_arguments.dig(:data, :target) || ''}")
+ end
+
+ def to_component
+ FileField.new(input: self)
+ end
+
+ # :nocov:
+ def type
+ :file_field
+ end
+ # :nocov:
+
+ # :nocov:
+ def focusable?
+ true
+ end
+ # :nocov:
+ end
+ end
+ end
+ end
+end
diff --git a/lib/primer/open_project/forms/dsl/input_methods.rb b/lib/primer/open_project/forms/dsl/input_methods.rb
index 7c217c9e2c70..21134f320c11 100644
--- a/lib/primer/open_project/forms/dsl/input_methods.rb
+++ b/lib/primer/open_project/forms/dsl/input_methods.rb
@@ -73,6 +73,10 @@ def work_package_autocompleter(**, &)
add_input WorkPackageAutocompleterInput.new(builder:, form:, **decorate_options(**), &)
end
+ def file_field(**)
+ add_input FileFieldInput.new(builder:, form:, **decorate_options(**))
+ end
+
def decorate_options(include_help_text: true, help_text_options: {}, **options)
if include_help_text && supports_help_texts?(form.model)
attribute_name = help_text_options[:attribute_name] || options[:name]
diff --git a/lib/primer/open_project/forms/file_field.html.erb b/lib/primer/open_project/forms/file_field.html.erb
new file mode 100644
index 000000000000..ff771e378594
--- /dev/null
+++ b/lib/primer/open_project/forms/file_field.html.erb
@@ -0,0 +1,7 @@
+<%= render(FormControl.new(input: @input, tag: :"primer-file-field-element2")) do %>
+ <%= content_tag(:div, **@field_wrap_arguments) do %>
+ <%= builder.file_field(@input.name, **@input.input_arguments) %>
+ Choose a fileā¦
+ Browse
+ <% end %>
+<% end %>
diff --git a/lib/primer/open_project/forms/file_field.rb b/lib/primer/open_project/forms/file_field.rb
new file mode 100644
index 000000000000..e79e3405de1b
--- /dev/null
+++ b/lib/primer/open_project/forms/file_field.rb
@@ -0,0 +1,56 @@
+# 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 Primer
+ module OpenProject
+ module Forms
+ # :nodoc:
+ class FileField < Primer::Forms::BaseComponent
+ delegate :builder, :form, to: :@input
+
+ def initialize(input:)
+ super()
+
+ @input = input
+ @input.add_input_classes("FormControl-file")
+
+ wrap_classes = [
+ "FormControl-file-wrap"
+ ]
+
+ @field_wrap_arguments = {
+ class: class_names(wrap_classes),
+ hidden: @input.hidden?
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/package-lock.json b/package-lock.json
index 2a3c0ab4ef34..12ffbc5efd6a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.1.0",
"hasInstallScript": true,
"dependencies": {
+ "@github/catalyst": "^1.7.0",
"@xeokit/xeokit-gltf-to-xkt": "^1.3.1"
},
"devDependencies": {
@@ -19,6 +20,12 @@
"npm": "^10.1.0"
}
},
+ "node_modules/@github/catalyst": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/@github/catalyst/-/catalyst-1.7.0.tgz",
+ "integrity": "sha512-qOAxrDdRZz9+v4y2WoAfh11rpRY/x4FRofPNmJyZFzAjubtzE3sCa/tAycWWufmQGoYiwwzL/qJBBgyg7avxPw==",
+ "license": "MIT"
+ },
"node_modules/@redocly/ajv": {
"version": "8.11.0",
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.0.tgz",
@@ -424,7 +431,6 @@
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"dev": true,
- "peer": true,
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
diff --git a/package.json b/package.json
index 0fa91d81f6e7..2dc96a8d722e 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
"@redocly/openapi-cli": "^1.0.0-beta.80"
},
"dependencies": {
+ "@github/catalyst": "^1.7.0",
"@xeokit/xeokit-gltf-to-xkt": "^1.3.1"
}
}