diff --git a/app/helpers/custom_styles_helper.rb b/app/helpers/custom_styles_helper.rb index 98ab85517f66..46bb574bd009 100644 --- a/app/helpers/custom_styles_helper.rb +++ b/app/helpers/custom_styles_helper.rb @@ -291,13 +291,24 @@ def custom_logo_upload(custom_style, mode:, field:) { field:, + mode:, label: t("admin.custom_styles.branding.modes.#{mode}.name"), present:, source: present ? custom_logo_url(custom_style, attachment) : nil, img_class: field.to_s.start_with?("logo_mobile") ? "custom-logo-mobile-preview" : "custom-logo-preview", accept: "image/*", delete_path: custom_style_logo_delete_path(field:), - instructions: t("admin.custom_styles.branding.modes.#{mode}.description") + instructions: t("admin.custom_styles.branding.modes.#{mode}.description"), + preview_data: logo_mode_data_attributes(mode) } end + + # Mirrors ApplicationHelper#user_theme_data_attributes/ThemeUtils#applyThemeToBody: with these + # two attributes on an element, CSS custom properties mapped in _primer_color_mapping.erb + # (e.g. --header-bg-color) resolve exactly as they would under that color mode, so a logo + # preview can show its background the way the real header would render it. + def logo_mode_data_attributes(mode) + color_mode = mode == :dark ? :dark : :light + { "color-mode": color_mode, "#{color_mode}-theme": mode } + end end diff --git a/app/views/custom_styles/_branding.html.erb b/app/views/custom_styles/_branding.html.erb index 8c8ea454a4fb..5784f2772400 100644 --- a/app/views/custom_styles/_branding.html.erb +++ b/app/views/custom_styles/_branding.html.erb @@ -26,22 +26,35 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<%= render(Primer::Beta::Subhead.new(spacious: true)) do |subhead| %> - <% subhead.with_heading(tag: :h3, size: :medium) { t("admin.custom_styles.branding.desktop.title") } %> +<%= render(Primer::Beta::Subhead.new(spacious: false)) do |subhead| %> + <% subhead.with_heading(tag: :h3, size: :medium, mt: 4) { t("admin.custom_styles.branding.desktop.title") } %> <% subhead.with_description { t("admin.custom_styles.branding.desktop.description") } %> <% end %> -<% custom_logo_uploads(@custom_style).each do |image| %> - <%= render partial: "custom_styles/uploads/image", locals: { image: } %> +<%= flex_layout(flex_wrap: :wrap, classes: "gap-3") do |flex| %> + <% custom_logo_uploads(@custom_style).each do |image| %> + <% flex.with_column(flex: 1, classes: "custom-style--growing-logo-form") do %> + <%= render partial: "custom_styles/uploads/image", locals: { image: } %> + <% end %> + <% end %> <% end %> -<%= render(Primer::Beta::Subhead.new(spacious: true)) do |subhead| %> - <% subhead.with_heading(tag: :h3, size: :medium) { t("admin.custom_styles.branding.mobile.title") } %> +<%= render(Primer::Beta::Subhead.new(spacious: false)) do |subhead| %> + <% subhead.with_heading(tag: :h3, size: :medium, mt: 4) { t("admin.custom_styles.branding.mobile.title") } %> <% subhead.with_description { t("admin.custom_styles.branding.mobile.description") } %> <% end %> -<% custom_logo_uploads(@custom_style, mobile: true).each do |image| %> - <%= render partial: "custom_styles/uploads/image", locals: { image: } %> +<%= flex_layout(flex_wrap: :wrap, classes: "gap-3") do |flex| %> + <% custom_logo_uploads(@custom_style, mobile: true).each do |image| %> + <% flex.with_column(flex: 1, classes: "custom-style--growing-logo-form") do %> + <%= render partial: "custom_styles/uploads/image", locals: { image: } %> + <% end %> + <% end %> +<% end %> + +<%= render(Primer::Beta::Subhead.new(spacious: false)) do |subhead| %> + <% subhead.with_heading(tag: :h3, size: :medium, mt: 4) { t("admin.custom_styles.branding.favicon.title") } %> + <% subhead.with_description { t("admin.custom_styles.branding.favicon.title_description") } %> <% end %> <% @@ -55,15 +68,20 @@ See COPYRIGHT and LICENSE files for more details. %> <%= render partial: "custom_styles/uploads/image", locals: { image: { field: :favicon, - label: I18n.t(:label_custom_favicon), + label: t("admin.custom_styles.branding.favicon.field"), + instructions: t("admin.custom_styles.branding.favicon.field_description"), present: favicon_present, source: favicon_source, img_class: "custom-favicon-preview", accept: "image/*", - delete_path: custom_style_favicon_delete_path, - instructions: I18n.t("text_custom_favicon_instructions") + delete_path: custom_style_favicon_delete_path } } %> +<%= render(Primer::Beta::Subhead.new(spacious: false)) do |subhead| %> + <% subhead.with_heading(tag: :h3, size: :medium, mt: 4) { t("admin.custom_styles.branding.touch_icon.title") } %> + <% subhead.with_description { t("admin.custom_styles.branding.touch_icon.title_description") } %> +<% end %> + <% touch_icon_present = @custom_style.persisted? && @custom_style.touch_icon.present? touch_icon_source = if touch_icon_present @@ -75,11 +93,11 @@ See COPYRIGHT and LICENSE files for more details. %> <%= render partial: "custom_styles/uploads/image", locals: { image: { field: :touch_icon, - label: I18n.t(:label_custom_touch_icon), + label: t("admin.custom_styles.branding.touch_icon.field"), + instructions: t("admin.custom_styles.branding.touch_icon.field_description"), present: touch_icon_present, source: touch_icon_source, img_class: "custom-touch-icon-preview", accept: "image/*", - delete_path: custom_style_touch_icon_delete_path, - instructions: I18n.t("text_custom_touch_icon_instructions") + delete_path: custom_style_touch_icon_delete_path } } %> diff --git a/app/views/custom_styles/_pdf_export_styles.html.erb b/app/views/custom_styles/_pdf_export_styles.html.erb index 2e28ba8b7b7d..70278415c970 100644 --- a/app/views/custom_styles/_pdf_export_styles.html.erb +++ b/app/views/custom_styles/_pdf_export_styles.html.erb @@ -29,41 +29,43 @@ See COPYRIGHT and LICENSE files for more details.

<%= I18n.t("label_custom_export_images_instructions") %>

