diff --git a/frontend/src/app/shared/components/fields/display/display-field.service.ts b/frontend/src/app/shared/components/fields/display/display-field.service.ts index a15fb0520b15..2fd1ab36f9b3 100644 --- a/frontend/src/app/shared/components/fields/display/display-field.service.ts +++ b/frontend/src/app/shared/components/fields/display/display-field.service.ts @@ -27,18 +27,27 @@ //++ import { Injectable, Injector } from '@angular/core'; + import { HalResource } from 'core-app/features/hal/resources/hal-resource'; -import { AbstractFieldService, IFieldType } from 'core-app/shared/components/fields/field.service'; -import { DisplayField } from 'core-app/shared/components/fields/display/display-field.module'; import { IFieldSchema } from 'core-app/shared/components/fields/field.base'; -import { MultipleLinesCustomOptionsDisplayField } from 'core-app/shared/components/fields/display/field-types/multiple-lines-custom-options-display-field.module'; -import { MultipleLinesUserFieldModule } from 'core-app/shared/components/fields/display/field-types/multiple-lines-user-display-field.module'; -import { ProgressTextDisplayField } from 'core-app/shared/components/fields/display/field-types/progress-text-display-field.module'; +import { DisplayField } from 'core-app/shared/components/fields/display/display-field.module'; +import { AbstractFieldService, IFieldType } from 'core-app/shared/components/fields/field.service'; +import { + MultipleLinesCustomOptionsDisplayField, +} from 'core-app/shared/components/fields/display/field-types/multiple-lines-custom-options-display-field.module'; +import { + MultipleLinesUserFieldModule, +} from 'core-app/shared/components/fields/display/field-types/multiple-lines-user-display-field.module'; +import { + ProgressTextDisplayField, +} from 'core-app/shared/components/fields/display/field-types/progress-text-display-field.module'; import { DateDisplayField } from 'core-app/shared/components/fields/display/field-types/date-display-field.module'; - -export interface IDisplayFieldType extends IFieldType { - new(resource:HalResource, attributeType:string, schema:IFieldSchema, context:DisplayFieldContext):DisplayField; -} +import { + HierarchyItemDisplayField, +} from 'core-app/shared/components/fields/display/field-types/hierarchy-item-display-field.module'; +import { + MultipleLinesHierarchyItemDisplayField, +} from 'core-app/shared/components/fields/display/field-types/multiple-lines-hierarchy-item-display-field.module'; export interface DisplayFieldContext { /** The injector to use for the context of this field. Relevant for embedded service injection */ @@ -51,6 +60,10 @@ export interface DisplayFieldContext { options:{ [key:string]:any }; } +export interface IDisplayFieldType extends IFieldType { + new(resource:HalResource, attributeType:string, schema:IFieldSchema, context:DisplayFieldContext):DisplayField; +} + @Injectable({ providedIn: 'root' }) export class DisplayFieldService extends AbstractFieldService { /** @@ -80,6 +93,17 @@ export class DisplayFieldService extends AbstractFieldService= 0; + if (context.container === 'single-view' && isHierarchyItemsField) { + return new HierarchyItemDisplayField(fieldName, context) as DisplayField; + } + + const isMultilineHierarchyItemsField = ['[]CustomField::Hierarchy::Item'].indexOf(schema.type) >= 0; + if (context.container === 'single-view' && isMultilineHierarchyItemsField) { + return new MultipleLinesHierarchyItemDisplayField(fieldName, context) as DisplayField; + } + // Separate class seems not needed (merge with []CustomOption above?) const isVersionMultiLinesField = ['[]Version'].indexOf(schema.type) >= 0; if (context.container === 'single-view' && isVersionMultiLinesField) { @@ -104,6 +128,6 @@ export class DisplayFieldService extends AbstractFieldService { + element.appendChild(path); + }); + } + + private branch(item:HalResource):Observable { + const itemLink = item.$link as HalLink; + + return from(itemLink.$fetch()) + .pipe( + switchMap((resource:HalResource) => renderHierarchyItem(resource)), + ); + } +} diff --git a/frontend/src/app/shared/components/fields/display/field-types/multiple-lines-custom-options-display-field.module.ts b/frontend/src/app/shared/components/fields/display/field-types/multiple-lines-custom-options-display-field.module.ts index d1e3109c5a05..c16be64260f4 100644 --- a/frontend/src/app/shared/components/fields/display/field-types/multiple-lines-custom-options-display-field.module.ts +++ b/frontend/src/app/shared/components/fields/display/field-types/multiple-lines-custom-options-display-field.module.ts @@ -44,6 +44,10 @@ export class MultipleLinesCustomOptionsDisplayField extends ResourcesDisplayFiel } } + public get valueString():string { + return this.stringValue.join(', '); + } + protected renderValues(values:string[], element:HTMLElement) { values.forEach((value) => { const div = document.createElement('div'); diff --git a/frontend/src/app/shared/components/fields/display/field-types/multiple-lines-hierarchy-item-display-field.module.ts b/frontend/src/app/shared/components/fields/display/field-types/multiple-lines-hierarchy-item-display-field.module.ts new file mode 100644 index 000000000000..4e60d43c9c19 --- /dev/null +++ b/frontend/src/app/shared/components/fields/display/field-types/multiple-lines-hierarchy-item-display-field.module.ts @@ -0,0 +1,70 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { combineLatest, from, Observable } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { HalLink } from 'core-app/features/hal/hal-link/hal-link'; +import { + ResourcesDisplayField, +} from 'core-app/shared/components/fields/display/field-types/resources-display-field.module'; +import { renderHierarchyItem } from 'core-app/shared/components/fields/display/field-types/render-hierarchy-item'; + +export class MultipleLinesHierarchyItemDisplayField extends ResourcesDisplayField { + public render(element:HTMLElement, _displayText:string) { + const items = this.attribute as HalResource[]; + if (items.length === 0) { + this.renderEmpty(element); + return; + } + + element.innerHTML = ''; + element.classList.add('hierarchy-items'); + this.branches(items).subscribe((elements) => { + elements.forEach((el) => { + element.appendChild(el); + }); + }); + } + + public get valueString():string { + return this.stringValue.join(', '); + } + + private branches(items:HalResource[]):Observable { + return combineLatest(items.map((value:HalResource) => { + const itemLink = value.$link as HalLink; + + return from(itemLink.$fetch()) + .pipe( + switchMap((resource:HalResource) => renderHierarchyItem(resource)), + ); + })); + } +} diff --git a/frontend/src/app/shared/components/fields/display/field-types/render-hierarchy-item.ts b/frontend/src/app/shared/components/fields/display/field-types/render-hierarchy-item.ts new file mode 100644 index 000000000000..da91fb19b34c --- /dev/null +++ b/frontend/src/app/shared/components/fields/display/field-types/render-hierarchy-item.ts @@ -0,0 +1,72 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { from, Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { HalResource } from 'core-app/features/hal/resources/hal-resource'; +import { CollectionResource } from 'core-app/features/hal/resources/collection-resource'; + +export function renderHierarchyItem(item:HalResource):Observable { + const customFieldItemLinks = item.$links as { branch:() => HalResource[] }; + return from(customFieldItemLinks.branch()) + .pipe( + map((ancestors:CollectionResource) => spansFromAncestors(ancestors)), + map((spans) => { + const div = document.createElement('div'); + div.className = 'path'; + spans.forEach((span) => div.appendChild(span)); + return div; + }), + ); +} + +function spansFromAncestors(ancestors:CollectionResource):HTMLSpanElement[] { + const spans:HTMLSpanElement[] = []; + + ancestors.elements + .filter((el) => !!el.label) + .forEach((el, idx, all) => { + const span = document.createElement('span'); + span.textContent = el.label as string; + spans.push(span); + + if (idx < all.length - 1) { + const separator = document.createElement('span'); + separator.textContent = '/'; + spans.push(separator); + } else if (el.short !== null) { + const short = document.createElement('span'); + short.textContent = `(${el.short})`; + short.className = 'color-fg-subtle'; + spans.push(short); + } + }); + + return spans; +} diff --git a/frontend/src/global_styles/content/work_packages/inplace_editing/_display_fields.sass b/frontend/src/global_styles/content/work_packages/inplace_editing/_display_fields.sass index 542e7f0c5f27..4b18c2e597cb 100644 --- a/frontend/src/global_styles/content/work_packages/inplace_editing/_display_fields.sass +++ b/frontend/src/global_styles/content/work_packages/inplace_editing/_display_fields.sass @@ -79,6 +79,7 @@ display-field vertical-align: middle // Inherit from Parent, e.g. strikethrough for baseline comparison text-decoration: inherit + &:first-of-type padding-right: 5px @@ -93,6 +94,7 @@ display-field // Table specific styles .wp-table--cell-container & width: 100% + .-actual-value, .-derived-value @include wp-table--time-values @@ -110,6 +112,15 @@ display-field &.spentTime .time-logging--value padding: 0 2px + &.hierarchy-items + .path + & > span + margin-right: 0.25rem + + & > span:last-child + margin-right: 0 + + .wp-table--cell-container .inline-edit--display-field.-placeholder, &.estimatedTime, diff --git a/lib/api/v3/custom_fields/hierarchy/hierarchy_item_representer.rb b/lib/api/v3/custom_fields/hierarchy/hierarchy_item_representer.rb index 656218ef5126..c978dd7993c4 100644 --- a/lib/api/v3/custom_fields/hierarchy/hierarchy_item_representer.rb +++ b/lib/api/v3/custom_fields/hierarchy/hierarchy_item_representer.rb @@ -68,6 +68,10 @@ def _type } end end + + link :branch do + { href: api_v3_paths.custom_field_item_branch(represented.id) } + end end end end diff --git a/lib/api/v3/custom_fields/hierarchy/item_branch_api.rb b/lib/api/v3/custom_fields/hierarchy/item_branch_api.rb index 823b092c1956..b5729d891d89 100644 --- a/lib/api/v3/custom_fields/hierarchy/item_branch_api.rb +++ b/lib/api/v3/custom_fields/hierarchy/item_branch_api.rb @@ -43,7 +43,7 @@ class ItemBranchAPI < ::API::OpenProjectAPI .fmap { |items| items.map { |item| HierarchicalItemAggregate.new(item:, depth: item.depth - 1) } } .either( ->(items) do - self_link = api_v3_paths.custom_field_item(@custom_field_item.id) + self_link = api_v3_paths.custom_field_item_branch(@custom_field_item.id) HierarchyItemCollectionRepresenter.new(items, self_link:, current_user:) end, ->(error) do diff --git a/lib/api/v3/utilities/path_helper.rb b/lib/api/v3/utilities/path_helper.rb index 1bf20c07963e..3a862ac231c5 100644 --- a/lib/api/v3/utilities/path_helper.rb +++ b/lib/api/v3/utilities/path_helper.rb @@ -215,6 +215,10 @@ def self.custom_field_item(id) "#{root}/custom_field_items/#{id}" end + def self.custom_field_item_branch(id) + "#{custom_field_item(id)}/branch" + end + def self.custom_field_items(id, parent = nil, depth = nil) query = { parent:, depth: }.compact_blank.to_query