From dfd664b6c21f16af2c2c11f5c501134ef9b0a034 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 18 Nov 2024 17:54:53 +0100 Subject: [PATCH 01/21] [#59280] Ensure that the new Activity tab renders quickly also for work packages with over 100 comments https://community.openproject.org/work_packages/59280 From bb8f133d425fd61a4f68a3a716fb994f25182db3 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 18 Nov 2024 17:55:24 +0100 Subject: [PATCH 02/21] refactored stem rendering for better rendering performance --- .../journals/item_component/details.html.erb | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/app/components/work_packages/activities_tab/journals/item_component/details.html.erb b/app/components/work_packages/activities_tab/journals/item_component/details.html.erb index 1398253b079c..2bed453e20e3 100644 --- a/app/components/work_packages/activities_tab/journals/item_component/details.html.erb +++ b/app/components/work_packages/activities_tab/journals/item_component/details.html.erb @@ -20,17 +20,12 @@ end else if journal.details.any? - if journal.notes.present? - render_details(details_container) - else - render_details_header(details_container) - render_details(details_container) - end - elsif journal.notes.present? + render_details_header(details_container) unless journal.notes.present? render_details(details_container) - else - # empty row to render the flex layout with its minimal height + elsif !journal.notes.present? render_empty_line(details_container) + else + render_details(details_container) end end end From 8b06a06b685b0ac7a499bc38e72bf68741459331 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 18 Nov 2024 18:59:25 +0100 Subject: [PATCH 03/21] introduce deffered loading for older journals --- .../activities_tab/index_component.html.erb | 78 ++++++++++--------- .../activities_tab/index_component.rb | 5 +- .../journals/index_component.html.erb | 75 ++++++++++++------ .../journals/index_component.rb | 25 +++++- .../activities_tab_controller.rb | 3 +- 5 files changed, 121 insertions(+), 65 deletions(-) diff --git a/app/components/work_packages/activities_tab/index_component.html.erb b/app/components/work_packages/activities_tab/index_component.html.erb index 6ff2baeee91c..6b4492a05afe 100644 --- a/app/components/work_packages/activities_tab/index_component.html.erb +++ b/app/components/work_packages/activities_tab/index_component.html.erb @@ -1,53 +1,59 @@ <%= - content_tag("turbo-frame", id: "work-package-activities-tab-content") do - flex_layout(classes: "work-packages-activities-tab-index-component", mb: [5, 5, 5, 5, 0]) do |activties_tab_wrapper_container| - activties_tab_wrapper_container.with_row(classes: "work-packages-activities-tab-index-component--errors") do - render( - WorkPackages::ActivitiesTab::ErrorStreamComponent.new - ) - end - activties_tab_wrapper_container.with_row do - component_wrapper(data: wrapper_data_attributes) do - flex_layout do |activties_tab_container| - activties_tab_container.with_row(mb: 2) do - render( - WorkPackages::ActivitiesTab::Journals::FilterAndSortingComponent.new( - work_package:, - filter: - ) - ) - end - activties_tab_container.with_row(flex_layout: true, mt: 3) do |journals_wrapper_container| - journals_wrapper_container.with_row( - classes: "work-packages-activities-tab-index-component--journals-container work-packages-activities-tab-index-component--journals-container_with-initial-input-compensation", - data: { "work-packages--activities-tab--index-target": "journalsContainer" } - ) do + unless deferred + content_tag("turbo-frame", id: "work-package-activities-tab-content") do + flex_layout(classes: "work-packages-activities-tab-index-component", mb: [5, 5, 5, 5, 0]) do |activties_tab_wrapper_container| + activties_tab_wrapper_container.with_row(classes: "work-packages-activities-tab-index-component--errors") do + render( + WorkPackages::ActivitiesTab::ErrorStreamComponent.new + ) + end + activties_tab_wrapper_container.with_row do + component_wrapper(data: wrapper_data_attributes) do + flex_layout do |activties_tab_container| + activties_tab_container.with_row(mb: 2) do render( - WorkPackages::ActivitiesTab::Journals::IndexComponent.new(work_package:, filter:) + WorkPackages::ActivitiesTab::Journals::FilterAndSortingComponent.new( + work_package:, + filter: + ) ) end - if adding_comment_allowed? + activties_tab_container.with_row(flex_layout: true, mt: 3) do |journals_wrapper_container| journals_wrapper_container.with_row( - classes: "work-packages-activities-tab-index-component--input-container work-packages-activities-tab-index-component--input-container_sort-#{journal_sorting}", - mt: 3, - mb: [3, nil, nil, nil, 0], - pt: 2, - pb: 2, - pl: 3, - pr: [3, nil, nil, nil, 2], - border: [nil, nil, nil, nil, :top], - border_radius: [2, nil, nil, nil, 0], - bg: :subtle + classes: "work-packages-activities-tab-index-component--journals-container work-packages-activities-tab-index-component--journals-container_with-initial-input-compensation", + data: { "work-packages--activities-tab--index-target": "journalsContainer" } ) do render( - WorkPackages::ActivitiesTab::Journals::NewComponent.new(work_package:) + WorkPackages::ActivitiesTab::Journals::IndexComponent.new(work_package:, filter:) ) end + if adding_comment_allowed? + journals_wrapper_container.with_row( + classes: "work-packages-activities-tab-index-component--input-container work-packages-activities-tab-index-component--input-container_sort-#{journal_sorting}", + mt: 3, + mb: [3, nil, nil, nil, 0], + pt: 2, + pb: 2, + pl: 3, + pr: [3, nil, nil, nil, 2], + border: [nil, nil, nil, nil, :top], + border_radius: [2, nil, nil, nil, 0], + bg: :subtle + ) do + render( + WorkPackages::ActivitiesTab::Journals::NewComponent.new(work_package:) + ) + end + end end end end end end end + else + render( + WorkPackages::ActivitiesTab::Journals::IndexComponent.new(work_package:, filter:, deferred:) + ) end %> diff --git a/app/components/work_packages/activities_tab/index_component.rb b/app/components/work_packages/activities_tab/index_component.rb index 65ed1463d37c..4b80371b21f3 100644 --- a/app/components/work_packages/activities_tab/index_component.rb +++ b/app/components/work_packages/activities_tab/index_component.rb @@ -36,17 +36,18 @@ class IndexComponent < ApplicationComponent include OpTurbo::Streamable include WorkPackages::ActivitiesTab::SharedHelpers - def initialize(work_package:, last_server_timestamp:, filter: :all) + def initialize(work_package:, last_server_timestamp:, filter: :all, deferred: false) super @work_package = work_package @filter = filter @last_server_timestamp = last_server_timestamp + @deferred = deferred end private - attr_reader :work_package, :filter, :last_server_timestamp + attr_reader :work_package, :filter, :last_server_timestamp, :deferred def wrapper_data_attributes stimulus_controller = "work-packages--activities-tab--index" diff --git a/app/components/work_packages/activities_tab/journals/index_component.html.erb b/app/components/work_packages/activities_tab/journals/index_component.html.erb index aa761592d114..c541372a20a6 100644 --- a/app/components/work_packages/activities_tab/journals/index_component.html.erb +++ b/app/components/work_packages/activities_tab/journals/index_component.html.erb @@ -1,34 +1,61 @@ <%= - component_wrapper(class: "work-packages-activities-tab-journals-index-component") do - flex_layout(data: { test_selector: "op-wp-journals-#{filter}-#{journal_sorting}" }) do |journals_index_wrapper_container| - journals_index_wrapper_container.with_row( - classes: "work-packages-activities-tab-journals-index-component--journals-inner-container", - mb: inner_container_margin_bottom - ) do - flex_layout(id: insert_target_modifier_id, - data: { test_selector: "op-wp-journals-container" }) do |journals_index_container| - if empty_state? - journals_index_container.with_row(mt: 2, mb: 3) do - render( - WorkPackages::ActivitiesTab::Journals::EmptyComponent.new - ) + unless deferred + component_wrapper(class: "work-packages-activities-tab-journals-index-component") do + flex_layout(data: { test_selector: "op-wp-journals-#{filter}-#{journal_sorting}" }) do |journals_index_wrapper_container| + journals_index_wrapper_container.with_row( + classes: "work-packages-activities-tab-journals-index-component--journals-inner-container", + mb: inner_container_margin_bottom + ) do + flex_layout(id: insert_target_modifier_id, + data: { test_selector: "op-wp-journals-container" }) do |journals_index_container| + if empty_state? + journals_index_container.with_row(mt: 2, mb: 3) do + render( + WorkPackages::ActivitiesTab::Journals::EmptyComponent.new + ) + end + end + + if !journal_sorting_desc? && journals.count > MAX_RECENT_JOURNALS + journals_index_container.with_row do + helpers.turbo_frame_tag("work-package-activities-tab-content-older-journals", src: work_package_activities_path(work_package, filter:, deferred: true)) + end end - end - journals.each do |journal| - journals_index_container.with_row do - render(WorkPackages::ActivitiesTab::Journals::ItemComponent.new( - journal:, filter:, - grouped_emoji_reactions: wp_journals_grouped_emoji_reactions[journal.id] - )) + recent_journals.each do |journal| + journals_index_container.with_row do + render(WorkPackages::ActivitiesTab::Journals::ItemComponent.new( + journal:, filter:, + grouped_emoji_reactions: wp_journals_grouped_emoji_reactions[journal.id] + )) + end + end + + if journal_sorting_desc? && journals.count > MAX_RECENT_JOURNALS + journals_index_container.with_row do + helpers.turbo_frame_tag("work-package-activities-tab-content-older-journals", src: work_package_activities_path(work_package, filter:, deferred: true)) + end end end end - end - unless empty_state? || journal_sorting_desc? - journals_index_wrapper_container - .with_row(classes: "work-packages-activities-tab-journals-index-component--stem-connection") + unless empty_state? || journal_sorting_desc? + journals_index_wrapper_container + .with_row(classes: "work-packages-activities-tab-journals-index-component--stem-connection") + end + end + end + else + helpers.turbo_frame_tag("work-package-activities-tab-content-older-journals") do + flex_layout do |older_journals_container| + older_journals.each do |journal| + older_journals_container.with_row do + render(WorkPackages::ActivitiesTab::Journals::ItemComponent.new( + journal:, filter:, + grouped_emoji_reactions: wp_journals_grouped_emoji_reactions[journal.id] + )) + end + end end end end diff --git a/app/components/work_packages/activities_tab/journals/index_component.rb b/app/components/work_packages/activities_tab/journals/index_component.rb index a2120884a006..6d2022b92b9d 100644 --- a/app/components/work_packages/activities_tab/journals/index_component.rb +++ b/app/components/work_packages/activities_tab/journals/index_component.rb @@ -32,21 +32,24 @@ module WorkPackages module ActivitiesTab module Journals class IndexComponent < ApplicationComponent + MAX_RECENT_JOURNALS = 30 + include ApplicationHelper include OpPrimer::ComponentHelpers include OpTurbo::Streamable include WorkPackages::ActivitiesTab::SharedHelpers - def initialize(work_package:, filter: :all) + def initialize(work_package:, filter: :all, deferred: false) super @work_package = work_package @filter = filter + @deferred = deferred end private - attr_reader :work_package, :filter + attr_reader :work_package, :filter, :deferred def insert_target_modified? true @@ -68,6 +71,24 @@ def journals .with_sequence_version end + def recent_journals + if journal_sorting_desc? + journals.first(MAX_RECENT_JOURNALS) + else + journals.last(MAX_RECENT_JOURNALS) + end + end + + def older_journals + if journal_sorting_desc? + journals.offset(MAX_RECENT_JOURNALS) + else + total = journals.count + limit = [total - MAX_RECENT_JOURNALS, 0].max + journals.limit(limit) + end + end + def journal_with_notes journals.where.not(notes: "") end diff --git a/app/controllers/work_packages/activities_tab_controller.rb b/app/controllers/work_packages/activities_tab_controller.rb index 93ac7ba6cde9..2ea5cca90399 100644 --- a/app/controllers/work_packages/activities_tab_controller.rb +++ b/app/controllers/work_packages/activities_tab_controller.rb @@ -43,7 +43,8 @@ def index WorkPackages::ActivitiesTab::IndexComponent.new( work_package: @work_package, filter: @filter, - last_server_timestamp: get_current_server_timestamp + last_server_timestamp: get_current_server_timestamp, + deferred: params[:deferred] == "true" ), layout: false ) From c6f5f47d83868822c543a69fdcd822104d355c19 Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Mon, 18 Nov 2024 19:12:56 +0100 Subject: [PATCH 04/21] simple waiting mechanism in order to wait for anchor to be rendered in async request --- .../work-packages/activities-tab/index.controller.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/work-packages/activities-tab/index.controller.ts b/frontend/src/stimulus/controllers/dynamic/work-packages/activities-tab/index.controller.ts index ef02a67e404a..9df5c036f223 100644 --- a/frontend/src/stimulus/controllers/dynamic/work-packages/activities-tab/index.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/work-packages/activities-tab/index.controller.ts @@ -355,15 +355,22 @@ export default class IndexController extends Controller { } } - private scrollToActivity(activityId:string) { + private tryScroll(activityId:string, attempts:number, maxAttempts:number) { const scrollableContainer = this.getScrollableContainer(); const activityElement = document.getElementById(`activity-anchor-${activityId}`); if (activityElement && scrollableContainer) { - scrollableContainer.scrollTop = activityElement.offsetTop-70; + scrollableContainer.scrollTop = activityElement.offsetTop - 70; + } else if (attempts < maxAttempts) { + setTimeout(() => this.tryScroll(activityId, attempts + 1, maxAttempts), 1000); } } + private scrollToActivity(activityId:string) { + const maxAttempts = 20; // wait max 20 seconds for the activity to be rendered + this.tryScroll(activityId, 0, maxAttempts); + } + private scrollToBottom() { const scrollableContainer = this.getScrollableContainer(); if (scrollableContainer) { From ecfb393c68158417c5be759f58df51f6f24e67b9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 19 Nov 2024 07:24:58 +0000 Subject: [PATCH 05/21] build(deps): bump cross-spawn from 7.0.3 to 7.0.6 in /frontend Bumps [cross-spawn](https://github.com/moxystudio/node-cross-spawn) from 7.0.3 to 7.0.6. - [Changelog](https://github.com/moxystudio/node-cross-spawn/blob/master/CHANGELOG.md) - [Commits](https://github.com/moxystudio/node-cross-spawn/compare/v7.0.3...v7.0.6) --- updated-dependencies: - dependency-name: cross-spawn dependency-type: indirect ... Signed-off-by: dependabot[bot] --- frontend/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index eca0153a8903..bcfda779de1f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8941,9 +8941,9 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -28303,9 +28303,9 @@ } }, "cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "requires": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", From 4f47e3fdc4e8bbb2d9bdc47b758c77f618d0fb60 Mon Sep 17 00:00:00 2001 From: Eric Schubert Date: Tue, 19 Nov 2024 14:21:48 +0100 Subject: [PATCH 06/21] [#59391] rework file links to paginated collection - https://community.openproject.org/wp/59391 - files tab counter now only calls for file links of wp with page size 0 - file links endpoint returns paginated collections - frontend requests file links with page size -1 (INFINITE) - file sync is still executed for ALL file links of a work package, not only the requested page - this is practically no change for the product, as we do not fetch single pages - but fetching only the total numbers with page size 0 now does not trigger a sync --- .../state/file-links/file-links.service.ts | 2 +- .../wp-tabs/wp-files-count.function.ts | 22 ++++++-- .../storages/storage/storage.component.ts | 5 +- .../storages/app/models/storages/file_link.rb | 6 +-- .../file_link_collection_representer.rb | 3 +- .../v3/file_links/file_link_representer.rb | 2 +- .../work_packages_file_links_api.rb | 50 ++++++++++++++----- 7 files changed, 62 insertions(+), 28 deletions(-) diff --git a/frontend/src/app/core/state/file-links/file-links.service.ts b/frontend/src/app/core/state/file-links/file-links.service.ts index 67735b9b7ed7..c2626f87c193 100644 --- a/frontend/src/app/core/state/file-links/file-links.service.ts +++ b/frontend/src/app/core/state/file-links/file-links.service.ts @@ -88,7 +88,7 @@ export class FileLinksResourceService extends ResourceStoreService { }), tap((fileLinkCollections) => { const storageId = idFromLink(fileLinkCollections.storage); - const collectionKey = `${fileLinksSelfLink}?filters=[{"storage":{"operator":"=","values":["${storageId}"]}}]`; + const collectionKey = `${fileLinksSelfLink}?pageSize=-1&filters=[{"storage":{"operator":"=","values":["${storageId}"]}}]`; const collection = { _embedded: { elements: fileLinkCollections.fileLinks } } as IHALCollection; insertCollectionIntoState(this.store, collection, collectionKey); }), diff --git a/frontend/src/app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-files-count.function.ts b/frontend/src/app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-files-count.function.ts index 36f6bed47e36..dfc00da48d04 100644 --- a/frontend/src/app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-files-count.function.ts +++ b/frontend/src/app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-files-count.function.ts @@ -27,26 +27,38 @@ //++ import { Injector } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; import { combineLatest, Observable, of } from 'rxjs'; import { map } from 'rxjs/operators'; import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; -import { FileLinksResourceService } from 'core-app/core/state/file-links/file-links.service'; import { AttachmentsResourceService } from 'core-app/core/state/attachments/attachments.service'; +import { IHALCollection } from 'core-app/core/apiv3/types/hal-collection.type'; +import { IFileLink } from 'core-app/core/state/file-links/file-link.model'; export function workPackageFilesCount( workPackage:WorkPackageResource, injector:Injector, ):Observable { const attachmentService = injector.get(AttachmentsResourceService); - const fileLinkService = injector.get(FileLinksResourceService); + const http = injector.get(HttpClient); const attachmentsCollection = workPackage.$links.attachments ? attachmentService.collection(workPackage.$links.attachments.href || '') : of([]); - const fileLinksCollection = fileLinkService.collection(workPackage.$links.fileLinks?.href || ''); + const totalFileLinks = workPackage.$links.fileLinks + ? http.get>(href(workPackage)) + : of({ total: 0 }); return combineLatest([ attachmentsCollection, - fileLinksCollection, - ]).pipe(map(([a, f]) => a.length + f.length)); + totalFileLinks, + ]).pipe(map(([a, f]) => a.length + f.total)); +} + +function href(workPackage:WorkPackageResource):string { + if (!workPackage.$links.fileLinks) { + return ''; + } + + return `${workPackage.$links.fileLinks.href}?pageSize=0`; } diff --git a/frontend/src/app/shared/components/storages/storage/storage.component.ts b/frontend/src/app/shared/components/storages/storage/storage.component.ts index dd967426192e..9877227ab475 100644 --- a/frontend/src/app/shared/components/storages/storage/storage.component.ts +++ b/frontend/src/app/shared/components/storages/storage/storage.component.ts @@ -84,6 +84,7 @@ import { IUploadLink } from 'core-app/core/state/storage-files/upload-link.model import { IStorageFile } from 'core-app/core/state/storage-files/storage-file.model'; import { TimezoneService } from 'core-app/core/datetime/timezone.service'; import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; +import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; import { UploadConflictModalComponent, } from 'core-app/shared/components/storages/upload-conflict-modal/upload-conflict-modal.component'; @@ -546,8 +547,8 @@ export class StorageComponent extends UntilDestroyedMixin implements OnInit, OnD } private fileLinkSelfLink(storage:IStorage):string { - const fileLinks = this.resource.fileLinks as { href:string }; - return `${fileLinks.href}?filters=[{"storage":{"operator":"=","values":["${storage.id}"]}}]`; + const fileLinks = (this.resource as WorkPackageResource).$links.fileLinks; + return `${fileLinks?.href}?pageSize=-1&filters=[{"storage":{"operator":"=","values":["${storage.id}"]}}]`; } public onDropFiles(event:DragEvent):void { diff --git a/modules/storages/app/models/storages/file_link.rb b/modules/storages/app/models/storages/file_link.rb index 11daa248a134..b98eff8e2d3e 100644 --- a/modules/storages/app/models/storages/file_link.rb +++ b/modules/storages/app/models/storages/file_link.rb @@ -37,11 +37,7 @@ class Storages::FileLink < ApplicationRecord validates :container_type, inclusion: { in: ["WorkPackage", nil] } validates :origin_id, presence: true - attr_writer :origin_status - - def origin_status - @origin_status || nil - end + attribute :origin_status delegate :project, to: :container diff --git a/modules/storages/lib/api/v3/file_links/file_link_collection_representer.rb b/modules/storages/lib/api/v3/file_links/file_link_collection_representer.rb index 3ca1d17aaa56..7ecfca7c285b 100644 --- a/modules/storages/lib/api/v3/file_links/file_link_collection_representer.rb +++ b/modules/storages/lib/api/v3/file_links/file_link_collection_representer.rb @@ -29,7 +29,8 @@ module API module V3 module FileLinks - class FileLinkCollectionRepresenter < ::API::Decorators::UnpaginatedCollection + class FileLinkCollectionRepresenter < ::API::Decorators::OffsetPaginatedCollection + property :count, getter: ->(*) { count(:id) } end end end diff --git a/modules/storages/lib/api/v3/file_links/file_link_representer.rb b/modules/storages/lib/api/v3/file_links/file_link_representer.rb index 28269707da65..5d792d40334b 100644 --- a/modules/storages/lib/api/v3/file_links/file_link_representer.rb +++ b/modules/storages/lib/api/v3/file_links/file_link_representer.rb @@ -91,7 +91,7 @@ class FileLinkRepresenter < ::API::Decorators::Single link :status, uncacheable: true do next if represented.origin_status.nil? - PERMISSION_LINKS[represented.origin_status] + PERMISSION_LINKS[represented.origin_status.to_sym] end link :staticOriginOpen do diff --git a/modules/storages/lib/api/v3/file_links/work_packages_file_links_api.rb b/modules/storages/lib/api/v3/file_links/work_packages_file_links_api.rb index 488ab74bc69a..0ac11d3f4a34 100644 --- a/modules/storages/lib/api/v3/file_links/work_packages_file_links_api.rb +++ b/modules/storages/lib/api/v3/file_links/work_packages_file_links_api.rb @@ -29,7 +29,27 @@ #++ class API::V3::FileLinks::WorkPackagesFileLinksAPI < API::OpenProjectAPI - # The `:resources` keyword defines the API namespace -> /api/v3/work_packages/:id/file_links/... + helpers do + def sync_and_convert_relation(file_links) + sync_result = ::Storages::FileLinkSyncService + .new(user: current_user) + .call(file_links) + .result + + value_list = sync_result + .map { |file_link| "(#{file_link.id},'#{file_link.origin_status}')" } + .join(",") + + origin_status_attribute = <<-SQL.squish + LEFT JOIN (VALUES #{value_list}) AS origin_status (id,status) ON origin_status.id = file_links.id + SQL + + ::Storages::FileLink.where(id: sync_result.map(&:id)) + .joins(origin_status_attribute) + .select("file_links.*, origin_status.status AS origin_status") + end + end + resources :file_links do get do query = ParamsToQueryService @@ -43,19 +63,23 @@ class API::V3::FileLinks::WorkPackagesFileLinksAPI < API::OpenProjectAPI raise ::API::Errors::InvalidQuery.new(message) end - result = if current_user.allowed_in_project?(:view_file_links, @work_package.project) - file_links = query.results.where(container_id: @work_package.id, - container_type: "WorkPackage", - storage: @work_package.project.storages) - ::Storages::FileLinkSyncService - .new(user: current_user) - .call(file_links) - .result - else - [] - end + relation = if current_user.allowed_in_project?(:view_file_links, @work_package.project) + file_links = query.results.where(container_id: @work_package.id, + container_type: "WorkPackage", + storage: @work_package.project.storages) + + if params[:pageSize] == "0" + file_links + else + sync_and_convert_relation(file_links) + end + else + ::Storages::FileLink.none + end + ::API::V3::FileLinks::FileLinkCollectionRepresenter.new( - result, + relation, + per_page: params[:pageSize], self_link: api_v3_paths.file_links(@work_package.id), current_user: ) From e86d5bd2514021524a9e5138f08b3ee168307d7a Mon Sep 17 00:00:00 2001 From: Eric Schubert Date: Tue, 19 Nov 2024 14:41:34 +0100 Subject: [PATCH 07/21] [#59391] updated API specs for new pagination in file links - avoid sql injection - file link collection is returned ordered by id asc --- .../file_link_collection_read_model.yml | 19 +-- .../schemas/file_link_read_model.yml | 36 +----- .../lib/api/v3/file_links/create_endpoint.rb | 89 ++++++------- ...in_origin_status_to_file_links_relation.rb | 31 +++++ .../work_packages_file_links_api.rb | 122 +++++++++--------- .../api/v3/file_links/file_links_api_spec.rb | 36 +++--- .../mixed_case_file_links_integration_spec.rb | 11 +- .../work_package_representer_spec.rb | 3 +- 8 files changed, 175 insertions(+), 172 deletions(-) create mode 100644 modules/storages/lib/api/v3/file_links/join_origin_status_to_file_links_relation.rb diff --git a/docs/api/apiv3/components/schemas/file_link_collection_read_model.yml b/docs/api/apiv3/components/schemas/file_link_collection_read_model.yml index 6d59d1137244..4d0693eea18e 100644 --- a/docs/api/apiv3/components/schemas/file_link_collection_read_model.yml +++ b/docs/api/apiv3/components/schemas/file_link_collection_read_model.yml @@ -1,28 +1,21 @@ # Schema: FileLinkCollectionReadModel --- allOf: - - $ref: './collection_model.yml' + - $ref: './paginated_collection_model.yml' - type: object - required: - - _links - - _embedded properties: _links: type: object - required: - - self properties: self: allOf: - - $ref: "./link.yml" + - $ref: './link.yml' - description: |- This file links collection **Resource**: FileLinkCollectionReadModel _embedded: type: object - required: - - elements properties: elements: type: array @@ -33,9 +26,17 @@ example: _type: Collection total: 2 count: 2 + pageSize: 30 + offset: 1 _links: self: href: '/api/v3/work_packages/42/file_links' + jumpTo: + href: '/api/v3/work_packages/42/file_links?offset=%7Boffset%7D&pageSize=30' + templated: true + changeSize: + href: '/api/v3/work_packages/42/file_links?offset=1&pageSize=%7Bsize%7D' + templated: true _embedded: elements: - id: 1337 diff --git a/docs/api/apiv3/components/schemas/file_link_read_model.yml b/docs/api/apiv3/components/schemas/file_link_read_model.yml index bcf766608739..8c912c5a4441 100644 --- a/docs/api/apiv3/components/schemas/file_link_read_model.yml +++ b/docs/api/apiv3/components/schemas/file_link_read_model.yml @@ -1,11 +1,6 @@ # Schema: FileLinkReadModel --- type: object -required: - - id - - _type - - originData - - _links properties: id: type: integer @@ -36,17 +31,6 @@ properties: $ref: './work_package_model.yml' _links: type: object - required: - - self - - storage - - container - - creator - - permission - - originOpen - - staticOriginOpen - - originOpenLocation - - staticOriginOpenLocation - - staticOriginDownload properties: self: allOf: @@ -166,24 +150,8 @@ example: container: _hint: Work package resource shortened for brevity _type: WorkPackage - _links: - self: - href: "/api/v3/work_packages/1528" - title: Develop API - schema: - href: "/api/v3/work_packages/schemas/11-2" id: 1528 subject: Develop API - description: - format: markdown - raw: Develop super cool OpenProject API. - html: "

Develop super cool OpenProject API.

" - scheduleManually: false - readonly: false - startDate: - dueDate: - createdAt: '2014-08-29T12:40:53.860Z' - updatedAt: '2014-08-29T12:44:41.036Z' _links: self: href: /api/v3/work_package/17/file_links/1337 @@ -199,8 +167,8 @@ example: delete: href: /api/v3/work_package/17/file_links/1337 status: - href: urn:openproject-org:api:v3:file-links:permission:View - title: View + href: urn:openproject-org:api:v3:file-links:permission:ViewAllowed + title: View allowed originOpen: href: https://nextcloud.deathstar.rocks/index.php/f/5503?openfile=1 staticOriginOpen: diff --git a/modules/storages/lib/api/v3/file_links/create_endpoint.rb b/modules/storages/lib/api/v3/file_links/create_endpoint.rb index 4d40fd76e8f2..af545e226cc8 100644 --- a/modules/storages/lib/api/v3/file_links/create_endpoint.rb +++ b/modules/storages/lib/api/v3/file_links/create_endpoint.rb @@ -28,59 +28,56 @@ # See COPYRIGHT and LICENSE files for more details. #++ -# Handles /api/v3/work_packages/:work_package_id/file_links as defined -# in modules/storages/lib/api/v3/file_links/work_packages_file_links_api.rb -# -# Multiple classes are involved during its lifecycle: -# - Storages::Peripherals::ParseCreateParamsService -# - API::V3::FileLinks::FileLinkCollectionRepresenter -# - Storages::FileLinks::CreateService -# -# These classes are either deduced from the model class, or given as parameter -# on class instantiation. -class API::V3::FileLinks::CreateEndpoint < API::Utilities::Endpoints::Create - include ::API::V3::Utilities::Endpoints::V3Deductions - include ::API::V3::Utilities::Endpoints::V3PresentSingle +module API + module V3 + module FileLinks + class CreateEndpoint < API::Utilities::Endpoints::Create + include Utilities::Endpoints::V3Deductions + include Utilities::Endpoints::V3PresentSingle - # As this endpoint receives a list of file links to create, it calls the - # create service multiple times, one time for each file link to create. The - # call is done by calling the `super` method. Results are aggregated in - # global_result using the `add_dependent!` method. - def process(request, params_elements) - global_result = ServiceResult.success + # As this endpoint receives a list of file links to create, it calls the + # create service multiple times, one time for each file link to create. The + # call is done by calling the `super` method. Results are aggregated in + # global_result using the `add_dependent!` method. + def process(request, params_elements) + global_result = ServiceResult.success - Storages::FileLink.transaction do - params_elements.each do |params| - # call the default API::Utilities::Endpoints::Create#process - # implementation for each of the params_element array - one_result = super(request, params) - # merge service result in one - global_result.add_dependent!(one_result) - end + ::Storages::FileLink.transaction do + params_elements.each do |params| + # call the default API::Utilities::Endpoints::Create#process + # implementation for each of the params_element array + one_result = super(request, params) + # merge service result in one + global_result.add_dependent!(one_result) + end - # rollback records created if an error occurred (validation failed) - raise ActiveRecord::Rollback if global_result.failure? - end + # rollback records created if an error occurred (validation failed) + raise ActiveRecord::Rollback if global_result.failure? + end - global_result - end + global_result + end - def present_success(request, service_call) - file_links = service_call.all_results.map do |file_link| - file_link.origin_status = :view_allowed - file_link - end + def present_success(request, service_call) + id_status_map = {} - render_representer.create( - file_links, - self_link: self_link(request), - current_user: request.current_user - ) - end + service_call.all_results.each do |file_link| + id_status_map[file_link.id] = "view_allowed" + end + + render_representer.create( + JoinOriginStatusToFileLinksRelation.create(id_status_map), + self_link: self_link(request), + current_user: request.current_user + ) + end - private + private - def self_link(_request) - "#{::API::V3::URN_PREFIX}file_links:no_link_provided" + def self_link(_request) + "#{URN_PREFIX}file_links:no_link_provided" + end + end + end end end diff --git a/modules/storages/lib/api/v3/file_links/join_origin_status_to_file_links_relation.rb b/modules/storages/lib/api/v3/file_links/join_origin_status_to_file_links_relation.rb new file mode 100644 index 000000000000..b26b43487780 --- /dev/null +++ b/modules/storages/lib/api/v3/file_links/join_origin_status_to_file_links_relation.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module API + module V3 + module FileLinks + class JoinOriginStatusToFileLinksRelation + # @param [Hash] id_status_map A hash mapping file link IDs to their origin status + # in the format { 137: "view_allowed", 142: "error" } + def self.create(id_status_map) + sanitized_sql = ActiveRecord::Base.send( + :sanitize_sql_array, + [origin_status_join(id_status_map.size), *id_status_map.flatten] + ) + + ::Storages::FileLink.where(id: id_status_map.keys) + .order(:id) + .joins(sanitized_sql) + .select("file_links.*, origin_status.status AS origin_status") + end + + def self.origin_status_join(value_count) + placeholders = Array.new(value_count).map { "(?,?)" }.join(",") + + <<-SQL.squish + LEFT JOIN (VALUES #{placeholders}) AS origin_status (id,status) ON origin_status.id = file_links.id + SQL + end + end + end + end +end diff --git a/modules/storages/lib/api/v3/file_links/work_packages_file_links_api.rb b/modules/storages/lib/api/v3/file_links/work_packages_file_links_api.rb index 0ac11d3f4a34..efdae12785f6 100644 --- a/modules/storages/lib/api/v3/file_links/work_packages_file_links_api.rb +++ b/modules/storages/lib/api/v3/file_links/work_packages_file_links_api.rb @@ -28,74 +28,78 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class API::V3::FileLinks::WorkPackagesFileLinksAPI < API::OpenProjectAPI - helpers do - def sync_and_convert_relation(file_links) - sync_result = ::Storages::FileLinkSyncService - .new(user: current_user) - .call(file_links) - .result +module API + module V3 + module FileLinks + class WorkPackagesFileLinksAPI < API::OpenProjectAPI + helpers do + def sync_and_convert_relation(file_links) + return ::Storages::FileLink.none if file_links.empty? - value_list = sync_result - .map { |file_link| "(#{file_link.id},'#{file_link.origin_status}')" } - .join(",") + sync_result = ::Storages::FileLinkSyncService + .new(user: current_user) + .call(file_links) + .result - origin_status_attribute = <<-SQL.squish - LEFT JOIN (VALUES #{value_list}) AS origin_status (id,status) ON origin_status.id = file_links.id - SQL + id_status_map = {} - ::Storages::FileLink.where(id: sync_result.map(&:id)) - .joins(origin_status_attribute) - .select("file_links.*, origin_status.status AS origin_status") - end - end + sync_result.each do |file_link| + id_status_map[file_link.id] = file_link.origin_status.to_s + end - resources :file_links do - get do - query = ParamsToQueryService - .new(::Storages::Storage, - current_user, - query_class: ::Queries::Storages::FileLinks::FileLinkQuery) - .call(params) + JoinOriginStatusToFileLinksRelation.create(id_status_map) + end + end - unless query.valid? - message = I18n.t("api_v3.errors.missing_or_malformed_parameter", parameter: "filters") - raise ::API::Errors::InvalidQuery.new(message) - end + resources :file_links do + get do + query = ParamsToQueryService + .new(::Storages::Storage, + current_user, + query_class: ::Queries::Storages::FileLinks::FileLinkQuery) + .call(params) - relation = if current_user.allowed_in_project?(:view_file_links, @work_package.project) - file_links = query.results.where(container_id: @work_package.id, - container_type: "WorkPackage", - storage: @work_package.project.storages) + unless query.valid? + message = I18n.t("api_v3.errors.missing_or_malformed_parameter", parameter: "filters") + raise ::API::Errors::InvalidQuery.new(message) + end - if params[:pageSize] == "0" - file_links - else - sync_and_convert_relation(file_links) - end - else - ::Storages::FileLink.none - end + relation = if current_user.allowed_in_project?(:view_file_links, @work_package.project) + file_links = query.results.where(container_id: @work_package.id, + container_type: "WorkPackage", + storage: @work_package.project.storages) - ::API::V3::FileLinks::FileLinkCollectionRepresenter.new( - relation, - per_page: params[:pageSize], - self_link: api_v3_paths.file_links(@work_package.id), - current_user: - ) - end + if params[:pageSize] == "0" + file_links + else + sync_and_convert_relation(file_links) + end + else + ::Storages::FileLink.none + end - post &::API::V3::FileLinks::WorkPackagesFileLinksCreateEndpoint - .new( - model: ::Storages::FileLink, - parse_service: Storages::Peripherals::ParseCreateParamsService, - render_representer: ::API::V3::FileLinks::FileLinkCollectionRepresenter, - params_modifier: ->(params) do - params[:container_id] = work_package.id - params[:container_type] = work_package.class.name - params - end + FileLinkCollectionRepresenter.new( + relation, + per_page: params[:pageSize], + self_link: api_v3_paths.file_links(@work_package.id), + current_user: ) - .mount + end + + post &WorkPackagesFileLinksCreateEndpoint + .new( + model: ::Storages::FileLink, + parse_service: ::Storages::Peripherals::ParseCreateParamsService, + render_representer: FileLinkCollectionRepresenter, + params_modifier: ->(params) do + params[:container_id] = work_package.id + params[:container_type] = work_package.class.name + params + end + ) + .mount + end + end + end end end diff --git a/modules/storages/spec/requests/api/v3/file_links/file_links_api_spec.rb b/modules/storages/spec/requests/api/v3/file_links/file_links_api_spec.rb index f3de4f0b1e63..057233a04bb4 100644 --- a/modules/storages/spec/requests/api/v3/file_links/file_links_api_spec.rb +++ b/modules/storages/spec/requests/api/v3/file_links/file_links_api_spec.rb @@ -146,7 +146,7 @@ def disable_module(project, modul) ) do expect(Storages::FileLink.count).to eq 2 Storages::FileLink.find_each.with_index do |file_link, i| - unset_keys = %w[container_id container_type] + unset_keys = %w[container_id container_type origin_status] set_keys = (file_link.attributes.keys - unset_keys) set_keys.each do |key| expect(file_link.attributes[key]).not_to( @@ -159,9 +159,8 @@ def disable_module(project, modul) end end - expect(response.body).to be_json_eql( - "urn:openproject-org:api:v3:file_links:no_link_provided".to_json - ).at_path("_links/self/href") + self_href = "urn:openproject-org:api:v3:file_links:no_link_provided?offset=1&pageSize=30".to_json + expect(response.body).to be_json_eql(self_href).at_path("_links/self/href") end end end @@ -313,14 +312,20 @@ def disable_module(project, modul) ) do expect(Storages::FileLink.count).to eq 2 Storages::FileLink.find_each.with_index do |file_link, i| - file_link.attributes.each do |(key, value)| - # check nil values to ensure the :file_link_element factory is accurate - expect(value).not_to be_nil, - "expected attribute #{key.inspect} of FileLink ##{i + 1} to be set.\ngot nil." + unset_keys = %w[origin_status] + set_keys = (file_link.attributes.keys - unset_keys) + set_keys.each do |key| + expect(file_link.attributes[key]).not_to( + be_nil, + "expected attribute #{key.inspect} of FileLink ##{i + 1} to be set.\ngot nil." + ) + end + unset_keys.each do |key| + expect(file_link.attributes[key]).to be_nil end end - expect(response.body).to be_json_eql(path.to_json).at_path("_links/self/href") + expect(response.body).to be_json_eql("#{path}?offset=1&pageSize=30".to_json).at_path("_links/self/href") end end @@ -391,23 +396,18 @@ def disable_module(project, modul) ] end - it_behaves_like "API V3 collection response", 3, 3, "FileLink" do - let(:elements) { Storages::FileLink.order(id: :asc) } + it_behaves_like "API V3 collection response", 1, 1, "FileLink" do + let(:elements) { [Storages::FileLink.first] } let(:expected_status_code) { 201 } end - it( - "creates only one FileLink for all duplicates and " \ - "uses metadata from the first item and " \ - "replies with as many embedded elements as in the request, all identical" - ) do + it "creates only one FileLink for all duplicates and uses metadata from the first item" do expect(Storages::FileLink.count).to eq 1 expect(Storages::FileLink.first.origin_name).to eq "first name" replied_elements = JSON.parse(last_response.body).dig("_embedded", "elements") - expect(replied_elements.count).to eq(embedded_elements.count) - expect(replied_elements[1..]).to all(eq(replied_elements.first)) + expect(replied_elements.count).to eq(1) end end diff --git a/modules/storages/spec/requests/api/v3/file_links/mixed_case_file_links_integration_spec.rb b/modules/storages/spec/requests/api/v3/file_links/mixed_case_file_links_integration_spec.rb index 4e3fddf3965d..a02c51569ff7 100644 --- a/modules/storages/spec/requests/api/v3/file_links/mixed_case_file_links_integration_spec.rb +++ b/modules/storages/spec/requests/api/v3/file_links/mixed_case_file_links_integration_spec.rb @@ -171,13 +171,14 @@ # total, count, element_type, collection_type = 'Collection' it_behaves_like "API V3 collection response", 6, 6, "FileLink", "Collection" do let(:elements) do + # ordered by id [ - file_link_timeout_happy, - file_link_error_happy, - file_link_unauth_happy, - file_link_deleted, + file_link_happy, file_link_other_user, - file_link_happy + file_link_deleted, + file_link_unauth_happy, + file_link_error_happy, + file_link_timeout_happy ] end end diff --git a/spec/lib/api/v3/work_packages/work_package_representer_spec.rb b/spec/lib/api/v3/work_packages/work_package_representer_spec.rb index fe62052c5c99..ad8e3533b04e 100644 --- a/spec/lib/api/v3/work_packages/work_package_representer_spec.rb +++ b/spec/lib/api/v3/work_packages/work_package_representer_spec.rb @@ -1267,7 +1267,8 @@ end end - describe "fileLinks" do + describe "fileLinks", + skip: "test setup broken - remove embedding with https://community.openproject.org/wp/59468" do let(:storage) { build_stubbed(:nextcloud_storage) } let(:file_link) { build_stubbed(:file_link, storage:, container: work_package) } From 6e0947974de628781c993241901e07ef4a0784a2 Mon Sep 17 00:00:00 2001 From: Eric Schubert Date: Wed, 20 Nov 2024 14:55:53 +0100 Subject: [PATCH 08/21] [#59391] applied PR comments --- ...in_origin_status_to_file_links_relation.rb | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/modules/storages/lib/api/v3/file_links/join_origin_status_to_file_links_relation.rb b/modules/storages/lib/api/v3/file_links/join_origin_status_to_file_links_relation.rb index b26b43487780..926c9fa97467 100644 --- a/modules/storages/lib/api/v3/file_links/join_origin_status_to_file_links_relation.rb +++ b/modules/storages/lib/api/v3/file_links/join_origin_status_to_file_links_relation.rb @@ -1,5 +1,33 @@ # frozen_string_literal: true +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + module API module V3 module FileLinks @@ -7,8 +35,7 @@ class JoinOriginStatusToFileLinksRelation # @param [Hash] id_status_map A hash mapping file link IDs to their origin status # in the format { 137: "view_allowed", 142: "error" } def self.create(id_status_map) - sanitized_sql = ActiveRecord::Base.send( - :sanitize_sql_array, + sanitized_sql = ActiveRecord::Base.sanitize_sql_array( [origin_status_join(id_status_map.size), *id_status_map.flatten] ) From 6c6e7ca5b0ce4284fdb6dafd6fc32995364b556f Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 20 Nov 2024 18:36:34 +0100 Subject: [PATCH 09/21] enhanced and used eager loading wrapper in order to avoid n+1, added further rendering optimizations --- .../journals/index_component.rb | 44 ++++++++++++------- .../activities_tab/journals/item_component.rb | 2 +- .../journals/item_component/details.html.erb | 4 +- .../journals/item_component/details.rb | 10 ++++- app/models/journal.rb | 10 +++++ .../activity_eager_loading_wrapper.rb | 15 +++++++ 6 files changed, 66 insertions(+), 19 deletions(-) diff --git a/app/components/work_packages/activities_tab/journals/index_component.rb b/app/components/work_packages/activities_tab/journals/index_component.rb index 6d2022b92b9d..469f0e3d4e11 100644 --- a/app/components/work_packages/activities_tab/journals/index_component.rb +++ b/app/components/work_packages/activities_tab/journals/index_component.rb @@ -63,34 +63,48 @@ def journal_sorting_desc? journal_sorting == "desc" end - def journals + def base_journals work_package .journals - .includes(:user, :notifications) + .includes( + :user, + :customizable_journals, + :attachable_journals, + :storable_journals, + :notifications + ) .reorder(version: journal_sorting) .with_sequence_version end + def journals + API::V3::Activities::ActivityEagerLoadingWrapper.wrap(base_journals) + end + def recent_journals - if journal_sorting_desc? - journals.first(MAX_RECENT_JOURNALS) - else - journals.last(MAX_RECENT_JOURNALS) - end + recent_ones = if journal_sorting_desc? + base_journals.first(MAX_RECENT_JOURNALS) + else + base_journals.last(MAX_RECENT_JOURNALS) + end + + API::V3::Activities::ActivityEagerLoadingWrapper.wrap(recent_ones) end def older_journals - if journal_sorting_desc? - journals.offset(MAX_RECENT_JOURNALS) - else - total = journals.count - limit = [total - MAX_RECENT_JOURNALS, 0].max - journals.limit(limit) - end + older_ones = if journal_sorting_desc? + base_journals.offset(MAX_RECENT_JOURNALS) + else + total = base_journals.count + limit = [total - MAX_RECENT_JOURNALS, 0].max + base_journals.limit(limit) + end + + API::V3::Activities::ActivityEagerLoadingWrapper.wrap(older_ones) end def journal_with_notes - journals.where.not(notes: "") + base_journals.where.not(notes: "") end def wp_journals_grouped_emoji_reactions diff --git a/app/components/work_packages/activities_tab/journals/item_component.rb b/app/components/work_packages/activities_tab/journals/item_component.rb index 0030013b1fdd..af733a7474c4 100644 --- a/app/components/work_packages/activities_tab/journals/item_component.rb +++ b/app/components/work_packages/activities_tab/journals/item_component.rb @@ -75,7 +75,7 @@ def updated? end def has_unread_notifications? - journal.notifications.where(read_ian: false, recipient_id: User.current.id).any? + journal.has_unread_notifications_for_user?(User.current) end def notification_on_details? diff --git a/app/components/work_packages/activities_tab/journals/item_component/details.html.erb b/app/components/work_packages/activities_tab/journals/item_component/details.html.erb index 2bed453e20e3..8a8c6c7c5956 100644 --- a/app/components/work_packages/activities_tab/journals/item_component/details.html.erb +++ b/app/components/work_packages/activities_tab/journals/item_component/details.html.erb @@ -14,12 +14,12 @@ when :only_comments render_empty_line(details_container) unless journal.notes.blank? && !journal.noop? when :only_changes - if journal.details.any? + if has_details? render_details_header(details_container) render_details(details_container) end else - if journal.details.any? + if has_details? render_details_header(details_container) unless journal.notes.present? render_details(details_container) elsif !journal.notes.present? diff --git a/app/components/work_packages/activities_tab/journals/item_component/details.rb b/app/components/work_packages/activities_tab/journals/item_component/details.rb index 59f0c5ed4fbf..529fbcd6ab95 100644 --- a/app/components/work_packages/activities_tab/journals/item_component/details.rb +++ b/app/components/work_packages/activities_tab/journals/item_component/details.rb @@ -53,6 +53,14 @@ def wrapper_uniq_by journal.id end + def journal_details + @journal_details ||= journal.details + end + + def has_details? + @has_details ||= journal_details.any? + end + def render_details_header(details_container) details_container.with_row( flex_layout: true, @@ -223,7 +231,7 @@ def skip_rendering_details? end def render_journal_details(details_container_inner) - journal.details.each do |detail| + journal_details.each do |detail| rendered_detail = journal.render_detail(detail) render_single_detail(details_container_inner, rendered_detail) if rendered_detail.present? end diff --git a/app/models/journal.rb b/app/models/journal.rb index 04f66834916a..95da1c3ed52a 100644 --- a/app/models/journal.rb +++ b/app/models/journal.rb @@ -177,6 +177,16 @@ def has_cause? cause_type.present? end + def has_unread_notifications_for_user?(user) + # we optionally set the instance variable @unread_notifications in the ActivityEagerLoadingWrapper + # in order to avoid N+1 queries + if instance_variable_defined?(:@unread_notifications) + @unread_notifications&.any? { |notification| notification.recipient_id == user.id } + else + notifications.where(read_ian: false, recipient_id: user.id).any? + end + end + private def has_file_links? diff --git a/lib/api/v3/activities/activity_eager_loading_wrapper.rb b/lib/api/v3/activities/activity_eager_loading_wrapper.rb index b21a7abc355a..8b7f20a61238 100644 --- a/lib/api/v3/activities/activity_eager_loading_wrapper.rb +++ b/lib/api/v3/activities/activity_eager_loading_wrapper.rb @@ -36,6 +36,7 @@ def wrap(journals) set_journable(journals) set_predecessor(journals) set_data(journals) + set_notifications(journals) end super @@ -72,6 +73,20 @@ def set_data(journals) end end + def set_notifications(journals) + notifications = Notification + .where(journal_id: journals.map(&:id)) + .where(read_ian: false) + .group_by(&:journal_id) + + journals.each do |journal| + journal.instance_variable_set( + :@unread_notifications, + notifications[journal.id] + ) + end + end + def journable_by_type_and_id(journals) journals .group_by(&:journable_type) From 56ce2ffa2c7dad067318a112eedb7323585189de Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 20 Nov 2024 18:39:17 +0100 Subject: [PATCH 10/21] limit scope of notification update streams --- app/controllers/work_packages/activities_tab_controller.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/controllers/work_packages/activities_tab_controller.rb b/app/controllers/work_packages/activities_tab_controller.rb index 2ea5cca90399..3940a5b40eac 100644 --- a/app/controllers/work_packages/activities_tab_controller.rb +++ b/app/controllers/work_packages/activities_tab_controller.rb @@ -375,6 +375,7 @@ def rerender_journals_with_updated_notification(journals, last_update_timestamp, # alternative approach in order to bypass the notification join issue in relation with the sequence_version query Notification .where(journal_id: journals.pluck(:id)) + .where(recipient_id: User.current.id) .where("notifications.updated_at > ?", last_update_timestamp) .find_each do |notification| update_item_show_component( From 7d0b69a147abda300969cec168978b89f27161af Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Wed, 20 Nov 2024 19:04:57 +0100 Subject: [PATCH 11/21] quickly disable the journals API call, which is not required anymore, will be fully cleaned up in other PR --- .../activity-panel/activity-base.controller.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/features/work-packages/components/wp-single-view-tabs/activity-panel/activity-base.controller.ts b/frontend/src/app/features/work-packages/components/wp-single-view-tabs/activity-panel/activity-base.controller.ts index bbc9236fcf77..4141468879f4 100644 --- a/frontend/src/app/features/work-packages/components/wp-single-view-tabs/activity-panel/activity-base.controller.ts +++ b/frontend/src/app/features/work-packages/components/wp-single-view-tabs/activity-panel/activity-base.controller.ts @@ -108,7 +108,7 @@ export class ActivityPanelBaseController extends UntilDestroyedMixin implements .pipe(this.untilDestroyed()) .subscribe((wp) => { this.workPackage = wp; - this.reloadActivities(); + // this.reloadActivities(); }); this @@ -119,7 +119,7 @@ export class ActivityPanelBaseController extends UntilDestroyedMixin implements distinctUntilChanged(), ) .subscribe(() => { - this.reloadActivities(); + // this.reloadActivities(); }); } From 5ca7abe4a4a86cf0e2994c0efb79e0c5b7c97793 Mon Sep 17 00:00:00 2001 From: jjabari-op <122434454+jjabari-op@users.noreply.github.com> Date: Thu, 21 Nov 2024 09:23:21 +0100 Subject: [PATCH 12/21] Update app/controllers/work_packages/activities_tab_controller.rb Co-authored-by: Kabiru Mwenja --- app/controllers/work_packages/activities_tab_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/work_packages/activities_tab_controller.rb b/app/controllers/work_packages/activities_tab_controller.rb index 3940a5b40eac..2b824dc6b5e6 100644 --- a/app/controllers/work_packages/activities_tab_controller.rb +++ b/app/controllers/work_packages/activities_tab_controller.rb @@ -44,7 +44,7 @@ def index work_package: @work_package, filter: @filter, last_server_timestamp: get_current_server_timestamp, - deferred: params[:deferred] == "true" + deferred: ActiveRecord::Type::Boolean.new.cast(params[:deferred]) ), layout: false ) From fbb39b4be45d7dddf642a765c05bc067b65c0c0e Mon Sep 17 00:00:00 2001 From: Jonas Jabari Date: Thu, 21 Nov 2024 10:06:11 +0100 Subject: [PATCH 13/21] revert angular based comment loading removal, breaks specs, needs to be done in the feature flag removal PR --- .../activity-panel/activity-base.controller.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/features/work-packages/components/wp-single-view-tabs/activity-panel/activity-base.controller.ts b/frontend/src/app/features/work-packages/components/wp-single-view-tabs/activity-panel/activity-base.controller.ts index 4141468879f4..bbc9236fcf77 100644 --- a/frontend/src/app/features/work-packages/components/wp-single-view-tabs/activity-panel/activity-base.controller.ts +++ b/frontend/src/app/features/work-packages/components/wp-single-view-tabs/activity-panel/activity-base.controller.ts @@ -108,7 +108,7 @@ export class ActivityPanelBaseController extends UntilDestroyedMixin implements .pipe(this.untilDestroyed()) .subscribe((wp) => { this.workPackage = wp; - // this.reloadActivities(); + this.reloadActivities(); }); this @@ -119,7 +119,7 @@ export class ActivityPanelBaseController extends UntilDestroyedMixin implements distinctUntilChanged(), ) .subscribe(() => { - // this.reloadActivities(); + this.reloadActivities(); }); } From 2ba1b5e95624bb131a7b2cecdbc54fa332f39e03 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Thu, 21 Nov 2024 13:58:12 +0300 Subject: [PATCH 14/21] tests[Op#59280]: add unit tests for eager loading wrapper --- .../api/v3/activities/activity_eager_loading_wrapper_spec.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spec/lib/api/v3/activities/activity_eager_loading_wrapper_spec.rb b/spec/lib/api/v3/activities/activity_eager_loading_wrapper_spec.rb index 8f5127204a9b..a430ab4dbae9 100644 --- a/spec/lib/api/v3/activities/activity_eager_loading_wrapper_spec.rb +++ b/spec/lib/api/v3/activities/activity_eager_loading_wrapper_spec.rb @@ -33,6 +33,7 @@ shared_let(:project) { create(:project) } shared_let(:work_package) { create(:work_package, project:, author: user) } shared_let(:meeting) { create(:meeting, project:, author: user) } + shared_let(:notifications) { create_list(:notification, 3, recipient: user, resource: work_package) } describe ".wrap" do it "returns wrapped journals with relations eager loaded" do @@ -43,7 +44,7 @@ expect(wrapped_journals.size).to eq(journals.size) wrapped_journals.each do |loaded_journal| - expect(loaded_journal.__getobj__.instance_variables).to include(:@predecessor) + expect(loaded_journal.__getobj__.instance_variables).to include(:@predecessor, :@unread_notifications) %i[journable data].each do |association| expect(loaded_journal.association_cached?(association)).to be true end From 292a116dc75dc54a19d7a78c9c25923df033bb41 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Thu, 21 Nov 2024 13:58:36 +0300 Subject: [PATCH 15/21] resolve Performance/CollectionLiteralInLoop --- .../api/v3/activities/activity_eager_loading_wrapper_spec.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spec/lib/api/v3/activities/activity_eager_loading_wrapper_spec.rb b/spec/lib/api/v3/activities/activity_eager_loading_wrapper_spec.rb index a430ab4dbae9..c8d21fde89e2 100644 --- a/spec/lib/api/v3/activities/activity_eager_loading_wrapper_spec.rb +++ b/spec/lib/api/v3/activities/activity_eager_loading_wrapper_spec.rb @@ -43,9 +43,10 @@ wrapped_journals = described_class.wrap(journals) expect(wrapped_journals.size).to eq(journals.size) + expected_associations_cached = %i[journable data] wrapped_journals.each do |loaded_journal| expect(loaded_journal.__getobj__.instance_variables).to include(:@predecessor, :@unread_notifications) - %i[journable data].each do |association| + expected_associations_cached.each do |association| expect(loaded_journal.association_cached?(association)).to be true end end From d9c81b970155cc20639e95c3cf803d5b8edec029 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 20 Nov 2024 21:30:03 +0100 Subject: [PATCH 16/21] Accept fingerprint as present --- modules/auth_saml/app/models/saml/provider.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/auth_saml/app/models/saml/provider.rb b/modules/auth_saml/app/models/saml/provider.rb index 515c3a42d67c..341bc594342c 100644 --- a/modules/auth_saml/app/models/saml/provider.rb +++ b/modules/auth_saml/app/models/saml/provider.rb @@ -97,7 +97,7 @@ def loaded_idp_certificates end def idp_certificate_configured? - idp_cert.present? + idp_cert.present? || idp_cert_fingerprint.present? end def idp_certificate_valid? From 92da8d9d879addaa9a60d035cf54df01a44f09a0 Mon Sep 17 00:00:00 2001 From: OpenProject Actions CI Date: Fri, 22 Nov 2024 03:24:56 +0000 Subject: [PATCH 17/21] update locales from crowdin [ci skip] --- config/locales/crowdin/cs.yml | 4 ++-- config/locales/crowdin/ru.yml | 2 +- modules/openid_connect/config/locales/crowdin/ru.yml | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/config/locales/crowdin/cs.yml b/config/locales/crowdin/cs.yml index 8fc427bfdf44..7f2b11a3b8d7 100644 --- a/config/locales/crowdin/cs.yml +++ b/config/locales/crowdin/cs.yml @@ -36,7 +36,7 @@ cs: label_type_to_comment: "Add a comment. Type @ to notify people." label_submit_comment: "Odeslat komentář" changed_on: "změněno dne" - created_on: "created this on" + created_on: "toto vytvořil/a dne" changed: "změněno" created: "vytvořeno" commented: "komentováno" @@ -266,7 +266,7 @@ cs: filled?: "must be filled" not_unique: "must be unique within the same hierarchy level" rules: - label: "Label" + label: "Popisek" global_search: placeholder: "Hledat v %{app_title}" overwritten_tabs: diff --git a/config/locales/crowdin/ru.yml b/config/locales/crowdin/ru.yml index 531ad0bf6f79..ffa9ad3daa0c 100644 --- a/config/locales/crowdin/ru.yml +++ b/config/locales/crowdin/ru.yml @@ -33,7 +33,7 @@ ru: label_activity_show_only_changes: "Показать только изменения" label_sort_asc: "Новые внизу" label_sort_desc: "Новые вверху" - label_type_to_comment: "Add a comment. Type @ to notify people." + label_type_to_comment: "Добавить комментарий. Введите @ для уведомления пользователей." label_submit_comment: "Отправить комментарий" changed_on: "изменено" created_on: "создано в" diff --git a/modules/openid_connect/config/locales/crowdin/ru.yml b/modules/openid_connect/config/locales/crowdin/ru.yml index 0f9c62e1da33..616c34dfcaa7 100644 --- a/modules/openid_connect/config/locales/crowdin/ru.yml +++ b/modules/openid_connect/config/locales/crowdin/ru.yml @@ -91,7 +91,7 @@ ru: custom: name: Пользовательский upsale: - description: Connect OpenProject to an OpenID connect identity provider + description: Подключить OpenProject к провайдеру идентификации OpenID connect label_add_new: Добавить нового провайдера OpenID label_edit: Редактировать провайдера OpenID %{name} label_empty_title: Провайдеры OpenID еще не настроены. @@ -113,7 +113,7 @@ ru: metadata_form_title: Конечная точка обнаружения OpenID Connect metadata_form_description: If your identity provider has a discovery endpoint URL. Use it below to pre-fill configuration. configuration_metadata: The information has been pre-filled using the supplied discovery endpoint. In most cases, they do not require editing. - configuration: Configuration details of the OpenID Connect provider + configuration: Подробности конфигурации провайдера OpenID Connect display_name: Отображаемое имя, видимое пользователям. attribute_mapping: Configure the mapping of attributes between OpenProject and the OpenID Connect provider. claims: Request additional claims for the ID token or userinfo response. From 424d325ed6904eaa0f9ca4a515d6b6353ea0fb75 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 Nov 2024 05:16:01 +0000 Subject: [PATCH 18/21] build(deps-dev): bump rubocop-performance from 1.22.1 to 1.23.0 Bumps [rubocop-performance](https://github.com/rubocop/rubocop-performance) from 1.22.1 to 1.23.0. - [Release notes](https://github.com/rubocop/rubocop-performance/releases) - [Changelog](https://github.com/rubocop/rubocop-performance/blob/master/CHANGELOG.md) - [Commits](https://github.com/rubocop/rubocop-performance/compare/v1.22.1...v1.23.0) --- updated-dependencies: - dependency-name: rubocop-performance dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 346a12061a92..789a816e3ab0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -689,7 +689,7 @@ GEM reline (>= 0.4.2) iso8601 (0.13.0) jmespath (1.6.2) - json (2.8.1) + json (2.8.2) json-jwt (1.16.7) activesupport (>= 4.2) aes_key_wrap @@ -1028,7 +1028,7 @@ GEM rubocop-ast (>= 1.32.2, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.35.0) + rubocop-ast (1.36.1) parser (>= 3.3.1.0) rubocop-capybara (2.21.0) rubocop (~> 1.41) @@ -1036,7 +1036,7 @@ GEM rubocop (~> 1.61) rubocop-openproject (0.2.0) rubocop - rubocop-performance (1.22.1) + rubocop-performance (1.23.0) rubocop (>= 1.48.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) rubocop-rails (2.27.0) From 782c7403a52984492dcdf19b6d3645bc5486cecc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 Nov 2024 05:16:32 +0000 Subject: [PATCH 19/21] build(deps): bump aws-sdk-s3 from 1.170.1 to 1.173.0 Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.170.1 to 1.173.0. - [Release notes](https://github.com/aws/aws-sdk-ruby/releases) - [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-s3/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-ruby/commits) --- updated-dependencies: - dependency-name: aws-sdk-s3 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 346a12061a92..56cc9e1cab46 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -352,16 +352,16 @@ GEM awesome_nested_set (3.7.0) activerecord (>= 4.0.0, < 8.0) aws-eventstream (1.3.0) - aws-partitions (1.1005.0) + aws-partitions (1.1012.0) aws-sdk-core (3.212.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.95.0) + aws-sdk-kms (1.96.0) aws-sdk-core (~> 3, >= 3.210.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.170.1) + aws-sdk-s3 (1.173.0) aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) From ea386efbfa41aa7211ded1c7b91d7383dd7c4767 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 Nov 2024 05:17:36 +0000 Subject: [PATCH 20/21] build(deps): bump spreadsheet from 1.3.1 to 1.3.3 Bumps [spreadsheet](https://github.com/zdavatz/spreadsheet) from 1.3.1 to 1.3.3. - [Changelog](https://github.com/zdavatz/spreadsheet/blob/master/History.md) - [Commits](https://github.com/zdavatz/spreadsheet/commits/1.3.3) --- updated-dependencies: - dependency-name: spreadsheet dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 346a12061a92..feca41973071 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1090,7 +1090,7 @@ GEM multi_json (~> 1.10) simpleidn (0.2.3) smart_properties (1.17.0) - spreadsheet (1.3.1) + spreadsheet (1.3.3) bigdecimal ruby-ole spring (4.2.1) From 1381cb0c22ea9f539f96806fc8bbd4d07dbc1f77 Mon Sep 17 00:00:00 2001 From: ulferts Date: Fri, 22 Nov 2024 08:48:03 +0100 Subject: [PATCH 21/21] adapt spec expectation to bumped json version --- spec/requests/api/v3/authentication_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/requests/api/v3/authentication_spec.rb b/spec/requests/api/v3/authentication_spec.rb index 4023d77b9469..658232433892 100644 --- a/spec/requests/api/v3/authentication_spec.rb +++ b/spec/requests/api/v3/authentication_spec.rb @@ -424,7 +424,7 @@ def set_basic_auth_header(user, password) headers: { "Accept" => "*/*", "Accept-Encoding" => "gzip;q=1.0,deflate;q=0.6,identity;q=0.3", - "User-Agent" => "JSON::JWK::Set::Fetcher 2.8.1" + "User-Agent" => "JSON::JWK::Set::Fetcher 2.8.2" } ) .to_return(status: 200, body: '{"keys":[{"kid":"CANAG6lJUPKqKDoWxxXL5wAHf2U18BAzm_LJm7RPTGk","kty":"RSA","alg":"RSA-OAEP","use":"enc","n":"nqJexS6n-SxKSDUxXp_dsNwDW6cZ4Rtgqq9ut_lp1CNSph5wTnLG3aQQsTEvx5o3-SZ-pHjJ0gtEpg7clAz-w-YQyZoAXkFtQqmZJxsmdS4K0yILxO3WUNdJQlutjmq-Ri50Senn5IV7yEYWLo8St1qzUqWZhp0HKudyty24triC9UJTK03W3_Tr5c1X8vKL8duAjvLB7p_sYUOrnLq5pD5lqwxVSAiN8qS5zVNZMrhGV5aN1vN_vue_tw8c2SVOCLLTrUh3441rYaeo-UwQZF7ZTm30xflqAIfe8qMoB20wtWYAXR0D5iqkkdEH4XanCYVm5vdUFIPPvXZhRDWoNQ","e":"AQAB","x5c":["MIICmzCCAYMCBgGQupeGPzANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZtYXN0ZXIwHhcNMjQwNzE2MDgwODMwWhcNMzQwNzE2MDgxMDEwWjARMQ8wDQYDVQQDDAZtYXN0ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCeol7FLqf5LEpINTFen92w3ANbpxnhG2Cqr263+WnUI1KmHnBOcsbdpBCxMS/Hmjf5Jn6keMnSC0SmDtyUDP7D5hDJmgBeQW1CqZknGyZ1LgrTIgvE7dZQ10lCW62Oar5GLnRJ6efkhXvIRhYujxK3WrNSpZmGnQcq53K3Lbi2uIL1QlMrTdbf9OvlzVfy8ovx24CO8sHun+xhQ6ucurmkPmWrDFVICI3ypLnNU1kyuEZXlo3W83++57+3DxzZJU4IstOtSHfjjWthp6j5TBBkXtlObfTF+WoAh97yoygHbTC1ZgBdHQPmKqSR0QfhdqcJhWbm91QUg8+9dmFENag1AgMBAAEwDQYJKoZIhvcNAQELBQADggEBAB/AGvP0gviPoJszj/oQgBsMpPGRHLpnTmrXnTaa7Xk2sgExAb4zUAwxGjtR347t697cpiKQYBkR2ndswnt93Sx/Ot+yn5BdYcNvZuEh5jb5bkH2V4h6/LrYljTymby+XPBEf+XLhBOjoI3SKtNJk4pEqVNwLuKKbObqJcE3G3VBVSdzRUcIrjZr7yAQeLnhczS3hJ0Ct6Y7S5Q6DK+/PU1+AvlW+7GfzpRMqVfLcqhNpRwdCVGlJYKaUJfIe1vav10D94xA0U1sKex3iA1S+1HlS2BCWx/0rXwgcquMpUZlOAKiT0K6SIFxBFFnM9eQbF97Dz7Bzw+jyqStGUcH9YA="],"x5t":"TuBfrOL00KXDrOWTv3jw7Uxx3hA","x5t#S256":"7su5lOXF5qcMuvp44ynsoyk3B0l9Sr_bOVlg768shpY"},{"kid":"97AmyvoS8BFFRfm585GPgA16G1H2V22EdxxuAYUuoKk","kty":"RSA","alg":"RS256","use":"sig","n":"jMB2r7BG4QJzLnA2_fgG1mxlh2RX_MSx0lc2lrPIVFGYBuAu8irwRLSexX5aQdD_AtnxLD4g9jiG6VEDwmWopEe0fr-QMl0IiES5tJuQMrjhajOkzr8xTYu6zl-knL0tu99iRbmKNYzEcv0TAgY_95n4gD5tPhYvY4gXuHrFKqYkJQPsSgoThlH7hAtfzsDt6yp3P2lQUESGg3pzc_J_NKnQkkggcNB06Hlz4DmcHxhWXK51P1V9cE7qh4PrhsJ-SOH5grcN9PtOZi6f2VlWdFdyisT-YehNklfVqBtdCLm7Ocghhl0HSgLuV-9dHCdwBLUpABsdsd0L3LRCUgRfjQ","e":"AQAB","x5c":["MIICmzCCAYMCBgGQupeFFTANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZtYXN0ZXIwHhcNMjQwNzE2MDgwODMwWhcNMzQwNzE2MDgxMDEwWjARMQ8wDQYDVQQDDAZtYXN0ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCMwHavsEbhAnMucDb9+AbWbGWHZFf8xLHSVzaWs8hUUZgG4C7yKvBEtJ7FflpB0P8C2fEsPiD2OIbpUQPCZaikR7R+v5AyXQiIRLm0m5AyuOFqM6TOvzFNi7rOX6ScvS2732JFuYo1jMRy/RMCBj/3mfiAPm0+Fi9jiBe4esUqpiQlA+xKChOGUfuEC1/OwO3rKnc/aVBQRIaDenNz8n80qdCSSCBw0HToeXPgOZwfGFZcrnU/VX1wTuqHg+uGwn5I4fmCtw30+05mLp/ZWVZ0V3KKxP5h6E2SV9WoG10Iubs5yCGGXQdKAu5X710cJ3AEtSkAGx2x3QvctEJSBF+NAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAIoBCsOO0bXiVspoXkqdOts4+3sULbbp5aEwQscmLX017Zvv5jxdkZxUYk8L08lNB+WlC1ES4VlmtE06D0cWYErGpArJzVBKgYSA3CkA9veBEugHviMqfwg3suNc8S+GtaRBvpbVZtXydjjqA8GZ4eKhPoJLHHCX6X2Ad33Cdt0/ftucjTqAKVzzzgWZejy+ZKP6ybAqYJ+EZoPUXlyWT3uwcpGEJ3nzOYYGTfxOSmAwnH2v5Z/JWr9ex5o/+QBuBhFcg0z8NcHa3Z0E6ZC9GGxV7XztBqYicO+nONHTLCctoJmyXvLM4j8qIG2UQgPIiwIL0Jkz6xQAYyXvsb+LhM8="],"x5t":"BFrni6MoX-CJwtMT4vzij1HBSTI","x5t#S256":"-Ge3y4JRezxhGTDfbkNoz7prkokzYtbKQ9ardPtfcz4"}]}', headers: {}) @@ -495,7 +495,7 @@ def set_basic_auth_header(user, password) headers: { "Accept" => "*/*", "Accept-Encoding" => "gzip;q=1.0,deflate;q=0.6,identity;q=0.3", - "User-Agent" => "JSON::JWK::Set::Fetcher 2.8.1" + "User-Agent" => "JSON::JWK::Set::Fetcher 2.8.2" } ) .to_return(status: 200, body: '{"keys":[{"kid":"CANAG6lJUPKqKDoWxxXL5wAHf2U18BAzm_LJm7RPTGk","kty":"RSA","alg":"RSA-OAEP","use":"enc","n":"nqJexS6n-SxKSDUxXp_dsNwDW6cZ4Rtgqq9ut_lp1CNSph5wTnLG3aQQsTEvx5o3-SZ-pHjJ0gtEpg7clAz-w-YQyZoAXkFtQqmZJxsmdS4K0yILxO3WUNdJQlutjmq-Ri50Senn5IV7yEYWLo8St1qzUqWZhp0HKudyty24triC9UJTK03W3_Tr5c1X8vKL8duAjvLB7p_sYUOrnLq5pD5lqwxVSAiN8qS5zVNZMrhGV5aN1vN_vue_tw8c2SVOCLLTrUh3441rYaeo-UwQZF7ZTm30xflqAIfe8qMoB20wtWYAXR0D5iqkkdEH4XanCYVm5vdUFIPPvXZhRDWoNQ","e":"AQAB","x5c":["MIICmzCCAYMCBgGQupeGPzANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZtYXN0ZXIwHhcNMjQwNzE2MDgwODMwWhcNMzQwNzE2MDgxMDEwWjARMQ8wDQYDVQQDDAZtYXN0ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCeol7FLqf5LEpINTFen92w3ANbpxnhG2Cqr263+WnUI1KmHnBOcsbdpBCxMS/Hmjf5Jn6keMnSC0SmDtyUDP7D5hDJmgBeQW1CqZknGyZ1LgrTIgvE7dZQ10lCW62Oar5GLnRJ6efkhXvIRhYujxK3WrNSpZmGnQcq53K3Lbi2uIL1QlMrTdbf9OvlzVfy8ovx24CO8sHun+xhQ6ucurmkPmWrDFVICI3ypLnNU1kyuEZXlo3W83++57+3DxzZJU4IstOtSHfjjWthp6j5TBBkXtlObfTF+WoAh97yoygHbTC1ZgBdHQPmKqSR0QfhdqcJhWbm91QUg8+9dmFENag1AgMBAAEwDQYJKoZIhvcNAQELBQADggEBAB/AGvP0gviPoJszj/oQgBsMpPGRHLpnTmrXnTaa7Xk2sgExAb4zUAwxGjtR347t697cpiKQYBkR2ndswnt93Sx/Ot+yn5BdYcNvZuEh5jb5bkH2V4h6/LrYljTymby+XPBEf+XLhBOjoI3SKtNJk4pEqVNwLuKKbObqJcE3G3VBVSdzRUcIrjZr7yAQeLnhczS3hJ0Ct6Y7S5Q6DK+/PU1+AvlW+7GfzpRMqVfLcqhNpRwdCVGlJYKaUJfIe1vav10D94xA0U1sKex3iA1S+1HlS2BCWx/0rXwgcquMpUZlOAKiT0K6SIFxBFFnM9eQbF97Dz7Bzw+jyqStGUcH9YA="],"x5t":"TuBfrOL00KXDrOWTv3jw7Uxx3hA","x5t#S256":"7su5lOXF5qcMuvp44ynsoyk3B0l9Sr_bOVlg768shpY"},{"kid":"9755555S8BFFRfm585GPgA16G1H2V22EdxxuAYUuoKk","kty":"RSA","alg":"RS256","use":"sig","n":"jMB2r7BG4QJzLnA2_fgG1mxlh2RX_MSx0lc2lrPIVFGYBuAu8irwRLSexX5aQdD_AtnxLD4g9jiG6VEDwmWopEe0fr-QMl0IiES5tJuQMrjhajOkzr8xTYu6zl-knL0tu99iRbmKNYzEcv0TAgY_95n4gD5tPhYvY4gXuHrFKqYkJQPsSgoThlH7hAtfzsDt6yp3P2lQUESGg3pzc_J_NKnQkkggcNB06Hlz4DmcHxhWXK51P1V9cE7qh4PrhsJ-SOH5grcN9PtOZi6f2VlWdFdyisT-YehNklfVqBtdCLm7Ocghhl0HSgLuV-9dHCdwBLUpABsdsd0L3LRCUgRfjQ","e":"AQAB","x5c":["MIICmzCCAYMCBgGQupeFFTANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZtYXN0ZXIwHhcNMjQwNzE2MDgwODMwWhcNMzQwNzE2MDgxMDEwWjARMQ8wDQYDVQQDDAZtYXN0ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCMwHavsEbhAnMucDb9+AbWbGWHZFf8xLHSVzaWs8hUUZgG4C7yKvBEtJ7FflpB0P8C2fEsPiD2OIbpUQPCZaikR7R+v5AyXQiIRLm0m5AyuOFqM6TOvzFNi7rOX6ScvS2732JFuYo1jMRy/RMCBj/3mfiAPm0+Fi9jiBe4esUqpiQlA+xKChOGUfuEC1/OwO3rKnc/aVBQRIaDenNz8n80qdCSSCBw0HToeXPgOZwfGFZcrnU/VX1wTuqHg+uGwn5I4fmCtw30+05mLp/ZWVZ0V3KKxP5h6E2SV9WoG10Iubs5yCGGXQdKAu5X710cJ3AEtSkAGx2x3QvctEJSBF+NAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAIoBCsOO0bXiVspoXkqdOts4+3sULbbp5aEwQscmLX017Zvv5jxdkZxUYk8L08lNB+WlC1ES4VlmtE06D0cWYErGpArJzVBKgYSA3CkA9veBEugHviMqfwg3suNc8S+GtaRBvpbVZtXydjjqA8GZ4eKhPoJLHHCX6X2Ad33Cdt0/ftucjTqAKVzzzgWZejy+ZKP6ybAqYJ+EZoPUXlyWT3uwcpGEJ3nzOYYGTfxOSmAwnH2v5Z/JWr9ex5o/+QBuBhFcg0z8NcHa3Z0E6ZC9GGxV7XztBqYicO+nONHTLCctoJmyXvLM4j8qIG2UQgPIiwIL0Jkz6xQAYyXvsb+LhM8="],"x5t":"BFrni6MoX-CJwtMT4vzij1HBSTI","x5t#S256":"-Ge3y4JRezxhGTDfbkNoz7prkokzYtbKQ9ardPtfcz4"}]}', headers: {})