<%= - render(Primer::Beta::Button.new( - target: "_blank", - data: { turbo: false }, - tag: :a, - href: export_demo_pdf_download_custom_style_path - )) do |_c| + render( + Primer::Beta::Button.new( + target: "_blank", + data: { turbo: false }, + tag: :a, + href: export_demo_pdf_download_custom_style_path + ) + ) do |_c| t("export.demo.button_text") end %> -<%= render partial: "custom_styles/uploads/image", locals: { image:{ - field: :export_logo, - label: I18n.t(:label_custom_export_logo), - present: @custom_style.id && @custom_style.export_logo.present?, - source: @custom_style.id && @custom_style.export_logo.present? ? - custom_style_export_logo_path(digest: @custom_style.digest, filename: @custom_style.export_logo_identifier) : - nil, - img_class: "custom-export-logo-preview", - accept: "image/png, image/jpg", - delete_path: custom_style_export_logo_delete_path, - instructions: I18n.t("text_custom_export_logo_instructions") -} } %> +<%= render partial: "custom_styles/uploads/image", locals: { image: { + field: :export_logo, + label: I18n.t(:label_custom_export_logo), + present: @custom_style.id && @custom_style.export_logo.present?, + source: if @custom_style.id && @custom_style.export_logo.present? + custom_style_export_logo_path(digest: @custom_style.digest, filename: @custom_style.export_logo_identifier) + end, + img_class: "custom-export-logo-preview", + accept: "image/png, image/jpeg", + delete_path: custom_style_export_logo_delete_path, + instructions: I18n.t("text_custom_export_logo_instructions") + } } %> -<%= render partial: "custom_styles/uploads/image", locals: { image:{ - field: :export_cover, - label: I18n.t(:label_custom_export_cover), - present: @custom_style.id && @custom_style.export_cover.present?, - source: @custom_style.id && @custom_style.export_cover.present? ? - custom_style_export_cover_path(digest: @custom_style.digest, filename: @custom_style.export_cover_identifier) : - nil, - img_class: "custom-export-cover-preview", - accept: "image/png, image/jpg", - delete_path: custom_style_export_cover_delete_path, - instructions: I18n.t("text_custom_export_cover_instructions") -} } %> +<%= render partial: "custom_styles/uploads/image", locals: { image: { + field: :export_cover, + label: I18n.t(:label_custom_export_cover), + present: @custom_style.id && @custom_style.export_cover.present?, + source: if @custom_style.id && @custom_style.export_cover.present? + custom_style_export_cover_path(digest: @custom_style.digest, filename: @custom_style.export_cover_identifier) + end, + img_class: "custom-export-cover-preview", + accept: "image/png, image/jpeg", + delete_path: custom_style_export_cover_delete_path, + instructions: I18n.t("text_custom_export_cover_instructions") + } } %> <%= form_tag update_custom_style_export_cover_text_color_path, html: { multipart: true, class: "form -vertical" } do %>
@@ -79,7 +81,7 @@ See COPYRIGHT and LICENSE files for more details. Color.new( hexcode: design_color_hex || "#000" ), - data: { target: "#" + design_color_name } + data: { target: "##{design_color_name}" } ) %> @@ -96,12 +98,14 @@ See COPYRIGHT and LICENSE files for more details. <%= - render(Primer::Beta::Button.new( - type: :submit, - data: { - "test-selector": "text-color-change" - } - )) do |button| + render( + Primer::Beta::Button.new( + type: :submit, + data: { + "test-selector": "text-color-change" + } + ) + ) do |_button| I18n.t(:button_save) end %> @@ -110,16 +114,16 @@ See COPYRIGHT and LICENSE files for more details. <% end %> <% if OpenProject::FeatureDecisions.minutes_styling_meeting_pdf_active? %> - <%= render partial: "custom_styles/uploads/image", locals: { image:{ - field: :export_footer, - label: I18n.t(:label_custom_export_footer), - present: @custom_style.id && @custom_style.export_footer.present?, - source: @custom_style.id && @custom_style.export_footer.present? ? - custom_style_export_footer_path(digest: @custom_style.digest, filename: @custom_style.export_footer_identifier) : - nil, - img_class: "custom-export-footer-preview", - accept: "image/png, image/jpg", - delete_path: custom_style_export_footer_delete_path, - instructions: I18n.t("text_custom_export_footer_instructions") - } } %> + <%= render partial: "custom_styles/uploads/image", locals: { image: { + field: :export_footer, + label: I18n.t(:label_custom_export_footer), + present: @custom_style.id && @custom_style.export_footer.present?, + source: if @custom_style.id && @custom_style.export_footer.present? + custom_style_export_footer_path(digest: @custom_style.digest, filename: @custom_style.export_footer_identifier) + end, + img_class: "custom-export-footer-preview", + accept: "image/png, image/jpeg", + delete_path: custom_style_export_footer_delete_path, + instructions: I18n.t("text_custom_export_footer_instructions") + } } %> <% end %> diff --git a/app/views/custom_styles/uploads/_image.html.erb b/app/views/custom_styles/uploads/_image.html.erb index 946823180194..e60b93009f31 100644 --- a/app/views/custom_styles/uploads/_image.html.erb +++ b/app/views/custom_styles/uploads/_image.html.erb @@ -26,47 +26,32 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<%= form_for @custom_style, url: custom_style_path, html: { multipart: true, class: "form -vertical" } do |f| %> -
-
- <%= image[:label] %> -

<%= image[:instructions] %>

