[74198] Remove newest projects in project widget on homepage (#23136)

* Add footer component for widget box

* Add footer component to the widget box as a slot

* Change projects widget to show the favorite projects

* Fix failing test

* Change the subitems widget

* Change the costs and budgets widgets

* Change the meeting widget

* Change the WPs widget in version

* Change memebers widget in project overview

* Change the favorite projects widget in my page

# Conflicts:
#	frontend/src/app/shared/components/grids/widgets/project-favorites/widget-project-favorites.component.ts

* Add the widget box to the lookbook

* Add footer for members widget in dashboard

* Fix members widget capability check

* Add feature spec for favorites projects in my page

* Remove committed demo project gitlink

* Remove temporary body variables from the costs and budgets widget templates

* Remove the scroll for favorites widget

* Remove scrollbar for members and favorite projects widgets

* Change projects block to favorite projects

* Refine feature specs

* Fix the widget footer styles globally

* Rename the component name from project favorites to favorite projects

* Rename the test selector for project name

* Move widget content inside the body

* grid widgets stretch their content area so widget footers stay pinned to the bottom

* Ensure frontend-rendered grid widgets keep their turbo-loaded content in the widget flex layout so server-rendered footers stay pinned to the bottom
This commit is contained in:
Behrokh Satarnejad
2026-05-29 08:37:17 +02:00
committed by GitHub
parent a152141163
commit bfa2588bf4
50 changed files with 683 additions and 283 deletions
@@ -0,0 +1,39 @@
# 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 Homescreen
module Blocks
class FavoriteProjects < Grids::WidgetComponent
def call
render(Grids::Widgets::FavoriteProjects.new(current_user:))
end
end
end
end
@@ -1,57 +0,0 @@
<%= widget_wrapper do %>
<% if @favorite_projects.any? %>
<p class="widget-box--additional-info"><%= t("projects.lists.favorited") %></p>
<ul class="widget-box--arrow-links">
<% @favorite_projects.each do |project| %>
<li>
<%= render(
Primer::Beta::Octicon.new(
icon: "star-fill",
classes: "op-primer--star-icon",
"aria-label": I18n.t(:label_favorite)
)
) %>
<%= link_to project, project_path(project),
title: short_project_description(project),
data: { test_selector: "favorite-project" } %>
</li>
<% end %>
</ul>
<% end %>
<% if @newest_projects.empty? %>
<p class="widget-box--additional-info">
<%= t("homescreen.additional.no_visible_projects") %>
</p>
<% else %>
<p class="widget-box--additional-info"><%= t("homescreen.additional.projects") %></p>
<ul class="widget-box--arrow-links">
<% @newest_projects.each do |project| %>
<li>
<%= link_to project, project_path(project), title: short_project_description(project) %>
<small>(<%= format_date(project.created_at) %>)</small>
</li>
<% end %>
</ul>
<% end %>
<div class="widget-box--blocks--buttons">
<% if current_user.allowed_globally?(:add_project) %>
<%= link_to new_project_path,
{ class: "button -primary",
aria: { label: t(:label_project_new) },
title: t(:label_project_new) } do %>
<%= op_icon("button--icon icon-add") %>
<span class="button--text"><%= Project.model_name.human %></span>
<% end %>
<% end %>
<%# If any project exists %>
<% unless @newest_projects.empty? %>
<%= link_to t(:label_project_view_all), projects_path,
class: "button -highlight-inverted",
title: t(:label_project_view_all) %>
<% end %>
</div>
<% end %>
@@ -66,7 +66,7 @@ module Projects
end
def render_view_all_link(widget)
widget.with_row do
widget.with_footer do
helpers.link_to(
I18n.t("projects.settings.versions.show_work_packages"),
helpers.project_work_packages_version_path(version)
+1 -1
View File
@@ -38,7 +38,7 @@ OpenProject::Static::Homescreen.manage :blocks do |blocks|
if: Proc.new { Setting.welcome_on_homescreen? && Setting.welcome_text.present? }
},
{
name: "projects"
name: "favorite_projects"
},
{
name: "new_features",
+3
View File
@@ -3639,6 +3639,9 @@ en:
additional:
projects: "Newest visible projects in this instance."
no_visible_projects: "There are no visible projects in this instance."
favorite_projects:
no_results: "You have no favorite projects"
no_results_subtext: "Add one or multiple projects as favorite through their overview or in a project list."
users: "Newest registered users in this instance."
blocks:
community: "OpenProject community"
@@ -79,8 +79,8 @@ import {
TimeEntriesCurrentUserConfigurationModalComponent,
} from './widgets/time-entries/current-user/configuration-modal/configuration.modal';
import {
WidgetProjectFavoritesComponent,
} from 'core-app/shared/components/grids/widgets/project-favorites/widget-project-favorites.component';
WidgetFavoriteProjectsComponent,
} from 'core-app/shared/components/grids/widgets/favorite-projects/widget-favorite-projects.component';
import { IconModule } from 'core-app/shared/components/icon/icon.module';
import { OpenprojectEnterpriseModule } from 'core-app/features/enterprise/openproject-enterprise.module';
import { ErrorBlankSlateComponent } from './widgets/error-blankslate/error-blankslate.component';
@@ -128,7 +128,7 @@ import { ErrorBlankSlateComponent } from './widgets/error-blankslate/error-blank
WidgetProjectDescriptionComponent,
WidgetProjectStatusComponent,
WidgetSubprojectsComponent,
WidgetProjectFavoritesComponent,
WidgetFavoriteProjectsComponent,
WidgetTimeEntriesCurrentUserComponent,
WidgetTimeEntriesProjectComponent,
@@ -17,6 +17,8 @@ export abstract class AbstractWidgetComponent extends UntilDestroyedMixin {
@HostBinding('style.grid-row-end') gridRowEnd:number;
@HostBinding('class.grid--widget-host') gridWidgetHost = true;
@Input() resource:GridWidgetResource;
@Output() resourceChanged = new EventEmitter<WidgetChangeset>();
@@ -45,6 +47,7 @@ export abstract class AbstractWidgetComponent extends UntilDestroyedMixin {
* We arbitrarily restrict this for some resources however,
* whose component classes will set this to false.
*/
// eslint-disable-next-line @typescript-eslint/class-literal-property-style
public get isEditable() {
return true;
}
@@ -0,0 +1,14 @@
<widget-header
[name]="widgetName"
[editable]="false">
<widget-menu slot="menu"
[resource]="resource" />
</widget-header>
<turbo-frame
class="grid--widget-content"
[id]="frameId"
[src]="src"
loading="lazy">
</turbo-frame>
@@ -0,0 +1,19 @@
import {
ChangeDetectionStrategy,
Component,
HostBinding,
} from '@angular/core';
import { AbstractTurboWidgetComponent } from 'core-app/shared/components/grids/widgets/abstract-turbo-widget.component';
@Component({
selector: 'op-favorite-projects-widget',
templateUrl: './widget-favorite-projects.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class WidgetFavoriteProjectsComponent extends AbstractTurboWidgetComponent {
@HostBinding('class.op-widget-favorite-projects') className = true;
override frameId = 'grids-widgets-favorite-projects';
override name = 'project_favorites';
}
@@ -10,15 +10,20 @@
[resource]="resource" />
</widget-header>
<div class="op-widget-box--body">
@if ({hasCapability: hasCapability$ | async}; as context) {
@if (context.hasCapability === false) {
@if ({hasCapability: hasCapability$ | async}; as context) {
@if (context.hasCapability === false) {
<div class="op-widget-box--body grid--widget-content">
<div class="op-toast -error">
<span [textContent]="text.missing_permission"></span>
</div>
}
@if (context.hasCapability === true) {
<turbo-frame [id]="frameId" [src]="src" loading="lazy" />
}
</div>
}
</div>
@if (context.hasCapability === true) {
<turbo-frame
class="grid--widget-content"
[id]="frameId"
[src]="src"
loading="lazy">
</turbo-frame>
}
}
@@ -1,21 +1,23 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
inject,
} from '@angular/core';
import { AbstractTurboWidgetComponent } from 'core-app/shared/components/grids/widgets/abstract-turbo-widget.component';
import { CurrentProjectService } from 'core-app/core/current-project/current-project.service';
import { CurrentUserService } from 'core-app/core/current-user/current-user.service';
@Component({
selector: 'op-members-widget',
templateUrl: './members.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false
standalone: false,
})
export class WidgetMembersComponent extends AbstractTurboWidgetComponent {
protected readonly currentProject = inject(CurrentProjectService);
protected readonly currentUser = inject(CurrentUserService);
get text() {
return { missing_permission: this.i18n.t('js.grid.widgets.missing_permission') };
}
text = {
missing_permission: this.i18n.t('js.grid.widgets.missing_permission'),
};
hasCapability$ = this.currentUser.hasCapabilities$('memberships/read', this.currentProject.id);
@@ -6,4 +6,4 @@
[resource]="resource" />
</widget-header>
<turbo-frame [id]="frameId" [src]="src" loading="lazy" />
<turbo-frame class="grid--widget-content" [id]="frameId" [src]="src" loading="lazy" />
@@ -10,4 +10,4 @@
[resource]="resource" />
</widget-header>
<turbo-frame [id]="frameId" [src]="src" loading="lazy" />
<turbo-frame class="grid--widget-content" [id]="frameId" [src]="src" loading="lazy" />
@@ -1,45 +0,0 @@
<widget-header
[name]="widgetName"
[editable]="false">
<widget-menu slot="menu"
[resource]="resource" />
</widget-header>
@if ((projects$ | async); as projects) {
<div class="op-widget-box--body">
@if (projects.length === 0) {
<div class="op-header-project-select--no-favorites">
<svg
class="op-header-project-select--no-favorites-icon"
star-icon
size="medium"
></svg>
<p>
<strong [textContent]="text.no_favorites"></strong>
<br/>
<span class="op-header-project-select--no-favorites-subtext"
[textContent]="text.no_favorites_subtext"></span>
</p>
</div>
}
@else {
<ul class="op-widget-project-favorites--list">
@for (project of projects; track project) {
<li>
<svg
star-fill-icon
class="op-primer--star-icon"
size="small"
></svg>
<a
class="op-widget-project-favorites--link"
[href]="projectPath(project)"
[textContent]="project.name">
</a>
</li>
}
</ul>
}
</div>
}
@@ -1,6 +0,0 @@
.op-widget-project-favorites
&--list
list-style: none
&--link
padding-left: 0.25rem
@@ -1,51 +0,0 @@
import { AbstractWidgetComponent } from 'core-app/shared/components/grids/widgets/abstract-widget.component';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostBinding, OnInit, ViewEncapsulation, inject } from '@angular/core';
import { HalResourceService } from 'core-app/features/hal/services/hal-resource.service';
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
import { CurrentProjectService } from 'core-app/core/current-project/current-project.service';
import { ProjectResource } from 'core-app/features/hal/resources/project-resource';
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
import { TimezoneService } from 'core-app/core/datetime/timezone.service';
import { ApiV3FilterBuilder } from 'core-app/shared/helpers/api-v3/api-v3-filter-builder';
import { Observable } from 'rxjs';
@Component({
templateUrl: './widget-project-favorites.component.html',
styleUrls: ['./widget-project-favorites.component.sass'],
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class WidgetProjectFavoritesComponent extends AbstractWidgetComponent implements OnInit {
readonly halResource = inject(HalResourceService);
readonly pathHelper = inject(PathHelperService);
readonly timezone = inject(TimezoneService);
readonly apiV3Service = inject(ApiV3Service);
readonly currentProject = inject(CurrentProjectService);
readonly cdr = inject(ChangeDetectorRef);
@HostBinding('class.op-widget-project-favorites') className = true;
public text = {
no_favorites: this.i18n.t('js.favorite_projects.no_results'),
no_favorites_subtext: this.i18n.t('js.favorite_projects.no_results_subtext'),
};
public projects$:Observable<ProjectResource[]>;
ngOnInit() {
const filters = new ApiV3FilterBuilder();
filters.add('favorited', '=', true);
filters.add('active', '=', true);
this.projects$ = this
.apiV3Service
.projects
.filtered(filters, { sortBy: '[["name","asc"]]', pageSize: '-1' })
.getPaginatedResults();
}
projectPath(project:ProjectResource) {
return this.pathHelper.projectPath(project.identifier);
}
}
@@ -10,4 +10,4 @@
[resource]="resource" />
</widget-header>
<turbo-frame [id]="frameId" [src]="src" loading="lazy" />
<turbo-frame class="grid--widget-content" [id]="frameId" [src]="src" loading="lazy" />
@@ -6,4 +6,4 @@
[resource]="resource" />
</widget-header>
<turbo-frame [id]="frameId" [src]="src" loading="lazy" />
<turbo-frame class="grid--widget-content" [id]="frameId" [src]="src" loading="lazy" />
@@ -26,8 +26,8 @@ import {
} from 'core-app/shared/components/grids/widgets/project-status/project-status.component';
import { WidgetSubprojectsComponent } from 'core-app/shared/components/grids/widgets/subprojects/subprojects.component';
import {
WidgetProjectFavoritesComponent,
} from 'core-app/shared/components/grids/widgets/project-favorites/widget-project-favorites.component';
WidgetFavoriteProjectsComponent,
} from 'core-app/shared/components/grids/widgets/favorite-projects/widget-favorite-projects.component';
import { I18nService } from 'core-app/core/i18n/i18n.service';
@Injectable()
@@ -237,7 +237,7 @@ export class GridWidgetsService {
},
{
identifier: 'project_favorites',
component: WidgetProjectFavoritesComponent,
component: WidgetFavoriteProjectsComponent,
title: this.I18n.t('js.grid.widgets.project_favorites.title'),
properties: {
name: this.I18n.t('js.grid.widgets.project_favorites.title'),
@@ -107,11 +107,16 @@ $grid--widget-padding: 20px 20px 20px 20px
.grid--area-content
height: 100%
ng-component, widget-wp-graph
.grid--widget-host
display: flex
flex-direction: column
height: 100%
> .grid--widget-content
display: flex
flex-direction: column
flex: 1 1 auto
&.cdk-drag-preview
overflow: hidden
background: white
@@ -140,6 +140,10 @@ $widget-box--enumeration-width: 20px
overflow-y: auto
@include styled-scroll-bar
&.op-widget-box--empty
align-items: center
justify-content: center
// styles specific to the custom-text widget
&.-custom-text
a.inplace-editing--trigger-link
@@ -217,6 +221,11 @@ $widget-box--enumeration-width: 20px
border-bottom-right-radius: var(--borderRadius-medium)
border-bottom-left-radius: var(--borderRadius-medium)
.op-widget-box--footer
margin-top: auto
padding: var(--stack-padding-normal)
border-top: var(--borderWidth-thin) solid var(--borderColor-muted)
.widget-box--arrow-links
list-style: none
margin: 0.5rem 0 1rem 0
@@ -0,0 +1,53 @@
# 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 OpenProject
module Grids
# @logical_path OpenProject/Grids
# @display min_height 300px
class WidgetBoxComponentPreview < Lookbook::Preview
# Use the default body for generic widget content.
def default
render_with_template
end
# Use rows for the primary repeated widget content.
def with_rows
render_with_template
end
# Use the footer for secondary navigation or actions that should stay at the bottom,
# such as "View all ..." links.
def with_footer
render_with_template
end
end
end
end
@@ -0,0 +1,7 @@
<div class="widget-boxes -flex">
<%= render(::Grids::WidgetBoxComponent.new(key: "widget-box-preview", title: "Widget title")) do |widget| %>
<% widget.with_body do %>
Widget body content
<% end %>
<% end %>
</div>
@@ -0,0 +1,15 @@
<div class="widget-boxes -flex" style="height: 280px;">
<%= render(::Grids::WidgetBoxComponent.new(key: "widget-box-footer-preview", title: "Widget title")) do |widget| %>
<% widget.with_row do %>
First row
<% end %>
<% widget.with_row do %>
Second row
<% end %>
<% widget.with_footer do %>
<%= render(Primer::Beta::Link.new(href: "#")) { "View all items" } %>
<% end %>
<% end %>
</div>
@@ -0,0 +1,15 @@
<div class="widget-boxes -flex">
<%= render(::Grids::WidgetBoxComponent.new(key: "widget-box-rows-preview", title: "Widget title")) do |widget| %>
<% widget.with_row do %>
First row
<% end %>
<% widget.with_row do %>
Second row
<% end %>
<% widget.with_row do %>
Third row
<% end %>
<% end %>
</div>
@@ -28,35 +28,40 @@ See COPYRIGHT and LICENSE files for more details.
++# %>
<%=
widget_wrapper do
widget_wrapper do |container|
if has_budgets_data?
flex_layout do |flex|
flex.with_row do
render(Primer::Beta::Text.new(color: :subtle, font_size: :small)) do
if project.children.any?
t(".count_caption_with_subitems.#{project.workspace_type.presence_in(%w[project program portfolio]) || 'project'}",
count: budget_count)
else
t(".count_caption", count: budget_count)
container.with_body do
flex_layout do |flex|
flex.with_row do
render(Primer::Beta::Text.new(color: :subtle, font_size: :small)) do
if project.children.any?
t(
".count_caption_with_subitems.#{project.workspace_type.presence_in(%w[project program portfolio]) || 'project'}",
count: budget_count
)
else
t(".count_caption", count: budget_count)
end
end
end
flex.with_row(mt: 3, align: :center) do
helpers.angular_component_tag(
"opce-budget-by-cost-type",
currency: Setting.costs_currency,
"chart-data": {
labels: chart_labels,
datasets: [{
label: t(:label_budget),
data: chart_data
}]
}.to_json
)
end
end
flex.with_row(mt: 3, align: :center) do
helpers.angular_component_tag(
"opce-budget-by-cost-type",
currency: Setting.costs_currency,
"chart-data": {
labels: chart_labels,
datasets: [{
label: t(:label_budget),
data: chart_data
}]
}.to_json
)
end
flex.with_row(mt: 3) do
render(Primer::Beta::Link.new(href: projects_budgets_path(project))) { t(".view_details") }
end
end
container.with_footer(test_selector: "budget-by-cost-type-widget-footer") do
render(Primer::Beta::Link.new(href: projects_budgets_path(project))) { t(".view_details") }
end
elsif has_budgets?
render(Primer::Beta::Blankslate.new) do |blankslate|
@@ -31,6 +31,8 @@
require "spec_helper"
RSpec.describe Budgets::Widgets::BudgetByCostType, type: :component do
include Rails.application.routes.url_helpers
def render_component(...)
render_inline(described_class.new(...))
end
@@ -65,6 +67,12 @@ RSpec.describe Budgets::Widgets::BudgetByCostType, type: :component do
expect(rendered_component).to have_css("opce-budget-by-cost-type")
end
it "renders view details link in the widget footer" do
expect(rendered_component).to have_test_selector("budget-by-cost-type-widget-footer") do |footer|
expect(footer).to have_link(href: projects_budgets_path(project))
end
end
it "displays caption with workspace type (no subitems for leaf project)" do
expect(rendered_component).to have_text("Data aggregated from 1 budget")
expect(rendered_component).to have_no_text(/Project/)
@@ -28,23 +28,26 @@ See COPYRIGHT and LICENSE files for more details.
++# %>
<%=
widget_wrapper do
widget_wrapper do |container|
if has_spending_data?
flex_layout do |flex|
flex.with_row(mt: 3, align: :center) do
helpers.angular_component_tag(
"opce-actual-costs",
currency: Setting.costs_currency,
"chart-data": {
labels: chart_labels,
datasets: chart_datasets
}.to_json
)
end
flex.with_row(mt: 3) do
render(Primer::Beta::Link.new(href: cost_reports_path(project))) { t(".view_details") }
container.with_body do
flex_layout do |flex|
flex.with_row(mt: 3, align: :center) do
helpers.angular_component_tag(
"opce-actual-costs",
currency: Setting.costs_currency,
"chart-data": {
labels: chart_labels,
datasets: chart_datasets
}.to_json
)
end
end
end
container.with_footer(test_selector: "actual-costs-widget-footer") do
render(Primer::Beta::Link.new(href: cost_reports_path(project))) { t(".view_details") }
end
else
render(Primer::Beta::Blankslate.new) do |blankslate|
blankslate.with_visual_icon(icon: :"op-cost-reports")
@@ -31,6 +31,8 @@
require "spec_helper"
RSpec.describe Costs::Widgets::ActualCosts, type: :component do
include Rails.application.routes.url_helpers
def render_component(...)
render_inline(described_class.new(...))
end
@@ -70,6 +72,12 @@ RSpec.describe Costs::Widgets::ActualCosts, type: :component do
expect(rendered_component).to have_css("opce-actual-costs")
end
it "renders view details link in the widget footer" do
expect(rendered_component).to have_test_selector("actual-costs-widget-footer") do |footer|
expect(footer).to have_link(href: cost_reports_path(project))
end
end
it "passes currency attribute" do
expect(rendered_component).to have_element "opce-actual-costs" do |element|
expect(element["currency"]).to eq(Setting.costs_currency)
@@ -39,5 +39,6 @@ See COPYRIGHT and LICENSE files for more details.
<% end %>
<% end %>
<% end %>
<%= footer %>
<% end %>
<% end %>
@@ -40,6 +40,7 @@ module Grids
}
renders_one :body, Body
renders_one :footer, Footer
renders_many :rows, Row
@@ -88,7 +89,7 @@ module Grids
end
def render?
rows.any? || header? || body?
rows.any? || header? || body? || footer?
end
def default_header
@@ -0,0 +1,50 @@
# 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 Grids
class WidgetBoxComponent < ApplicationComponent
class Footer < ApplicationComponent
def initialize(**system_arguments)
super()
@system_arguments = system_arguments
@system_arguments[:tag] = :div
@system_arguments[:classes] = class_names(
"op-widget-box--footer",
system_arguments[:classes]
)
end
def call
render(Primer::BaseComponent.new(**@system_arguments)) { content }
end
end
end
end
@@ -0,0 +1,73 @@
<%#-- 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.
++#%>
<%=
widget_wrapper do |container|
if favorite_projects.empty?
container.with_body(classes: "op-widget-box--empty") do
render(Primer::Beta::Blankslate.new(test_selector: "favorite-projects-widget-empty")) do |component|
component.with_visual_icon(icon: :project)
component.with_heading(tag: :h3).with_content(I18n.t("homescreen.additional.favorite_projects.no_results"))
component.with_description { I18n.t("homescreen.additional.favorite_projects.no_results_subtext") }
end
end
else
favorite_projects.each do |project|
container.with_row do
helpers.flex_layout do |row|
row.with_column do
render(
Primer::Beta::Octicon.new(
icon: "star-fill",
classes: "op-primer--star-icon",
"aria-label": I18n.t(:label_favorite)
)
)
end
row.with_column(ml: 2) do
render(
Primer::Beta::Link.new(
font_weight: :bold,
href: helpers.project_path(project),
title: short_project_description(project),
data: { "test-selector": "favorite-projects-widget--project-name" }
)
) { project.name }
end
end
end
end
container.with_footer(test_selector: "favorite-projects-widget-footer") do
render(Primer::Beta::Link.new(href: helpers.projects_path)) { I18n.t(:label_project_view_all) }
end
end
end
%>
@@ -1,6 +1,6 @@
# frozen_string_literal: true
# -- copyright
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
@@ -26,24 +26,19 @@
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
# ++
#++
module Homescreen
module Blocks
class Projects < Grids::WidgetComponent
include IconsHelper
module Grids
module Widgets
class FavoriteProjects < Grids::WidgetComponent
include ProjectsHelper
include Redmine::I18n
def initialize(*)
super
@favorite_projects = Project.visible.active.favorited_by(current_user)
@newest_projects = Project.visible.newest.take(3)
end
def title
I18n.t(:label_project_plural)
I18n.t("projects.lists.favorited")
end
def favorite_projects
@favorite_projects ||= Project.visible.active.favorited_by(current_user).order(name: :asc).to_a
end
end
end
@@ -28,7 +28,7 @@ See COPYRIGHT and LICENSE files for more details.
++#%>
<%=
widget_wrapper do
widget_wrapper do |container|
if can_view_members?
if members_by_role.any?
flex_layout(style: "gap: var(--base-size-8, 8px)") do |flex|
@@ -41,29 +41,30 @@ See COPYRIGHT and LICENSE files for more details.
flex.with_row(mb: 2) do
concat render(
Users::AvatarComponent
.with_collection(
role_data[:members],
size: :mini,
link: false,
show_name: true,
spacer_component:
)
)
Users::AvatarComponent
.with_collection(
role_data[:members],
size: :mini,
link: false,
show_name: true,
spacer_component:
)
)
if role_data[:has_more]
concat render(
Primer::Beta::Link.new(
href: project_members_path(@project, role_id: role_data[:role].id),
ml: 1,
font_weight: :semibold,
classes: "op-principal"
).with_content(t(".x_more", count: role_data[:remaining])))
Primer::Beta::Link.new(
href: project_members_path(@project, role_id: role_data[:role].id),
ml: 1,
font_weight: :semibold,
classes: "op-principal"
).with_content(t(".x_more", count: role_data[:remaining]))
)
end
end
end
flex.with_row(mt: 4) do
container.with_footer(test_selector: "members-widget-footer") do
render Primer::Beta::Link.new(href: project_members_path(@project)) do
t(".view_all_members")
end
@@ -88,7 +88,7 @@ See COPYRIGHT and LICENSE files for more details.
end
if has_more_subitems?
container.with_row do
container.with_footer do
render(Primer::Beta::Link.new(href: view_all_subitems_path)) { t(".view_all_subitems") }
end
end
@@ -0,0 +1,39 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
class Grids::Widgets::FavoriteProjectsController < Grids::WidgetController
skip_before_action :load_and_authorize_in_optional_project
no_authorization_required! :show
def show
render_widget Grids::Widgets::FavoriteProjects.new(current_user:)
end
end
+3 -1
View File
@@ -33,7 +33,9 @@ en:
title: 'Subitems'
project_favorites:
title: 'Favorite projects'
no_results: 'You currently have no favorite projects. Click on the star icon in the project dashboard to add one to your favorites.'
no_results: >-
You currently have no favorite projects. Add one or multiple projects
as favorite through their overview or in a project list.
time_entries_current_user:
title: 'My spent time'
displayed_days: 'Days displayed in the widget:'
+1
View File
@@ -44,6 +44,7 @@ Rails.application.routes.draw do
# global widget routes
namespace :widgets do
resource :news, only: %i[show]
resource :project_favorites, controller: :favorite_projects, only: %i[show]
end
end
end
@@ -45,4 +45,16 @@ RSpec.describe Grids::WidgetBoxComponent, type: :component do
it "renders turbo-frame around content" do
expect(rendered_component).to have_element :"turbo-frame", id: "cool_widget", target: "_top"
end
context "with footer content" do
subject(:rendered_component) do
render_inline(described_class.new(key: "cool_widget", title: "Cool Widget")) do |component|
component.with_footer { "Footer link" }
end
end
it "renders a widget footer" do
expect(rendered_component).to have_css ".op-widget-box--footer", text: "Footer link"
end
end
end
@@ -0,0 +1,95 @@
# 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 "rails_helper"
RSpec.describe Grids::Widgets::FavoriteProjects, type: :component do
include Rails.application.routes.url_helpers
shared_let(:admin) { create(:admin) }
current_user { admin }
subject(:rendered_component) { render_inline(described_class.new(current_user: admin)) }
context "with no favorite projects" do
let!(:visible_project) { create(:project, name: "Visible project") }
it "renders the empty state with the project icon" do
expect(rendered_component).to have_css("h3", text: "Favorite projects")
expect(rendered_component).to have_test_selector("favorite-projects-widget-empty")
expect(rendered_component).to have_octicon(:project)
expect(rendered_component).to have_text("You have no favorite projects")
expect(rendered_component).to have_no_link(visible_project.name)
expect(rendered_component).to have_no_link("View all projects")
end
end
context "with favorite projects" do
let!(:favorite_project) { create(:project, name: "Favorite project") }
let!(:visible_project) { create(:project, name: "Visible project") }
before do
create(:favorite, user: admin, favorited: favorite_project)
end
it "renders only favorite projects and a link to all projects" do
expect(rendered_component).to have_css("turbo-frame#grids-widgets-favorite-projects")
expect(rendered_component).to have_css("h3", text: "Favorite projects")
expect(rendered_component).to have_link(favorite_project.name, href: project_path(favorite_project))
expect(rendered_component).to have_test_selector("favorite-projects-widget--project-name", text: favorite_project.name)
expect(rendered_component).to have_test_selector("favorite-projects-widget-footer") do |footer|
expect(footer).to have_link("View all projects", href: projects_path)
end
expect(rendered_component).to have_no_link(visible_project.name)
end
context "when a favorited project is not visible to the user" do
let(:user) { create(:user) }
let!(:favorite_project) { create(:public_project, name: "Visible favorite project") }
let!(:invisible_favorite_project) { create(:private_project, name: "Invisible favorite project") }
current_user { user }
subject(:rendered_component) { render_inline(described_class.new(current_user: user)) }
before do
create(:favorite, user:, favorited: favorite_project)
create(:favorite, user:, favorited: invisible_favorite_project)
end
it "only renders visible favorite projects" do
expect(rendered_component).to have_link(favorite_project.name, href: project_path(favorite_project))
expect(rendered_component).to have_no_link(invisible_favorite_project.name)
end
end
end
end
@@ -112,13 +112,14 @@ RSpec.describe Grids::Widgets::Members, type: :component do
it "renders members items", :aggregate_failures do
expect(rendered_component).to have_element class: "op-widget-box--body" do |body|
expect(body).to have_link href: project_members_path(project)
expect(body).to have_element :"opce-principal"
end
end
it "renders link to view all members" do
expect(rendered_component).to have_link href: project_members_path(project)
expect(rendered_component).to have_test_selector("members-widget-footer") do |footer|
expect(footer).to have_link href: project_members_path(project)
end
end
end
end
@@ -166,10 +166,13 @@ RSpec.describe Grids::Widgets::Subitems, type: :component do
context "and a limit less than the number of all subitems" do
let(:params) { { limit: 2 } }
it "renders specified subitems, along with a 'view all' item", :aggregate_failures do
it "renders specified subitems and a footer link to view all subitems", :aggregate_failures do
expect(rendered_component).to have_list "Subitems" do |list|
expect(list).to have_list_item count: 2, text: /My Project No. \d+/
expect(list).to have_list_item text: "View all subitems"
end
expect(rendered_component).to have_css(".op-widget-box--footer") do |footer|
expect(footer).to have_link "View all subitems"
end
end
@@ -0,0 +1,53 @@
# 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 "rails_helper"
RSpec.describe Grids::Widgets::FavoriteProjectsController do
shared_let(:user) { create(:user) }
current_user { user }
describe "GET #show" do
let(:widget_instance) { instance_double(Grids::Widgets::FavoriteProjects, render_in: "content") }
before do
allow(Grids::Widgets::FavoriteProjects)
.to receive(:new)
.and_return(widget_instance)
get :show
end
it "renders widget", :aggregate_failures do
expect(response).to be_successful
expect(response.body).to eq "content"
end
end
end
@@ -139,6 +139,24 @@ RSpec.describe Grids::WidgetController do
end
end
describe "project_favorites routing" do
describe "GET #show" do
it do
expect(get("/widgets/project_favorites"))
.to route_to(controller: "grids/widgets/favorite_projects", action: "show")
end
end
end
describe "project_favorites named routing" do
describe "GET #show" do
it do
expect(get(widgets_project_favorites_path))
.to route_to(controller: "grids/widgets/favorite_projects", action: "show")
end
end
end
describe "subitems routing" do
describe "GET #show" do
it do
@@ -68,7 +68,7 @@ See COPYRIGHT and LICENSE files for more details.
end
end
container.with_row do
container.with_footer do
render(Primer::Beta::Link.new(href: all_meetings_link)) { t("meeting.widgets.view_details") }
end
end
@@ -87,22 +87,22 @@ RSpec.describe Meetings::Widgets::Meetings, type: :component do
let!(:meeting_other) { create(:meeting, project: project_red, author:, start_time: 3.weeks.from_now) }
it "does not render meetings the user is not participating in" do
expect(rendered_component).to have_list_item(count: 3) # 2 participating + "View all"
expect(rendered_component).to have_list_item(count: 2)
expect(rendered_component).to have_no_link href: project_meeting_path(project_red, meeting_other)
end
end
it "renders meetings items from all projects", :aggregate_failures do
expect(rendered_component).to have_list_item(count: 3)
expect(rendered_component).to have_list_item(count: 2)
expect(rendered_component).to have_link href: project_meeting_path(project_red, meeting_red)
expect(rendered_component).to have_list_item(position: 2) do |item|
expect(item).to have_link href: project_meeting_path(project_blue, meeting_blue)
expect(item).to have_content("2 hrs") # Duration is formatted
expect(item).to have_content("Project: #{project_blue.name}")
expect(item).to have_text("2 hrs")
expect(item).to have_text("Project: #{project_blue.name}")
end
expect(rendered_component).to have_list_item(position: 3) do |item|
expect(item).to have_link href: meetings_path
expect(item).to have_content("View all meetings")
expect(rendered_component).to have_css(".op-widget-box--footer") do |footer|
expect(footer).to have_link "View all meetings", href: meetings_path
end
end
end
@@ -129,16 +129,15 @@ RSpec.describe Meetings::Widgets::Meetings, type: :component do
end
it "renders only this projects meetings which the user participates in" do
expect(rendered_component).to have_list_item(count: 2)
expect(rendered_component).to have_list_item(count: 1)
expect(rendered_component).to have_list_item(position: 1) do |item|
expect(item).to have_link href: project_meeting_path(project_red, meeting_red)
expect(item).to have_content("1 hr")
expect(item).to have_no_content("Project: #{project_red.name}") # Project is not repeated
expect(item).to have_text("1 hr")
expect(item).to have_no_text("Project: #{project_red.name}") # Project is not repeated
end
expect(rendered_component).to have_list_item(position: 2) do |item|
expect(item).to have_link href: project_meetings_path(project_red)
expect(item).to have_content("View all meetings")
expect(rendered_component).to have_css(".op-widget-box--footer") do |footer|
expect(footer).to have_link "View all meetings", href: project_meetings_path(project_red)
end
end
end
@@ -1,3 +1,5 @@
# frozen_string_literal: true
module MyPage
class GridRegistration < ::Grids::Configuration::Registration
grid_class "Grids::MyPage"
@@ -54,7 +54,7 @@ RSpec.describe "onboarding tour for new users",
select "Deutsch", from: "user_language"
click_button "Save"
expect(page).to have_text "Neueste sichtbare Projekte in dieser Instanz."
expect(page).to have_text "Favorisierte Projekte"
end
it "I can start the tour without selecting a language" do
+3 -3
View File
@@ -85,7 +85,7 @@ RSpec.describe "Favorite projects", :js do
visit home_path
expect(page).to have_text "Favorite projects"
expect(page).to have_test_selector "favorite-project", text: "My favorite!"
expect(page).to have_test_selector "favorite-projects-widget--project-name", text: "My favorite!"
retry_block do
top_menu.toggle unless top_menu.open?
@@ -127,7 +127,7 @@ RSpec.describe "Favorite projects", :js do
visit home_path
expect(page).to have_text "Favorite projects"
expect(page).to have_test_selector "favorite-project", text: "My favorite!"
expect(page).to have_test_selector "favorite-projects-widget--project-name", text: "My favorite!"
expect(page).to have_no_text "Other project"
my_page.visit!
@@ -146,7 +146,7 @@ RSpec.describe "Favorite projects", :js do
visit home_path
expect(page).to have_text "Favorite projects"
expect(page).to have_test_selector "favorite-project", text: "My favorite!"
expect(page).to have_test_selector "favorite-projects-widget--project-name", text: "My favorite!"
retry_block do
top_menu.toggle unless top_menu.open?