mirror of
https://github.com/opf/openproject.git
synced 2026-06-13 19:20:00 +00:00
Merge remote-tracking branch 'origin/release/17.5' into dev
This commit is contained in:
@@ -95,14 +95,18 @@ See COPYRIGHT and LICENSE files for more details.
|
||||
<% flex.with_row(classes: "ellipsis") do %>
|
||||
<%= render(Primer::Beta::Text.new(color: :muted, font_size: :small)) do %>
|
||||
<%= "#{t('.parent')}: " %>
|
||||
<%= render(
|
||||
Primer::Beta::Link.new(
|
||||
href: work_package_path(work_package.parent),
|
||||
underline: false,
|
||||
color: :default
|
||||
)
|
||||
) do %>
|
||||
<%= work_package.parent.subject %>
|
||||
<% if parent_visible? %>
|
||||
<%= render(
|
||||
Primer::Beta::Link.new(
|
||||
href: work_package_path(work_package.parent),
|
||||
underline: false,
|
||||
color: :default
|
||||
)
|
||||
) do %>
|
||||
<%= work_package.parent.subject %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= t(".undisclosed") %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -85,9 +85,8 @@ module OpenProject
|
||||
}
|
||||
end
|
||||
|
||||
def show_parent?
|
||||
show_parent && work_package.parent&.visible?
|
||||
end
|
||||
def show_parent? = show_parent && work_package.parent
|
||||
def parent_visible? = work_package.parent&.visible?
|
||||
|
||||
def show_footer?
|
||||
additional_details? || show_parent?
|
||||
|
||||
+2
-1
@@ -45,7 +45,8 @@
|
||||
label: t("types.edit.form_configuration.add_query_group"),
|
||||
tag: :button,
|
||||
content_arguments: {
|
||||
data: { action: "click->admin--type-form-configuration--main#addQueryGroup" }
|
||||
data: { action: "click->admin--type-form-configuration--main#addQueryGroup",
|
||||
test_selector: "admin--type-form-configuration--add-query-group" }
|
||||
}
|
||||
) do |item|
|
||||
item.with_leading_visual_icon(icon: :table)
|
||||
|
||||
@@ -43,6 +43,14 @@ See COPYRIGHT and LICENSE files for more details.
|
||||
|
||||
<% content.with_main(classes: "type-form-configuration-page--main") do %>
|
||||
<div class="type-form-configuration-page--active-list" data-admin--type-form-configuration--rows-drag-and-drop-target="scrollContainer">
|
||||
<%= render(EnterpriseEdition::BannerComponent.new(:edit_attribute_groups, mb: 3)) %>
|
||||
|
||||
<% unless ee_available? %>
|
||||
<%= render(Primer::Alpha::Banner.new(scheme: :default, icon: :info, mb: 3)) do %>
|
||||
<%= t("text_form_configuration") %> <%= t("text_custom_field_hint_activate_per_project") %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= render(
|
||||
WorkPackageTypes::FormConfiguration::MainContentComponent.new(
|
||||
type: @type,
|
||||
|
||||
@@ -73,7 +73,10 @@ module Admin::Settings
|
||||
def find_slug
|
||||
@slug = Project.identifier_slugs.historically_reserved.find(params.expect(:id))
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render_404
|
||||
render_error_flash_message_via_turbo_stream(
|
||||
message: t("admin.reserved_identifiers.identifier_not_found")
|
||||
)
|
||||
respond_with_turbo_streams(status: :not_found)
|
||||
end
|
||||
|
||||
def build_query
|
||||
|
||||
@@ -229,7 +229,8 @@ class Journable::HistoricActiveRecordRelation < ActiveRecord::Relation
|
||||
raise NoMethodError, "Unknown timestamp type: #{timestamp.class}"
|
||||
end
|
||||
|
||||
"WHEN \"#{Journal.table_name}\".\"validity_period\" @> timestamp with time zone '#{comparison_time}' THEN '#{timestamp}'"
|
||||
quoted = ApplicationRecord.connection.quote(timestamp.to_s)
|
||||
"WHEN \"#{Journal.table_name}\".\"validity_period\" @> timestamp with time zone '#{comparison_time}' THEN #{quoted}"
|
||||
end
|
||||
.join(" ")
|
||||
end
|
||||
|
||||
@@ -40,9 +40,9 @@ class Timestamp
|
||||
|
||||
DATE_KEYWORD_REGEX =
|
||||
%r{
|
||||
^(?:#{ALLOWED_DATE_KEYWORDS.join('|')}) # match the relative date keyword
|
||||
\A(?:#{ALLOWED_DATE_KEYWORDS.join('|')}) # match the relative date keyword
|
||||
@(?:([0-1]?[0-9]|2[0-3]):[0-5]?[0-9]) # match the hour part
|
||||
[+-](?:([0-1]?[0-9]|2[0-3]):[0-5]?[0-9])$ # match the timezone offset
|
||||
[+-](?:([0-1]?[0-9]|2[0-3]):[0-5]?[0-9])\z # match the timezone offset
|
||||
}x
|
||||
|
||||
def initialize(string)
|
||||
|
||||
@@ -31,6 +31,30 @@
|
||||
class WorkPackages::Exports::ScheduleService
|
||||
attr_accessor :user
|
||||
|
||||
# Allowed option keys for the export pipeline from user input
|
||||
PERMITTED_EXPORT_OPTIONS = %i[
|
||||
filter_empty
|
||||
footer_text
|
||||
footer_text_center
|
||||
format
|
||||
gantt_mode
|
||||
gantt_width
|
||||
header_text_right
|
||||
hyphenation
|
||||
hyphenation_language
|
||||
language
|
||||
long_text_fields
|
||||
no_columns
|
||||
page
|
||||
page_orientation
|
||||
paper_size
|
||||
pdf_export_type
|
||||
save_export_settings
|
||||
show_descriptions
|
||||
show_images
|
||||
show_relations
|
||||
].freeze
|
||||
|
||||
def initialize(user:)
|
||||
self.user = user
|
||||
end
|
||||
@@ -50,7 +74,11 @@ class WorkPackages::Exports::ScheduleService
|
||||
mime_type:,
|
||||
query: serialize_query(query),
|
||||
query_attributes: serialize_query_props(query),
|
||||
**params)
|
||||
options: export_options(params))
|
||||
end
|
||||
|
||||
def export_options(params)
|
||||
params.permit(*PERMITTED_EXPORT_OPTIONS, columns: []).to_h
|
||||
end
|
||||
|
||||
##
|
||||
|
||||
@@ -31,14 +31,6 @@ See COPYRIGHT and LICENSE files for more details.
|
||||
|
||||
<%= render ::Types::EditPageHeaderComponent.new(type: @type, tabs: types_tabs) %>
|
||||
|
||||
<%= render(EnterpriseEdition::BannerComponent.new(:edit_attribute_groups, mb: 3)) %>
|
||||
|
||||
<% unless EnterpriseToken.allows_to?(:edit_attribute_groups) %>
|
||||
<%= render(Primer::Alpha::Banner.new(scheme: :default, icon: :info, mb: 3)) do %>
|
||||
<%= t("text_form_configuration") %> <%= t("text_custom_field_hint_activate_per_project") %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% no_filter_query = ::API::V3::Queries::QueryParamsRepresenter.new(Query.new_default.tap { |q| q.filters = [] }).to_json %>
|
||||
<%= render(
|
||||
WorkPackageTypes::FormConfigurationComponent.new(
|
||||
|
||||
@@ -34,11 +34,12 @@ module Exports
|
||||
class ExportJob < ::ApplicationJob
|
||||
queue_with_priority :above_normal
|
||||
|
||||
def perform(export:, user:, mime_type:, query:, **options)
|
||||
def perform(export:, user:, mime_type:, query:, query_attributes: nil, options: {})
|
||||
self.export = export
|
||||
self.current_user = user
|
||||
self.mime_type = mime_type
|
||||
self.query = query
|
||||
self.job_query_attributes = query_attributes
|
||||
self.options = options.with_indifferent_access
|
||||
|
||||
User.execute_as(user) do
|
||||
@@ -63,7 +64,7 @@ module Exports
|
||||
|
||||
class_attribute :model
|
||||
|
||||
attr_accessor :export, :current_user, :mime_type, :query, :options
|
||||
attr_accessor :export, :current_user, :mime_type, :query, :options, :job_query_attributes
|
||||
|
||||
def prepare!
|
||||
raise SubclassResponsibilityError
|
||||
|
||||
@@ -41,7 +41,7 @@ module WorkPackages
|
||||
private
|
||||
|
||||
def prepare!
|
||||
self.query = set_query_props(query || Query.new, options[:query_attributes])
|
||||
self.query = set_query_props(query || Query.new, job_query_attributes)
|
||||
end
|
||||
|
||||
def set_query_props(query, query_attributes)
|
||||
|
||||
+15
-14
@@ -66,8 +66,8 @@ en:
|
||||
col_identifier: "Identifier"
|
||||
col_project: "Project"
|
||||
col_reserved: "Reserved"
|
||||
not_available_in_semantic_mode: "Reserved identifiers are only available in numeric identifier mode."
|
||||
filter_label: "Filter identifiers"
|
||||
not_available_in_semantic_mode: "Reserved project identifiers are only available in numeric identifier mode."
|
||||
filter_label: "Search identifiers"
|
||||
btn_release: "Release"
|
||||
released_notice: 'Identifier "%{identifier}" has been released.'
|
||||
dialog:
|
||||
@@ -1442,12 +1442,12 @@ en:
|
||||
edit:
|
||||
form_configuration:
|
||||
tab: "Form configuration"
|
||||
label_group: "Group"
|
||||
reset_to_defaults: "Reset to defaults"
|
||||
add_attribute_group: "Add attribute group"
|
||||
add_query_group: "Add table of related work packages"
|
||||
delete_group: "Delete group"
|
||||
remove_attribute: "Remove from group"
|
||||
label_group: "Section"
|
||||
reset_to_defaults: "Reset form"
|
||||
add_attribute_group: "Section"
|
||||
add_query_group: "Related work packages table"
|
||||
delete_group: "Delete section"
|
||||
remove_attribute: "Remove from section"
|
||||
drag_to_activate: "Drag fields from here to activate them"
|
||||
drag_to_reorder: "Drag to reorder"
|
||||
edit_query: "Edit query"
|
||||
@@ -1457,12 +1457,12 @@ en:
|
||||
no_inactive_attributes: "No inactive attributes"
|
||||
blankslate_title: "No groups yet"
|
||||
blankslate_description: "Add groups using the button above or drag attributes from the left panel."
|
||||
group_actions: "Group actions"
|
||||
rename_group: "Rename group"
|
||||
confirm_delete_group: "Are you sure you want to delete this group? This action cannot be automatically reversed."
|
||||
group_name_label: "Group name"
|
||||
group_actions: "Section actions"
|
||||
rename_group: "Rename section"
|
||||
confirm_delete_group: "Are you sure you want to delete this section? This action cannot be automatically reversed."
|
||||
group_name_label: "Section name"
|
||||
row_actions: "Row actions"
|
||||
query_group_label: "Work packages table"
|
||||
query_group_label: "Related work packages table"
|
||||
empty_group_hint: "Drag attributes here"
|
||||
invalid_attribute_groups: "The form configuration payload is invalid."
|
||||
invalid_query: "The embedded query configuration is invalid."
|
||||
@@ -4426,7 +4426,7 @@ en:
|
||||
label_project_new: "New project"
|
||||
label_project_plural: "Projects"
|
||||
label_project_list_plural: "Project lists"
|
||||
label_reserved_identifiers: "Reserved identifiers"
|
||||
label_reserved_identifiers: "Reserved project identifiers"
|
||||
label_project_life_cycle: "Project life cycle"
|
||||
label_project_attributes_plural: "Project attributes"
|
||||
label_project_custom_field_plural: "Project attributes"
|
||||
@@ -4988,6 +4988,7 @@ en:
|
||||
menu:
|
||||
label_actions: "Work package actions"
|
||||
parent: "Parent"
|
||||
undisclosed: "Undisclosed"
|
||||
|
||||
permission_add_work_package_comments: "Add comments"
|
||||
permission_add_work_packages: "Add work packages"
|
||||
|
||||
@@ -62,7 +62,7 @@ A document has:
|
||||
|
||||
1. A title, a category, number of active editors and last saved date
|
||||
2. *More* menu with with options to edit, copy link and delete a document
|
||||
3. The he document text itself
|
||||
3. The document text itself
|
||||
4. Attachments
|
||||
|
||||
## Add a new document to the project
|
||||
@@ -102,10 +102,35 @@ Take a look at this example for an illustration.
|
||||
|
||||
### Link work packages to documents
|
||||
|
||||
You can link an existing work package to a document. To do that, start editing a document, type **/**, scroll down the list of available options and select *Link to existing work package*.
|
||||
You can link existing work packages to a document in two ways:
|
||||
|
||||
1. **Using the slash menu**
|
||||
|
||||
Start editing a document, type **/** to open the slash menu, then select **Link to existing work package** from the list of available options.
|
||||
|
||||

|
||||
|
||||
2. **Using inline work package links**
|
||||
|
||||
You can also link work packages directly within a line of text or a paragraph. Type **#**, **##**, or **###**, followed by a work package ID or part of the subject. Matching work packages will be suggested automatically.
|
||||
|
||||

|
||||
|
||||
The amount of work package information shown depends on the display format used:
|
||||
|
||||
- **#** displays the work package identifier.
|
||||
- **##** displays the work package type, identifier, and subject.
|
||||
- **###** displays the work package status, type, identifier, and subject.
|
||||
|
||||

|
||||
|
||||
Inline work package links behave like regular links and can be placed naturally within a paragraph, without adding a line break. Alternatively, work packages can be displayed as separate block-style cards.
|
||||
|
||||
Click the work package title to open it in a new browser tab. To change the display style of the linked work package, click the work package ID on the left to open the context menu. You can choose one of the available display options: **Tiny**, **Compact**, **Regular**, or **Compact card**.
|
||||
|
||||

|
||||
|
||||
|
||||
## Delete a project document
|
||||
|
||||
You can easily delete a document in OpenProject.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 969 KiB After Width: | Height: | Size: 977 KiB |
@@ -32,6 +32,10 @@
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
import { renderStreamMessage } from '@hotwired/turbo';
|
||||
|
||||
// Module-level store for cursor offsets, keyed by the field's stable key.
|
||||
// This survives Turbo Stream DOM replacement, while still being scoped per field.
|
||||
const cursorOffsets = new Map<string, number>();
|
||||
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
url: String,
|
||||
@@ -50,6 +54,10 @@ export default class extends Controller {
|
||||
this.boundFormDataHandler = (e:FormDataEvent) => this.appendStableKeySystemArguments(e);
|
||||
form.addEventListener('formdata', this.boundFormDataHandler);
|
||||
}
|
||||
|
||||
if (this.element instanceof HTMLInputElement || this.element instanceof HTMLTextAreaElement) {
|
||||
this.setCursorPosition(this.element);
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
@@ -72,6 +80,8 @@ export default class extends Controller {
|
||||
return;
|
||||
}
|
||||
|
||||
this.storeCursorPositionData(e);
|
||||
|
||||
const response = await fetch(this.urlValue, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'text/vnd.turbo-stream.html' },
|
||||
@@ -138,4 +148,86 @@ export default class extends Controller {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// When the controller is connected to a text input (i.e. the edit field has
|
||||
// just been rendered), apply the stored char offset so the cursor lands where
|
||||
// the user clicked in the display field.
|
||||
private setCursorPosition(element:HTMLInputElement|HTMLTextAreaElement):void {
|
||||
const key = this.stableKey;
|
||||
const offset = key !== undefined ? cursorOffsets.get(key) : undefined;
|
||||
if (key !== undefined) cursorOffsets.delete(key);
|
||||
|
||||
if (offset !== undefined) {
|
||||
// requestAnimationFrame ensures autofocus has run and the element is focused.
|
||||
// setSelectionRange is not supported on all input types (e.g. number, date) —
|
||||
// those will silently keep the browser's default cursor placement.
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
element.setSelectionRange(offset, offset);
|
||||
this.scrollToCursor(element, offset);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// setSelectionRange moves the cursor but does not update scrollLeft.
|
||||
// Approximate the cursor's pixel position proportionally via scrollWidth
|
||||
// and center it in the visible area.
|
||||
private scrollToCursor(element:HTMLInputElement|HTMLTextAreaElement, offset:number):void {
|
||||
const { scrollWidth, clientWidth, value } = element;
|
||||
if (!value.length) return;
|
||||
// Estimate the cursor's pixel position proportionally within the full text width.
|
||||
// Then shift scrollLeft so the cursor lands in the center of the visible area.
|
||||
// Math.max(0, ...) prevents a negative scroll when the cursor is near the start.
|
||||
const cursorX = (offset / value.length) * scrollWidth;
|
||||
element.scrollLeft = Math.max(0, cursorX - clientWidth / 2);
|
||||
}
|
||||
|
||||
private storeCursorPositionData(e:Event):void {
|
||||
const key = this.stableKey;
|
||||
if (!key) return;
|
||||
|
||||
if (e instanceof MouseEvent) {
|
||||
const container = e.currentTarget as HTMLElement;
|
||||
|
||||
// For plain-text inputs: store the char offset at the click position so
|
||||
// the rendered text input can place the cursor accurately via setSelectionRange.
|
||||
let range:Range | null = null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-explicit-any
|
||||
if ((e as any).rangeParent) {
|
||||
range = document.createRange();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-explicit-any
|
||||
range.setStart((e as any).rangeParent, (e as any).rangeOffset);
|
||||
} else {
|
||||
const legacyDocument = document as { caretRangeFromPoint?:(x:number, y:number) => Range };
|
||||
range = legacyDocument.caretRangeFromPoint?.(e.clientX, e.clientY) ?? null;
|
||||
}
|
||||
|
||||
if (range && container.contains(range.startContainer)) {
|
||||
cursorOffsets.set(key, this.getCharOffset(container, range.startContainer, range.startOffset));
|
||||
} else {
|
||||
cursorOffsets.delete(key);
|
||||
}
|
||||
} else {
|
||||
cursorOffsets.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
private get stableKey():string | undefined {
|
||||
return this.element.closest<HTMLElement>('[data-inplace-edit-stable-key]')?.dataset.inplaceEditStableKey;
|
||||
}
|
||||
|
||||
private getCharOffset(root:Element, targetNode:Node, targetOffset:number):number {
|
||||
let count = 0;
|
||||
let node:Node|null;
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
|
||||
while ((node = walker.nextNode())) {
|
||||
if (node === targetNode) return count + targetOffset;
|
||||
count += (node as Text).length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,10 +47,7 @@ class CostQuery::ScheduleExportService
|
||||
job.perform_later(export: export_storage,
|
||||
user:,
|
||||
mime_type: format,
|
||||
query_id:,
|
||||
query_name:,
|
||||
query: filter_params,
|
||||
project:,
|
||||
cost_types:)
|
||||
options: { query_id:, query_name:, project:, cost_types: })
|
||||
end
|
||||
end
|
||||
|
||||
@@ -67,8 +67,7 @@ RSpec.describe CostQuery::PDF::ExportTimesheetJob do
|
||||
user:,
|
||||
mime_type: :pdf,
|
||||
query:,
|
||||
project:,
|
||||
cost_types: [-1, 0]
|
||||
options: { project:, cost_types: [-1, 0] }
|
||||
)
|
||||
job.perform_now
|
||||
job
|
||||
|
||||
@@ -67,8 +67,7 @@ RSpec.describe CostQuery::XLS::ExportJob do
|
||||
user:,
|
||||
mime_type: :xls,
|
||||
query:,
|
||||
project:,
|
||||
cost_types: [-1, 0]
|
||||
options: { project:, cost_types: [-1, 0] }
|
||||
)
|
||||
job.perform_now
|
||||
job
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe OpenProject::Common::WorkPackageCardComponent, type: :component do
|
||||
include Rails.application.routes.url_helpers
|
||||
|
||||
shared_let(:type_feature) { create(:type_feature) }
|
||||
shared_let(:default_status) { create(:default_status) }
|
||||
shared_let(:default_priority) { create(:default_priority) }
|
||||
@@ -38,6 +40,7 @@ RSpec.describe OpenProject::Common::WorkPackageCardComponent, type: :component d
|
||||
shared_let(:project) { create(:project, types: [type_feature]) }
|
||||
|
||||
let(:menu_src) { "/work_packages/#{work_package.id}/menu" }
|
||||
let(:parent) { nil }
|
||||
let(:work_package) do
|
||||
create(:work_package,
|
||||
project:,
|
||||
@@ -47,7 +50,8 @@ RSpec.describe OpenProject::Common::WorkPackageCardComponent, type: :component d
|
||||
subject: "Card subject",
|
||||
story_points: 5,
|
||||
position: 1,
|
||||
sprint: nil)
|
||||
sprint: nil,
|
||||
parent:)
|
||||
end
|
||||
|
||||
let(:component) do
|
||||
@@ -59,71 +63,118 @@ RSpec.describe OpenProject::Common::WorkPackageCardComponent, type: :component d
|
||||
render_inline(component)
|
||||
end
|
||||
|
||||
describe "card content" do
|
||||
it "renders the work-package info line (type + id)" do
|
||||
expect(rendered_component).to have_text("FEATURE")
|
||||
expect(rendered_component).to have_text("##{work_package.id}")
|
||||
it "renders the work-package info line (type + id)" do
|
||||
expect(rendered_component).to have_text("FEATURE")
|
||||
expect(rendered_component).to have_text("##{work_package.id}")
|
||||
end
|
||||
|
||||
it "renders the subject in semibold text" do
|
||||
expect(rendered_component).to have_text("Card subject")
|
||||
end
|
||||
|
||||
it "does not render story points by default" do
|
||||
expect(rendered_component).to have_no_text("5 points", normalize_ws: true)
|
||||
end
|
||||
|
||||
it "renders the metric slot when provided" do
|
||||
rendered = render_inline(component) do |card|
|
||||
card.with_metric { "Custom metric" }
|
||||
end
|
||||
|
||||
it "renders the subject in semibold text" do
|
||||
expect(rendered_component).to have_text("Card subject")
|
||||
end
|
||||
expect(rendered).to have_text("Custom metric")
|
||||
end
|
||||
|
||||
it "does not render story points by default" do
|
||||
expect(rendered_component).to have_no_text("5 points", normalize_ws: true)
|
||||
end
|
||||
it "renders a WorkPackageCardComponent::Menu kebab" do
|
||||
expect(rendered_component).to have_element :"action-menu"
|
||||
expect(rendered_component).to have_button(menu_button_id)
|
||||
end
|
||||
|
||||
it "renders the metric slot when provided" do
|
||||
rendered = render_inline(component) do |card|
|
||||
card.with_metric { "Custom metric" }
|
||||
it "uses the work package actions label" do
|
||||
expect(rendered_component).to have_button(
|
||||
menu_button_id,
|
||||
accessible_name: I18n.t("open_project.common.work_package_card_component.menu.label_actions")
|
||||
)
|
||||
end
|
||||
|
||||
it "uses the provided menu src" do
|
||||
expect(rendered_component).to have_element "include-fragment", src: menu_src
|
||||
end
|
||||
|
||||
it "supports inline menu items through the menu slot" do
|
||||
rendered = render_inline(component) do |card|
|
||||
card.with_menu(button_aria_label: "Card actions") do |menu|
|
||||
menu.with_item(label: "Open", href: "/work_packages/#{work_package.id}")
|
||||
end
|
||||
|
||||
expect(rendered).to have_text("Custom metric")
|
||||
end
|
||||
|
||||
it "renders a WorkPackageCardComponent::Menu kebab" do
|
||||
expect(rendered_component).to have_element :"action-menu"
|
||||
expect(rendered_component).to have_button(menu_button_id)
|
||||
expect(rendered).to have_link "Open", href: "/work_packages/#{work_package.id}"
|
||||
expect(rendered).to have_button(menu_button_id, accessible_name: "Card actions")
|
||||
expect(rendered).to have_no_element "include-fragment"
|
||||
end
|
||||
|
||||
it "supports deferred menu loading through the menu slot" do
|
||||
rendered = render_inline(described_class.new(work_package:)) do |card|
|
||||
card.with_menu(src: menu_src)
|
||||
end
|
||||
|
||||
it "uses the work package actions label" do
|
||||
expect(rendered_component).to have_button(
|
||||
menu_button_id,
|
||||
accessible_name: I18n.t("open_project.common.work_package_card_component.menu.label_actions")
|
||||
)
|
||||
expect(rendered).to have_element "include-fragment", src: menu_src
|
||||
end
|
||||
|
||||
it "uses the menu slot before the initializer menu source" do
|
||||
rendered = render_inline(component) do |card|
|
||||
card.with_menu(src: "/slot-menu")
|
||||
end
|
||||
|
||||
it "uses the provided menu src" do
|
||||
expect(rendered_component).to have_element "include-fragment", src: menu_src
|
||||
expect(rendered).to have_element "include-fragment", src: "/slot-menu"
|
||||
expect(rendered).to have_no_element "include-fragment", src: menu_src
|
||||
end
|
||||
|
||||
describe "parent display" do
|
||||
context "when show_parent is false (default)" do
|
||||
let(:parent) { create(:work_package, project:) }
|
||||
|
||||
it "does not render the parent row" do
|
||||
expect(rendered_component).to have_no_text("Parent")
|
||||
end
|
||||
end
|
||||
|
||||
it "supports inline menu items through the menu slot" do
|
||||
rendered = render_inline(component) do |card|
|
||||
card.with_menu(button_aria_label: "Card actions") do |menu|
|
||||
menu.with_item(label: "Open", href: "/work_packages/#{work_package.id}")
|
||||
context "when show_parent is true" do
|
||||
let(:component) { described_class.new(work_package:, menu_src:, show_parent: true) }
|
||||
|
||||
context "when the work package has no parent" do
|
||||
let(:parent) { nil }
|
||||
|
||||
it "does not render the parent row" do
|
||||
expect(rendered_component).to have_no_text("Parent")
|
||||
end
|
||||
end
|
||||
|
||||
expect(rendered).to have_link "Open", href: "/work_packages/#{work_package.id}"
|
||||
expect(rendered).to have_button(menu_button_id, accessible_name: "Card actions")
|
||||
expect(rendered).to have_no_element "include-fragment"
|
||||
end
|
||||
context "when the work package has a visible parent" do
|
||||
let(:parent) { create(:work_package, project:, subject: "Parent subject") }
|
||||
|
||||
it "supports deferred menu loading through the menu slot" do
|
||||
rendered = render_inline(described_class.new(work_package:)) do |card|
|
||||
card.with_menu(src: menu_src)
|
||||
before { allow(work_package.parent).to receive(:visible?).and_return(true) }
|
||||
|
||||
it "renders a link to the parent" do
|
||||
expect(rendered_component).to have_text("Parent")
|
||||
expect(rendered_component).to have_link("Parent subject", href: work_package_path(parent))
|
||||
end
|
||||
|
||||
it "does not render Undisclosed" do
|
||||
expect(rendered_component).to have_no_text("Undisclosed")
|
||||
end
|
||||
end
|
||||
|
||||
expect(rendered).to have_element "include-fragment", src: menu_src
|
||||
end
|
||||
context "when the work package has a non-visible parent" do
|
||||
let(:parent) { create(:work_package, project:, subject: "Hidden subject") }
|
||||
|
||||
it "uses the menu slot before the initializer menu source" do
|
||||
rendered = render_inline(component) do |card|
|
||||
card.with_menu(src: "/slot-menu")
|
||||
before { allow(work_package.parent).to receive(:visible?).and_return(false) }
|
||||
|
||||
it "renders Undisclosed instead of a link" do
|
||||
expect(rendered_component).to have_text("Parent")
|
||||
expect(rendered_component).to have_text("Undisclosed")
|
||||
expect(rendered_component).to have_no_link("Hidden subject")
|
||||
end
|
||||
end
|
||||
|
||||
expect(rendered).to have_element "include-fragment", src: "/slot-menu"
|
||||
expect(rendered).to have_no_element "include-fragment", src: menu_src
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -117,9 +117,11 @@ RSpec.describe Admin::Settings::ProjectReservedIdentifiersController do
|
||||
end
|
||||
|
||||
context "with an unknown id" do
|
||||
it "renders 404" do
|
||||
it "responds with a turbo stream error flash" do
|
||||
get :confirm_dialog, params: { id: 0 }, format: :turbo_stream
|
||||
expect(response).to have_http_status(:not_found)
|
||||
expect(response.media_type).to eq("text/vnd.turbo-stream.html")
|
||||
expect(response.body).to include(I18n.t("admin.reserved_identifiers.identifier_not_found"))
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -127,9 +129,11 @@ RSpec.describe Admin::Settings::ProjectReservedIdentifiersController do
|
||||
context "when the slug is the project's own current active identifier" do
|
||||
let!(:slug) { project.slugs.find_by!(slug: "current-id") }
|
||||
|
||||
it "renders 404 because the slug is not historically reserved" do
|
||||
it "responds with a turbo stream error flash because the slug is not historically reserved" do
|
||||
get :confirm_dialog, params: { id: slug.id }, format: :turbo_stream
|
||||
expect(response).to have_http_status(:not_found)
|
||||
expect(response.media_type).to eq("text/vnd.turbo-stream.html")
|
||||
expect(response.body).to include(I18n.t("admin.reserved_identifiers.identifier_not_found"))
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -147,9 +151,11 @@ RSpec.describe Admin::Settings::ProjectReservedIdentifiersController do
|
||||
end
|
||||
|
||||
context "with an unknown id" do
|
||||
it "renders 404" do
|
||||
delete :destroy, params: { id: 0 }
|
||||
it "responds with a turbo stream error flash" do
|
||||
delete :destroy, params: { id: 0 }, format: :turbo_stream
|
||||
expect(response).to have_http_status(:not_found)
|
||||
expect(response.media_type).to eq("text/vnd.turbo-stream.html")
|
||||
expect(response.body).to include(I18n.t("admin.reserved_identifiers.identifier_not_found"))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -97,7 +97,7 @@ RSpec.describe "form query configuration", :js do
|
||||
form.expect_group("Empty test", "Empty test")
|
||||
end
|
||||
|
||||
it "can edit a query group by clicking the Work packages table link" do
|
||||
it "can edit a query group by clicking the related work packages table link" do
|
||||
form.add_query_group("Link test", :children, expect: false)
|
||||
form.expect_group("Link test", "Link test")
|
||||
|
||||
|
||||
@@ -156,7 +156,7 @@ RSpec.describe "form configuration", :js, :selenium do
|
||||
form.drag_and_drop(form.find_attribute_handle(:version), form.inactive_group)
|
||||
form.expect_inactive(:version)
|
||||
|
||||
# Rename group
|
||||
# Rename section
|
||||
form.rename_group("Details", "Whatever")
|
||||
form.rename_group("People", "Cool Stuff")
|
||||
|
||||
@@ -272,6 +272,10 @@ RSpec.describe "form configuration", :js, :selenium do
|
||||
initial_order = form.group_order
|
||||
|
||||
form.add_button_dropdown.click
|
||||
|
||||
expect(page).to have_text(I18n.t("types.edit.form_configuration.add_attribute_group"))
|
||||
expect(page).to have_text(I18n.t("types.edit.form_configuration.add_query_group"))
|
||||
|
||||
click_on I18n.t("types.edit.form_configuration.add_attribute_group")
|
||||
|
||||
expect(page.find_test_selector("type-form-configuration-group-name-input", wait: 10).value).to eq("")
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# 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.
|
||||
#++
|
||||
|
||||
require "spec_helper"
|
||||
|
||||
RSpec.describe Journable::HistoricActiveRecordRelation do
|
||||
let(:project) { create(:project) }
|
||||
let!(:work_package) { create(:work_package, project:) }
|
||||
|
||||
describe "#timestamp_case_when_statements" do
|
||||
context "when a Timestamp object wraps a multi-line string" do
|
||||
let(:crafted_ts) { Timestamp.new("oneDayAgo@00:00+00:00\n@' extra_content") }
|
||||
let(:historic_relation) do
|
||||
described_class.new(WorkPackage.all, timestamp: [crafted_ts])
|
||||
end
|
||||
|
||||
it "does not allow the extra content to break out of the SQL string literal" do
|
||||
sql = historic_relation.send(:timestamp_case_when_statements)
|
||||
# The apostrophe in the crafted input must be SQL-escaped (doubled), not left bare.
|
||||
# An unescaped @' sequence would close the string literal and allow SQL injection.
|
||||
expect(sql).not_to include("@' extra_content")
|
||||
end
|
||||
end
|
||||
|
||||
context "when a Timestamp object wraps a single-line date-keyword string" do
|
||||
let(:valid_ts) { Timestamp.parse("oneDayAgo@00:00+00:00") }
|
||||
let(:historic_relation) do
|
||||
described_class.new(WorkPackage.all, timestamp: [valid_ts])
|
||||
end
|
||||
|
||||
it "generates a WHEN/THEN clause containing the label" do
|
||||
sql = historic_relation.send(:timestamp_case_when_statements)
|
||||
expect(sql).to match(/WHEN .+ THEN .+oneDayAgo/)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,64 @@
|
||||
# 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.
|
||||
#++
|
||||
|
||||
require "spec_helper"
|
||||
|
||||
# Timestamp strings that span multiple lines must be rejected at every entry point.
|
||||
# The date-keyword regex previously used ^ and $ (line anchors), which caused it to
|
||||
# match only the first line of a multi-line string. The remaining lines were silently
|
||||
# kept in the stored string and later interpolated verbatim into SQL.
|
||||
RSpec.describe Timestamp do
|
||||
let(:valid_keyword) { "oneDayAgo@00:00+00:00" }
|
||||
let(:multiline_input) { "#{valid_keyword}\n@' extra_content" }
|
||||
|
||||
describe ".parse" do
|
||||
it "accepts a single-line date-keyword timestamp" do
|
||||
expect { described_class.parse(valid_keyword) }.not_to raise_error
|
||||
end
|
||||
|
||||
it "rejects a multi-line string whose first line is a valid date keyword" do
|
||||
expect { described_class.parse(multiline_input) }.to raise_error(ArgumentError)
|
||||
end
|
||||
|
||||
it "rejects a multi-line string whose first line is a valid ISO 8601 datetime" do
|
||||
expect { described_class.parse("2024-01-01T00:00:00Z\nextra_content") }.to raise_error(ArgumentError)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#valid?" do
|
||||
it "returns true for a single-line date-keyword timestamp" do
|
||||
expect(described_class.new(valid_keyword)).to be_valid
|
||||
end
|
||||
|
||||
it "returns false for a multi-line string whose first line is a valid date keyword" do
|
||||
expect(described_class.new(multiline_input)).not_to be_valid
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,116 @@
|
||||
# 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.
|
||||
#++
|
||||
|
||||
require "spec_helper"
|
||||
|
||||
RSpec.describe WorkPackages::Exports::ScheduleService do
|
||||
let(:user) { create(:user) }
|
||||
let(:project) { create(:project) }
|
||||
let(:query) { build(:query, project:, user:) }
|
||||
let(:service) { described_class.new(user:) }
|
||||
let(:captured_job_args) { {} }
|
||||
|
||||
# In the service QueriesHelper#retrieve_query calls params.permit! before
|
||||
# export_list is invoked. Reproduce that precondition here so to_unsafe_h succeeds.
|
||||
def permitted(hash)
|
||||
ActionController::Parameters.new(hash).permit!
|
||||
end
|
||||
|
||||
before do
|
||||
allow(WorkPackages::Export).to receive(:create).and_return(build_stubbed(:work_packages_export))
|
||||
allow(WorkPackages::ExportJob).to receive(:perform_later) do |**args|
|
||||
captured_job_args.merge!(args)
|
||||
instance_double(WorkPackages::ExportJob, job_id: "test-job-id")
|
||||
end
|
||||
end
|
||||
|
||||
describe "#call — parameter safety" do
|
||||
context "when browser params include query_attributes alongside export options" do
|
||||
let(:injected_filters) { "--- \nid:\n :operator: \"*\"\n :values: []\n" }
|
||||
let(:browser_params) do
|
||||
permitted(
|
||||
"query_attributes" => { "filters" => injected_filters },
|
||||
"columns" => %w[id subject]
|
||||
)
|
||||
end
|
||||
|
||||
it "passes query_attributes from the server, not from browser params" do
|
||||
expected_filters = Queries::WorkPackages::FilterSerializer.dump(query.filters)
|
||||
|
||||
service.call(query:, mime_type: :csv, params: browser_params)
|
||||
|
||||
expect(captured_job_args[:query_attributes]).to be_a(Hash)
|
||||
expect(captured_job_args[:query_attributes]["filters"]).to eq(expected_filters)
|
||||
end
|
||||
|
||||
it "browser-supplied query_attributes appear only inside :options, not as a top-level job kwarg" do
|
||||
service.call(query:, mime_type: :csv, params: browser_params)
|
||||
|
||||
# query_attributes should not appear in options either
|
||||
expect(captured_job_args[:options]).not_to have_key("query_attributes")
|
||||
expect(captured_job_args[:options]).not_to have_key(:query_attributes)
|
||||
end
|
||||
|
||||
it "forwards permitted export options inside :options" do
|
||||
service.call(query:, mime_type: :csv, params: browser_params)
|
||||
|
||||
expect(captured_job_args[:options]).to include("columns" => %w[id subject])
|
||||
end
|
||||
end
|
||||
|
||||
context "when browser params include reserved job kwarg names" do
|
||||
let(:browser_params) do
|
||||
permitted(
|
||||
"user" => "myuser",
|
||||
"export" => "fake_export",
|
||||
"mime_type" => "text/csv",
|
||||
"query" => "overridden_query",
|
||||
"query_attributes" => { "filters" => "injected" }
|
||||
)
|
||||
end
|
||||
|
||||
it "preserves service-set :user and :mime_type as top-level job kwargs" do
|
||||
service.call(query:, mime_type: :csv, params: browser_params)
|
||||
|
||||
expect(captured_job_args[:user]).to eq(user)
|
||||
expect(captured_job_args[:mime_type]).to eq(:csv)
|
||||
end
|
||||
|
||||
it "does not consume reserved names into :options", :aggregate_failures do
|
||||
service.call(query:, mime_type: :csv, params: browser_params)
|
||||
|
||||
reserved = %w[user export mime_type query query_attributes]
|
||||
reserved.each do |key|
|
||||
expect(captured_job_args[:options]).not_to have_key(key), "expected #{key} to be absent from :options"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -37,7 +37,7 @@ module Components
|
||||
include Rails.application.routes.url_helpers
|
||||
|
||||
def add_button_dropdown
|
||||
page.find(:test_id, "type-form-configuration-add-button", text: /\A#{Regexp.escape(I18n.t(:button_add))}\z/)
|
||||
page.find_test_selector("type-form-configuration-add-button")
|
||||
end
|
||||
|
||||
def reset_button
|
||||
@@ -127,7 +127,7 @@ module Components
|
||||
SeleniumHubWaiter.wait unless using_cuprite?
|
||||
|
||||
add_button_dropdown.click
|
||||
click_on I18n.t("types.edit.form_configuration.add_query_group")
|
||||
page.find_test_selector("admin--type-form-configuration--add-query-group").click
|
||||
|
||||
modal = ::Components::WorkPackages::TableConfigurationModal.new
|
||||
expect(page).to have_css(".wp-table--configuration-modal", wait: 10)
|
||||
|
||||
@@ -44,7 +44,7 @@ module Pages
|
||||
end
|
||||
|
||||
def table_container
|
||||
container.first(".work-package-table", wait: 10)
|
||||
container.find("table.work-package-table", wait: 10)
|
||||
end
|
||||
|
||||
def click_reference_inline_create
|
||||
|
||||
Reference in New Issue
Block a user