- <% if image[:present] %> - <%= tag("img", src: image[:source], class: image[:img_class]) %> - <% end %> -
-
-
-
- <%= f.file_field image[:field], required: true, class: "attachment_choose_file", accept: image[:accept], size: "15" %> -
-
-
-
- <%= render( - Primer::Beta::Button.new( - type: :submit - ) - ) do |button| %> - <% button.with_leading_visual_icon(icon: image[:present] ? :upload : :plus) %> - <%= t(image[:present] ? :button_replace : :button_upload) %> - <% end %> - <% if image[:present] %> - <%= render( - Primer::Beta::Button.new( - tag: :a, - scheme: :danger, - href: image[:delete_path], - data: { - turbo_method: :delete, - test_selector: "delete-custom-style-image-#{image[:field]}" - } - ) - ) do |button| %> - <% button.with_leading_visual_icon(icon: :trash) %> - <%= t(:button_delete) %> - <% end %> - <% end %> -
-
-<% end %> +<%= + primer_form_with( + model: @custom_style, + url: custom_style_path, + multipart: true, + data: { controller: "auto-submit" } + ) do |f| + render_inline_form(f) do |form| + form.file_field( + name: image[:field], + label: image[:label], + description: image[:instructions], + accept: image[:accept], + preview_url: (image[:source] if image[:present]), + preview_class: image[:img_class], + preview_data: image[:preview_data] || {}, + required: true, + auto_submit: true, + delete_path: (image[:delete_path] if image[:present]), + delete_button_arguments: { + data: { + turbo_method: :delete, + test_selector: "delete-custom-style-image-#{image[:field]}" + } + } + ) + end + end +%> diff --git a/config/locales/en.yml b/config/locales/en.yml index 747830be0278..27f4b543a22b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1492,6 +1492,11 @@ en: desktop: description: "By default the OpenProject logos are used, but if you upload new logos the theme will automatically be changed to Custom theme. For better results, we recommend a transparent logo." title: "Custom desktop logos" + favicon: + field: "Favicon" + field_description: "The image should be 32x32px in PNG format on transparent background." + title: "Custom favicon" + title_description: "This is the tiny icon that appears next to the page title in the browser. By default the OpenProject favicon is used, but if you upload a new favicon the theme will automatically be changed to Custom theme." mobile: description: "By default the OpenProject logos are used for the mobile browser version, the native mobile app will not be updated by this change. For better results, we recommend a transparent logo." title: "Custom mobile logos" @@ -1505,6 +1510,11 @@ en: light_high_contrast: description: "The app header will be grey. We recommend a black logo on transparent background." name: "High contrast mode logo" + touch_icon: + field: "Touch icon" + field_description: "The image should be 180x180px in PNG format and colour background to avoid contrast problems." + title: "Custom touch icon" + title_description: "This is the icon that appears when bookmark in the tablets or mobiles home. By default the OpenProject touch icon is used, but if you upload a new touch icon the theme will automatically be changed to Custom theme." color_groups: base_colors: "Base colors" main_menu: "Main lateral menu" diff --git a/frontend/src/app/shared/helpers/dom-helpers.spec.ts b/frontend/src/app/shared/helpers/dom-helpers.spec.ts index 88f4c7797da0..8bcff1433bea 100644 --- a/frontend/src/app/shared/helpers/dom-helpers.spec.ts +++ b/frontend/src/app/shared/helpers/dom-helpers.spec.ts @@ -33,6 +33,7 @@ import { toggleEnabled, enableElement, disableElement, + setInputFiles, } from './dom-helpers'; describe('dom-helpers', () => { @@ -536,4 +537,29 @@ describe('dom-helpers', () => { }); }); }); + + describe('setInputFiles', () => { + it('puts the given file on the input', () => { + const input = document.createElement('input'); + input.type = 'file'; + const file = new File(['content'], 'contract.pdf', { type: 'application/pdf' }); + + setInputFiles(input, file); + + expect(input.files).toHaveLength(1); + expect(input.files?.[0]).toBe(file); + }); + + it('replaces a previously set file rather than appending to it', () => { + const input = document.createElement('input'); + input.type = 'file'; + setInputFiles(input, new File(['first'], 'first.pdf', { type: 'application/pdf' })); + + const second = new File(['second'], 'second.pdf', { type: 'application/pdf' }); + setInputFiles(input, second); + + expect(input.files).toHaveLength(1); + expect(input.files?.[0]).toBe(second); + }); + }); }); diff --git a/frontend/src/app/shared/helpers/dom-helpers.ts b/frontend/src/app/shared/helpers/dom-helpers.ts index 97bf7c557bfa..e77fc7765b1e 100644 --- a/frontend/src/app/shared/helpers/dom-helpers.ts +++ b/frontend/src/app/shared/helpers/dom-helpers.ts @@ -289,6 +289,17 @@ export function isHTMLInputElement(element:EventTarget|Element|null):element is return element !== null && element instanceof HTMLInputElement; } +/** + * Sets a single file on an ``. The `files` property is a read-only + * `FileList`, so a `DataTransfer` is the only way to assign to it programmatically — e.g. after + * a drag-and-drop, or to replace a chosen file with a processed version of itself. + */ +export function setInputFiles(input:HTMLInputElement, file:File):void { + const transfer = new DataTransfer(); + transfer.items.add(file); + input.files = transfer.files; +} + /** * Reads a CSS custom property off the document body, so that a value defined by the stylesheet * and by the theme in force can be used where only a plain string will do -- a canvas fill, say. diff --git a/frontend/src/app/shared/helpers/images/object-url-preview.spec.ts b/frontend/src/app/shared/helpers/images/object-url-preview.spec.ts new file mode 100644 index 000000000000..ac50c3c31da2 --- /dev/null +++ b/frontend/src/app/shared/helpers/images/object-url-preview.spec.ts @@ -0,0 +1,93 @@ +//-- 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, see . +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { vi } from 'vitest'; + +import { decodeImageFile, updateObjectUrlPreview } from './object-url-preview'; + +// A minimal, valid 1x1 transparent PNG, so Image#decode() has real pixel data to decode. +const ONE_PIXEL_PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='; + +function pngFile(name = 'pixel.png'):File { + const bytes = Uint8Array.from(atob(ONE_PIXEL_PNG_BASE64), (character) => character.charCodeAt(0)); + return new File([bytes], name, { type: 'image/png' }); +} + +describe('object-url-preview', () => { + describe('updateObjectUrlPreview', () => { + it('creates an object URL for a new source', () => { + const url = updateObjectUrlPreview(undefined, pngFile()); + + expect(url).toMatch(/^blob:/); + }); + + it('revokes the previous URL when swapping in a new source', () => { + const revokeSpy = vi.spyOn(URL, 'revokeObjectURL'); + const first = updateObjectUrlPreview(undefined, pngFile('first.png')); + + const second = updateObjectUrlPreview(first, pngFile('second.png')); + + expect(revokeSpy).toHaveBeenCalledWith(first); + expect(second).not.toEqual(first); + }); + + it('revokes the previous URL and returns undefined when no new source is given', () => { + const revokeSpy = vi.spyOn(URL, 'revokeObjectURL'); + const url = updateObjectUrlPreview(undefined, pngFile()); + + const result = updateObjectUrlPreview(url, undefined); + + expect(revokeSpy).toHaveBeenCalledWith(url); + expect(result).toBeUndefined(); + }); + + it('returns undefined without revoking anything when there is nothing to show or clear', () => { + const revokeSpy = vi.spyOn(URL, 'revokeObjectURL'); + + expect(updateObjectUrlPreview(undefined, undefined)).toBeUndefined(); + expect(revokeSpy).not.toHaveBeenCalled(); + }); + }); + + describe('decodeImageFile', () => { + it('decodes a file into a loaded image element', async () => { + const image = await decodeImageFile(pngFile()); + + expect(image).toBeInstanceOf(HTMLImageElement); + expect(image.naturalWidth).toBe(1); + expect(image.naturalHeight).toBe(1); + }); + + it('revokes the object URL used to decode the image', async () => { + const revokeSpy = vi.spyOn(URL, 'revokeObjectURL'); + + await decodeImageFile(pngFile()); + + expect(revokeSpy).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/frontend/src/app/shared/helpers/images/object-url-preview.ts b/frontend/src/app/shared/helpers/images/object-url-preview.ts new file mode 100644 index 000000000000..1da38905db44 --- /dev/null +++ b/frontend/src/app/shared/helpers/images/object-url-preview.ts @@ -0,0 +1,56 @@ +//-- 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, see . +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +/** + * Swaps an object URL preview for a new source, revoking the previous one so it does not leak. + * Pass `undefined` as `source` to just revoke the previous URL (e.g. on controller disconnect). + * + * @param previousUrl The object URL returned by an earlier call, if any + * @param source The new file/blob to preview, or undefined to clear the preview + */ +export function updateObjectUrlPreview(previousUrl:string | undefined, source:File | Blob | undefined):string | undefined { + if (previousUrl) URL.revokeObjectURL(previousUrl); + + return source ? URL.createObjectURL(source) : undefined; +} + +/** + * Decodes a file into an `HTMLImageElement`, e.g. so it can be drawn onto a canvas for resizing. + * The object URL used to load it is only needed for decoding, so it is revoked again immediately + * afterwards — the decoded image keeps its pixel data regardless. + */ +export async function decodeImageFile(file:File):Promise { + const url = URL.createObjectURL(file); + try { + const image = new Image(); + image.src = url; + await image.decode(); + return image; + } finally { + URL.revokeObjectURL(url); + } +} diff --git a/frontend/src/global_styles/content/_custom_logo.sass b/frontend/src/global_styles/content/_custom_logo.sass index 70a3e2ce9800..6fed2b39ff3b 100644 --- a/frontend/src/global_styles/content/_custom_logo.sass +++ b/frontend/src/global_styles/content/_custom_logo.sass @@ -28,3 +28,32 @@ img.custom-favicon-preview img.custom-touch-icon-preview height: 180px background-color: whiteSmoke + +// Makes the desktop/mobile logo upload fields (rendered side by side via +// flex_layout in custom_styles/_branding.html.erb) grow to the same height, so +// their dashed-border drop zones line up even when the label/description text +// above them wraps to a different number of lines. +// +// Primer::Forms wraps a single file_field input in several of its own divs +// (form > .FormControl-spacingWrapper > group box > .FormControl) before our +// own drop zone; collapsing those with display: contents lets the drop zone +// grow as if it were a direct child of this row item, without touching +// Primer's classes anywhere else they're used. +.custom-style--growing-logo-form + display: flex + flex-direction: column + flex: 1 + + form, + .FormControl-spacingWrapper, + .FormControl-spacingWrapper > div, + .FormControl + display: contents + + .FormControl-file-field-wrap + flex: 1 + display: flex + flex-direction: column + + .FormControl-file-field-drop-zone + flex: 1 diff --git a/frontend/src/stimulus/controllers/dynamic/avatars/settings.controller.ts b/frontend/src/stimulus/controllers/dynamic/avatars/settings.controller.ts index 449cab066d53..6dcab5a3c143 100644 --- a/frontend/src/stimulus/controllers/dynamic/avatars/settings.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/avatars/settings.controller.ts @@ -27,6 +27,8 @@ import { Controller } from '@hotwired/stimulus'; import { resizeImage } from 'core-app/shared/helpers/images/resizer'; +import { decodeImageFile, updateObjectUrlPreview } from 'core-app/shared/helpers/images/object-url-preview'; +import { setInputFiles } from 'core-app/shared/helpers/dom-helpers'; const SURROUNDING_SPACE = /^[\s\u0085]+|[\s\u0085]+$/gu; const INVISIBLE_CODEPOINT = /[\p{Cc}\p{Cs}\p{Noncharacter_Code_Point}]|(?![\u200D\u{E0020}-\u{E007F}])\p{Cf}/u; @@ -87,7 +89,7 @@ export default class AvatarSettingsController extends Controller { disconnect() { this.imageGeneration += 1; - if (this.imagePreview) URL.revokeObjectURL(this.imagePreview); + this.imagePreview = updateObjectUrlPreview(this.imagePreview, undefined); } update(event?:Event) { @@ -184,27 +186,20 @@ export default class AvatarSettingsController extends Controller { this.resizing = true; this.uploadErrorTarget.hidden = true; this.update(); - const source = URL.createObjectURL(file); try { if (!['image/jpeg', 'image/png', 'image/gif'].includes(file.type)) throw new Error('Invalid image'); - const image = new Image(); - image.src = source; - await image.decode(); + const image = await decodeImageFile(file); if (generation !== this.imageGeneration) return; const [, blob] = resizeImage(128, image); - const transfer = new DataTransfer(); - transfer.items.add(new File([blob], file.name, { type: blob.type })); - this.fileTarget.files = transfer.files; - if (this.imagePreview) URL.revokeObjectURL(this.imagePreview); - this.imagePreview = URL.createObjectURL(blob); + setInputFiles(this.fileTarget, new File([blob], file.name, { type: blob.type })); + this.imagePreview = updateObjectUrlPreview(this.imagePreview, blob); } catch { if (generation !== this.imageGeneration) return; this.fileTarget.value = ''; this.invalidImage = true; this.uploadErrorTarget.hidden = false; } finally { - URL.revokeObjectURL(source); if (generation === this.imageGeneration) { this.resizing = false; this.update(); diff --git a/frontend/src/stimulus/controllers/file-field.controller.spec.ts b/frontend/src/stimulus/controllers/file-field.controller.spec.ts new file mode 100644 index 000000000000..b982441643ec --- /dev/null +++ b/frontend/src/stimulus/controllers/file-field.controller.spec.ts @@ -0,0 +1,295 @@ +//-- 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, see . +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { vi } from 'vitest'; + +import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers'; +import type FileFieldControllerType from './file-field.controller'; + +const IDENTIFIER = 'file-field'; +const DRAGGING_OVER = 'FormControl-file-field-drop-zone--dragging-over'; +const DRAGGING_OVER_FORBIDDEN = 'FormControl-file-field-drop-zone--dragging-over-forbidden'; +const HAS_PREVIEW = 'FormControl-file-field-wrap--has-preview'; + +describe('File field controller', () => { + let ctx:StimulusTestContext; + let FileFieldController:typeof FileFieldControllerType; + + beforeAll(async () => { + ({ default: FileFieldController } = await import('./file-field.controller')); + }); + + beforeEach(async () => { + ctx = await setupStimulusTest({ controllers: { [IDENTIFIER]: FileFieldController } }); + }); + + afterEach(() => { + ctx.dispose(); + }); + + async function mount(accept = '') { + await ctx.mount(` +
+ + + +
`); + } + + const target = (name:string) => ctx.container.querySelector(`[data-${IDENTIFIER}-target="${name}"]`)!; + const fileInput = () => target('file') as HTMLInputElement; + const dropBox = () => target('dropBox'); + const preview = () => target('preview') as HTMLImageElement; + const wrap = () => ctx.container.querySelector(`[data-controller="${IDENTIFIER}"]`)!; + + function file(name = 'contract.pdf') { + return new File(['content'], name, { type: 'application/pdf' }); + } + + function imageFile(name = 'logo.png') { + return new File(['content'], name, { type: 'image/png' }); + } + + async function choose(chosenFile:File) { + const transfer = new DataTransfer(); + transfer.items.add(chosenFile); + fileInput().files = transfer.files; + fileInput().dispatchEvent(new Event('change', { bubbles: true })); + await ctx.nextFrame(); + } + + it('opens the native file picker when the drop zone is clicked', async () => { + await mount(); + const clickSpy = vi.spyOn(fileInput(), 'click'); + + dropBox().dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(clickSpy).toHaveBeenCalledOnce(); + }); + + it('shows the chosen file name in place of the placeholder label', async () => { + await mount(); + const transfer = new DataTransfer(); + transfer.items.add(file()); + fileInput().files = transfer.files; + + fileInput().dispatchEvent(new Event('change', { bubbles: true })); + await ctx.nextFrame(); + + expect(target('filename').textContent).toEqual('contract.pdf'); + }); + + it('restores the placeholder label once the file list is empty again', async () => { + await mount(); + const transfer = new DataTransfer(); + transfer.items.add(file()); + fileInput().files = transfer.files; + fileInput().dispatchEvent(new Event('change', { bubbles: true })); + await ctx.nextFrame(); + + fileInput().files = new DataTransfer().files; + fileInput().dispatchEvent(new Event('change', { bubbles: true })); + await ctx.nextFrame(); + + expect(target('filename').textContent).toEqual('Upload file'); + }); + + it('highlights the drop zone while dragging over it, and clears it on drag leave', async () => { + await mount(); + + dropBox().dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true })); + expect(dropBox().classList.contains(DRAGGING_OVER)).toBe(true); + + dropBox().dispatchEvent(new DragEvent('dragleave', { bubbles: true, cancelable: true })); + expect(dropBox().classList.contains(DRAGGING_OVER)).toBe(false); + }); + + it('puts a dropped file on the hidden input and shows its name', async () => { + await mount(); + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file()); + + dropBox().dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer })); + await ctx.nextFrame(); + + expect(fileInput().files?.[0].name).toEqual('contract.pdf'); + expect(target('filename').textContent).toEqual('contract.pdf'); + expect(dropBox().classList.contains(DRAGGING_OVER)).toBe(false); + }); + + it('fires a real change event on drop, so other listeners on the input (e.g. auto-submit) run too', async () => { + await mount(); + const changeListener = vi.fn(); + fileInput().addEventListener('change', changeListener); + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file()); + + dropBox().dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer })); + await ctx.nextFrame(); + + expect(changeListener).toHaveBeenCalledOnce(); + }); + + describe('restricting dropped files to the accept attribute', () => { + it('ignores a dropped file that does not match an image/* accept pattern', async () => { + await mount('image/*'); + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file()); + + dropBox().dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer })); + await ctx.nextFrame(); + + expect(fileInput().files?.length).toEqual(0); + expect(target('filename').textContent).toEqual('Upload file'); + }); + + it('accepts a dropped file that matches an exact MIME type in a comma-separated list', async () => { + await mount('image/png, image/jpg'); + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(imageFile()); + + dropBox().dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer })); + await ctx.nextFrame(); + + expect(fileInput().files?.[0].name).toEqual('logo.png'); + }); + + it('rejects a dropped file that does not match any exact MIME type in a comma-separated list', async () => { + await mount('image/png, image/jpg'); + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file()); + + dropBox().dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer })); + await ctx.nextFrame(); + + expect(fileInput().files?.length).toEqual(0); + }); + }); + + describe('previewing whether a dragged-over file will be accepted', () => { + it('grays out the drop zone and shows a forbidden cursor while hovering a file that does not match the accept attribute', async () => { + await mount('image/*'); + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file()); + + dropBox().dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer })); + + expect(dropBox().classList.contains(DRAGGING_OVER)).toBe(false); + expect(dropBox().classList.contains(DRAGGING_OVER_FORBIDDEN)).toBe(true); + }); + + it('highlights the drop zone while hovering a file that matches the accept attribute', async () => { + await mount('image/*'); + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(imageFile()); + + dropBox().dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer })); + + expect(dropBox().classList.contains(DRAGGING_OVER)).toBe(true); + expect(dropBox().classList.contains(DRAGGING_OVER_FORBIDDEN)).toBe(false); + }); + + it('stays permissive while hovering, since the dragged file\'s name is unknown before drop', async () => { + await mount('.ttf'); + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file()); + + dropBox().dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer })); + + expect(dropBox().classList.contains(DRAGGING_OVER)).toBe(true); + expect(dropBox().classList.contains(DRAGGING_OVER_FORBIDDEN)).toBe(false); + }); + + it('clears the forbidden styling on drag leave', async () => { + await mount('image/*'); + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file()); + dropBox().dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer })); + + dropBox().dispatchEvent(new DragEvent('dragleave', { bubbles: true, cancelable: true })); + + expect(dropBox().classList.contains(DRAGGING_OVER_FORBIDDEN)).toBe(false); + }); + }); + + describe('previewing an image', () => { + it('shows the chosen image and hides the icon/label/caption placeholder', async () => { + await mount(); + + await choose(imageFile()); + + expect(preview().src).toMatch(/^blob:/); + expect(wrap().classList.contains(HAS_PREVIEW)).toBe(true); + }); + + it('keeps the placeholder visible for a file that is not an image', async () => { + await mount(); + + await choose(file()); + + expect(wrap().classList.contains(HAS_PREVIEW)).toBe(false); + }); + + it('revokes the previous preview URL once a new file replaces it', async () => { + await mount(); + const revokeSpy = vi.spyOn(URL, 'revokeObjectURL'); + await choose(imageFile('first.png')); + const firstUrl = preview().src; + + await choose(imageFile('second.png')); + + expect(revokeSpy).toHaveBeenCalledWith(firstUrl); + expect(preview().src).not.toEqual(firstUrl); + }); + + it('hides the preview and restores the placeholder once the file list is empty again', async () => { + await mount(); + await choose(imageFile()); + + fileInput().files = new DataTransfer().files; + fileInput().dispatchEvent(new Event('change', { bubbles: true })); + await ctx.nextFrame(); + + expect(wrap().classList.contains(HAS_PREVIEW)).toBe(false); + }); + }); +}); diff --git a/frontend/src/stimulus/controllers/file-field.controller.ts b/frontend/src/stimulus/controllers/file-field.controller.ts new file mode 100644 index 000000000000..fb6180515e92 --- /dev/null +++ b/frontend/src/stimulus/controllers/file-field.controller.ts @@ -0,0 +1,143 @@ +//-- 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, see . +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { Controller } from '@hotwired/stimulus'; +import { updateObjectUrlPreview } from 'core-app/shared/helpers/images/object-url-preview'; +import { setInputFiles } from 'core-app/shared/helpers/dom-helpers'; + +const DRAGGING_OVER = 'FormControl-file-field-drop-zone--dragging-over'; +const DRAGGING_OVER_FORBIDDEN = 'FormControl-file-field-drop-zone--dragging-over-forbidden'; +const HAS_PREVIEW = 'FormControl-file-field-wrap--has-preview'; + +export default class FileFieldController extends Controller { + static targets = ['file', 'filename', 'dropBox', 'preview']; + + declare readonly fileTarget:HTMLInputElement; + declare readonly filenameTarget:HTMLElement; + declare readonly dropBoxTarget:HTMLButtonElement; + declare readonly hasPreviewTarget:boolean; + declare readonly previewTarget:HTMLImageElement; + + private previewUrl?:string; + + disconnect():void { + this.previewUrl = updateObjectUrlPreview(this.previewUrl, undefined); + } + + openFilePicker():void { + this.fileTarget.click(); + } + + dragOver(event:DragEvent):void { + event.preventDefault(); + + const accepted = this.isAcceptedType(event.dataTransfer?.items?.[0]?.type); + this.dropBoxTarget.classList.toggle(DRAGGING_OVER, accepted); + this.dropBoxTarget.classList.toggle(DRAGGING_OVER_FORBIDDEN, !accepted); + + if (event.dataTransfer) { + event.dataTransfer.dropEffect = accepted ? 'copy' : 'none'; + } + } + + dragLeave():void { + this.dropBoxTarget.classList.remove(DRAGGING_OVER, DRAGGING_OVER_FORBIDDEN); + } + + dropFile(event:DragEvent):void { + event.preventDefault(); + this.dropBoxTarget.classList.remove(DRAGGING_OVER, DRAGGING_OVER_FORBIDDEN); + + const file = event.dataTransfer?.files?.[0]; + if (!file || !this.isAcceptedFile(file)) { return; } + + // A drop lands on the drop zone, not the hidden input, but only the input's value gets + // submitted — so we copy the file onto it. Firing a real 'change' event (instead of calling + // fileChosen() directly) makes a drop behave exactly like picking a file natively, so other + // listeners on the input, e.g. auto-submit, run as well. + setInputFiles(this.fileTarget, file); + this.fileTarget.dispatchEvent(new Event('change', { bubbles: true })); + } + + private acceptPatterns():string[] { + return this.fileTarget.accept + .split(',') + .map((pattern) => pattern.trim()) + .filter(Boolean); + } + + // Unlike choosing a file through the native picker, a browser never filters a drag-and-drop + // payload against the input's `accept` attribute, so we have to check it ourselves. + private isAcceptedFile(file:File):boolean { + const patterns = this.acceptPatterns(); + if (patterns.length === 0) { return true; } + + return patterns.some((pattern) => { + if (pattern.startsWith('.')) { + return file.name.toLowerCase().endsWith(pattern.toLowerCase()); + } + + return this.matchesMimePattern(pattern, file.type); + }); + } + + // During dragover the browser only exposes the dragged item's MIME type (guessed from its + // extension), never the File itself — the name is withheld until drop. So an accept pattern + // that is itself an extension (e.g. ".ttf") can't be checked yet; we stay permissive for those + // and let dropFile's isAcceptedFile do the authoritative check once the file is known. + private isAcceptedType(type?:string):boolean { + const typePatterns = this.acceptPatterns().filter((pattern) => !pattern.startsWith('.')); + if (typePatterns.length === 0) { return true; } + + return !!type && typePatterns.some((pattern) => this.matchesMimePattern(pattern, type)); + } + + private matchesMimePattern(pattern:string, type:string):boolean { + return pattern.endsWith('/*') ? type.startsWith(pattern.slice(0, -1)) : type === pattern; + } + + fileChosen():void { + const file = this.fileTarget.files?.[0]; + this.filenameTarget.textContent = file ? file.name : this.filenameTarget.dataset.empty ?? ''; + this.updatePreview(file); + } + + private updatePreview(file?:File):void { + if (!this.hasPreviewTarget) { return; } + + const isImage = !!file && file.type.startsWith('image/'); + this.previewUrl = updateObjectUrlPreview(this.previewUrl, isImage ? file : undefined); + + if (this.previewUrl) { + this.previewTarget.src = this.previewUrl; + } else { + this.previewTarget.removeAttribute('src'); + } + + this.element.classList.toggle(HAS_PREVIEW, !!this.previewUrl); + } +} diff --git a/frontend/src/stimulus/setup.ts b/frontend/src/stimulus/setup.ts index 81bdf6d39aa7..9b5df02e6c6d 100644 --- a/frontend/src/stimulus/setup.ts +++ b/frontend/src/stimulus/setup.ts @@ -45,6 +45,7 @@ import PasswordRequirementsController from './controllers/password-requirements. import PreviewController from './controllers/dynamic/work-packages/date-picker/preview.controller'; import KeepScrollPositionController from './controllers/keep-scroll-position.controller'; import PatternInputController from './controllers/pattern-input.controller'; +import FileFieldController from './controllers/file-field.controller'; import HoverCardTriggerController from './controllers/hover-card-trigger.controller'; import ScrollIntoViewController from './controllers/scroll-into-view.controller'; import ReloadFrameOnEventController from './controllers/reload-frame-on-event.controller'; @@ -110,6 +111,7 @@ OpenProjectStimulusApplication.preregister('zen-mode', OpZenModeController); OpenProjectStimulusApplication.preregister('work-packages--date-picker--preview', PreviewController); OpenProjectStimulusApplication.preregister('keep-scroll-position', KeepScrollPositionController); OpenProjectStimulusApplication.preregister('pattern-input', PatternInputController); +OpenProjectStimulusApplication.preregister('file-field', FileFieldController); OpenProjectStimulusApplication.preregister('scroll-into-view', ScrollIntoViewController); OpenProjectStimulusApplication.preregister('reload-frame-on-event', ReloadFrameOnEventController); OpenProjectStimulusApplication.preregister('ckeditor-focus', CkeditorFocusController); diff --git a/lib/primer/open_project/forms/_index.sass b/lib/primer/open_project/forms/_index.sass index 939d25ff05fd..4059bcc87285 100644 --- a/lib/primer/open_project/forms/_index.sass +++ b/lib/primer/open_project/forms/_index.sass @@ -1,2 +1,3 @@ @import "advanced_form_group" +@import "file_field" @import "filterable_tree_view" 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..dfa3bf6b9d2e --- /dev/null +++ b/lib/primer/open_project/forms/dsl/file_field_input.rb @@ -0,0 +1,76 @@ +# 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, see . +# +# 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, :description, :icon, :accept, :preview_url, :preview_class, :preview_data, + :delete_path, :delete_button_arguments, :auto_submit + + def initialize(name:, label:, description: nil, icon: :upload, accept: nil, preview_url: nil, + preview_class: nil, preview_data: {}, delete_path: nil, delete_button_arguments: {}, + auto_submit: false, **system_arguments) + @name = name + @label = label + @description = description + @icon = icon + @accept = accept + @preview_url = preview_url + @preview_class = preview_class + @preview_data = preview_data + @delete_path = delete_path + @delete_button_arguments = delete_button_arguments + @auto_submit = auto_submit + + super(**system_arguments, visually_hide_label: true) + 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 c4caddbecd18..1062beef2bec 100644 --- a/lib/primer/open_project/forms/dsl/input_methods.rb +++ b/lib/primer/open_project/forms/dsl/input_methods.rb @@ -125,6 +125,10 @@ def filterable_tree_view(**, &) add_input FilterableTreeViewInput.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..97dff62db028 --- /dev/null +++ b/lib/primer/open_project/forms/file_field.html.erb @@ -0,0 +1,84 @@ +<%#-- 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, see . + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<%= render(Primer::Forms::FormControl.new(input: @input)) do %> + <%= content_tag(:div, **field_wrap_arguments, data: { controller: "file-field" }) do %> + <%= + builder.file_field( + name, + hidden: true, + accept:, + disabled: @input.disabled?, + data: { + "file-field-target": "file", + action: file_input_actions + } + ) + %> + + <%= + content_tag( + :button, + type: "button", + class: "FormControl-file-field-drop-zone", + disabled: @input.disabled?, + "aria-label": label, + data: { + "file-field-target": "dropBox", + action: "click->file-field#openFilePicker + dragover->file-field#dragOver + dragleave->file-field#dragLeave + drop->file-field#dropFile" + } + ) do + %> + <%= content_tag(:img, nil, **preview_attributes) %> + + <%= render(Primer::Beta::Octicon.new(icon:, classes: "FormControl-file-field-icon")) %> + <%= label %> + <% if description.present? %> + <%= description %> + <% end %> + + <% end %> + + <% if preview_url.present? && !@input.disabled? %> + + <%= render(Primer::Alpha::ActionMenu.new) do |menu| %> + <% menu.with_show_button(icon: "kebab-horizontal", "aria-label": menu_label, scheme: :invisible) %> + <% menu.with_item(**replace_item_arguments) { |item| item.with_leading_visual_icon(icon: :sync) } %> + <% if delete_path.present? %> + <% menu.with_item(**delete_item_arguments) { |item| item.with_leading_visual_icon(icon: :trash) } %> + <% end %> + <% end %> + + <% end %> + <% 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..eb7c4c4906ef --- /dev/null +++ b/lib/primer/open_project/forms/file_field.rb @@ -0,0 +1,93 @@ +# 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, see . +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Primer + module OpenProject + module Forms + # :nodoc: + class FileField < Primer::Forms::BaseComponent + prepend WrappedInput + + delegate :builder, :form, :name, :label, :description, :icon, :accept, :preview_url, :preview_class, + :preview_data, :delete_path, to: :@input + + def initialize(input:) + super() + @input = input + end + + def preview_attributes + { + class: class_names("FormControl-file-field-preview", preview_class), + alt: "", + src: preview_url, + data: { "file-field-target": "preview" }.merge(preview_data) + }.compact + end + + def field_wrap_arguments + @field_wrap_arguments.merge( + class: class_names( + @field_wrap_arguments[:class], + "FormControl-file-field-wrap", + "FormControl-file-field-wrap--has-preview" => preview_url.present? + ) + ) + end + + def menu_label + I18n.t(:button_actions) + end + + def file_input_actions + actions = ["change->file-field#fileChosen"] + actions << "change->auto-submit#submit" if @input.auto_submit + + actions.join(" ") + end + + def replace_item_arguments + { + label: I18n.t(:button_replace), + content_arguments: { data: { action: "click->file-field#openFilePicker" } } + } + end + + def delete_item_arguments + { + label: I18n.t(:button_delete), + scheme: :danger, + href: delete_path, + content_arguments: @input.delete_button_arguments + } + end + end + end + end +end diff --git a/lib/primer/open_project/forms/file_field.sass b/lib/primer/open_project/forms/file_field.sass new file mode 100644 index 000000000000..d5bc5ce91474 --- /dev/null +++ b/lib/primer/open_project/forms/file_field.sass @@ -0,0 +1,70 @@ +.FormControl-file-field-wrap + position: relative + + // Only rendered server-side for an already-persisted image (see preview_url in + // file_field.rb), so it needs no visibility toggling of its own. + .FormControl-file-field-menu + position: absolute + top: var(--base-size-8) + right: var(--base-size-8) + + // Toggled via a modifier class rather than the `hidden` attribute: Primer's own + // `.octicon { display: ... }` rule has the same specificity as the browser's `[hidden]` + // default and loads later, so it silently wins and the icon stays visible. + .FormControl-file-field-preview + display: none + + .FormControl-file-field-placeholder + display: contents + + &--has-preview + .FormControl-file-field-preview + display: block + + .FormControl-file-field-placeholder + display: none + +.FormControl-file-field-drop-zone + @include unset-button-styles + display: flex + flex-direction: column + align-items: center + justify-content: center + gap: var(--stack-gap-condensed) + width: 100% + padding: var(--stack-padding-normal) + border: var(--borderWidth-thin, 1px) dashed var(--borderColor-default) + border-radius: var(--borderRadius-medium) + text-align: center + cursor: pointer + + &:hover, + &--dragging-over + border-color: var(--borderColor-accent-emphasis) + background-color: var(--bgColor-accent-muted) + + &--dragging-over-forbidden + cursor: not-allowed + border-color: var(--borderColor-neutral-muted) + background-color: var(--bgColor-neutral-muted) + + &:focus-visible + outline: 2px solid var(--focus-outlineColor) + outline-offset: 2px + + &[disabled] + cursor: not-allowed + opacity: 0.5 + +.FormControl-file-field-icon + fill: currentColor + +.FormControl-file-field-preview + max-width: 100% + max-height: 200px + border-radius: var(--borderRadius-medium) + object-fit: contain + +.FormControl-file-field-label + font-weight: var(--base-text-weight-semibold, 600) + color: var(--fgColor-default) diff --git a/lookbook/previews/open_project/common/advanced_form_inputs_preview.rb b/lookbook/previews/open_project/common/advanced_form_inputs_preview.rb index 9dc653cfbaa7..1d704d6d7a25 100644 --- a/lookbook/previews/open_project/common/advanced_form_inputs_preview.rb +++ b/lookbook/previews/open_project/common/advanced_form_inputs_preview.rb @@ -78,6 +78,30 @@ def segmented_control_input_multi def segmented_control_input_boolean render_with_template end + + # **FileFieldInput** + # + # A full-width, dashed-border drop zone replacing the browser-native file + # input button. Clicking it, or dropping a file onto it, opens/accepts the + # native file picker; the chosen file's name then replaces the label text. + # Toggle the field's options freely, including `auto_submit` (submits the + # form as soon as a file is chosen) and whether a preview image is present. + # Please note, the uploading and deleting do not work in the preview. + # @param label text + # @param description text + # @param caption text + # @param accept text + # @param auto_submit toggle + # @param with_preview toggle + # @param disabled toggle + def file_field(label: "Upload a file", description: "PDF, DOCX up to 10 MB", caption: "Shown below the field", + accept: ".pdf,.docx", auto_submit: false, with_preview: false, disabled: false) + preview_url = ActionController::Base.helpers.asset_path("icon_logo.svg") if with_preview + + render_with_template( + locals: { label:, description:, caption:, accept:, auto_submit:, disabled:, preview_url: } + ) + end end end end diff --git a/lookbook/previews/open_project/common/advanced_form_inputs_preview/file_field.html.erb b/lookbook/previews/open_project/common/advanced_form_inputs_preview/file_field.html.erb new file mode 100644 index 000000000000..6ca5934269f2 --- /dev/null +++ b/lookbook/previews/open_project/common/advanced_form_inputs_preview/file_field.html.erb @@ -0,0 +1,26 @@ +<% + the_form = Class.new(ApplicationForm) do + form do |f| + f.file_field( + name: :attachment, + label:, + description:, + caption:, + accept:, + auto_submit:, + disabled:, + preview_url:, + delete_path: (preview_url.present? ? "#" : nil) + ) + end + end +%> + +<%= + primer_form_with( + url: "/abc", + id: "file-field-playground" + ) do |f| + render(the_form.new(f)) + end +%> diff --git a/spec/features/custom_styles/tabs_navigation_spec.rb b/spec/features/custom_styles/tabs_navigation_spec.rb index 1f21dbc806f4..6ce5597a0c51 100644 --- a/spec/features/custom_styles/tabs_navigation_spec.rb +++ b/spec/features/custom_styles/tabs_navigation_spec.rb @@ -79,6 +79,7 @@ it "selects a color theme", :js do select("OpenProject Gray", from: "theme") + wait_for_reload expect_flash(message: I18n.t(:notice_successful_update)) @@ -154,17 +155,20 @@ click_on "Branding" expect(page).to have_current_path custom_style_path(tab: "branding") expect(page).to have_test_selector("color-theme-select") - expect(page).to have_field("custom_style_logo") - expect(page).to have_field("custom_style_logo_dark") - expect(page).to have_field("custom_style_logo_light_high_contrast") - expect(page).to have_field("custom_style_logo_mobile") - expect(page).to have_field("custom_style_logo_mobile_dark") - expect(page).to have_field("custom_style_logo_mobile_light_high_contrast") - expect(page).to have_field("custom_style_favicon") - expect(page).to have_field("custom_style_touch_icon") + # The native file inputs are intentionally hidden behind FileField's own + # drop zone (see lib/primer/open_project/forms/file_field.html.erb). + expect(page).to have_field("custom_style_logo", visible: :all) + expect(page).to have_field("custom_style_logo_dark", visible: :all) + expect(page).to have_field("custom_style_logo_light_high_contrast", visible: :all) + expect(page).to have_field("custom_style_logo_mobile", visible: :all) + expect(page).to have_field("custom_style_logo_mobile_dark", visible: :all) + expect(page).to have_field("custom_style_logo_mobile_light_high_contrast", visible: :all) + expect(page).to have_field("custom_style_favicon", visible: :all) + expect(page).to have_field("custom_style_touch_icon", visible: :all) # select a color theme and redirect to the branding tab select("OpenProject Navy Blue", from: "theme") + wait_for_reload expect_flash(message: I18n.t(:notice_successful_update)) expect(page).to have_current_path custom_style_path(tab: "branding") @@ -185,9 +189,12 @@ expect(desktop_logo_background).to include(custom_logo_path) + # The delete action now lives inside FileField's kebab action menu + # rather than being a standalone visible button. + find(:button, accessible_name: I18n.t(:button_actions)).click find_test_selector("delete-custom-style-image-logo").click + wait_for_reload - expect(page).to have_no_test_selector("delete-custom-style-image-logo") expect(custom_style.reload.logo).not_to be_present expect(desktop_logo_background).not_to include(custom_logo_path) expect(desktop_logo_background) diff --git a/spec/helpers/custom_styles_helper_spec.rb b/spec/helpers/custom_styles_helper_spec.rb index f4e657f5590f..9e13e1e00d4f 100644 --- a/spec/helpers/custom_styles_helper_spec.rb +++ b/spec/helpers/custom_styles_helper_spec.rb @@ -95,8 +95,7 @@ let(:current_theme) { build_stubbed(:custom_style) } before do - allow(current_theme).to receive(:favicon).and_return(true) - allow(current_theme).to receive(:touch_icon).and_return(true) + allow(current_theme).to receive_messages(favicon: true, touch_icon: true) end context "without EE", with_ee: false do @@ -368,6 +367,15 @@ expect(uploads.dig(:logo_dark, :delete_path)) .to eq(custom_style_logo_delete_path(field: :logo_dark)) end + + it "includes the preview's color-mode/theme data attributes for each logo mode" do + uploads = helper.custom_logo_uploads(style).index_by { |upload| upload[:field] } + + expect(uploads.dig(:logo, :preview_data)).to eq({ "color-mode": :light, "light-theme": :light }) + expect(uploads.dig(:logo_light_high_contrast, :preview_data)) + .to eq({ "color-mode": :light, "light-theme": :light_high_contrast }) + expect(uploads.dig(:logo_dark, :preview_data)).to eq({ "color-mode": :dark, "dark-theme": :dark }) + end end describe ".export_fonts_fields" do diff --git a/spec/lib/primer/open_project/forms/dsl/input_methods_spec.rb b/spec/lib/primer/open_project/forms/dsl/input_methods_spec.rb index 99efc794f745..1ff994882af8 100644 --- a/spec/lib/primer/open_project/forms/dsl/input_methods_spec.rb +++ b/spec/lib/primer/open_project/forms/dsl/input_methods_spec.rb @@ -218,6 +218,13 @@ it_behaves_like "supporting help texts" end + describe "#file_field" do + let(:field_group) { form_dsl.file_field(name:, label:, **options) } + + include_examples "input class", Primer::OpenProject::Forms::Dsl::FileFieldInput + it_behaves_like "supporting help texts" + end + describe "#project_autocompleter" do let(:field_group) { form_dsl.project_autocompleter(name:, label:, autocomplete_options: {}, **options) } diff --git a/spec/lib/primer/open_project/forms/file_field_spec.rb b/spec/lib/primer/open_project/forms/file_field_spec.rb new file mode 100644 index 000000000000..ff99d91d17cf --- /dev/null +++ b/spec/lib/primer/open_project/forms/file_field_spec.rb @@ -0,0 +1,204 @@ +# 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, see . +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe Primer::OpenProject::Forms::FileField, type: :forms do + include_context "with rendered inline form" + + let(:model) { build_stubbed(:project) } + let(:hidden_file_field) { "input[type=file][name='project[file]']" } + + def render_file_field(**) + vc_render_inline_form do |form| + form.file_field(name: :file, label: "Upload file", **) + end + end + + it "renders a hidden native file input" do + render_file_field + + expect(page).to have_css(hidden_file_field, visible: :all) + end + + it "scopes the file-field Stimulus controller over both the hidden input and the drop zone" do + render_file_field + + controller_scope = page.find("[data-controller='file-field']", visible: :all) + + expect(controller_scope).to have_css("[data-file-field-target='file']", visible: :all) + expect(controller_scope).to have_css("[data-file-field-target='dropBox']") + end + + it "renders a dashed drop zone showing the label as placeholder text" do + render_file_field + + expect(page).to have_button("Upload file", class: "FormControl-file-field-drop-zone") + end + + it "renders the caption below the drop zone when given, via the shared FormControl layout" do + render_file_field(caption: "PDF up to 10 MB") + + expect(page).to have_css(".FormControl > .FormControl-caption", text: "PDF up to 10 MB") + end + + it "renders the description inside the drop zone, separately from the caption below it" do + render_file_field(description: "PDF, DOCX up to 10 MB") + + expect(page).to have_css( + "button.FormControl-file-field-drop-zone .FormControl-caption", + text: "PDF, DOCX up to 10 MB" + ) + end + + it "renders an accessible, visually hidden label associated with the hidden file input" do + render_file_field + + label = page.find(".FormControl-label.sr-only", visible: :all) + expect(label[:for]).to eq(page.find(hidden_file_field, visible: :all)[:id]) + end + + it "disables both the hidden input and the drop zone when disabled" do + render_file_field(disabled: true) + + expect(page).to have_css("#{hidden_file_field}[disabled]", visible: :all) + expect(page).to have_button("Upload file", class: "FormControl-file-field-drop-zone", disabled: true) + end + + context "without auto_submit" do + it "only wires the change action to fileChosen" do + render_file_field + + field = page.find(hidden_file_field, visible: :all) + expect(field["data-action"]).to eq("change->file-field#fileChosen") + end + end + + context "with auto_submit" do + it "also wires the change action to auto-submit, so picking a file submits the form" do + render_file_field(auto_submit: true) + + field = page.find(hidden_file_field, visible: :all) + expect(field["data-action"]).to eq("change->file-field#fileChosen change->auto-submit#submit") + end + end + + context "without a preview_url" do + it "does not mark the wrapper as having a preview, so the icon/label/caption stay visible" do + render_file_field + + expect(page).to have_css("[data-controller='file-field']:not(.FormControl-file-field-wrap--has-preview)") + expect(page.find("img.FormControl-file-field-preview", visible: :all)[:src]).to be_blank + end + end + + context "with a preview_url" do + it "marks the wrapper as having a preview, hiding the icon/label/caption placeholder" do + render_file_field(preview_url: "/uploads/logo.png") + + expect(page).to have_css("[data-controller='file-field'].FormControl-file-field-wrap--has-preview") + preview = page.find("img.FormControl-file-field-preview") + expect(preview[:src]).to eq("/uploads/logo.png") + end + + it "adds the given preview_class alongside the component's own class" do + render_file_field(preview_url: "/uploads/logo.png", preview_class: "custom-logo-preview") + + expect(page).to have_css("img.FormControl-file-field-preview.custom-logo-preview") + end + + it "adds preview_data as data attributes on the preview image, alongside the component's own" do + render_file_field(preview_url: "/uploads/logo.png", preview_data: { "color-mode": :light, "light-theme": :light }) + + preview = page.find("img.FormControl-file-field-preview", visible: :all) + expect(preview["data-file-field-target"]).to eq("preview") + expect(preview["data-color-mode"]).to eq("light") + expect(preview["data-light-theme"]).to eq("light") + end + end + + describe "the actions menu" do + context "without a preview_url" do + it "does not render the menu, since there is nothing uploaded yet to act on" do + render_file_field(delete_path: "/uploads/logo") + + expect(page).to have_no_css(".FormControl-file-field-menu", visible: :all) + end + end + + context "with a preview_url, when disabled" do + it "does not render the menu, since the field cannot be acted on" do + render_file_field(preview_url: "/uploads/logo.png", delete_path: "/uploads/logo", disabled: true) + + expect(page).to have_no_css(".FormControl-file-field-menu", visible: :all) + end + end + + context "with a preview_url" do + it "renders a Replace item that triggers the same action as clicking the drop zone" do + render_file_field(preview_url: "/uploads/logo.png") + + expect(page).to have_css( + ".FormControl-file-field-menu [data-action='click->file-field#openFilePicker']", + text: "Replace", + visible: :all + ) + end + + context "without a delete_path" do + it "does not render a Delete item" do + render_file_field(preview_url: "/uploads/logo.png") + + expect(page).to have_no_css(".FormControl-file-field-menu a", text: "Delete", visible: :all) + end + end + + context "with a delete_path" do + it "renders a Delete item with the translated label and given href" do + render_file_field(preview_url: "/uploads/logo.png", delete_path: "/uploads/logo") + + link = page.find(".FormControl-file-field-menu a", text: "Delete", visible: :all) + expect(link[:href]).to eq("/uploads/logo") + end + + it "passes delete_button_arguments through to the Delete item" do + render_file_field( + preview_url: "/uploads/logo.png", + delete_path: "/uploads/logo", + delete_button_arguments: { data: { turbo_method: :delete, test_selector: "delete-logo" } } + ) + + link = page.find(".FormControl-file-field-menu a", text: "Delete", visible: :all) + expect(link["data-turbo-method"]).to eq("delete") + expect(link["data-test-selector"]).to eq("delete-logo") + end + end + end + end +end