From 3885f1b310ea28b3231ad4a1882f85cb9c8e6eb5 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 19 Jan 2026 10:35:59 -0300 Subject: [PATCH 01/57] [#57688] Primerize Backlogs Initial pass refreshing design and implementation of Backlogs. - Reimplements Backlogs index with View Components and Turbo. - Reimplements BurodownChart with Chart.js (via ng2-charts). - Tidies up Sprint page headers. https://community.openproject.org/wp/57688 --- app/components/primer/component_helpers.rb | 41 +++ frontend/src/app/app.module.ts | 2 + .../backlogs/burndown-chart.component.html | 14 + .../backlogs/burndown-chart.component.ts | 84 +++++ .../assets/sass/backlogs/_master_backlog.sass | 299 +----------------- .../src/global_styles/primer/_overrides.sass | 13 + .../dynamic/backlogs.controller.ts | 48 +-- .../controllers/dynamic/backlogs/backlog.ts | 182 ----------- .../controllers/dynamic/backlogs/burndown.ts | 67 ---- .../dynamic/backlogs/master_backlog.ts | 42 --- .../dynamic/backlogs/story.controller.ts | 149 +++++++++ .../backlogs/taskboard-legacy.controller.ts | 18 ++ .../backlogs/backlog_component.html.erb | 67 ++++ .../components/backlogs/backlog_component.rb | 89 ++++++ .../backlog_header_component.html.erb | 104 ++++++ .../backlogs/backlog_header_component.rb | 81 +++++ .../backlogs/backlog_menu_component.html.erb | 114 +++++++ .../backlogs/backlog_menu_component.rb | 57 ++++ .../sprint_page_header_component.html.erb} | 22 +- .../backlogs/sprint_page_header_component.rb | 56 ++++ .../backlogs/story_component.html.erb} | 43 ++- .../components/backlogs/story_component.rb | 52 +++ .../backlogs/story_menu_component.html.erb} | 58 ++-- .../backlogs/story_menu_component.rb | 98 ++++++ .../controllers/rb_application_controller.rb | 4 - .../rb_master_backlogs_controller.rb | 21 +- .../app/controllers/rb_sprints_controller.rb | 74 ++++- .../app/controllers/rb_stories_controller.rb | 68 +++- .../app/forms/backlogs/backlog_header_form.rb | 81 +++++ .../app/helpers/burndown_charts_helper.rb | 45 +-- .../backlogs/app/helpers/rb_common_helper.rb | 121 +------ .../app/helpers/rb_master_backlogs_helper.rb | 121 ------- modules/backlogs/app/models/backlog.rb | 24 +- modules/backlogs/app/models/sprint.rb | 4 + .../rb_burndown_charts/_burndown.html.erb | 113 +------ .../views/rb_burndown_charts/show.html.erb | 21 +- .../views/rb_master_backlogs/index.html.erb | 87 ++--- .../app/views/rb_sprints/_sprint.html.erb | 78 ----- .../app/views/rb_stories/_helpers.html.erb | 75 ----- .../app/views/rb_stories/_story.html.erb | 79 ----- .../app/views/rb_taskboards/show.html.erb | 53 ++-- .../app/views/shared/_server_variables.js.erb | 9 +- modules/backlogs/config/locales/en.yml | 47 ++- modules/backlogs/config/locales/js-en.yml | 3 + modules/backlogs/config/routes.rb | 28 +- .../lib/open_project/backlogs/engine.rb | 8 +- .../backlogs/backlog_component_spec.rb | 149 +++++++++ .../backlogs/backlog_header_component_spec.rb | 214 +++++++++++++ .../backlogs/backlog_menu_component_spec.rb | 207 ++++++++++++ .../sprint_page_header_component_spec.rb | 123 +++++++ .../backlogs/story_component_spec.rb | 133 ++++++++ .../backlogs/story_menu_component_spec.rb | 195 ++++++++++++ .../rb_master_backlogs_controller_spec.rb | 70 ++++ .../controllers/rb_sprints_controller_spec.rb | 126 ++++++++ .../controllers/rb_stories_controller_spec.rb | 102 ++++++ .../features/backlogs/change_status_spec.rb | 2 +- .../features/backlogs/context_menu_spec.rb | 8 +- .../features/backlogs/create_story_spec.rb | 61 ++-- .../spec/features/empty_backlogs_spec.rb | 19 +- .../spec/features/stories_in_backlog_spec.rb | 157 --------- modules/backlogs/spec/models/backlog_spec.rb | 15 + .../rb_master_backlogs_routing_spec.rb | 11 + .../spec/routing/rb_sprints_routing_spec.rb | 18 ++ .../spec/routing/rb_stories_routing_spec.rb | 20 ++ .../backlogs/spec/support/pages/backlogs.rb | 39 +-- .../views/rb_burndown_charts/show_spec.rb | 2 +- 66 files changed, 3024 insertions(+), 1611 deletions(-) create mode 100644 app/components/primer/component_helpers.rb create mode 100644 frontend/src/app/features/backlogs/burndown-chart.component.html create mode 100644 frontend/src/app/features/backlogs/burndown-chart.component.ts delete mode 100644 frontend/src/stimulus/controllers/dynamic/backlogs/backlog.ts delete mode 100644 frontend/src/stimulus/controllers/dynamic/backlogs/burndown.ts delete mode 100644 frontend/src/stimulus/controllers/dynamic/backlogs/master_backlog.ts create mode 100644 frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.ts create mode 100644 frontend/src/stimulus/controllers/dynamic/backlogs/taskboard-legacy.controller.ts create mode 100644 modules/backlogs/app/components/backlogs/backlog_component.html.erb create mode 100644 modules/backlogs/app/components/backlogs/backlog_component.rb create mode 100644 modules/backlogs/app/components/backlogs/backlog_header_component.html.erb create mode 100644 modules/backlogs/app/components/backlogs/backlog_header_component.rb create mode 100644 modules/backlogs/app/components/backlogs/backlog_menu_component.html.erb create mode 100644 modules/backlogs/app/components/backlogs/backlog_menu_component.rb rename modules/backlogs/app/{views/shared/_validation_errors.html.erb => components/backlogs/sprint_page_header_component.html.erb} (81%) create mode 100644 modules/backlogs/app/components/backlogs/sprint_page_header_component.rb rename modules/backlogs/app/{views/layouts/backlogs.html.erb => components/backlogs/story_component.html.erb} (57%) create mode 100644 modules/backlogs/app/components/backlogs/story_component.rb rename modules/backlogs/app/{views/rb_master_backlogs/_backlog.html.erb => components/backlogs/story_menu_component.html.erb} (54%) create mode 100644 modules/backlogs/app/components/backlogs/story_menu_component.rb create mode 100644 modules/backlogs/app/forms/backlogs/backlog_header_form.rb delete mode 100644 modules/backlogs/app/helpers/rb_master_backlogs_helper.rb delete mode 100644 modules/backlogs/app/views/rb_sprints/_sprint.html.erb delete mode 100644 modules/backlogs/app/views/rb_stories/_helpers.html.erb delete mode 100644 modules/backlogs/app/views/rb_stories/_story.html.erb create mode 100644 modules/backlogs/spec/components/backlogs/backlog_component_spec.rb create mode 100644 modules/backlogs/spec/components/backlogs/backlog_header_component_spec.rb create mode 100644 modules/backlogs/spec/components/backlogs/backlog_menu_component_spec.rb create mode 100644 modules/backlogs/spec/components/backlogs/sprint_page_header_component_spec.rb create mode 100644 modules/backlogs/spec/components/backlogs/story_component_spec.rb create mode 100644 modules/backlogs/spec/components/backlogs/story_menu_component_spec.rb create mode 100644 modules/backlogs/spec/controllers/rb_master_backlogs_controller_spec.rb create mode 100644 modules/backlogs/spec/controllers/rb_sprints_controller_spec.rb create mode 100644 modules/backlogs/spec/controllers/rb_stories_controller_spec.rb diff --git a/app/components/primer/component_helpers.rb b/app/components/primer/component_helpers.rb new file mode 100644 index 000000000000..603fde56b4d8 --- /dev/null +++ b/app/components/primer/component_helpers.rb @@ -0,0 +1,41 @@ +# 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 Primer + module ComponentHelpers + def stack(**, &) + render(Primer::Alpha::Stack.new(**), &) + end + + def stack_item(**, &) + render(Primer::Alpha::StackItem.new(**), &) + end + end +end diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 028cdc1ed2c2..802e6b3ec6e4 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -206,6 +206,7 @@ import { WorkPackageFullCreateEntryComponent } from 'core-app/features/work-pack import { WorkPackageFullViewEntryComponent } from 'core-app/features/work-packages/routing/wp-full-view/wp-full-view-entry.component'; import { MyPageComponent } from './features/my-page/my-page.component'; import { DashboardComponent } from './features/overview/dashboard.component'; +import { BurndownChartComponent } from './features/backlogs/burndown-chart.component'; export function initializeServices(injector:Injector) { return () => { @@ -419,5 +420,6 @@ export class OpenProjectModule implements DoBootstrap { registerCustomElement('opce-my-page', MyPageComponent, { injector }); registerCustomElement('opce-dashboard', DashboardComponent, { injector }); + registerCustomElement('opce-burndown-chart', BurndownChartComponent, { injector }); } } diff --git a/frontend/src/app/features/backlogs/burndown-chart.component.html b/frontend/src/app/features/backlogs/burndown-chart.component.html new file mode 100644 index 000000000000..7a1b592ddee3 --- /dev/null +++ b/frontend/src/app/features/backlogs/burndown-chart.component.html @@ -0,0 +1,14 @@ + + +
+ +
+ Debug + +
{{maxValue() }}
+
{{lineChartData() | json}}
+
+
diff --git a/frontend/src/app/features/backlogs/burndown-chart.component.ts b/frontend/src/app/features/backlogs/burndown-chart.component.ts new file mode 100644 index 000000000000..a07ae8b41e52 --- /dev/null +++ b/frontend/src/app/features/backlogs/burndown-chart.component.ts @@ -0,0 +1,84 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { JsonPipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; +import { ChartData, ChartOptions } from 'chart.js'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import PrimerColorsPlugin from 'core-app/shared/components/work-package-graphs/plugin.primer-colors'; +import { BaseChartDirective, provideCharts, withDefaultRegisterables } from 'ng2-charts'; + +const BURNDOWN_Y_SCALE_MIN = 25; + +@Component({ + selector: 'op-burndown-chart', + templateUrl: './burndown-chart.component.html', + imports: [BaseChartDirective, JsonPipe], + providers: [provideCharts(withDefaultRegisterables(PrimerColorsPlugin))], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class BurndownChartComponent { + readonly i18n = inject(I18nService); + readonly chartData = input.required(); + + readonly lineChartData = computed>(() => { + const data = JSON.parse(this.chartData()) as ChartData<'line'>; + return data; + }); + + readonly maxValue = computed(() => { + return this.lineChartData().datasets + .flatMap((dataset) => dataset.data) + .filter((item):item is number => typeof item === 'number') + .reduce((a, b) => Math.max(a, b), 0); + }); + + readonly lineChartOptions = computed>(() => ({ + scales: { + x: { + title: { + display: true, + text: this.i18n.t('js.burndown.day') + } + }, + y: { + title: { + display: true, + text: this.i18n.t('js.burndown.points') + }, + suggestedMin: 0, + max: this.maxValue() + BURNDOWN_Y_SCALE_MIN + } + }, + plugins: { + legend: { + position: 'top' + } + } + })); +} diff --git a/frontend/src/assets/sass/backlogs/_master_backlog.sass b/frontend/src/assets/sass/backlogs/_master_backlog.sass index a6f0a9e3cd13..576744dfda7f 100644 --- a/frontend/src/assets/sass/backlogs/_master_backlog.sass +++ b/frontend/src/assets/sass/backlogs/_master_backlog.sass @@ -26,299 +26,8 @@ * See COPYRIGHT and LICENSE files for more details. ++ */ -#rb - #backlogs_container - width: 100% - display: flex - flex-wrap: wrap - justify-content: space-between - #owner_backlogs_container - min-width: 420px - order: 2 - width: 49% - flex: 0 0 49% - #sprint_backlogs_container - min-width: 420px - width: 49% - flex: 0 0 49% - min-height: 230px - #owner_backlogs_container .backlog .header > .add_new_story - height: 28px - line-height: 31px - padding: 0 - position: absolute - right: 10px - text-align: right - top: 1px - width: 100px - #backlogs_container .backlog - border: 1px solid var(--borderColor-default) - display: block - margin: 0 0 10px 0 - width: 100% +li[data-empty-list-item] + display: none -#rb - #backlogs_container .backlog .header - background-color: var(--bgColor-muted) - height: 30px - position: relative - width: 100% - .backlog .header .backlog-menu - border-right: 1px solid var(--borderColor-default) - cursor: pointer - height: 30px - overflow: visible - position: absolute - top: 0 - right: 0 - width: 30px - .icon-context - position: absolute - top: 7px - left: 12px - // Firefox wrongly positions icon - &:before - padding: 0 - &.open - &+ .items - display: block - .items - display: none - background-color: var(--overlay-bgColor) - border: 1px solid var(--borderColor-default) - position: absolute - top: 30px - right: -2px - list-style: none - margin: 0 - padding: 0 - z-index: 1000 - .item - display: block - width: 160px - height: 2rem - font-size: 0.9rem - text-align: left - text-decoration: none - vertical-align: middle - overflow: hidden - white-space: nowrap - &.hover, &:hover - background-color: #999 - a - display: block - height: 100% - padding: 6px - width: 100% - &.hover a, &:hover a - color: #FFFFFF - text-decoration: none - #backlogs_container - .backlog - .header - .velocity - height: 28px - line-height: 31px - padding: 0 3px 0 9px - position: absolute - right: 25px - text-align: right - top: 0px - width: 32px - .toggler - font-family: "openproject-icon-font" - height: 30px - line-height: 31px - padding: 0 - position: absolute - left: 0 - top: 0 - width: 23px - cursor: pointer - &:before - position: absolute - left: 6px - top: 10px - &.closed:before - position: absolute - left: 6px - top: 10px - &:hover - cursor: pointer - background-color: #D8D8D8 - .sprint - background-color: transparent - cursor: pointer - display: block - height: 29px - width: auto - margin-left: 30px - margin-right: 50px - &.error.icon-bug - background: none - text-align: center - &:before - position: absolute - color: red - .id, .status - display: none - - .name - line-height: 2rem - font-weight: var(--base-text-weight-bold) - overflow: hidden - white-space: nowrap - margin-left: 0.5em - - .start_date, .effective_date - float: right - height: 28px - line-height: 2rem - width: 6.5em - margin-left: 0.5em - .stories - list-style: none - min-height: 2rem - margin: 0 - padding: 0 0 0px 0 - z-index: 500 - overflow-y: auto - overflow-x: hidden - &.closed - display: none - - .error.icon.icon-bug - text-align: left - .stories:not(.prevent_drag) .story - cursor: move - .stories .story - display: block - font-size: 0.9rem - margin: 0 - overflow: hidden - position: relative - width: 100% - &.odd - background-color: var(--bgColor-neutral-muted) - &.even - background-color: var(--body-background) - &.error.icon-bug - background: none - text-align: center - &:before - position: absolute - color: red - pointer-events: none - &.hover, &:hover - background-color: var(--highlight-neutral-bgColor) - &.closed - text-decoration: line-through - .id - float: left - margin-left: 1em - margin-right: 1em - padding: 5px 2px 4px 2px - width: 4em - text-align: right - white-space: nowrap - .type_id .t - float: left - padding: 5px 2px 4px 2px - text-align: right - white-space: nowrap - .subject - overflow: hidden - margin-left: 4em - padding: 5px 2px 4px 2px - white-space: nowrap - min-height: 1em - .status_id - float: right - padding: 5px 2px 4px 2px - margin-left: 1em - width: 68px - .story_points - float: right - padding: 5px 1rem 4px 2px - width: 3.5rem - min-height: 14px - height: 2rem - text-align: center - .type_id .v, .id .v, .status_id .v, .version_id, .higher_item_id - display: none - -.rb_dialog - .burndown_chart - margin-top: 20px - margin-bottom: 20px - margin-left: 20px - #charts - h3 - border: 0px - overflow: hidden - fieldset.burndown_control - padding-left: 10px - border: none - .axislabel - font-weight: var(--base-text-weight-bold) - -/* In-place Sprint Editor */ - -#rb #backlogs_container - .sprint.editing - .editors, > .editor - display: block - label, > * - display: none - + - .velocity, .add_new_story - display: none - .backlog .sprint.editing - .editors - display: flex - align-items: center - flex-direction: row-reverse - - .editor - font-size: 0.9rem - line-height: 1.5rem - height: 30px - margin: 0 - padding: 0 - - &.name - flex-basis: 15em - &.start_date, - &.effective_date - margin-left: 0.5em - flex-basis: 12.5em - - .stories .story.editing - > - *, .editors label - display: none - .editors - display: block - select, input - display: inline-block - float: none - margin: 5px 3px 4px 2px - font-size: 0.8rem - // reset the line-height (foundation sets it to "normal" but that does not work here) - line-height: inherit - .type_id.editor - width: 15% - /* sets max-width for IE */ - max-width: 140px - /* for the cool guys */ - .subject.editor - width: 55% - .status_id.editor - width: 15% - float: right - .story_points.editor - float: right - width: 10% - -.backlog - font-size: 0.9rem +li[data-empty-list-item]:only-child + display: list-item diff --git a/frontend/src/global_styles/primer/_overrides.sass b/frontend/src/global_styles/primer/_overrides.sass index 5dec0007aaa4..f5c910305917 100644 --- a/frontend/src/global_styles/primer/_overrides.sass +++ b/frontend/src/global_styles/primer/_overrides.sass @@ -152,3 +152,16 @@ ul.SegmentedControl, .ActionListContent[aria-disabled="true"] .ActionListItem-label[class^="__hl_"], .ActionListItem-label[class*=" __hl_"] color: var(--control-fgColor-disabled) !important + +.Box-row--focus-blue + &:focus-visible + background-color: var(--bgColor-accent-muted) + +.Box-row--clickable + cursor: pointer + +.Box-row--draggable + padding-left: calc(var(--stack-padding-normal) / 2) + +.Box-header--collapsible + padding-left: calc(var(--stack-padding-normal) / 2) diff --git a/frontend/src/stimulus/controllers/dynamic/backlogs.controller.ts b/frontend/src/stimulus/controllers/dynamic/backlogs.controller.ts index a551ee1768e3..b2ed8978f20f 100644 --- a/frontend/src/stimulus/controllers/dynamic/backlogs.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/backlogs.controller.ts @@ -1,24 +1,32 @@ -import { Controller } from '@hotwired/stimulus'; - -import 'jquery.flot'; -import 'jquery.flot/excanvas'; +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ -import 'core-vendor/jquery.jeditable.mini'; -import 'core-vendor/jquery.colorcontrast'; - -import './backlogs/common'; -import './backlogs/master_backlog'; -import './backlogs/backlog'; -import './backlogs/burndown'; -import './backlogs/model'; -import './backlogs/editable_inplace'; -import './backlogs/sprint'; -import './backlogs/work_package'; -import './backlogs/story'; -import './backlogs/task'; -import './backlogs/impediment'; -import './backlogs/taskboard'; -import './backlogs/show_main'; +import { Controller } from '@hotwired/stimulus'; export default class BacklogsController extends Controller { } diff --git a/frontend/src/stimulus/controllers/dynamic/backlogs/backlog.ts b/frontend/src/stimulus/controllers/dynamic/backlogs/backlog.ts deleted file mode 100644 index 91cb0937b8c4..000000000000 --- a/frontend/src/stimulus/controllers/dynamic/backlogs/backlog.ts +++ /dev/null @@ -1,182 +0,0 @@ -//-- 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. -//++ - -/****************************************** - BACKLOG - A backlog is a visual representation of - a sprint and its stories. It is not a - sprint. Imagine it this way: A sprint is - a start and end date, and a set of - objectives. A backlog is something you - would draw up on the board or a spread- - sheet (or in Redmine Backlogs!) to - visualize the sprint. -******************************************/ - -// @ts-expect-error TS(2304): Cannot find name 'RB'. -RB.Backlog = (function ($) { - // @ts-expect-error TS(2304): Cannot find name 'RB'. - return RB.Object.create({ - - initialize(el:any) { - this.$ = $(el); - this.el = el; - - // Associate this object with the element for later retrieval - this.$.data('this', this); - - // Make the list sortable - this.getList().sortable({ - connectWith: '.stories', - dropOnEmpty: true, - start: this.dragStart, - stop: this.dragStop, - update: this.dragComplete, - receive: this.dragChanged, - remove: this.dragChanged, - containment: $('#backlogs_container'), - cancel: 'input, textarea, button, select, option, .prevent_drag', - scroll: true, - helper(event:any, ui:any) { - const $clone = $(ui).clone(); - $clone.css('position', 'absolute'); - return $clone.get(0); - }, - }); - - // Observe menu items - this.$.find('.add_new_story').click(this.handleNewStoryClick); - - if (this.isSprintBacklog()) { - // @ts-expect-error TS(2304): Cannot find name 'RB'. - RB.Factory.initialize(RB.Sprint, this.getSprint()); - // @ts-expect-error TS(2304): Cannot find name 'RB'. - this.burndown = RB.Factory.initialize(RB.Burndown, this.$.find('.show_burndown_chart')); - this.burndown.setSprintId(this.getSprint().data('this').getID()); - } - - // Initialize each item in the backlog - this.getStories().each(function (this:any, index:any) { - // 'this' refers to an element with class="story" - // @ts-expect-error TS(2304): Cannot find name 'RB'. - RB.Factory.initialize(RB.Story, this); - }); - - if (this.isSprintBacklog()) { - this.refresh(); - } - }, - - dragChanged(e:any, ui:any) { - $(this).parents('.backlog').data('this').refresh(); - }, - - dragComplete(e:any, ui:any) { - const isDropTarget = (ui.sender === null || ui.sender === undefined); - - // jQuery triggers dragComplete of source and target. - // Thus we have to check here. Otherwise, the story - // would be saved twice. - if (isDropTarget) { - ui.item.data('this').saveDragResult(); - } - }, - - dragStart(e:any, ui:any) { - ui.item.addClass('dragging'); - }, - - dragStop(e:any, ui:any) { - ui.item.removeClass('dragging'); - }, - - getSprint() { - return $(this.el).find('.model.sprint').first(); - }, - - getStories() { - return this.getList().children('.story'); - }, - - getList() { - return this.$.children('.stories').first(); - }, - - handleNewStoryClick(e:any) { - const toggler = $(this).parents('.header').find('.toggler'); - if (toggler.hasClass('closed')) { - toggler.click(); - } - e.preventDefault(); - $(this).parents('.backlog').data('this').newStory(); - }, - - // return true if backlog has an element with class="sprint" - isSprintBacklog() { - return $(this.el).find('.sprint').length === 1; - }, - - newStory() { - let story; - let o; - - story = $('#story_template').children().first().clone(); - this.getList().prepend(story); - - // @ts-expect-error TS(2304): Cannot find name 'RB'. - o = RB.Factory.initialize(RB.Story, story[0]); - o.edit(); - - story.find('.editor').first().focus(); - }, - - refresh() { - this.recalcVelocity(); - this.recalcOddity(); - }, - - recalcVelocity() { - let total:any; - - if (!this.isSprintBacklog()) { - return; - } - - total = 0; - this.getStories().each(function (this:any, index:any) { - total += $(this).data('this').getPoints(); - }); - this.$.children('.header').children('.velocity').text(total); - }, - - recalcOddity() { - this.$.find('.story:even').removeClass('odd').addClass('even'); - this.$.find('.story:odd').removeClass('even').addClass('odd'); - }, - }); -}(jQuery)); diff --git a/frontend/src/stimulus/controllers/dynamic/backlogs/burndown.ts b/frontend/src/stimulus/controllers/dynamic/backlogs/burndown.ts deleted file mode 100644 index b6942b866767..000000000000 --- a/frontend/src/stimulus/controllers/dynamic/backlogs/burndown.ts +++ /dev/null @@ -1,67 +0,0 @@ -//-- 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. -//++ - -// @ts-expect-error TS(2304): Cannot find name 'RB'. -RB.Burndown = (function ($) { - // @ts-expect-error TS(2304): Cannot find name 'RB'. - return RB.Object.create({ - - initialize(el:any) { - this.$ = $(el); - this.el = el; - - // Associate this object with the element for later retrieval - this.$.data('this', this); - - // Observe menu items - this.$.click(this.show); - }, - - setSprintId(sprintId:any) { - this.sprintId = sprintId; - }, - - getSprintId() { - return this.sprintId; - }, - - show(e:any) { - e.preventDefault(); - - if ($('#charts').length === 0) { - $('
').appendTo('body'); - } - // @ts-expect-error TS(2304): Cannot find name 'RB'. - $('#charts').html(`
${RB.i18n.generating_graph}
`); - - // @ts-expect-error TS(2304): Cannot find name 'RB'. - const url = RB.urlFor('show_burndown_chart', { sprint_id: $(this).data('this').sprintId, project_id: RB.constants.project_id }); - window.open(url); - }, - }); -}(jQuery)); diff --git a/frontend/src/stimulus/controllers/dynamic/backlogs/master_backlog.ts b/frontend/src/stimulus/controllers/dynamic/backlogs/master_backlog.ts deleted file mode 100644 index 9f055c6bcfcf..000000000000 --- a/frontend/src/stimulus/controllers/dynamic/backlogs/master_backlog.ts +++ /dev/null @@ -1,42 +0,0 @@ -//-- 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. -//++ - -// Initialize the backlogs after DOM is loaded -jQuery(($) => { - // Initialize each backlog - $('.backlog').each(function (index) { - // 'this' refers to an element with class="backlog" - // @ts-expect-error TS(2304): Cannot find name 'RB'. - RB.Factory.initialize(RB.Backlog, this); - }); - - $('.backlog .toggler').on('click', function () { - $(this).toggleClass('closed icon-arrow-up1 icon-arrow-down1'); - $(this).parents('.backlog').find('ul.stories').toggleClass('closed'); - }); -}); diff --git a/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.ts b/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.ts new file mode 100644 index 000000000000..9340149f9fea --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.ts @@ -0,0 +1,149 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { Controller } from '@hotwired/stimulus'; +import * as Turbo from '@hotwired/turbo'; + +export default class StoryController extends Controller implements EventListenerObject { + static values = { + splitUrl: String, + fullUrl: String, + }; + + declare splitUrlValue:string; + declare fullUrlValue:string; + + private abortController:AbortController|null = null; + private clickTimeout:number|null = null; + + connect():void { + this.abortController = new AbortController(); + const { signal } = this.abortController; + + this.element.addEventListener('click', this, { signal }); + this.element.addEventListener('dblclick', this, { signal }); + this.element.addEventListener('keydown', this, { signal }); + } + + disconnect():void { + this.abortController?.abort(); + this.abortController = null; + + if (this.clickTimeout !== null) { + clearTimeout(this.clickTimeout); + this.clickTimeout = null; + } + } + + handleEvent(event:Event):void { + switch (event.type) { + case 'click': + this.onClick(event as MouseEvent); + break; + case 'dblclick': + this.onDblClick(event as MouseEvent); + break; + case 'keydown': + this.onKeydown(event as KeyboardEvent); + break; + } + } + + private onClick(event:MouseEvent):void { + const target = event.target; + if (!(target instanceof HTMLElement)) return; + + if ( + target.closest('a') || + target.closest('button') || + target.closest('[data-drag-handle]') + ) { + return; + } + + if (this.clickTimeout !== null) return; + + this.clickTimeout = window.setTimeout(() => { + this.clickTimeout = null; + this.openSplitPane(); + }, 250); + } + + private onDblClick(event:MouseEvent):void { + const target = event.target; + if (!(target instanceof HTMLElement)) return; + + if ( + target.closest('a') || + target.closest('button') || + target.closest('[data-drag-handle]') + ) { + return; + } + + if (this.clickTimeout !== null) { + clearTimeout(this.clickTimeout); + this.clickTimeout = null; + } + + this.openFullPane(); + } + + private onKeydown(event:KeyboardEvent):void { + if (event.key !== 'Enter') return; + + const target = event.target; + if (!(target instanceof HTMLElement)) return; + + if ( + target.closest('a') || + target.closest('button') || + target.closest('input') || + target.closest('textarea') || + target.closest('select') || + target.closest("[contenteditable='true']") + ) { + return; + } + + event.preventDefault(); + if (event.shiftKey) { + this.openFullPane(); + } else { + this.openSplitPane(); + } + } + + private openSplitPane():void { + Turbo.visit(this.splitUrlValue, { frame: 'content-bodyRight', action: 'advance' }); + } + + private openFullPane():void { + Turbo.visit(this.fullUrlValue, { frame: '_top' }); + } +} diff --git a/frontend/src/stimulus/controllers/dynamic/backlogs/taskboard-legacy.controller.ts b/frontend/src/stimulus/controllers/dynamic/backlogs/taskboard-legacy.controller.ts new file mode 100644 index 000000000000..ed66caeff516 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/backlogs/taskboard-legacy.controller.ts @@ -0,0 +1,18 @@ +import { Controller } from '@hotwired/stimulus'; + +import 'core-vendor/jquery.jeditable.mini'; +import 'core-vendor/jquery.colorcontrast'; + +import './common'; +import './model'; +import './editable_inplace'; +import './sprint'; +import './work_package'; +import './story'; +import './task'; +import './impediment'; +import './taskboard'; +import './show_main'; + +export default class TaskboardLegacyController extends Controller { +} diff --git a/modules/backlogs/app/components/backlogs/backlog_component.html.erb b/modules/backlogs/app/components/backlogs/backlog_component.html.erb new file mode 100644 index 000000000000..bca17b3bff19 --- /dev/null +++ b/modules/backlogs/app/components/backlogs/backlog_component.html.erb @@ -0,0 +1,67 @@ +<%# -- 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. + +++# %> + +<%= component_wrapper(tag: :section) do %> + <%= render(Primer::Beta::BorderBox.new(**@system_arguments)) do |border_box| %> + <% border_box.with_header(classes: "Box-header--collapsible") do %> + <%= render(Backlogs::BacklogHeaderComponent.new(backlog:, project: @project, folded: folded?)) %> + <% end %> + <% border_box.with_row(data: { empty_list_item: true }) do %> + <% if backlog.sprint_backlog? %> + <%= + render Primer::Beta::Blankslate.new(role: "status", aria: { live: "polite" }) do |blankslate| + blankslate.with_heading(tag: :h4).with_content(t(".sprint_backlog.blankslate_title")) + blankslate.with_description_content(t(".sprint_backlog.blankslate_description")) + end + %> + <% else %> + <%= + render Primer::Beta::Blankslate.new(role: "status", aria: { live: "polite" }) do |blankslate| + blankslate.with_heading(tag: :h4).with_content(t(".product_backlog.blankslate_title")) + blankslate.with_description_content(t(".product_backlog.blankslate_description")) + end + %> + <% end %> + <% end %> + <% backlog.stories.each do |story| %> + <% border_box.with_row( + id: dom_id(story), + classes: "Box-row--hover-gray Box-row--focus-blue Box-row--clickable Box-row--draggable", + data: draggable_item_config(story).merge( + controller: "backlogs--story", + backlogs__story_split_url_value: details_backlogs_project_backlogs_path(project, story), + backlogs__story_full_url_value: work_package_path(story) + ), + tabindex: 0 + ) do %> + <%= render(Backlogs::StoryComponent.new(story:, sprint:, max_position:)) %> + <% end %> + <% end %> + <% end %> +<% end %> diff --git a/modules/backlogs/app/components/backlogs/backlog_component.rb b/modules/backlogs/app/components/backlogs/backlog_component.rb new file mode 100644 index 000000000000..e313299ba649 --- /dev/null +++ b/modules/backlogs/app/components/backlogs/backlog_component.rb @@ -0,0 +1,89 @@ +# 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 Backlogs + class BacklogComponent < ApplicationComponent + include Primer::AttributesHelper + include Primer::ComponentHelpers + include OpTurbo::Streamable + include RbCommonHelper + + attr_reader :backlog, :project, :current_user + + delegate :sprint, :stories, to: :backlog + + def initialize(backlog:, project:, current_user: User.current, **system_arguments) + super() + + @backlog = backlog + @project = project + @current_user = current_user + + @system_arguments = system_arguments + @system_arguments[:id] = dom_id(backlog) + @system_arguments[:list_id] = "#{@system_arguments[:id]}-list" + @system_arguments[:data] = merge_data( + @system_arguments, + { data: drop_target_config } + ) + end + + def wrapper_uniq_by + backlog.sprint_id + end + + private + + def folded? + current_user.backlogs_preference(:versions_default_fold_state) == "closed" + end + + def max_position + stories.map(&:position).max + end + + def drop_target_config + { + generic_drag_and_drop_target: "container", + target_container_accessor: ":scope > ul", + target_id: backlog.sprint_id, + target_allowed_drag_type: "story" + } + end + + def draggable_item_config(story) + { + draggable_id: story.id, + draggable_type: "story", + drop_url: move_backlogs_project_sprint_story_path(project, sprint, story) + } + end + end +end diff --git a/modules/backlogs/app/components/backlogs/backlog_header_component.html.erb b/modules/backlogs/app/components/backlogs/backlog_header_component.html.erb new file mode 100644 index 000000000000..05729bfb7ce3 --- /dev/null +++ b/modules/backlogs/app/components/backlogs/backlog_header_component.html.erb @@ -0,0 +1,104 @@ +<%# -- 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. + +++# %> + +<%= component_wrapper(tag: :header) do %> + <% if show? %> + <%= + stack( + tag: :"collapsible-header", + direction: :horizontal, + align: :center, + justify: :space_between, + classes: class_names( + "CollapsibleHeader", + "CollapsibleHeader--collapsed" => @collapsed + ), + data: { collapsed: (@collapsed if @collapsed) } + ) do + %> + <%= stack_item(classes: "hide-when-print") do %> + <%= + render( + Primer::BaseComponent.new( + tag: :div, + role: "button", + tabindex: 0, + classes: "CollapsibleSection--triggerArea", + aria: { + label: t(".label_toggle_backlog", name: sprint.name), + controls: "#{dom_id(backlog)}-list", + expanded: !@collapsed + }, + data: { + target: "collapsible-header.triggerElement", + action: "click:collapsible-header#toggle keydown:collapsible-header#toggleViaKeyboard" + } + ) + ) do + %> + <%= render(Primer::Beta::Octicon.new(icon: "chevron-up", hidden: @collapsed, data: { target: "collapsible-header.arrowUp" })) %> + <%= render(Primer::Beta::Octicon.new(icon: "chevron-down", hidden: !@collapsed, data: { target: "collapsible-header.arrowDown" })) %> + <% end %> + <% end %> + <%= stack_item(grow: true) do %> + <%= stack(direction: :horizontal, align: :center) do %> + <%= render Primer::Beta::Truncate.new(tag: :h4, classes: "Box-title") do %> + <%= sprint.name %> + <% end %> + + <%= stack_item do %> + <%= render(Primer::Beta::Counter.new(count: story_count, round: true)) %> + <% end %> + + <%= stack_item(grow: true) do %> + <%= render(Primer::Beta::Text.new(color: :subtle, role: "group")) do %> + <%= format_date_range(date_range) %> + <% end %> + <% end %> + <% end %> + <% end %> + + <%= render(Primer::Beta::Truncate.new(color: :subtle, classes: "velocity")) do %> + <%= t(:"backlogs.points", count: story_points) %> + <% end %> + + <%= render(Backlogs::BacklogMenuComponent.new(backlog:, project: @project)) %> + <% end %> + <% else %> + <%= + primer_form_with( + url: backlogs_project_sprint_path(project, sprint), + model: sprint, + method: :patch + ) do |f| + render(Backlogs::BacklogHeaderForm.new(f, cancel_path: show_name_backlogs_project_sprint_path(project, sprint))) + end + %> + <% end %> +<% end %> diff --git a/modules/backlogs/app/components/backlogs/backlog_header_component.rb b/modules/backlogs/app/components/backlogs/backlog_header_component.rb new file mode 100644 index 000000000000..0e73c686412c --- /dev/null +++ b/modules/backlogs/app/components/backlogs/backlog_header_component.rb @@ -0,0 +1,81 @@ +# 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 Backlogs + class BacklogHeaderComponent < ApplicationComponent + include OpTurbo::Streamable + include Primer::ComponentHelpers + include Redmine::I18n + include RbCommonHelper + + STATE_DEFAULT = :show + STATE_OPTIONS = [STATE_DEFAULT, :edit].freeze + + attr_reader :backlog, :project, :state, :collapsed, :current_user + + delegate :sprint, :stories, to: :backlog + delegate :name, to: :sprint, prefix: :sprint + delegate :edit?, :show?, to: :state + + def initialize( + backlog:, + project:, + state: STATE_DEFAULT, + folded: false, + current_user: User.current + ) + super() + + @backlog = backlog + @project = project + @state = ActiveSupport::StringInquirer.new(state.to_s) + @collapsed = folded + @current_user = current_user + end + + def wrapper_uniq_by + backlog.sprint_id + end + + private + + def story_points + @story_points ||= stories.sum { |story| story.story_points || 0 } + end + + def story_count + @story_count ||= stories.size + end + + def date_range + [sprint.start_date, sprint.effective_date].compact + end + end +end diff --git a/modules/backlogs/app/components/backlogs/backlog_menu_component.html.erb b/modules/backlogs/app/components/backlogs/backlog_menu_component.html.erb new file mode 100644 index 000000000000..416e57ffdad5 --- /dev/null +++ b/modules/backlogs/app/components/backlogs/backlog_menu_component.html.erb @@ -0,0 +1,114 @@ +<%# -- 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. + +++# %> + +<%= + render(Primer::Alpha::ActionMenu.new(anchor_align: :end, classes: "hide-when-print")) do |menu| + menu.with_show_button( + scheme: :invisible, + icon: :"kebab-horizontal", + "aria-label": t(".label_actions"), + tooltip_direction: :se + ) + + if user_allowed?(:update_sprints) + menu.with_item( + label: t(".action_menu.edit_sprint"), + href: edit_name_backlogs_project_sprint_path(project, sprint), + content_arguments: { data: { turbo_stream: true } } + ) do |item| + item.with_leading_visual_icon(icon: :pencil) + end + + menu.with_divider + end + + if user_allowed?(:add_work_packages) + menu.with_item( + label: t(:"backlogs.add_new_story"), + href: new_project_work_packages_dialog_path( + project, + version_id: sprint.id, + type_id: available_story_types.first + ), + content_arguments: { data: { turbo_stream: true } } + ) do |item| + item.with_leading_visual_icon(icon: :compose) + end + end + + menu.with_item( + label: t(:label_stories_tasks), + tag: :a, + href: backlogs_project_sprint_query_path(project, sprint) + ) do |item| + item.with_leading_visual_icon(icon: :"op-view-list") + end + + if user_allowed?(:manage_versions) + menu.with_item( + label: t(:"backlogs.properties"), + tag: :a, + href: edit_version_path(sprint, back_url: backlogs_project_backlogs_path(project), project_id: project.id) + ) do |item| + item.with_leading_visual_icon(icon: :gear) + end + end + + if backlog.sprint_backlog? + if user_allowed?(:view_taskboards) + menu.with_item( + label: t(:label_task_board), + tag: :a, + href: backlogs_project_sprint_taskboard_path(project, sprint) + ) do |item| + item.with_leading_visual_icon(icon: :"op-view-cards") + end + end + + menu.with_item( + label: t(:"backlogs.show_burndown_chart"), + tag: :a, + href: backlogs_project_sprint_burndown_chart_path(project, sprint), + disabled: !sprint.has_burndown? + ) do |item| + item.with_leading_visual_icon(icon: :graph) + end + + if project.module_enabled? "wiki" + menu.with_item( + label: t(:label_wiki), + tag: :a, + href: edit_backlogs_project_sprint_wiki_path(project, sprint) + ) do |item| + item.with_leading_visual_icon(icon: :book) + end + end + end + end +%> diff --git a/modules/backlogs/app/components/backlogs/backlog_menu_component.rb b/modules/backlogs/app/components/backlogs/backlog_menu_component.rb new file mode 100644 index 000000000000..07f5ad79bad5 --- /dev/null +++ b/modules/backlogs/app/components/backlogs/backlog_menu_component.rb @@ -0,0 +1,57 @@ +# 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 Backlogs + class BacklogMenuComponent < ApplicationComponent + include RbCommonHelper + + attr_reader :backlog, :project, :current_user + + delegate :sprint, :stories, to: :backlog + + def initialize(backlog:, project:, current_user: User.current) + super() + + @backlog = backlog + @project = project + @current_user = current_user + end + + private + + def user_allowed?(permission) + current_user.allowed_in_project?(permission, project) + end + + def available_story_types + @available_story_types ||= story_types & project.types + end + end +end diff --git a/modules/backlogs/app/views/shared/_validation_errors.html.erb b/modules/backlogs/app/components/backlogs/sprint_page_header_component.html.erb similarity index 81% rename from modules/backlogs/app/views/shared/_validation_errors.html.erb rename to modules/backlogs/app/components/backlogs/sprint_page_header_component.html.erb index 8837b4d6ba77..7ddc8a202644 100644 --- a/modules/backlogs/app/views/shared/_validation_errors.html.erb +++ b/modules/backlogs/app/components/backlogs/sprint_page_header_component.html.erb @@ -1,4 +1,4 @@ -<%#-- copyright +<%# -- copyright OpenProject is an open source project management software. Copyright (C) the OpenProject GmbH @@ -25,12 +25,16 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. See COPYRIGHT and LICENSE files for more details. -++#%> +++# %> -<%= validation_errors.length > 1 ? t(:error_intro_plural) : t(:error_intro_singular) %> -
    - <%- validation_errors.each_full do |msg| %> -
  • <%= msg %>
  • - <%- end %> -
-<%= t(:error_outro) %> +<%= + render Primer::OpenProject::PageHeader.new do |header| + header.with_title_content(@sprint.name) + + header.with_description do + format_date_range(date_range) + end + + header.with_breadcrumbs(breadcrumb_items) + end +%> diff --git a/modules/backlogs/app/components/backlogs/sprint_page_header_component.rb b/modules/backlogs/app/components/backlogs/sprint_page_header_component.rb new file mode 100644 index 000000000000..3fcba09e6188 --- /dev/null +++ b/modules/backlogs/app/components/backlogs/sprint_page_header_component.rb @@ -0,0 +1,56 @@ +# 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 Backlogs + class SprintPageHeaderComponent < ApplicationComponent + include Primer::ComponentHelpers + include ApplicationHelper + include RbCommonHelper + + def initialize(sprint:, project:) + super + + @sprint = sprint + @project = project + end + + def breadcrumb_items + [{ href: project_overview_path(@project.id), text: @project.name }, + { href: backlogs_project_backlogs_path(@project), text: t(:label_backlogs) }, + @sprint.name] + end + + private + + def date_range + [@sprint.start_date, @sprint.effective_date].compact + end + end +end diff --git a/modules/backlogs/app/views/layouts/backlogs.html.erb b/modules/backlogs/app/components/backlogs/story_component.html.erb similarity index 57% rename from modules/backlogs/app/views/layouts/backlogs.html.erb rename to modules/backlogs/app/components/backlogs/story_component.html.erb index fb3aeec57ad3..d9cbaf2a9bca 100644 --- a/modules/backlogs/app/views/layouts/backlogs.html.erb +++ b/modules/backlogs/app/components/backlogs/story_component.html.erb @@ -1,4 +1,4 @@ -<%#-- copyright +<%# -- copyright OpenProject is an open source project management software. Copyright (C) the OpenProject GmbH @@ -25,16 +25,33 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. See COPYRIGHT and LICENSE files for more details. -++#%> - -<% content_for :header_tags do %> - <%= frontend_stylesheet_link_tag "backlogs.css" %> -<% end %> - -<% content_for :additional_js_dom_ready do %> - <%= render(partial: "shared/server_variables", formats: [:js]) %> +++# %> + +<%= stack(tag: :article, direction: :horizontal, justify: :space_between) do %> + <%= stack_item(classes: "hide-when-print") do %> + <%= + render( + Primer::OpenProject::DragHandle.new( + role: "button", + tabindex: 0, + aria: { + label: t(".label_drag_story", name: story.subject) + } + ) + ) + %> + <% end %> + + <%= stack_item(grow: true) do %> + <%= render(WorkPackages::InfoLineComponent.new(work_package: story)) %> + <%= render(Primer::Beta::Text.new(font_weight: :semibold)) do %> + <%= story.subject %> + <% end %> + <% end %> + + <%= render(Primer::Beta::Truncate.new(color: :subtle, mt: 1)) do %> + <%= t(:"backlogs.points", count: story_points) %> + <% end %> + + <%= render(Backlogs::StoryMenuComponent.new(story:, sprint:, max_position:)) %> <% end %> - -<% content_controller "backlogs" %> - -<%= render template: "layouts/base", locals: local_assigns.merge(turbo_opt_out: true) %> diff --git a/modules/backlogs/app/components/backlogs/story_component.rb b/modules/backlogs/app/components/backlogs/story_component.rb new file mode 100644 index 000000000000..2a22543cdcd9 --- /dev/null +++ b/modules/backlogs/app/components/backlogs/story_component.rb @@ -0,0 +1,52 @@ +# 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 Backlogs + class StoryComponent < ApplicationComponent + include Primer::ComponentHelpers + + attr_reader :story, :sprint, :max_position, :current_user + + def initialize(story:, sprint:, max_position:, current_user: User.current) + super() + + @story = story + @sprint = sprint + @max_position = max_position + @current_user = current_user + end + + private + + def story_points + story.story_points || 0 + end + end +end diff --git a/modules/backlogs/app/views/rb_master_backlogs/_backlog.html.erb b/modules/backlogs/app/components/backlogs/story_menu_component.html.erb similarity index 54% rename from modules/backlogs/app/views/rb_master_backlogs/_backlog.html.erb rename to modules/backlogs/app/components/backlogs/story_menu_component.html.erb index 77d2f4f29703..424bb34efbdf 100644 --- a/modules/backlogs/app/views/rb_master_backlogs/_backlog.html.erb +++ b/modules/backlogs/app/components/backlogs/story_menu_component.html.erb @@ -1,4 +1,4 @@ -<%#-- copyright +<%# -- copyright OpenProject is an open source project management software. Copyright (C) the OpenProject GmbH @@ -25,27 +25,37 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. See COPYRIGHT and LICENSE files for more details. -++#%> - -<% - folded = current_user.backlogs_preference(:versions_default_fold_state) == "closed" - editable = User.current.allowed_in_project?(:edit_work_packages, @project) +++# %> + +<%= + render(Primer::Alpha::ActionMenu.new(anchor_align: :end, classes: "hide-when-print")) do |menu| + menu.with_show_button( + scheme: :invisible, + icon: :"kebab-horizontal", + "aria-label": t(".label_actions"), + tooltip_direction: :se + ) + + menu.with_item( + tag: :a, + label: t(:"js.button_open_details"), + href: details_backlogs_project_backlogs_path(project, story), + content_arguments: { turbo_frame: "content-bodyRight", turbo_action: "advance" } + ) do |item| + item.with_leading_visual_icon(icon: :"op-view-split") + end + + menu.with_item( + tag: :a, + label: t(:"js.button_open_fullscreen"), + href: work_package_path(story), + content_arguments: { turbo_frame: "_top" } + ) do |item| + item.with_leading_visual_icon(icon: :"screen-full") + end + + menu.with_divider + + build_move_menu(menu) + end %> -
-
- <% icon = folded ? "icon-arrow-down1" : "icon-arrow-up1" %> -
">
- <%= render partial: "rb_sprints/sprint", object: backlog.sprint %> -
- <%= render_backlog_menu backlog %> -
-
    <%= " prevent_drag" unless editable %>"> - <% reset_cycle "stories" %> - <% backlog.stories.each_with_index do |story, index| %> - <% higher_item = index == 0 ? nil : backlog.stories[index - 1] %> - - <%= render partial: "rb_stories/story", - locals: { story:, higher_item: } %> - <% end %> -
-
diff --git a/modules/backlogs/app/components/backlogs/story_menu_component.rb b/modules/backlogs/app/components/backlogs/story_menu_component.rb new file mode 100644 index 000000000000..c65d2ac91430 --- /dev/null +++ b/modules/backlogs/app/components/backlogs/story_menu_component.rb @@ -0,0 +1,98 @@ +# 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 Backlogs + class StoryMenuComponent < ApplicationComponent + attr_reader :story, :sprint, :project, :max_position, :current_user + + def initialize(story:, sprint:, max_position:, current_user: User.current) + super() + + @story = story + @sprint = sprint + @project = sprint.project + @max_position = max_position + @current_user = current_user + end + + private + + def build_move_menu(menu) + build_move_item( + menu, + label: I18n.t(:label_sort_highest), + direction: "highest", + icon: :"move-to-top", + disabled: first_item? + ) + build_move_item( + menu, + label: I18n.t(:label_sort_higher), + direction: "higher", + icon: :"chevron-up", + disabled: first_item? + ) + build_move_item( + menu, + label: I18n.t(:label_sort_lower), + direction: "lower", + icon: :"chevron-down", + disabled: last_item? + ) + build_move_item( + menu, + label: I18n.t(:label_sort_lowest), + direction: "lowest", + icon: :"move-to-bottom", + disabled: last_item? + ) + end + + def build_move_item(menu, label:, direction:, icon:, **) + menu.with_item( + label:, + tag: :button, + href: reorder_backlogs_project_sprint_story_path(project, sprint, story), + form_arguments: { method: :post, inputs: [{ name: "direction", value: direction }] }, + ** + ) do |item| + item.with_leading_visual_icon(icon:) + end + end + + def first_item? + story.position == 1 + end + + def last_item? + story.position == max_position + end + end +end diff --git a/modules/backlogs/app/controllers/rb_application_controller.rb b/modules/backlogs/app/controllers/rb_application_controller.rb index 37e0502db725..b756ae04b643 100644 --- a/modules/backlogs/app/controllers/rb_application_controller.rb +++ b/modules/backlogs/app/controllers/rb_application_controller.rb @@ -32,10 +32,6 @@ class RbApplicationController < ApplicationController before_action :load_sprint_and_project, :check_if_plugin_is_configured, :authorize - # Use special backlogs layout to initialize stimulus side-loading legacy backlogs scripts - # and CSS from frontend - layout "backlogs" - private # Loads the project to be used by the authorize filter to determine if diff --git a/modules/backlogs/app/controllers/rb_master_backlogs_controller.rb b/modules/backlogs/app/controllers/rb_master_backlogs_controller.rb index 0602221ec5d5..9ee8e2882697 100644 --- a/modules/backlogs/app/controllers/rb_master_backlogs_controller.rb +++ b/modules/backlogs/app/controllers/rb_master_backlogs_controller.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -27,12 +29,29 @@ #++ class RbMasterBacklogsController < RbApplicationController + include WorkPackages::WithSplitView + menu_item :backlogs def index @owner_backlogs = Backlog.owner_backlogs(@project) @sprint_backlogs = Backlog.sprint_backlogs(@project) + end - @last_update = (@sprint_backlogs + @owner_backlogs).filter_map(&:updated_at).max + def split_view + @owner_backlogs = Backlog.owner_backlogs(@project) + @sprint_backlogs = Backlog.sprint_backlogs(@project) + + respond_to do |format| + format.html do + if turbo_frame_request? + render "work_packages/split_view", layout: false + else + render :index + end + end + end end + + def split_view_base_route = backlogs_project_backlogs_path(request.query_parameters) end diff --git a/modules/backlogs/app/controllers/rb_sprints_controller.rb b/modules/backlogs/app/controllers/rb_sprints_controller.rb index 3603a756b444..6abdcee3e023 100644 --- a/modules/backlogs/app/controllers/rb_sprints_controller.rb +++ b/modules/backlogs/app/controllers/rb_sprints_controller.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -26,20 +28,66 @@ # See COPYRIGHT and LICENSE files for more details. #++ -# Responsible for exposing sprint CRUD. It SHOULD NOT be used for displaying the -# taskboard since the taskboard is a management interface used for managing -# objects within a sprint. For info about the taskboard, see -# RbTaskboardsController class RbSprintsController < RbApplicationController + include OpTurbo::ComponentStream + + def edit_name + @backlog = Backlog.for(sprint: @sprint, project: @project) + + update_via_turbo_stream( + component: Backlogs::BacklogHeaderComponent.new( + backlog: @backlog, + project: @project, + state: :edit + ) + ) + + respond_with_turbo_streams + end + + def show_name + @backlog = Backlog.for(sprint: @sprint, project: @project) + + update_via_turbo_stream( + component: Backlogs::BacklogHeaderComponent.new( + backlog: @backlog, + project: @project, + state: :show + ) + ) + + respond_with_turbo_streams + end + def update - result = @sprint.update(params.permit(:name, - :start_date, - :effective_date)) - status = (result ? 200 : 400) + call = Versions::UpdateService + .new(user: current_user, model: @sprint) + .call(attributes: sprint_params) + + if call.success? + status = 200 + state = :show + + @sprint = call.result - respond_to do |format| - format.html { render partial: "sprint", status:, object: @sprint } + render_success_flash_message_via_turbo_stream(message: I18n.t(:notice_successful_update)) + else + status = 422 + state = :edit + + render_error_flash_message_via_turbo_stream(message: I18n.t(:notice_unsuccessful_update_with_reason, reason: call.message)) end + + @backlog = Backlog.for(sprint: @sprint, project: @project) + + update_via_turbo_stream( + component: Backlogs::BacklogHeaderComponent.new( + backlog: @backlog, + project: @project, + state: + ) + ) + respond_with_turbo_streams(status:) end # Overwrite load_sprint_and_project to load the sprint from the :id instead of @@ -52,4 +100,10 @@ def load_sprint_and_project # This overrides sprint's project if we set another project, say a subproject @project = Project.find(params[:project_id]) if params[:project_id] end + + private + + def sprint_params + params.expect(sprint: %i[name start_date effective_date]) + end end diff --git a/modules/backlogs/app/controllers/rb_stories_controller.rb b/modules/backlogs/app/controllers/rb_stories_controller.rb index 3b6dbc3402a3..76439dd4184d 100644 --- a/modules/backlogs/app/controllers/rb_stories_controller.rb +++ b/modules/backlogs/app/controllers/rb_stories_controller.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -27,12 +29,14 @@ #++ class RbStoriesController < RbApplicationController + include OpTurbo::ComponentStream + # This is a constant here because we will recruit it elsewhere to whitelist # attributes. This is necessary for now as we still directly use `attributes=` # in non-controller code. PERMITTED_PARAMS = %i[id status_id version_id story_points type_id subject author_id - sprint_id] + sprint_id].freeze def create call = Stories::CreateService @@ -59,15 +63,71 @@ def update respond_with_story(call) end + def move + story = Story.find(params[:id]) + + # call = Stories::UpdateService + # .new(user: current_user, story:) + # .call(attributes: move_params) + + if story.update(version_id: move_params[:target_id], **move_params.except(:target_id)) + render_success_flash_message_via_turbo_stream( + message: I18n.t(:enumeration_caption_order_changed) + ) + else + render_error_flash_message_via_turbo_stream( + message: I18n.t(:enumeration_could_not_be_moved) + call.errors.full_messages.to_sentence + ) + end + + backlog = Backlog.for(sprint: @sprint, project: @project) + replace_via_turbo_stream(component: Backlogs::BacklogComponent.new(backlog:, project: @project)) + + if story.saved_change_to_version_id? + new_sprint = story.version.becomes(Sprint) + new_backlog = Backlog.for(sprint: new_sprint, project: @project) + replace_via_turbo_stream(component: Backlogs::BacklogComponent.new(backlog: new_backlog, project: @project)) + end + + respond_with_turbo_streams + end + + def reorder + story = Story.find(params[:id]) + + if story.update(move_to: reorder_param) + render_success_flash_message_via_turbo_stream( + message: I18n.t(:enumeration_caption_order_changed) + ) + else + render_error_flash_message_via_turbo_stream( + message: I18n.t(:enumeration_could_not_be_moved) + call.errors.full_messages.to_sentence + ) + end + + backlog = Backlog.for(sprint: @sprint, project: @project) + + replace_via_turbo_stream(component: Backlogs::BacklogComponent.new(backlog:, project: @project)) + + respond_with_turbo_streams + end + private def respond_with_story(call) status = call.success? ? 200 : 400 story = call.result - respond_to do |format| - format.html { render partial: "story", object: story, status:, locals: { errors: call.errors } } - end + respond_with_turbo_streams + end + + def move_params + params.require(%i[position target_id]) + params.permit(:position, :target_id) + end + + def reorder_param + params.expect(:direction) end def story_params diff --git a/modules/backlogs/app/forms/backlogs/backlog_header_form.rb b/modules/backlogs/app/forms/backlogs/backlog_header_form.rb new file mode 100644 index 000000000000..0a258dd8dd71 --- /dev/null +++ b/modules/backlogs/app/forms/backlogs/backlog_header_form.rb @@ -0,0 +1,81 @@ +# 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 Backlogs + class BacklogHeaderForm < ApplicationForm + attr_reader :cancel_path + + form do |f| + f.group(layout: :horizontal) do |group| + group.text_field( + name: :name, + label: attribute_name(:name), + placeholder: attribute_name(:name), + visually_hide_label: true, + autofocus: true, + autocomplete: "off" + ) + + group.single_date_picker( + name: :start_date, + label: attribute_name(:start_date), + placeholder: attribute_name(:start_date), + visually_hide_label: true, + leading_visual: { icon: :calendar }, + datepicker_options: {} + ) + group.single_date_picker( + name: :effective_date, + label: attribute_name(:effective_date), + placeholder: attribute_name(:effective_date), + visually_hide_label: true, + leading_visual: { icon: :calendar }, + datepicker_options: {} + ) + + group.submit(scheme: :primary, name: :submit, label: I18n.t(:button_save)) + group.button( + scheme: :secondary, + name: :cancel, + label: I18n.t(:button_cancel), + tag: :a, + data: { turbo_stream: true }, + href: cancel_path + ) + end + end + + def initialize(cancel_path:) + super() + + @cancel_path = cancel_path + end + end +end diff --git a/modules/backlogs/app/helpers/burndown_charts_helper.rb b/modules/backlogs/app/helpers/burndown_charts_helper.rb index 03c031eab721..1c087f111259 100644 --- a/modules/backlogs/app/helpers/burndown_charts_helper.rb +++ b/modules/backlogs/app/helpers/burndown_charts_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -27,52 +29,23 @@ #++ module BurndownChartsHelper - def yaxis_labels(burndown) - max = burndown.max[:points] - - mvalue = (max / 25) + 1 - - labels = (0..mvalue).map { |i| "[#{i * 25}, #{i * 25}]" } - - mvalue = mvalue + 1 if mvalue == 1 || ((max % 25) == 0) - - labels << "[#{mvalue * 25}, '#{I18n.t('backlogs.points')}']" - - result = labels.join(", ") - - result.html_safe - end - def xaxis_labels(burndown) # 14 entries (plus the axis label) have come along as the best value for a good optical result. # Thus it is enough space between the entries. entries_displayed = (burndown.days.length / 14.0).ceil - result = burndown.days.enum_for(:each_with_index).map do |d, i| + burndown.days.enum_for(:each_with_index).map do |d, i| if (i % entries_displayed) == 0 - "[#{i + 1}, '#{escape_javascript(::I18n.t('date.abbr_day_names')[d.wday % 7])} #{d.strftime('%d/%m')}']" + ["#{escape_javascript(::I18n.t('date.abbr_day_names')[d.wday % 7])} #{d.strftime('%d/%m')}"] end - end.join(",").html_safe + - ", [#{burndown.days.length + 1}, - '#{I18n.t('backlogs.date')}']".html_safe + end end def dataseries(burndown) - dataset = {} - burndown.series.each do |s| - dataset[s.first] = { - label: I18n.t("backlogs." + s.first.to_s), - data: s.last.enum_for(:each_with_index).map { |val, i| [i + 1, val] } + burndown.series.map do |s| + { + label: I18n.t("burndown.#{s.first}"), + data: s.last.enum_for(:each) } end - - dataset - end - - def burndown_series_checkboxes(burndown) - boxes = "" - burndown.series(:all).map { |s| s.first.to_s }.sort.each do |series| - boxes += "#{I18n.t('backlogs.' + series.to_s)}
" - end - boxes.html_safe end end diff --git a/modules/backlogs/app/helpers/rb_common_helper.rb b/modules/backlogs/app/helpers/rb_common_helper.rb index d25776d4fe73..c59c635c974d 100644 --- a/modules/backlogs/app/helpers/rb_common_helper.rb +++ b/modules/backlogs/app/helpers/rb_common_helper.rb @@ -27,6 +27,12 @@ #++ module RbCommonHelper + def format_date_range(dates) + dates + .map { |date| tag.time(datetime: date.iso8601) { format_date(date) } } + .then { |dates| safe_join(dates, " – ") } + end + def assignee_id_or_empty(story) story.assigned_to_id.to_s end @@ -57,14 +63,6 @@ def color_contrast_class(task) end end - # Return true if the difference between two colors - # matches the W3C recommendations for readability - # See http://www.wat-c.org/tools/CCA/1.1/ - def colors_diff_ok?(color_1, color_2) - cont, bright = find_color_diff color_1, color_2 - (cont > 500) && (bright > 125) # Acceptable diff according to w3c - end - def color_contrast(color) _, bright = find_color_diff 0x000000, color (bright > 128) @@ -95,18 +93,13 @@ def is_assigned_task?(task) def background_color_hex(task) background_color = get_backlogs_preference(task.assigned_to, :task_color) - background_color_hex = background_color.sub("#", "0x").hex + background_color.sub("#", "0x").hex end def id_or_empty(item) item.id.to_s end - def shortened_id(record) - id = record.id.to_s - (id.length > 8 ? "#{id[0..1]}...#{id[-4..-1]}" : id) - end - def work_package_link_or_empty(work_package) modal_link_to_work_package(work_package.id, work_package, class: "prevent_edit") unless work_package.new_record? end @@ -120,53 +113,14 @@ def modal_link_to(title, path, options = {}) link_to(title, path, options.merge(id: html_id, target: "_blank")) end - def sprint_link_or_empty(item) - item_id = item.id.to_s - text = (item_id.length > 8 ? "#{item_id[0..1]}...#{item_id[-4..-1]}" : item_id) - if item.new_record? - "" - else - link_to(text, backlogs_project_sprint_path(id: item.id, project_id: item.project.identifier), class: "prevent_edit") - end - end - def mark_if_closed(story) !story.new_record? && work_package_status_for_id(story.status_id).is_closed? ? "closed" : "" end - def story_points_or_empty(story) - story.story_points.to_s - end - - def status_id_or_default(story) - story.new_record? ? new_record_status.id : story.status_id - end - - def status_label_or_default(story) - story.new_record? ? new_record_status.name : h(work_package_status_for_id(story.status_id).name) - end - - def sprint_html_id_or_empty(sprint) - sprint.id.nil? ? "" : "sprint_#{sprint.id}" - end - def story_html_id_or_empty(story) story.id.nil? ? "" : "story_#{story.id}" end - def type_id_or_empty(story) - story.type_id.to_s - end - - def type_name_or_empty(story) - return "" if story.type_id.nil? - - type = backlogs_types_by_id[story.type_id] - return "" if type.nil? - - h(type.name) - end - def date_string_with_milliseconds(d, add = 0) return "" if d.blank? @@ -177,49 +131,8 @@ def remaining_hours(item) item.remaining_hours.blank? || item.remaining_hours == 0 ? "" : item.remaining_hours end - def available_story_types - @available_story_types ||= begin - types = story_types & @project.types if @project - - types - end - end - - def available_statuses_by_type - @available_statuses_by_type ||= begin - available_statuses_by_type = Hash.new do |type_hash, type| - type_hash[type] = Hash.new do |status_hash, status| - status_hash[status] = [status] - end - end - - all_workflows.each do |w| - type_status = available_statuses_by_type[story_types_by_id[w.type_id]][w.old_status] - - type_status << w.new_status unless type_status.include?(w.new_status) - end - - available_statuses_by_type - end - end - - def show_burndown_link(project, sprint) - link_to(I18n.t("backlogs.show_burndown_chart"), - backlogs_project_sprint_burndown_chart_path(project.identifier, sprint), - class: "show_burndown_chart button", - target: :_blank, rel: :noopener) - end - private - def new_record_status - @new_record_status ||= all_work_package_status.first - end - - def default_work_package_status - @default_work_package_status ||= all_work_package_status.detect(&:is_default) - end - def work_package_status_for_id(id) @all_work_package_status_by_id ||= all_work_package_status.inject({}) do |mem, status| mem[status.id] = status @@ -254,13 +167,6 @@ def backlogs_types end end - def backlogs_types_by_id - @backlogs_types_by_id ||= backlogs_types.inject({}) do |mem, type| - mem[type.id] = type - mem - end - end - def story_types @story_types ||= begin backlogs_type_ids = Setting.plugin_openproject_backlogs["story_types"].map(&:to_i) @@ -269,20 +175,7 @@ def story_types end end - def story_types_by_id - @story_types_by_id ||= story_types.inject({}) do |mem, type| - mem[type.id] = type - mem - end - end - def get_backlogs_preference(assignee, attr) assignee.is_a?(User) ? assignee.backlogs_preference(attr) : "#24B3E7" end - - def template_story - Story.new.tap do |s| - s.type = available_story_types.first - end - end end diff --git a/modules/backlogs/app/helpers/rb_master_backlogs_helper.rb b/modules/backlogs/app/helpers/rb_master_backlogs_helper.rb deleted file mode 100644 index 89866fd1cff1..000000000000 --- a/modules/backlogs/app/helpers/rb_master_backlogs_helper.rb +++ /dev/null @@ -1,121 +0,0 @@ -#-- 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 RbMasterBacklogsHelper - include Redmine::I18n - - def render_backlog_menu(backlog) - # associated javascript defined in taskboard.js - content_tag(:div, class: "backlog-menu") do - [ - content_tag(:div, "", class: "menu-trigger icon-context icon-pulldown icon-small"), - content_tag(:ul, class: "items") do - backlog_menu_items_for(backlog).map do |item| - content_tag(:li, item, class: "item") - end.join.html_safe - end - ].join.html_safe - end - end - - def backlog_menu_items_for(backlog) - items = common_backlog_menu_items_for(backlog) - - if backlog.sprint_backlog? - items.merge!(sprint_backlog_menu_items_for(backlog)) - end - - menu = [] - %i[new_story stories_tasks task_board burndown cards wiki configs properties].each do |key| - menu << items[key] if items.keys.include?(key) - end - - menu - end - - def common_backlog_menu_items_for(backlog) - items = {} - - if current_user.allowed_in_project?(:add_work_packages, @project) - items[:new_story] = content_tag(:a, - I18n.t("backlogs.add_new_story"), - href: "#", - class: "add_new_story") - end - - items[:stories_tasks] = link_to(I18n.t(:label_stories_tasks), - controller: "/rb_queries", - action: "show", - project_id: @project, - sprint_id: backlog.sprint) - - if current_user.allowed_in_project?(:manage_versions, @project) - items[:properties] = properties_link(backlog) - end - - items - end - - def properties_link(backlog) - back_path = backlogs_project_backlogs_path(@project) - - version_path = edit_version_path(backlog.sprint, back_url: back_path, project_id: @project.id) - - link_to(I18n.t(:"backlogs.properties"), version_path) - end - - def sprint_backlog_menu_items_for(backlog) - items = {} - - if current_user.allowed_in_project?(:view_taskboards, @project) - items[:task_board] = link_to(I18n.t(:label_task_board), - { controller: "/rb_taskboards", - action: "show", - project_id: @project, - sprint_id: backlog.sprint }, - class: "show_task_board") - end - - if backlog.sprint.has_burndown? - items[:burndown] = content_tag(:a, - I18n.t("backlogs.show_burndown_chart"), - href: "#", - class: "show_burndown_chart") - end - - if @project.module_enabled? "wiki" - items[:wiki] = link_to(I18n.t(:label_wiki), - controller: "/rb_wikis", - action: "edit", - project_id: @project, - sprint_id: backlog.sprint) - end - - items - end -end diff --git a/modules/backlogs/app/models/backlog.rb b/modules/backlogs/app/models/backlog.rb index 9dd2cbbaa898..482106b803cc 100644 --- a/modules/backlogs/app/models/backlog.rb +++ b/modules/backlogs/app/models/backlog.rb @@ -27,11 +27,18 @@ #++ class Backlog + extend ActiveModel::Naming + attr_accessor :sprint, :stories - def self.owner_backlogs(project, options = {}) - options.reverse_merge!(limit: nil) + delegate :id, to: :sprint, prefix: true + + def self.for(sprint:, project:) + owner_backlog = sprint.settings(project)&.display == VersionSetting::DISPLAY_RIGHT + new(sprint:, stories: sprint.stories(project), owner_backlog:) + end + def self.owner_backlogs(project) backlogs = Sprint.apply_to(project).with_status_open.displayed_right(project).order(:name) stories_by_sprints = Story.backlogs(project.id, backlogs.map(&:id)) @@ -47,11 +54,10 @@ def self.sprint_backlogs(project) sprints.map { |sprint| new(stories: stories_by_sprints[sprint.id], sprint:) } end - def initialize(options = {}) - options = options.with_indifferent_access - @sprint = options["sprint"] - @stories = options["stories"] - @owner_backlog = options["owner_backlog"] + def initialize(sprint:, stories:, owner_backlog: false) + @sprint = sprint + @stories = stories + @owner_backlog = owner_backlog end def updated_at @@ -65,4 +71,8 @@ def owner_backlog? def sprint_backlog? !owner_backlog? end + + def to_key + [sprint_id] + end end diff --git a/modules/backlogs/app/models/sprint.rb b/modules/backlogs/app/models/sprint.rb index 5cdb1f0f2454..cdb61d41a8d0 100644 --- a/modules/backlogs/app/models/sprint.rb +++ b/modules/backlogs/app/models/sprint.rb @@ -156,6 +156,10 @@ def impediments(project) Impediment.default_scope.where(version_id: self, project_id: project) end + def settings(project) + version_settings.find { it.project_id == project.id || it.project_id.nil? } + end + private def create_wiki_page(page_title, author: User.current) diff --git a/modules/backlogs/app/views/rb_burndown_charts/_burndown.html.erb b/modules/backlogs/app/views/rb_burndown_charts/_burndown.html.erb index 5dc27ca0548f..487f17ba88a5 100644 --- a/modules/backlogs/app/views/rb_burndown_charts/_burndown.html.erb +++ b/modules/backlogs/app/views/rb_burndown_charts/_burndown.html.erb @@ -27,112 +27,7 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<%= nonced_javascript_tag do %> - jQuery(function () { - var Burndown = { - datasets: <%= dataseries(burndown).to_json.html_safe %> , - previousPoint: null, - - setDatasetColor: function () { - var i = 0; - - jQuery.each(Burndown.datasets, function (key, val) { - val.color = i; - val.points = {show: false, radius: 2}; - val.lines = {show: true}; - ++i; - }); - }, - - plotAccordingToChoices: function () { - var data = []; - - jQuery('.burndown_control').find("input:checked").each(function () { - var key = jQuery(this).attr('value'); - - if (key && Burndown.datasets[key]) { - data.push(Burndown.datasets[key]); - } - }); - - if (data.length === 0) { //in order to render an empty graph if no data is selected - data.push({data : []}); - } - - Burndown.plot(data); - }, - - plot: function (data) { - if (data.length > 0) { - jQuery.plot(jQuery(".burndown_chart"), data, { - yaxis: { min: 0, - ticks: [ <%= yaxis_labels(burndown) %> ] }, - xaxis: { - ticks: [ <%= xaxis_labels(burndown) %> ], - tickDecimals: 0, - max: <%= burndown.days.length + 1 %>, - min: 1 - }, - grid: { hoverable: true, clickable: true } - }); - } - }, - - showTooltip: function(x, y, contents) { - jQuery('
' + contents + '
').css( { - position: 'absolute', - display: 'none', - top: y + 5, - left: x + 5, - border: '1px solid #fdd', - padding: '2px', - 'background-color': '#fee', - opacity: 0.80 - }).appendTo("body").css('z-index', 2000).fadeIn(200); - }, - - showTooltipOnHover: function (event, pos, item) { - - if (item) { - if (Burndown.previousPoint != item.dataIndex) { - Burndown.previousPoint = item.dataIndex; - - jQuery("#tooltip").remove(); - var x = item.datapoint[0].toFixed(0), - y = item.datapoint[1].toFixed(0); - - Burndown.showTooltip(item.pageX, item.pageY, - item.series.label + ": " + y); - } - } - else { - jQuery("#tooltip").remove(); - Burndown.previousPoint = null; - } - }, - - init: function () { - Burndown.setDatasetColor(); - - jQuery('.burndown_control input').click(Burndown.plotAccordingToChoices); - jQuery(".burndown_chart").bind("plothover", Burndown.showTooltipOnHover); - - Burndown.plotAccordingToChoices(); - }, - - saveInit: function() { - // Ensure jQuery.plot is defined before progressing. - // This static page might already be ready but the webpack required - // jquery.flot.js file might not be loaded yet. - - if (jQuery.plot) { - this.init(); - } else { - setTimeout(() => { this.saveInit()}, 50); - } - } - }; - - Burndown.saveInit(); - }); -<% end %> +<%= angular_component_tag "opce-burndown-chart", "chart-data": { + labels: xaxis_labels(@burndown), + datasets: dataseries(@burndown) + }.to_json %> diff --git a/modules/backlogs/app/views/rb_burndown_charts/show.html.erb b/modules/backlogs/app/views/rb_burndown_charts/show.html.erb index 3282a40457d7..dfc5f70dd02d 100644 --- a/modules/backlogs/app/views/rb_burndown_charts/show.html.erb +++ b/modules/backlogs/app/views/rb_burndown_charts/show.html.erb @@ -27,19 +27,18 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -

- <%= "#{@sprint.name}: #{@sprint.start_date.present? ? I18n.l(@sprint.start_date) : ''} - #{@sprint.effective_date.present? ? I18n.l(@sprint.effective_date) : ''}" %> -

+<% html_title @sprint.name %> + +<%= render(Backlogs::SprintPageHeaderComponent.new(sprint: @sprint, project: @project)) %> <% if @burndown %> <%= render partial: "burndown", locals: { div: "burndown_", burndown: @burndown } %> - -
<%= t("backlogs.generating_chart") %>
- -
- <%= t("backlogs.chart_options") %> - <%= burndown_series_checkboxes(@burndown) %> -
<% else %> - <%= t("backlogs.no_burndown_data") %> + <%= + render(Primer::Beta::Blankslate.new(border: true, spacious: true)) do |blankslate| + blankslate.with_visual_icon(icon: :graph) + blankslate.with_heading(tag: :h2).with_content(t(".blankslate_title")) + blankslate.with_description_content(t(".blankslate_description")) + end + %> <% end %> diff --git a/modules/backlogs/app/views/rb_master_backlogs/index.html.erb b/modules/backlogs/app/views/rb_master_backlogs/index.html.erb index 0ecc2ab079ad..c4e99028ee11 100644 --- a/modules/backlogs/app/views/rb_master_backlogs/index.html.erb +++ b/modules/backlogs/app/views/rb_master_backlogs/index.html.erb @@ -27,50 +27,61 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -
<% html_title t(:label_backlogs) %> -<%= - render Primer::OpenProject::PageHeader.new do |header| - header.with_title { t(:label_backlogs) } - header.with_breadcrumbs( - [{ href: project_overview_path(@project.id), text: @project.name }, - t(:label_backlogs)] - ) - end -%> +<% content_for :content_header do %> + <%= + render Primer::OpenProject::PageHeader.new do |header| + header.with_title { t(:label_backlogs) } + header.with_breadcrumbs( + [{ href: project_overview_path(@project.id), text: @project.name }, + t(:label_backlogs)] + ) + end + %> -<%= render(Primer::OpenProject::SubHeader.new) do |subheader| - subheader.with_action_button( - scheme: :primary, - leading_icon: :plus, - label: I18n.t(:label_version_new), - tag: :a, - href: url_for({ controller: "/versions", action: "new", project_id: @project }) - ) do - t("activerecord.models.version") + <%= render(Primer::OpenProject::SubHeader.new) do |subheader| + subheader.with_action_button( + scheme: :primary, + leading_icon: :plus, + label: I18n.t(:label_version_new), + tag: :a, + href: url_for({ controller: "/versions", action: "new", project_id: @project }) + ) do + t("activerecord.models.version") + end + end %> +<% end %> + +<% content_for :content_body do %> + <% if (@owner_backlogs.empty? && @sprint_backlogs.empty?) %> + <%= + render(Primer::Beta::Blankslate.new(border: true, spacious: true)) do |blankslate| + blankslate.with_visual_icon(icon: :versions) + blankslate.with_heading(tag: :h2).with_content(t(:backlogs_empty_title)) + + if current_user.allowed_in_project?(:manage_versions, @project) + blankslate.with_description_content(t(:backlogs_empty_action_text)) + end end - end %> + %> + <% end %> -<% if (@owner_backlogs.empty? && @sprint_backlogs.empty?) %> - <%= no_results_box action_url: new_project_version_path(@project), - display_action: authorize_for("versions", "new"), - custom_title: t(:backlogs_empty_title), - custom_action_text: t(:backlogs_empty_action_text) %> -<% end %> +
+
-
-
-
- <%= render partial: "backlog", collection: @owner_backlogs %> -
-
- <%= render partial: "backlog", collection: @sprint_backlogs %> +
+ <%= render(Backlogs::BacklogComponent.with_collection(@sprint_backlogs, project: @project)) %> +
+
+ <%= render(Backlogs::BacklogComponent.with_collection(@owner_backlogs, project: @project)) %> +
+<% end %> -
- <%= render partial: "rb_stories/helpers" %> -
<%= date_string_with_milliseconds(@last_update, 0.001) %>
-
-
+<% content_for :content_body_right do %> + <%= render(split_view_instance) if render_work_package_split_view? %> +<% end %> diff --git a/modules/backlogs/app/views/rb_sprints/_sprint.html.erb b/modules/backlogs/app/views/rb_sprints/_sprint.html.erb deleted file mode 100644 index af87f44bda95..000000000000 --- a/modules/backlogs/app/views/rb_sprints/_sprint.html.erb +++ /dev/null @@ -1,78 +0,0 @@ -<%#-- 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. - -++#%> - -
-
-
<%= sprint_link_or_empty(sprint) %>
-
<%= id_or_empty(sprint) %>
-
- - <% editable = User.current.allowed_in_project?(:update_sprints, @project) ? "editable" : "" %> - -
-
<%= sprint.effective_date %>
-
<%= sprint.start_date %>
-
<%= sprint.name %>
-
- - <% if User.current.allowed_in_project?(:update_sprints, @project) %> -
- <%= angular_component_tag "opce-basic-single-date-picker", - inputs: { - value: sprint.effective_date, - inputClassNames: "effective_date editor", - id: "effective_date_#{sprint.id}", - name: :effective_date - } %> - <%= angular_component_tag "opce-basic-single-date-picker", - inputs: { - value: sprint.start_date, - inputClassNames: "start_date editor", - id: "start_date_#{sprint.id}", - name: :start_date - } %> - <%= text_field_tag :name, - sprint.name, - class: "name editor" %> -
- <% end %> - -
- <%= render partial: "shared/model_errors", object: sprint.errors %> -
-
diff --git a/modules/backlogs/app/views/rb_stories/_helpers.html.erb b/modules/backlogs/app/views/rb_stories/_helpers.html.erb deleted file mode 100644 index 34816d40855f..000000000000 --- a/modules/backlogs/app/views/rb_stories/_helpers.html.erb +++ /dev/null @@ -1,75 +0,0 @@ -<%#-- 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. - -++#%> - - -<% available_statuses_by_type.each do |type, statuses| %> - <% statuses.each do |old_status, allowed_statuses| %> - - <% end %> -<% end %> - -<% all_work_package_status.each do |old_status| %> - -<% end %> - - - - - -
- <%= render partial: "rb_stories/story", object: template_story, locals: { project: @project, permission: :add_work_packages } %> -
diff --git a/modules/backlogs/app/views/rb_stories/_story.html.erb b/modules/backlogs/app/views/rb_stories/_story.html.erb deleted file mode 100644 index 0f791ab2aacd..000000000000 --- a/modules/backlogs/app/views/rb_stories/_story.html.erb +++ /dev/null @@ -1,79 +0,0 @@ -<%#-- 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. - -++#%> - -<% - project ||= story.project - permission ||= :edit_work_packages - other_fields_editable = User.current.allowed_in_project?(permission, project) - status_field_editable = other_fields_editable || User.current.allowed_in_project?(:change_work_package_status, project) -%> -
  • " id="<%= story_html_id_or_empty(story) %>"> -
    -
    <%= work_package_link_or_empty(story) %>
    -
    <%= id_or_empty(story) %>
    -
    -
    <%= story_points_or_empty(story) %>
    -
    -
    <%= status_label_or_default(story) %>
    -
    <%= status_id_or_default(story) %>
    -
    -
    -
    <%= type_name_or_empty(story) %>:
    -
    <%= type_id_or_empty(story) %>
    -
    -
    <%= story.subject %>
    -
    <%= story.version_id %>
    -
    <%= !defined?(higher_item) || higher_item.nil? ? "" : higher_item.id %>
    -
    - <%= render(partial: "shared/model_errors", object: errors) if defined?(errors) && !errors.empty? %> -
    -
  • diff --git a/modules/backlogs/app/views/rb_taskboards/show.html.erb b/modules/backlogs/app/views/rb_taskboards/show.html.erb index 7416de928d27..acae533782b2 100644 --- a/modules/backlogs/app/views/rb_taskboards/show.html.erb +++ b/modules/backlogs/app/views/rb_taskboards/show.html.erb @@ -27,33 +27,36 @@ See COPYRIGHT and LICENSE files for more details. ++#%> +<% content_for :additional_js_dom_ready do %> + <%= render(partial: "shared/server_variables", formats: [:js]) %> +<% end %> + +<% content_controller "backlogs--taskboard-legacy" %> + <% html_title @sprint.name %> -<%= - render Primer::OpenProject::PageHeader.new do |header| - header.with_title { @sprint.name } - header.with_breadcrumbs( - [{ href: project_overview_path(@project.id), text: @project.name }, - { href: backlogs_project_backlogs_path(@project), text: t(:label_backlogs) }, - @sprint.name] - ) - end -%> -<%# we decided to keep current toolbar design for taskboard %> -
    -
    -
  • -
    - -
    - -
  • - <% if @sprint.has_burndown? %> -
  • - <%= show_burndown_link(@project, @sprint) %> -
  • + +<%= render(Backlogs::SprintPageHeaderComponent.new(sprint: @sprint, project: @project)) %> + +<%= render(Primer::OpenProject::SubHeader.new) do |component| %> + <% component.with_filter_component(id: "col_width") do %> + <%= + render( + Primer::Alpha::TextField.new( + name: :col_width_input, + type: :number, + label: t(:"backlogs.column_width"), + placeholder: t(:"backlogs.column_width"), + visually_hide_label: true, + leading_visual: { icon: :"zoom-in" }, + step: 1, + input_width: :xsmall, + autocomplete: "off" + ) + ) + %> <% end %> -
    -
    +<% end %> +
    diff --git a/modules/backlogs/app/views/shared/_server_variables.js.erb b/modules/backlogs/app/views/shared/_server_variables.js.erb index d03965ee336b..c50138cfda95 100644 --- a/modules/backlogs/app/views/shared/_server_variables.js.erb +++ b/modules/backlogs/app/views/shared/_server_variables.js.erb @@ -36,11 +36,6 @@ RB.constants = { sprint_id: <%= @sprint ? @sprint.id : "null" %> }; -RB.i18n = { - generating_graph: '<%= j I18n.t("backlogs.generating_chart").html_safe %>', - burndown_graph: '<%= j I18n.t("backlogs.burndown_graph").html_safe %>' -}; - RB.urlFor = (function () { const routes = { update_sprint: '<%= backlogs_project_sprint_path(project_id: @project.identifier, id: ":id") %>', @@ -52,9 +47,7 @@ RB.urlFor = (function () { update_task: '<%= backlogs_project_sprint_task_path(project_id: @project.identifier, sprint_id: ":sprint_id", id: ":id") %>', create_impediment: '<%= backlogs_project_sprint_impediments_path(project_id: @project.identifier, sprint_id: ":sprint_id") %>', - update_impediment: '<%= backlogs_project_sprint_impediment_path(project_id: @project.identifier, sprint_id: ":sprint_id", id: ":id") %>', - - show_burndown_chart: '<%= backlogs_project_sprint_burndown_chart_path(project_id: @project.identifier, sprint_id: ":sprint_id") %>' + update_impediment: '<%= backlogs_project_sprint_impediment_path(project_id: @project.identifier, sprint_id: ":sprint_id", id: ":id") %>' }; return function (routeName, options) { diff --git a/modules/backlogs/config/locales/en.yml b/modules/backlogs/config/locales/en.yml index 17a4515f2fff..d6529e816a53 100644 --- a/modules/backlogs/config/locales/en.yml +++ b/modules/backlogs/config/locales/en.yml @@ -34,6 +34,8 @@ en: activerecord: attributes: + sprint: + duration: "Sprint duration" work_package: position: "Position" story_points: "Story Points" @@ -55,14 +57,14 @@ en: task_type: "Task type" backlogs: - add_new_story: "New Story" + add_new_story: "New story" any: "any" backlog_settings: "Backlogs settings" burndown_graph: "Burndown Graph" card_paper_size: "Paper size for card printing" chart_options: "Chart options" close: "Close" - column_width: "Column width:" + column_width: "Column width" date: "Day" definition_of_done: "Definition of Done" generating_chart: "Generating Graph..." @@ -72,8 +74,9 @@ en: caption_versions_default_fold_state: "Versions will not be expanded by default when viewing backlogs. Each one has to be manually expanded." work_package_is_closed: "Work package is done, when" label_is_done_status: "Status %{status_name} means done" - no_burndown_data: "No burndown data available. It is necessary to have the sprint start- and end dates set." - points: "Points" + points: + one: "%{count} point" + other: "%{count} points" positions_could_not_be_rebuilt: "Positions could not be rebuilt." positions_rebuilt_successfully: "Positions rebuilt successfully." properties: "Properties" @@ -83,8 +86,9 @@ en: remaining_hours_ideal: "Remaining work (ideal)" show_burndown_chart: "Burndown Chart" story: "Story" - story_points: "Story Points" - story_points_ideal: "Story Points (ideal)" + story_points: + one: "%{count} story point" + other: "%{count} story points" task: "Task" task_color: "Task color" unassigned: "Unassigned" @@ -93,12 +97,33 @@ en: button_update_backlogs: "Update backlogs module" x_more: "%{count} more..." + backlog_component: + sprint_backlog: + blankslate_title: "Sprint Backlog is empty" + blankslate_description: "No items planned yet. Drag items here to add them to the Sprint." + product_backlog: + blankslate_title: "Product Backlog is empty" + blankslate_description: "There is no upcoming work defined in the Product Backlog" + + backlog_header_component: + label_toggle_backlog: "Collapse/Expand %{name}" + + backlog_menu_component: + label_actions: "Backlog actions" + action_menu: + edit_sprint: "Edit sprint" + + story_component: + label_drag_story: "Move %{name}" + + story_menu_component: + label_actions: "Story actions" + backlogs_active: "active" backlogs_any: "any" backlogs_inactive: "Project shows no activity" backlogs_points_burn_direction: "Points burn up/down" backlogs_product_backlog: "Product backlog" - backlogs_product_backlog_is_empty: "Product backlog is empty" backlogs_product_backlog_unsized: "The top of the product backlog has unsized stories" backlogs_sizing_inconsistent: "Story sizes vary against their estimates" backlogs_sprint_notes_missing: "Closed sprints without retrospective/review notes" @@ -115,6 +140,9 @@ en: backlogs_empty_title: "No versions are defined to be used in backlogs" backlogs_empty_action_text: "To get started with backlogs, create a new version and assign it to a backlogs column." + burndown: + story_points: "Story points" + story_points_ideal: "Story points (ideal)" button_edit_wiki: "Edit wiki page" @@ -180,6 +208,11 @@ en: project_module_backlogs: "Backlogs" + rb_burndown_charts: + show: + blankslate_title: "No burndown data available" + blankslate_description: "Set start and end date for the sprint to generate a burndown chart." + rb_label_copy_tasks: "Copy work packages" rb_label_copy_tasks_all: "All" rb_label_copy_tasks_none: "None" diff --git a/modules/backlogs/config/locales/js-en.yml b/modules/backlogs/config/locales/js-en.yml index de420c5e3583..059e83f9b75e 100644 --- a/modules/backlogs/config/locales/js-en.yml +++ b/modules/backlogs/config/locales/js-en.yml @@ -31,3 +31,6 @@ en: work_packages: properties: storyPoints: "Story Points" + burndown: + day: "Day" + points: "Points" diff --git a/modules/backlogs/config/routes.rb b/modules/backlogs/config/routes.rb index e3a3bb299459..ee43551ae7d9 100644 --- a/modules/backlogs/config/routes.rb +++ b/modules/backlogs/config/routes.rb @@ -27,11 +27,23 @@ #++ Rails.application.routes.draw do + concern :with_split_view do |options| + get "details/:work_package_id(/:tab)", + action: options.fetch(:action, :split_view), + defaults: { tab: :overview }, + as: :details, + work_package_split_view: true + end + scope "", as: "backlogs" do scope "projects/:project_id", as: "project" do - resources :backlogs, controller: :rb_master_backlogs, only: :index + resources :backlogs, controller: :rb_master_backlogs, only: :index do + collection do + concerns :with_split_view, base_route: :backlogs_project_backlogs_path + end + end - resources :sprints, controller: :rb_sprints, only: %i[show update] do + resources :sprints, controller: :rb_sprints, only: %i[update] do resource :query, controller: :rb_queries, only: :show resource :taskboard, controller: :rb_taskboards, only: :show @@ -44,7 +56,17 @@ resources :tasks, controller: :rb_tasks, only: %i[create update] - resources :stories, controller: :rb_stories, only: %i[create update] + resources :stories, controller: :rb_stories, only: %i[create update] do + member do + put :move + post :reorder + end + end + + member do + get :edit_name + get :show_name + end end resource :query, controller: :rb_queries, only: :show diff --git a/modules/backlogs/lib/open_project/backlogs/engine.rb b/modules/backlogs/lib/open_project/backlogs/engine.rb index 3d9ee5b82716..81308d001752 100644 --- a/modules/backlogs/lib/open_project/backlogs/engine.rb +++ b/modules/backlogs/lib/open_project/backlogs/engine.rb @@ -60,6 +60,8 @@ def self.settings end OpenProject::AccessControl.permission(:edit_work_packages).tap do |edit| + edit.controller_actions << "rb_stories/move" + edit.controller_actions << "rb_stories/reorder" edit.controller_actions << "rb_stories/update" edit.controller_actions << "rb_tasks/update" edit.controller_actions << "rb_impediments/update" @@ -73,8 +75,8 @@ def self.settings project_module :backlogs, dependencies: :work_package_tracking do # Master backlog permissions permission :view_master_backlog, - { rb_master_backlogs: :index, - rb_sprints: %i[index show], + { rb_master_backlogs: %i[index split_view], + rb_sprints: %i[index show show_name], rb_wikis: :show, rb_stories: %i[index show], rb_queries: :show, @@ -102,7 +104,7 @@ def self.settings # :show_sprints and :list_sprints are implicit in :view_master_backlog permission permission :update_sprints, { - rb_sprints: %i[edit update], + rb_sprints: %i[edit_name update], rb_wikis: %i[edit update] }, permissible_on: :project, diff --git a/modules/backlogs/spec/components/backlogs/backlog_component_spec.rb b/modules/backlogs/spec/components/backlogs/backlog_component_spec.rb new file mode 100644 index 000000000000..ed362f8dae1b --- /dev/null +++ b/modules/backlogs/spec/components/backlogs/backlog_component_spec.rb @@ -0,0 +1,149 @@ +# 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 Backlogs::BacklogComponent, type: :component do + shared_let(:type_feature) { create(:type_feature) } + shared_let(:type_task) { create(:type_task) } + shared_let(:default_status) { create(:default_status) } + shared_let(:default_priority) { create(:default_priority) } + shared_let(:user) { create(:admin) } + current_user { user } + + let(:project) { create(:project, types: [type_feature, type_task]) } + let(:sprint) { create(:sprint, project:, name: "Sprint 1", start_date: Date.yesterday, effective_date: Date.tomorrow) } + let(:stories) { [] } + let(:backlog) { Backlog.new(sprint:, stories:) } + + before do + allow(Setting) + .to receive(:plugin_openproject_backlogs) + .and_return("story_types" => [type_feature.id.to_s], "task_type" => type_task.id.to_s) + + allow(user).to receive(:backlogs_preference).with(:versions_default_fold_state).and_return("open") + end + + def render_component + render_inline(described_class.new(backlog:, project:, current_user: user)) + end + + describe "rendering" do + context "with stories" do + let(:story1) do + create(:story, + project:, + type: type_feature, + status: default_status, + priority: default_priority, + story_points: 5, + position: 1, + version: sprint) + end + let(:story2) do + create(:story, + project:, + type: type_feature, + status: default_status, + priority: default_priority, + story_points: 3, + position: 2, + version: sprint) + end + let(:stories) { [story1, story2] } + + it "renders a Primer::Beta::BorderBox" do + render_component + + expect(page).to have_css(".Box") + end + + it "has the sprint ID in the DOM id" do + render_component + + expect(page).to have_css(".Box#backlog_#{sprint.id}") + end + + it "renders BacklogHeaderComponent in header" do + render_component + + expect(page).to have_css(".Box-header h4", text: "Sprint 1") + end + + it "renders StoryComponent for each story" do + render_component + + expect(page).to have_css(".Box-row", count: 3) # 2 stories + 1 empty list item + expect(page).to have_text(story1.subject) + expect(page).to have_text(story2.subject) + end + + it "has the empty blankslate row with data attribute" do + render_component + + # The empty row has data-empty-list-item attribute + expect(page).to have_css("[data-empty-list-item]", visible: :all) + end + + it "has drop target data attributes" do + render_component + + box = page.find(".Box") + expect(box["data-target-id"]).to eq(sprint.id.to_s) + expect(box["data-target-allowed-drag-type"]).to eq("story") + end + + it "has draggable data attributes on story rows" do + render_component + + story_row = page.find(".Box-row[id='story_#{story1.id}']") + expect(story_row["data-draggable-id"]).to eq(story1.id.to_s) + expect(story_row["data-draggable-type"]).to eq("story") + expect(story_row["data-drop-url"]).to include("move") + end + + it "renders story rows with proper classes" do + render_component + + story_row = page.find(".Box-row[id='story_#{story1.id}']") + expect(story_row[:class]).to include("Box-row--hover-gray") + expect(story_row[:class]).to include("Box-row--focus-blue") + expect(story_row[:class]).to include("Box-row--clickable") + end + end + + context "without stories" do + let(:stories) { [] } + let(:rendered_component) { render_component } + + it_behaves_like "rendering Blank Slate", heading: "Sprint Backlog is empty" + end + end +end diff --git a/modules/backlogs/spec/components/backlogs/backlog_header_component_spec.rb b/modules/backlogs/spec/components/backlogs/backlog_header_component_spec.rb new file mode 100644 index 000000000000..01023c788647 --- /dev/null +++ b/modules/backlogs/spec/components/backlogs/backlog_header_component_spec.rb @@ -0,0 +1,214 @@ +# 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 Backlogs::BacklogHeaderComponent, type: :component do + shared_let(:type_feature) { create(:type_feature) } + shared_let(:type_task) { create(:type_task) } + shared_let(:default_status) { create(:default_status) } + shared_let(:default_priority) { create(:default_priority) } + shared_let(:user) { create(:admin) } + current_user { user } + + let(:project) { create(:project, types: [type_feature, type_task]) } + let(:start_date) { Date.new(2024, 1, 15) } + let(:effective_date) { Date.new(2024, 1, 29) } + let(:sprint) { create(:sprint, project:, name: "Sprint 1", start_date:, effective_date:) } + let(:stories) { [] } + let(:backlog) { Backlog.new(sprint:, stories:) } + let(:state) { :show } + let(:folded) { false } + + before do + allow(Setting) + .to receive(:plugin_openproject_backlogs) + .and_return("story_types" => [type_feature.id.to_s], "task_type" => type_task.id.to_s) + end + + def render_component(state: :show, folded: false) + render_inline(described_class.new(backlog:, project:, state:, folded:, current_user: user)) + end + + describe "show state (default)" do + context "with stories" do + let(:story1) do + create(:story, + project:, + type: type_feature, + status: default_status, + priority: default_priority, + story_points: 5, + version: sprint) + end + let(:story2) do + create(:story, + project:, + type: type_feature, + status: default_status, + priority: default_priority, + story_points: 3, + version: sprint) + end + let(:story_with_nil_points) do + create(:story, + project:, + type: type_feature, + status: default_status, + priority: default_priority, + story_points: nil, + version: sprint) + end + let(:stories) { [story1, story2, story_with_nil_points] } + + it "displays sprint name in h4" do + render_component + + expect(page).to have_css("h4", text: "Sprint 1") + end + + it "shows story count via Primer::Beta::Counter" do + render_component + + expect(page).to have_css(".Counter", text: "3") + end + + it "shows formatted date range with time tags" do + render_component + + expect(page).to have_css("time[datetime='2024-01-15']") + expect(page).to have_css("time[datetime='2024-01-29']") + end + + it "shows story points total (nil treated as 0)" do + render_component + + # 5 + 3 + 0 = 8 points + expect(page).to have_text("8 points") + end + + it "renders collapse/expand chevrons" do + render_component + + expect(page).to have_octicon(:"chevron-up", visible: :all) + expect(page).to have_octicon(:"chevron-down", visible: :all) + end + + it "renders BacklogMenuComponent" do + render_component + + expect(page).to have_css("action-menu") + end + end + + context "with no stories" do + let(:stories) { [] } + + it "shows 0 story count" do + render_component + + expect(page).to have_css(".Counter", text: "0") + end + + it "shows 0 points" do + render_component + + expect(page).to have_text("0 points") + end + end + + context "when sprint has no dates" do + let(:sprint) { create(:sprint, project:, name: "Sprint 1", start_date: nil, effective_date: nil) } + + it "renders without date range" do + render_component + + expect(page).to have_no_css("time") + end + end + end + + describe "folded state" do + context "when folded is true" do + it "renders chevron-up hidden and chevron-down visible" do + render_component(folded: true) + + # When folded, chevron-up is hidden (has hidden attribute on svg) + # and chevron-down is visible (for expanding) + expect(page).to have_css("svg[hidden][data-target='collapsible-header.arrowUp']", visible: :hidden) + expect(page).to have_css("svg[data-target='collapsible-header.arrowDown']:not([hidden])", visible: :all) + end + end + + context "when folded is false" do + it "renders chevron-down hidden and chevron-up visible" do + render_component(folded: false) + + # When expanded, chevron-down is hidden (has hidden attribute) + # and chevron-up is visible (for collapsing) + expect(page).to have_css("svg[hidden][data-target='collapsible-header.arrowDown']", visible: :hidden) + expect(page).to have_css("svg[data-target='collapsible-header.arrowUp']:not([hidden])", visible: :all) + end + end + end + + describe "edit state" do + it "renders a form" do + render_component(state: :edit) + + expect(page).to have_css("form") + end + + it "renders text field for name" do + render_component(state: :edit) + + expect(page).to have_field(Sprint.human_attribute_name(:name), with: "Sprint 1") + end + + it "renders date picker components" do + render_component(state: :edit) + + # Date pickers have calendar icons as leading visuals + expect(page).to have_octicon(:calendar, count: 2) + end + + it "shows Save button" do + render_component(state: :edit) + + expect(page).to have_button(I18n.t(:button_save)) + end + + it "shows Cancel button" do + render_component(state: :edit) + + expect(page).to have_link(I18n.t(:button_cancel)) + end + end +end diff --git a/modules/backlogs/spec/components/backlogs/backlog_menu_component_spec.rb b/modules/backlogs/spec/components/backlogs/backlog_menu_component_spec.rb new file mode 100644 index 000000000000..30f26b2ccf74 --- /dev/null +++ b/modules/backlogs/spec/components/backlogs/backlog_menu_component_spec.rb @@ -0,0 +1,207 @@ +# 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 Backlogs::BacklogMenuComponent, type: :component do + shared_let(:type_feature) { create(:type_feature) } + shared_let(:type_task) { create(:type_task) } + + let(:project) { create(:project, types: [type_feature, type_task]) } + let(:sprint) { create(:sprint, project:, name: "Sprint 1", start_date: Date.yesterday, effective_date: Date.tomorrow) } + let(:stories) { [] } + let(:backlog) { Backlog.new(sprint:, stories:) } + let(:user) { create(:user) } + let(:permissions) { [] } + + before do + allow(Setting) + .to receive(:plugin_openproject_backlogs) + .and_return("story_types" => [type_feature.id.to_s], "task_type" => type_task.id.to_s) + + # Set up user with specific permissions + create(:member, + project:, + principal: user, + roles: [create(:project_role, permissions:)]) + login_as(user) + end + + def render_component + render_inline(described_class.new(backlog:, project:, current_user: user)) + end + + describe "permission-based items" do + context "with :update_sprints permission" do + let(:permissions) { %i[view_master_backlog update_sprints] } + + it "shows Edit item with pencil icon" do + render_component + + expect(page).to have_css("action-menu") + expect(page).to have_text(I18n.t("backlogs.backlog_menu_component.action_menu.edit_sprint")) + expect(page).to have_octicon(:pencil) + end + end + + context "without :update_sprints permission" do + let(:permissions) { [:view_master_backlog] } + + it "does not show Edit item" do + render_component + + expect(page).to have_no_text(I18n.t("backlogs.backlog_menu_component.action_menu.edit_sprint")) + end + end + + context "with :add_work_packages permission" do + let(:permissions) { %i[view_master_backlog add_work_packages] } + + it "shows Add new story item with compose icon" do + render_component + + expect(page).to have_text(I18n.t(:"backlogs.add_new_story")) + expect(page).to have_octicon(:compose) + end + end + + context "without :add_work_packages permission" do + let(:permissions) { [:view_master_backlog] } + + it "does not show Add new story item" do + render_component + + expect(page).to have_no_text(I18n.t(:"backlogs.add_new_story")) + end + end + + context "with :manage_versions permission" do + let(:permissions) { %i[view_master_backlog manage_versions] } + + it "shows Properties item with gear icon" do + render_component + + expect(page).to have_text(I18n.t(:"backlogs.properties")) + expect(page).to have_octicon(:gear) + end + end + + context "without :manage_versions permission" do + let(:permissions) { [:view_master_backlog] } + + it "does not show Properties item" do + render_component + + expect(page).to have_no_text(I18n.t(:"backlogs.properties")) + end + end + + context "with :view_taskboards permission" do + let(:permissions) { %i[view_master_backlog view_taskboards] } + + it "shows Task board item" do + render_component + + expect(page).to have_text(I18n.t(:label_task_board)) + end + end + + context "without :view_taskboards permission" do + let(:permissions) { [:view_master_backlog] } + + it "does not show Task board item" do + render_component + + expect(page).to have_no_text(I18n.t(:label_task_board)) + end + end + end + + describe "always-visible items" do + let(:permissions) { [:view_master_backlog] } + + it "shows Stories/Tasks link" do + render_component + + expect(page).to have_text(I18n.t(:label_stories_tasks)) + end + + it "shows Burndown chart link" do + render_component + + expect(page).to have_text(I18n.t(:"backlogs.show_burndown_chart")) + end + + context "when sprint has no burndown (no dates)" do + let(:sprint) { create(:sprint, project:, name: "Sprint 1", start_date: nil, effective_date: nil) } + + it "shows Burndown chart link as disabled" do + render_component + + burndown_item = page.find("li", text: I18n.t(:"backlogs.show_burndown_chart")) + expect(burndown_item[:class]).to include("ActionListItem--disabled") + end + end + + context "when sprint has burndown" do + it "shows Burndown chart link as enabled" do + render_component + + burndown_item = page.find("li", text: I18n.t(:"backlogs.show_burndown_chart")) + expect(burndown_item[:class]).not_to include("ActionListItem--disabled") + end + end + end + + describe "module-based items" do + context "when wiki module is enabled" do + let(:permissions) { [:view_master_backlog] } + let(:project) { create(:project, types: [type_feature, type_task], enabled_module_names: %w[backlogs wiki]) } + + it "shows Wiki item" do + render_component + + expect(page).to have_text(I18n.t(:label_wiki)) + expect(page).to have_octicon(:book) + end + end + + context "when wiki module is disabled" do + let(:permissions) { [:view_master_backlog] } + let(:project) { create(:project, types: [type_feature, type_task], enabled_module_names: %w[backlogs]) } + + it "does not show Wiki item" do + render_component + + expect(page).to have_no_text(I18n.t(:label_wiki)) + end + end + end +end diff --git a/modules/backlogs/spec/components/backlogs/sprint_page_header_component_spec.rb b/modules/backlogs/spec/components/backlogs/sprint_page_header_component_spec.rb new file mode 100644 index 000000000000..34c50fd51e81 --- /dev/null +++ b/modules/backlogs/spec/components/backlogs/sprint_page_header_component_spec.rb @@ -0,0 +1,123 @@ +# 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 Backlogs::SprintPageHeaderComponent, type: :component do + let(:project) { create(:project, name: "Test Project") } + let(:start_date) { Date.new(2024, 1, 15) } + let(:effective_date) { Date.new(2024, 1, 29) } + let(:sprint) { create(:sprint, project:, name: "Sprint 1", start_date:, effective_date:) } + + def render_component + render_inline(described_class.new(sprint:, project:)) + end + + describe "rendering" do + it "renders Primer::OpenProject::PageHeader" do + render_component + + expect(page).to have_css(".PageHeader") + end + + it "displays sprint name as title" do + render_component + + expect(page).to have_css(".PageHeader-title", text: "Sprint 1") + end + + it "shows date range in description with time tags" do + render_component + + expect(page).to have_css("time[datetime='2024-01-15']") + expect(page).to have_css("time[datetime='2024-01-29']") + end + + it "renders breadcrumbs" do + render_component + + expect(page).to have_css(".PageHeader-breadcrumbs") + end + + it "includes project link in breadcrumbs" do + render_component + + expect(page).to have_link("Test Project") + end + + it "includes backlogs link in breadcrumbs" do + render_component + + expect(page).to have_link(I18n.t(:label_backlogs)) + end + + it "includes sprint name as text (not link) in breadcrumbs" do + render_component + + # The last breadcrumb item should be the sprint name as plain text + breadcrumbs = page.find(".PageHeader-breadcrumbs") + expect(breadcrumbs).to have_text("Sprint 1") + end + end + + describe "date handling" do + context "when sprint has only start_date" do + let(:sprint) { create(:sprint, project:, name: "Sprint 1", start_date:, effective_date: nil) } + + it "renders only start date" do + render_component + + expect(page).to have_css("time[datetime='2024-01-15']") + expect(page).to have_no_css("time[datetime='2024-01-29']") + end + end + + context "when sprint has only effective_date" do + let(:sprint) { create(:sprint, project:, name: "Sprint 1", start_date: nil, effective_date:) } + + it "renders only effective date" do + render_component + + expect(page).to have_no_css("time[datetime='2024-01-15']") + expect(page).to have_css("time[datetime='2024-01-29']") + end + end + + context "when sprint has no dates" do + let(:sprint) { create(:sprint, project:, name: "Sprint 1", start_date: nil, effective_date: nil) } + + it "renders no time elements" do + render_component + + expect(page).to have_no_css("time") + end + end + end +end diff --git a/modules/backlogs/spec/components/backlogs/story_component_spec.rb b/modules/backlogs/spec/components/backlogs/story_component_spec.rb new file mode 100644 index 000000000000..e09562c2701d --- /dev/null +++ b/modules/backlogs/spec/components/backlogs/story_component_spec.rb @@ -0,0 +1,133 @@ +# 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 Backlogs::StoryComponent, type: :component do + shared_let(:type_feature) { create(:type_feature) } + shared_let(:type_task) { create(:type_task) } + shared_let(:default_status) { create(:default_status) } + shared_let(:default_priority) { create(:default_priority) } + shared_let(:user) { create(:admin) } + current_user { user } + + let(:project) { create(:project, types: [type_feature, type_task]) } + let(:sprint) { create(:sprint, project:, name: "Sprint 1", start_date: Date.yesterday, effective_date: Date.tomorrow) } + let(:story_points) { 5 } + let(:story) do + create(:story, + subject: "Test Story Subject", + project:, + type: type_feature, + status: default_status, + priority: default_priority, + story_points:, + position: 1, + version: sprint) + end + let(:max_position) { 3 } + + before do + allow(Setting) + .to receive(:plugin_openproject_backlogs) + .and_return("story_types" => [type_feature.id.to_s], "task_type" => type_task.id.to_s) + end + + def render_component + render_inline(described_class.new(story:, sprint:, max_position:, current_user: user)) + end + + describe "rendering" do + it "renders Primer::OpenProject::DragHandle" do + render_component + + # DragHandle renders with grabber icon + expect(page).to have_octicon(:grabber) + end + + it "renders WorkPackages::InfoLineComponent" do + render_component + + # InfoLine renders type and ID info + expect(page).to have_text("FEATURE") + expect(page).to have_text("##{story.id}") + end + + it "shows story subject in semibold text" do + render_component + + expect(page).to have_text("Test Story Subject") + end + + it "shows story points" do + render_component + + expect(page).to have_text("5 points") + end + + it "renders StoryMenuComponent" do + render_component + + expect(page).to have_css("action-menu") + end + end + + describe "story points handling" do + context "when story_points is nil" do + let(:story_points) { nil } + + it "shows 0 points" do + render_component + + expect(page).to have_text("0 points") + end + end + + context "when story_points is 0" do + let(:story_points) { 0 } + + it "shows 0 points" do + render_component + + expect(page).to have_text("0 points") + end + end + + context "when story_points is 1" do + let(:story_points) { 1 } + + it "shows 1 point (singular)" do + render_component + + expect(page).to have_text("1 point") + end + end + end +end diff --git a/modules/backlogs/spec/components/backlogs/story_menu_component_spec.rb b/modules/backlogs/spec/components/backlogs/story_menu_component_spec.rb new file mode 100644 index 000000000000..7a77d94a9a9e --- /dev/null +++ b/modules/backlogs/spec/components/backlogs/story_menu_component_spec.rb @@ -0,0 +1,195 @@ +# 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 Backlogs::StoryMenuComponent, type: :component do + shared_let(:type_feature) { create(:type_feature) } + shared_let(:type_task) { create(:type_task) } + shared_let(:default_status) { create(:default_status) } + shared_let(:default_priority) { create(:default_priority) } + shared_let(:user) { create(:admin) } + current_user { user } + + let(:project) { create(:project, types: [type_feature, type_task]) } + let(:sprint) { create(:sprint, project:, name: "Sprint 1", start_date: Date.yesterday, effective_date: Date.tomorrow) } + let(:position) { 2 } + let(:max_position) { 3 } + let(:story) do + create(:story, + subject: "Test Story", + project:, + type: type_feature, + status: default_status, + priority: default_priority, + story_points: 5, + position:, + version: sprint) + end + + before do + allow(Setting) + .to receive(:plugin_openproject_backlogs) + .and_return("story_types" => [type_feature.id.to_s], "task_type" => type_task.id.to_s) + end + + def render_component(position: 2, max_position: 3) + story.update!(position:) + render_inline(described_class.new(story:, sprint:, max_position:, current_user: user)) + end + + describe "standard items" do + it "shows Open details link (split view)" do + render_component + + expect(page).to have_text(I18n.t(:"js.button_open_details")) + expect(page).to have_octicon(:"op-view-split") + end + + it "shows Open fullscreen link (full page)" do + render_component + + expect(page).to have_text(I18n.t(:"js.button_open_fullscreen")) + expect(page).to have_octicon(:"screen-full") + end + + it "shows a divider before move options" do + render_component + + expect(page).to have_css(".ActionList-sectionDivider") + end + end + + describe "move menu items" do + it "shows Move to top item with move-to-top icon" do + render_component + + expect(page).to have_text(I18n.t(:label_sort_highest)) + expect(page).to have_octicon(:"move-to-top") + end + + it "shows Move up item with chevron-up icon" do + render_component + + expect(page).to have_text(I18n.t(:label_sort_higher)) + expect(page).to have_octicon(:"chevron-up") + end + + it "shows Move down item with chevron-down icon" do + render_component + + expect(page).to have_text(I18n.t(:label_sort_lower)) + expect(page).to have_octicon(:"chevron-down") + end + + it "shows Move to bottom item with move-to-bottom icon" do + render_component + + expect(page).to have_text(I18n.t(:label_sort_lowest)) + expect(page).to have_octicon(:"move-to-bottom") + end + end + + describe "position logic" do + context "when item is first (position=1)" do + it "disables Move to top and Move up" do + render_component(position: 1, max_position: 3) + + # Move to top should be disabled + move_to_top = page.find("li", text: I18n.t(:label_sort_highest)) + expect(move_to_top[:class]).to include("ActionListItem--disabled") + + # Move up should be disabled + move_up = page.find("li", text: I18n.t(:label_sort_higher)) + expect(move_up[:class]).to include("ActionListItem--disabled") + end + + it "enables Move down and Move to bottom" do + render_component(position: 1, max_position: 3) + + # Move down should be enabled + move_down = page.find("li", text: I18n.t(:label_sort_lower)) + expect(move_down[:class]).not_to include("ActionListItem--disabled") + + # Move to bottom should be enabled + move_to_bottom = page.find("li", text: I18n.t(:label_sort_lowest)) + expect(move_to_bottom[:class]).not_to include("ActionListItem--disabled") + end + end + + context "when item is last (position=max)" do + it "disables Move down and Move to bottom" do + render_component(position: 3, max_position: 3) + + # Move down should be disabled + move_down = page.find("li", text: I18n.t(:label_sort_lower)) + expect(move_down[:class]).to include("ActionListItem--disabled") + + # Move to bottom should be disabled + move_to_bottom = page.find("li", text: I18n.t(:label_sort_lowest)) + expect(move_to_bottom[:class]).to include("ActionListItem--disabled") + end + + it "enables Move to top and Move up" do + render_component(position: 3, max_position: 3) + + # Move to top should be enabled + move_to_top = page.find("li", text: I18n.t(:label_sort_highest)) + expect(move_to_top[:class]).not_to include("ActionListItem--disabled") + + # Move up should be enabled + move_up = page.find("li", text: I18n.t(:label_sort_higher)) + expect(move_up[:class]).not_to include("ActionListItem--disabled") + end + end + + context "when item is in the middle" do + it "enables all move options" do + render_component(position: 2, max_position: 3) + + expect(page.find("li", text: I18n.t(:label_sort_highest))[:class]).not_to include("ActionListItem--disabled") + expect(page.find("li", text: I18n.t(:label_sort_higher))[:class]).not_to include("ActionListItem--disabled") + expect(page.find("li", text: I18n.t(:label_sort_lower))[:class]).not_to include("ActionListItem--disabled") + expect(page.find("li", text: I18n.t(:label_sort_lowest))[:class]).not_to include("ActionListItem--disabled") + end + end + + context "when there is only one item (position=1, max=1)" do + it "disables all move options" do + render_component(position: 1, max_position: 1) + + expect(page.find("li", text: I18n.t(:label_sort_highest))[:class]).to include("ActionListItem--disabled") + expect(page.find("li", text: I18n.t(:label_sort_higher))[:class]).to include("ActionListItem--disabled") + expect(page.find("li", text: I18n.t(:label_sort_lower))[:class]).to include("ActionListItem--disabled") + expect(page.find("li", text: I18n.t(:label_sort_lowest))[:class]).to include("ActionListItem--disabled") + end + end + end +end diff --git a/modules/backlogs/spec/controllers/rb_master_backlogs_controller_spec.rb b/modules/backlogs/spec/controllers/rb_master_backlogs_controller_spec.rb new file mode 100644 index 000000000000..595d6f32d701 --- /dev/null +++ b/modules/backlogs/spec/controllers/rb_master_backlogs_controller_spec.rb @@ -0,0 +1,70 @@ +# 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 RbMasterBacklogsController do + shared_let(:type_feature) { create(:type_feature) } + shared_let(:type_task) { create(:type_task) } + shared_let(:user) { create(:admin) } + current_user { user } + + let(:project) { create(:project) } + let(:status) { create(:status, name: "status 1", is_default: true) } + let(:sprint) { create(:sprint, project:) } + let(:story) { create(:story, status:, version: sprint, project:) } + + before do + allow(Setting) + .to receive(:plugin_openproject_backlogs) + .and_return({ "story_types" => [type_feature.id], "task_type" => type_task.id }) + end + + describe "GET #index" do + it do + get :index, params: { project_id: project.id } + + expect(response).to be_successful + end + end + + describe "GET #split_view" do + it do + get :split_view, params: { + project_id: project.id, + tab: :overview, + work_package_id: story.id, + work_package_split_view: true + } + + expect(response).to be_successful + end + end +end diff --git a/modules/backlogs/spec/controllers/rb_sprints_controller_spec.rb b/modules/backlogs/spec/controllers/rb_sprints_controller_spec.rb new file mode 100644 index 000000000000..cb1a310ac487 --- /dev/null +++ b/modules/backlogs/spec/controllers/rb_sprints_controller_spec.rb @@ -0,0 +1,126 @@ +# 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 RbSprintsController do + shared_let(:type_feature) { create(:type_feature) } + shared_let(:type_task) { create(:type_task) } + shared_let(:user) { create(:admin) } + current_user { user } + + before do + allow(Setting) + .to receive(:plugin_openproject_backlogs) + .and_return({ "story_types" => [type_feature.id], "task_type" => type_task.id }) + + allow(Project) + .to receive(:find) + .with(project.identifier) + .and_return(project) + + allow(Sprint) + .to receive(:find) + .with(sprint.id.to_s) + .and_return(sprint) + end + + describe "GET #edit_name" do + let(:project) { build_stubbed(:project) } + let(:sprint) { build_stubbed(:sprint) } + + it "responds with success", :aggregate_failures do + get :edit_name, params: { project_id: project.identifier, id: sprint.id }, format: :turbo_stream + + expect(response).to be_successful + expect(response).to have_http_status :ok + expect(response).to have_turbo_stream action: "update", target: "backlogs-backlog-header-component-#{sprint.id}" + end + end + + describe "GET #show_name" do + let(:project) { build_stubbed(:project) } + let(:sprint) { build_stubbed(:sprint) } + + it "responds with success", :aggregate_failures do + get :show_name, params: { project_id: project.identifier, id: sprint.id }, format: :turbo_stream + + expect(response).to be_successful + expect(response).to have_http_status :ok + expect(response).to have_turbo_stream action: "update", target: "backlogs-backlog-header-component-#{sprint.id}" + end + end + + describe "PATCH #update" do + let(:project) { build_stubbed(:project) } + let(:sprint) { build_stubbed(:sprint) } + + before do + update_service = instance_double(Versions::UpdateService, call: service_result) + + allow(Versions::UpdateService) + .to receive(:new) + .with(user:, model: sprint) + .and_return(update_service) + end + + context "when service call succeeds" do + let(:service_result) { ServiceResult.success(result: sprint) } + + it "responds with success", :aggregate_failures do + patch :update, params: { project_id: project.identifier, id: sprint.id, sprint: { name: "Updated Sprint" } }, + format: :turbo_stream + + expect(response).to be_successful + expect(response).to have_http_status :ok + expect(response).to have_turbo_stream action: "update", target: "backlogs-backlog-header-component-#{sprint.id}" + expect(response).to have_turbo_stream action: "flash", target: "op-primer-flash-component" + end + end + + context "when service call fails" do + let(:service_result) { ServiceResult.failure(result: sprint) } + + before do + project.name = "" + end + + it "responds with 422", :aggregate_failures do + patch :update, params: { project_id: project.identifier, id: sprint.id, sprint: { name: "" } }, + format: :turbo_stream + + expect(response).not_to be_successful + expect(response).to have_http_status :unprocessable_entity + expect(response).to have_turbo_stream action: "update", target: "backlogs-backlog-header-component-#{sprint.id}" + expect(response).to have_turbo_stream action: "flash", target: "op-primer-flash-component" + end + end + end +end diff --git a/modules/backlogs/spec/controllers/rb_stories_controller_spec.rb b/modules/backlogs/spec/controllers/rb_stories_controller_spec.rb new file mode 100644 index 000000000000..096c4a0d6084 --- /dev/null +++ b/modules/backlogs/spec/controllers/rb_stories_controller_spec.rb @@ -0,0 +1,102 @@ +# 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 RbStoriesController do + shared_let(:type_feature) { create(:type_feature) } + shared_let(:type_task) { create(:type_task) } + shared_let(:user) { create(:admin) } + current_user { user } + + let(:project) { create(:project) } + let(:status) { create(:status, name: "status 1", is_default: true) } + let(:sprint) { create(:sprint, project:) } + let(:story) { create(:story, status:, version: sprint, project:) } + + before do + allow(Setting) + .to receive(:plugin_openproject_backlogs) + .and_return({ "story_types" => [type_feature.id], "task_type" => type_task.id }) + end + + describe "POST #create" do + it "responds with success", :aggregate_failures do + post :create, params: { project_id: project.id, sprint_id: sprint.id }, + format: :turbo_stream + + expect(response).to be_successful + expect(response).to have_http_status :ok + end + end + + describe "PUT #move" do + let(:other_sprint) { create(:sprint, name: "Sprint 2", project:) } + + it "responds with success", :aggregate_failures do + put :move, params: { + project_id: project.id, + sprint_id: sprint.id, + id: story.id, + target_id: other_sprint.id, + position: 1 + }, + format: :turbo_stream + + expect(response).to be_successful + expect(response).to have_http_status :ok + expect(response).to have_turbo_stream action: "replace", target: "backlogs-backlog-component-#{sprint.id}" + expect(response).to have_turbo_stream action: "replace", target: "backlogs-backlog-component-#{other_sprint.id}" + expect(response).to have_turbo_stream action: "flash", target: "op-primer-flash-component" + end + end + + describe "POST #reorder" do + it "responds with success", :aggregate_failures do + post :reorder, params: { project_id: project.id, sprint_id: sprint.id, id: story.id, direction: "highest" }, + format: :turbo_stream + + expect(response).to be_successful + expect(response).to have_http_status :ok + expect(response).to have_turbo_stream action: "replace", target: "backlogs-backlog-component-#{sprint.id}" + expect(response).to have_turbo_stream action: "flash", target: "op-primer-flash-component" + end + end + + describe "PATCH #update" do + it "responds with success", :aggregate_failures do + patch :update, params: { project_id: project.id, sprint_id: sprint.id, id: story.id }, + format: :turbo_stream + + expect(response).to be_successful + expect(response).to have_http_status :ok + end + end +end diff --git a/modules/backlogs/spec/features/backlogs/change_status_spec.rb b/modules/backlogs/spec/features/backlogs/change_status_spec.rb index 028e3d04fe17..9077e4230ad2 100644 --- a/modules/backlogs/spec/features/backlogs/change_status_spec.rb +++ b/modules/backlogs/spec/features/backlogs/change_status_spec.rb @@ -31,7 +31,7 @@ require "spec_helper" require_relative "../../support/pages/backlogs" -RSpec.describe "Backlogs context menu", :js do +RSpec.describe.skip "Backlogs change status", :js do shared_let(:story_type) { create(:type_feature) } shared_let(:task_type) { create(:type_task) } shared_let(:project) { create(:project, types: [story_type, task_type]) } diff --git a/modules/backlogs/spec/features/backlogs/context_menu_spec.rb b/modules/backlogs/spec/features/backlogs/context_menu_spec.rb index e64d2d59ed87..85ac48a307c0 100644 --- a/modules/backlogs/spec/features/backlogs/context_menu_spec.rb +++ b/modules/backlogs/spec/features/backlogs/context_menu_spec.rb @@ -113,9 +113,9 @@ def within_backlog_context_menu(&) sprint.update(start_date: nil) end - it 'does not display the "Burndown chart" menu entry' do + it 'disables the "Burndown chart" menu entry' do within_backlog_context_menu do |menu| - expect(menu).to have_no_link I18n.t("backlogs.show_burndown_chart") + expect(menu).to have_link I18n.t("backlogs.show_burndown_chart", aria: { disabled: true }) end end end @@ -125,9 +125,9 @@ def within_backlog_context_menu(&) sprint.update(effective_date: nil) end - it 'does not display the "Burndown chart" menu entry' do + it 'disables the "Burndown chart" menu entry' do within_backlog_context_menu do |menu| - expect(menu).to have_no_link I18n.t("backlogs.show_burndown_chart") + expect(menu).to have_link I18n.t("backlogs.show_burndown_chart", aria: { disabled: true }) end end end diff --git a/modules/backlogs/spec/features/backlogs/create_story_spec.rb b/modules/backlogs/spec/features/backlogs/create_story_spec.rb index abb3f9620c89..c2d8928e4f2a 100644 --- a/modules/backlogs/spec/features/backlogs/create_story_spec.rb +++ b/modules/backlogs/spec/features/backlogs/create_story_spec.rb @@ -27,6 +27,7 @@ #++ require "spec_helper" +require_relative "../../support/pages/backlogs" RSpec.describe "Backlogs", :js, :selenium, driver: :firefox_de do # using FF due to regression #64158 let(:story_type) do @@ -88,6 +89,8 @@ create(:default_priority) end + let(:backlogs_page) { Pages::Backlogs.new(project) } + before do login_as(user) @@ -100,32 +103,36 @@ end it "allows creating a new story" do - visit backlogs_project_backlogs_path(project) + backlogs_page.visit! + + backlogs_page.click_in_backlog_menu(backlog_version, "New story") - within("#backlog_#{backlog_version.id}", wait: 10) do - menu = find(".backlog-menu") - menu.click - click_link "New Story" + within_dialog "New work package" do fill_in "Subject", with: "The new story" - fill_in "Story Points", with: "5" + # TODO: removed in OP #57688, to be reimplemented + # fill_in "Story Points", with: "5" - # inactive types should not be selectable - # but the user can choose from the active types - expect(page) - .to have_no_css("option", text: inactive_story_type.name) + # inactive types should not be selectable but the user can choose from the + # active types + # TODO: removed in OP #57688, to be reimplemented + # expect(page).to have_no_css("option", text: inactive_story_type.name) - select story_type2.name, from: "Type" + select_combo_box_option story_type2.name, from: "Type" # saving the new story - find(:css, "input[name=subject]").native.send_key :return + click_on "Create" + end - # velocity should be summed up immediately - expect(page) - .to have_css(".velocity", text: "12") + expect_and_dismiss_flash type: :success, message: "New work package created" - # this will ensure that the page refresh is through before we check the order - menu.click - click_link "New Story" + # velocity should be summed up immediately + # TODO: removed in OP #57688, to be reimplemented + # xpect(page).to have_css(".velocity", text: "12") + + # this will ensure that the page refresh is through before we check the order + backlogs_page.click_in_backlog_menu(backlog_version, "New story") + + within_dialog "New work package" do fill_in "Subject", with: "Another story" end @@ -135,15 +142,15 @@ expect(page) .to have_no_content "Another story" - expect(page) - .to have_css ".story:nth-of-type(1)", text: "The new story" - expect(page) - .to have_css ".story:nth-of-type(2)", text: existing_story1.subject - expect(page) - .to have_css ".story:nth-of-type(3)", text: existing_story2.subject + new_story = WorkPackage.find_by(subject: "The new story") - # created with the selected type - expect(page) - .to have_css ".story:nth-of-type(1) .type_id", text: story_type2.name + # stories are ordered by position (ASC), with NULL positions at the end ordered by ID + # existing stories have positions 1 and 2, new story has no position so appears at end + backlogs_page.expect_stories_in_order(backlog_version, existing_story1, existing_story2, new_story) + + # created with the selected type (HighlightedTypeComponent renders type name in uppercase) + within("#story_#{new_story.id}") do + expect(page).to have_text(story_type2.name.upcase) + end end end diff --git a/modules/backlogs/spec/features/empty_backlogs_spec.rb b/modules/backlogs/spec/features/empty_backlogs_spec.rb index b4cf24b380f9..b558b1914aca 100644 --- a/modules/backlogs/spec/features/empty_backlogs_spec.rb +++ b/modules/backlogs/spec/features/empty_backlogs_spec.rb @@ -48,12 +48,11 @@ context "as admin" do let(:current_user) { create(:admin) } - it "shows a no results box with action" do - expect(page).to have_css(".generic-table--no-results-container", text: I18n.t(:backlogs_empty_title)) - expect(page).to have_css(".generic-table--no-results-description", text: I18n.t(:backlogs_empty_action_text)) - - link = page.find ".generic-table--no-results-description a" - expect(link[:href]).to include(new_project_version_path(project)) + it "shows blankslate with description" do + within ".blankslate" do + expect(page).to have_heading(I18n.t(:backlogs_empty_title)) + expect(page).to have_text(I18n.t(:backlogs_empty_action_text)) + end end end @@ -61,9 +60,11 @@ let(:role) { create(:project_role, permissions: %i(view_master_backlog)) } let(:current_user) { create(:user, member_with_roles: { project => role }) } - it "only shows a no results box" do - expect(page).to have_css(".generic-table--no-results-container", text: I18n.t(:backlogs_empty_title)) - expect(page).to have_no_css(".generic-table--no-results-description") + it "shows a blankslate without description" do + within ".blankslate" do + expect(page).to have_heading(I18n.t(:backlogs_empty_title)) + expect(page).to have_no_text(I18n.t(:backlogs_empty_action_text)) + end end end end diff --git a/modules/backlogs/spec/features/stories_in_backlog_spec.rb b/modules/backlogs/spec/features/stories_in_backlog_spec.rb index 26ee04562ecf..5cc482d6688d 100644 --- a/modules/backlogs/spec/features/stories_in_backlog_spec.rb +++ b/modules/backlogs/spec/features/stories_in_backlog_spec.rb @@ -168,162 +168,5 @@ # Velocity is calculated by summing up all story points in a sprint backlogs_page .expect_velocity(sprint, 30) - - SeleniumHubWaiter.wait - # Creating a story - backlogs_page - .click_in_backlog_menu(sprint, "New Story") - - SeleniumHubWaiter.wait - backlogs_page - .edit_new_story(subject: "New story", - story_points: 10) - - new_story = nil - retry_block do - new_story = WorkPackage.find_by(subject: "New story") - raise "Expected story" unless new_story - end - - backlogs_page - .expect_story_in_sprint(new_story, sprint) - - # All positions will be unique in the sprint - expect(Story.where(version: sprint, type: story, project:).pluck(:position)) - .to contain_exactly(1, 2, 3) - - backlogs_page - .expect_stories_in_order(sprint, new_story, sprint_story1, sprint_story2) - - # Creating the story will update the velocity - backlogs_page - .expect_velocity(sprint, 40) - - # Editing in a sprint - - SeleniumHubWaiter.wait - backlogs_page - .edit_story(sprint_story1, - subject: "Altered story1", - story_points: 15) - - retry_block do - sprint_story1.reload - raise "Expected story to be renamed" unless sprint_story1.subject == "Altered story1" - end - - backlogs_page - .expect_for_story(sprint_story1, subject: "Altered story1") - - # Updating the story_points of a story will update the velocity of the sprint - backlogs_page - .expect_velocity(sprint, 45) - - SeleniumHubWaiter.wait - # Moving a story to top - backlogs_page - .drag_in_sprint(sprint_story1, new_story) - - backlogs_page - .expect_stories_in_order(sprint, sprint_story1, new_story, sprint_story2) - - expect(Story.where(version: sprint, type: story, project:).pluck(:position)) - .to contain_exactly(1, 2, 3) - - # Moving a story to bottom - backlogs_page - .drag_in_sprint(sprint_story1, sprint_story2, before: false) - - backlogs_page - .expect_stories_in_order(sprint, new_story, sprint_story2, sprint_story1) - - expect(Story.where(version: sprint, type: story, project:).pluck(:position)) - .to contain_exactly(1, 2, 3) - - # Moving a story to from the backlog to the sprint (3rd position) - - SeleniumHubWaiter.wait - backlogs_page - .drag_in_sprint(backlog_story1, sprint_story2, before: false) - - backlogs_page - .expect_stories_in_order(sprint, new_story, sprint_story2, backlog_story1, sprint_story1) - - expect(Story.where(version: sprint, type: story, project:).pluck(:position)) - .to contain_exactly(1, 2, 3, 4) - - # Available statuses when editing - - backlogs_page - .enter_edit_story_mode(backlog_story1) - - # The available statuses include those available by the workflow: - # Current and every reachable one - backlogs_page - .expect_status_options(backlog_story1, - [default_status, other_status]) - - SeleniumHubWaiter.wait - backlogs_page - .alter_attributes_in_edit_story_mode(backlog_story1, - subject: "Altered backlog story1", - status: other_status.name) - backlogs_page - .save_story_from_edit_mode(backlog_story1) - - retry_block do - backlog_story1.reload - raise "Expected story to be renamed" unless backlog_story1.subject == "Altered backlog story1" - end - - expect(backlog_story1.status) - .to eql other_status - - backlogs_page - .expect_for_story(backlog_story1, - subject: "Altered backlog story1", - status: other_status.name) - - SeleniumHubWaiter.wait - backlogs_page - .enter_edit_story_mode(backlog_story1) - - # Since we switched to other status, only the current status and the next one is available now. - backlogs_page - .expect_status_options(backlog_story1, - [other_status]) - - # Available statuses when editing and switching the type - backlogs_page - .alter_attributes_in_edit_story_mode(backlog_story1, - type: other_story) - # This will result in an error as the current status is not available - backlogs_page - .save_story_from_edit_mode(backlog_story1) - - backlogs_page - .expect_for_story(backlog_story1, - subject: "Altered backlog story1", - status: default_status.name, - type: other_story.name) - - # Clicking would lead to having the burndown chart opened in another tab - # which seems hard to test with selenium. - backlogs_page - .expect_in_backlog_menu(sprint, "Burndown Chart") - - # One can switch to the work package page by clicking on the id - # Clicking on it will open the wp in another tab which seems to trip up selenium. - backlogs_page - .expect_story_link_to_wp_page(sprint_story1) - - # Go to the index page of work packages within that sprint via the menu - backlogs_page - .click_in_backlog_menu(sprint, "Stories/Tasks") - - wp_table = Pages::WorkPackagesTable.new(project) - - wp_table - .expect_work_package_listed(new_story, sprint_story2, backlog_story1, sprint_story1) end end diff --git a/modules/backlogs/spec/models/backlog_spec.rb b/modules/backlogs/spec/models/backlog_spec.rb index c7fa23008393..8e3c4d2f75cc 100644 --- a/modules/backlogs/spec/models/backlog_spec.rb +++ b/modules/backlogs/spec/models/backlog_spec.rb @@ -53,4 +53,19 @@ end end end + + describe "ActiveModel naming" do + let(:sprint) { build_stubbed(:sprint) } + + subject(:instance) { described_class.new(sprint:, stories: []) } + + it "exposes an ActiveModel model_name" do + expect(described_class).to respond_to(:model_name) + expect(described_class.model_name).to respond_to(:param_key) + end + + it "implements #to_key" do + expect(instance).to respond_to(:to_key) + end + end end diff --git a/modules/backlogs/spec/routing/rb_master_backlogs_routing_spec.rb b/modules/backlogs/spec/routing/rb_master_backlogs_routing_spec.rb index bf96ed71ff03..b8ca73aab0a2 100644 --- a/modules/backlogs/spec/routing/rb_master_backlogs_routing_spec.rb +++ b/modules/backlogs/spec/routing/rb_master_backlogs_routing_spec.rb @@ -35,5 +35,16 @@ action: "index", project_id: "project_42") } + + it { + expect(get("/projects/project_42/backlogs/details/33")).to route_to( + controller: "rb_master_backlogs", + action: "split_view", + project_id: "project_42", + work_package_id: "33", + tab: :overview, + work_package_split_view: true + ) + } end end diff --git a/modules/backlogs/spec/routing/rb_sprints_routing_spec.rb b/modules/backlogs/spec/routing/rb_sprints_routing_spec.rb index 03f8f518e3da..1390a902423f 100644 --- a/modules/backlogs/spec/routing/rb_sprints_routing_spec.rb +++ b/modules/backlogs/spec/routing/rb_sprints_routing_spec.rb @@ -30,6 +30,24 @@ RSpec.describe RbSprintsController do describe "routing" do + it { + expect(get("/projects/project_42/sprints/21/edit_name")).to route_to( + controller: "rb_sprints", + action: "edit_name", + project_id: "project_42", + id: "21" + ) + } + + it { + expect(get("/projects/project_42/sprints/21/show_name")).to route_to( + controller: "rb_sprints", + action: "show_name", + project_id: "project_42", + id: "21" + ) + } + it { expect(put("/projects/project_42/sprints/21")).to route_to(controller: "rb_sprints", action: "update", diff --git a/modules/backlogs/spec/routing/rb_stories_routing_spec.rb b/modules/backlogs/spec/routing/rb_stories_routing_spec.rb index 14d137d7d6c5..c6dcb09166bd 100644 --- a/modules/backlogs/spec/routing/rb_stories_routing_spec.rb +++ b/modules/backlogs/spec/routing/rb_stories_routing_spec.rb @@ -37,6 +37,26 @@ sprint_id: "21") } + it { + expect(put("/projects/project_42/sprints/21/stories/85/move")).to route_to( + controller: "rb_stories", + action: "move", + project_id: "project_42", + sprint_id: "21", + id: "85" + ) + } + + it { + expect(post("/projects/project_42/sprints/21/stories/85/reorder")).to route_to( + controller: "rb_stories", + action: "reorder", + project_id: "project_42", + sprint_id: "21", + id: "85" + ) + } + it { expect(put("/projects/project_42/sprints/21/stories/85")).to route_to(controller: "rb_stories", action: "update", diff --git a/modules/backlogs/spec/support/pages/backlogs.rb b/modules/backlogs/spec/support/pages/backlogs.rb index 7e8fe177624e..c41a5cbc19ea 100644 --- a/modules/backlogs/spec/support/pages/backlogs.rb +++ b/modules/backlogs/spec/support/pages/backlogs.rb @@ -45,8 +45,8 @@ def enter_edit_story_mode(story, text: nil) end def enter_edit_backlog_mode(backlog) - within_backlog(backlog) do - find(".start_date.editable").click + within_backlog_menu(backlog) do |menu| + menu.find(:menuitem, "Edit sprint").click end end @@ -77,11 +77,11 @@ def alter_attributes_in_edit_backlog_mode(backlog, attributes) attributes.each do |key, value| case key when :name - find("input[name=name]").set value + fill_in "Name", with: value when :start_date - find("input[name=start_date]").set value + fill_in "Start date", with: value when :effective_date - find("input[name=effective_date]").set value + fill_in "Finish date", with: value else raise NotImplementedError end @@ -109,10 +109,7 @@ def save_story_from_edit_mode(story) def save_backlog_from_edit_mode(backlog) within_backlog(backlog) do - find("input[name=name]").native.send_key :return - - expect(page) - .to have_css(".start_date.editable") + find_field("Name").send_keys :return end end @@ -136,17 +133,9 @@ def edit_story(story, attributes) save_story_from_edit_mode(story) end - def edit_new_story(attributes) - within(".story.editing") do - alter_attributes_in_edit_story_mode(nil, attributes) - - save_story_from_edit_mode(nil) - end - end - def click_in_backlog_menu(backlog, item_name) within_backlog_menu(backlog) do |menu| - menu.find(".item", text: item_name).click + menu.find(:menuitem, text: item_name).click end end @@ -160,7 +149,7 @@ def drag_in_sprint(moved, target, before: true) def fold_backlog(backlog) within_backlog(backlog) do - find(".toggler").click + find(:button, accessible_name: "Collapse/Expand #{backlog.name}").click end end @@ -252,12 +241,9 @@ def expect_in_backlog_menu(backlog, item_name) end def expect_and_dismiss_error(message) - within ".ui-dialog" do - expect(page) - .to have_content message + expect(page).to have_content message - click_button("OK") - end + click_on "Cancel" end def path @@ -266,10 +252,9 @@ def path def within_backlog_menu(backlog, &) within_backlog(backlog) do - menu = find(".backlog-menu") - menu.click + find(:button, accessible_name: "Backlog actions").click - yield menu + within(:menu, &) end end diff --git a/modules/backlogs/spec/views/rb_burndown_charts/show_spec.rb b/modules/backlogs/spec/views/rb_burndown_charts/show_spec.rb index d7e09fa8f8cc..a950505fdf6e 100644 --- a/modules/backlogs/spec/views/rb_burndown_charts/show_spec.rb +++ b/modules/backlogs/spec/views/rb_burndown_charts/show_spec.rb @@ -117,7 +117,7 @@ render expect(view).to render_template(partial: "_burndown", count: 0) - expect(rendered).to include(I18n.t("backlogs.no_burndown_data")) + expect(rendered).to include(I18n.t("rb_burndown_charts.show.blankslate_title")) end end end From 73d891d5d379f259c35af83404dfb0848f17aedd Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 24 Jan 2026 21:46:38 -0300 Subject: [PATCH 02/57] Burndown chart: Force Turbo to perform page reload --- modules/backlogs/app/views/rb_burndown_charts/show.html.erb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/modules/backlogs/app/views/rb_burndown_charts/show.html.erb b/modules/backlogs/app/views/rb_burndown_charts/show.html.erb index dfc5f70dd02d..86ea72309d8a 100644 --- a/modules/backlogs/app/views/rb_burndown_charts/show.html.erb +++ b/modules/backlogs/app/views/rb_burndown_charts/show.html.erb @@ -29,6 +29,10 @@ See COPYRIGHT and LICENSE files for more details. <% html_title @sprint.name %> +<%= content_for :header_tags do %> + +<% end -%> + <%= render(Backlogs::SprintPageHeaderComponent.new(sprint: @sprint, project: @project)) %> <% if @burndown %> From 3838dd6091c6496c6f51393e813b57313a7c6899 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 27 Jan 2026 12:49:30 -0300 Subject: [PATCH 03/57] UX/UI improvements: responsiveness, story layout Multiple design fixes based on UX/UI feedback: - improvements to Backlogs header responsiveness (including form). - harmonization of story row layout with other modules using Border Box lists (i.e. Meetings). --- app/components/primer/component_helpers.rb | 41 --------- frontend/src/assets/sass/backlogs/_index.sass | 5 ++ .../assets/sass/backlogs/_master_backlog.sass | 83 +++++++++++++++++ .../src/global_styles/primer/_overrides.sass | 14 ++- .../backlogs/backlog_component.html.erb | 2 +- .../components/backlogs/backlog_component.rb | 2 +- .../backlog_header_component.html.erb | 80 +++++++---------- .../backlogs/backlog_header_component.rb | 2 +- .../backlogs/collapsible_component.html.erb | 61 +++++++++++++ .../backlogs/collapsible_component.rb | 90 +++++++++++++++++++ .../backlogs/sprint_page_header_component.rb | 1 - .../backlogs/story_component.html.erb | 24 +++-- .../components/backlogs/story_component.rb | 2 +- modules/backlogs/config/locales/en.yml | 4 + .../backlogs/backlog_component_spec.rb | 2 +- .../backlogs/backlog_header_component_spec.rb | 2 +- 16 files changed, 307 insertions(+), 108 deletions(-) delete mode 100644 app/components/primer/component_helpers.rb create mode 100644 modules/backlogs/app/components/backlogs/collapsible_component.html.erb create mode 100644 modules/backlogs/app/components/backlogs/collapsible_component.rb diff --git a/app/components/primer/component_helpers.rb b/app/components/primer/component_helpers.rb deleted file mode 100644 index 603fde56b4d8..000000000000 --- a/app/components/primer/component_helpers.rb +++ /dev/null @@ -1,41 +0,0 @@ -# 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 Primer - module ComponentHelpers - def stack(**, &) - render(Primer::Alpha::Stack.new(**), &) - end - - def stack_item(**, &) - render(Primer::Alpha::StackItem.new(**), &) - end - end -end diff --git a/frontend/src/assets/sass/backlogs/_index.sass b/frontend/src/assets/sass/backlogs/_index.sass index bb1eb5d44a78..0f2b73435f43 100644 --- a/frontend/src/assets/sass/backlogs/_index.sass +++ b/frontend/src/assets/sass/backlogs/_index.sass @@ -33,6 +33,11 @@ * See COPYRIGHT and LICENSE files for more details. */ +// Variables +@import "../../../global_styles/openproject/_variable_defaults.scss" + +@import "../../../global_styles/openproject/_variables.sass" + @import global @import global_print @import jqplot diff --git a/frontend/src/assets/sass/backlogs/_master_backlog.sass b/frontend/src/assets/sass/backlogs/_master_backlog.sass index 576744dfda7f..a69e83b511d4 100644 --- a/frontend/src/assets/sass/backlogs/_master_backlog.sass +++ b/frontend/src/assets/sass/backlogs/_master_backlog.sass @@ -31,3 +31,86 @@ li[data-empty-list-item] li[data-empty-list-item]:only-child display: list-item + +$op-backlogs-header--points-min-width: 5rem + +.op-backlogs-header + display: grid + grid-template-columns: 1fr minmax($op-backlogs-header--points-min-width, max-content) auto + grid-template-areas: "collapsible points menu" + align-items: center + +.op-backlogs-header--collapsible + margin-left: calc(var(--stack-padding-normal) / 2) + +.op-backlogs-header--points + margin-left: var(--stack-gap-normal) + +.op-backlogs-header--menu + margin-left: var(--stack-gap-normal) + +.op-backlogs-collapsible + display: flex + flex-wrap: flex + align-items: center + column-gap: var(--stack-gap-normal) + row-gap: var(--base-size-4) + + &--title-line + display: flex + align-items: center + gap: var(--stack-gap-condensed) + flex: 1 + min-width: fit-content + + &--description + display: inline + white-space: nowrap + + &--toggle + svg + transition: opacity 120ms ease + transform-origin: center + + &[hidden] + opacity: 0 + +@media screen and (max-width: $breakpoint-sm) + .op-backlogs-collapsible + flex-direction: column + align-items: flex-start + + &--description + [data-collapsed] & + display: none + +.op-backlogs-story + display: grid + grid-template-columns: var(--control-xsmall-size) 1fr minmax($op-backlogs-header--points-min-width, max-content) auto + grid-template-rows: auto auto + grid-template-areas: "drag_handle info_line points menu" "drag_handle subject subject subject" + align-items: center + margin-bottom: var(--base-size-4) + +.op-backlogs-story--drag_handle + align-self: start + display: flex + padding-top: var(--base-size-8) + +.op-backlogs-story--drag_handle_button + padding: var(--base-size-4) + +.op-backlogs-story--info_line + align-self: end + margin-bottom: var(--base-size-4) + +.op-backlogs-story--points + margin-left: var(--stack-gap-normal) + +.op-backlogs-story--menu + margin-left: var(--stack-gap-normal) + +.op-backlogs-story--subject + align-self: start // Align to top of second row + word-wrap: break-word + overflow-wrap: break-word diff --git a/frontend/src/global_styles/primer/_overrides.sass b/frontend/src/global_styles/primer/_overrides.sass index f5c910305917..c79dc6a82b4c 100644 --- a/frontend/src/global_styles/primer/_overrides.sass +++ b/frontend/src/global_styles/primer/_overrides.sass @@ -160,8 +160,14 @@ ul.SegmentedControl, .Box-row--clickable cursor: pointer -.Box-row--draggable - padding-left: calc(var(--stack-padding-normal) / 2) +.Box-row:is(.Box-row--draggable) + padding-left: 0 -.Box-header--collapsible - padding-left: calc(var(--stack-padding-normal) / 2) + .DragHandle + visibility: hidden + + &:hover, + &:focus-visible, + &:focus-within + .DragHandle + visibility: visible diff --git a/modules/backlogs/app/components/backlogs/backlog_component.html.erb b/modules/backlogs/app/components/backlogs/backlog_component.html.erb index bca17b3bff19..05f5c412acbd 100644 --- a/modules/backlogs/app/components/backlogs/backlog_component.html.erb +++ b/modules/backlogs/app/components/backlogs/backlog_component.html.erb @@ -29,7 +29,7 @@ See COPYRIGHT and LICENSE files for more details. <%= component_wrapper(tag: :section) do %> <%= render(Primer::Beta::BorderBox.new(**@system_arguments)) do |border_box| %> - <% border_box.with_header(classes: "Box-header--collapsible") do %> + <% border_box.with_header do %> <%= render(Backlogs::BacklogHeaderComponent.new(backlog:, project: @project, folded: folded?)) %> <% end %> <% border_box.with_row(data: { empty_list_item: true }) do %> diff --git a/modules/backlogs/app/components/backlogs/backlog_component.rb b/modules/backlogs/app/components/backlogs/backlog_component.rb index e313299ba649..2b72005c4fca 100644 --- a/modules/backlogs/app/components/backlogs/backlog_component.rb +++ b/modules/backlogs/app/components/backlogs/backlog_component.rb @@ -31,7 +31,6 @@ module Backlogs class BacklogComponent < ApplicationComponent include Primer::AttributesHelper - include Primer::ComponentHelpers include OpTurbo::Streamable include RbCommonHelper @@ -49,6 +48,7 @@ def initialize(backlog:, project:, current_user: User.current, **system_argument @system_arguments = system_arguments @system_arguments[:id] = dom_id(backlog) @system_arguments[:list_id] = "#{@system_arguments[:id]}-list" + @system_arguments[:padding] = :condensed @system_arguments[:data] = merge_data( @system_arguments, { data: drop_target_config } diff --git a/modules/backlogs/app/components/backlogs/backlog_header_component.html.erb b/modules/backlogs/app/components/backlogs/backlog_header_component.html.erb index 05729bfb7ce3..50cf85034798 100644 --- a/modules/backlogs/app/components/backlogs/backlog_header_component.html.erb +++ b/modules/backlogs/app/components/backlogs/backlog_header_component.html.erb @@ -29,66 +29,50 @@ See COPYRIGHT and LICENSE files for more details. <%= component_wrapper(tag: :header) do %> <% if show? %> - <%= - stack( - tag: :"collapsible-header", - direction: :horizontal, - align: :center, - justify: :space_between, - classes: class_names( - "CollapsibleHeader", - "CollapsibleHeader--collapsed" => @collapsed - ), - data: { collapsed: (@collapsed if @collapsed) } - ) do - %> - <%= stack_item(classes: "hide-when-print") do %> + <%= grid_layout("op-backlogs-header", tag: :div) do |grid| %> + <% grid.with_area(:collapsible) do %> <%= render( - Primer::BaseComponent.new( - tag: :div, - role: "button", - tabindex: 0, - classes: "CollapsibleSection--triggerArea", + Backlogs::CollapsibleComponent.new( + collapsible_id: "#{dom_id(backlog)}-list", + toggle_label: t(".label_toggle_backlog", name: sprint.name), + collapsed: + ) + ) do |collapsible| + collapsible.with_title { sprint.name } + collapsible.with_count( + scheme: :default, + count: story_count, + round: true, aria: { - label: t(".label_toggle_backlog", name: sprint.name), - controls: "#{dom_id(backlog)}-list", - expanded: !@collapsed - }, - data: { - target: "collapsible-header.triggerElement", - action: "click:collapsible-header#toggle keydown:collapsible-header#toggleViaKeyboard" + label: t(".label_story_count", count: story_count), + live: "polite" } ) - ) do + collapsible.with_description(role: "group") do + format_date_range(date_range) + end + end %> - <%= render(Primer::Beta::Octicon.new(icon: "chevron-up", hidden: @collapsed, data: { target: "collapsible-header.arrowUp" })) %> - <%= render(Primer::Beta::Octicon.new(icon: "chevron-down", hidden: !@collapsed, data: { target: "collapsible-header.arrowDown" })) %> - <% end %> <% end %> - <%= stack_item(grow: true) do %> - <%= stack(direction: :horizontal, align: :center) do %> - <%= render Primer::Beta::Truncate.new(tag: :h4, classes: "Box-title") do %> - <%= sprint.name %> - <% end %> - <%= stack_item do %> - <%= render(Primer::Beta::Counter.new(count: story_count, round: true)) %> - <% end %> - - <%= stack_item(grow: true) do %> - <%= render(Primer::Beta::Text.new(color: :subtle, role: "group")) do %> - <%= format_date_range(date_range) %> - <% end %> - <% end %> + <% grid.with_area(:points) do %> + <%= + render( + Primer::Beta::Truncate.new( + color: :subtle, + classes: "velocity", + aria: { live: "polite" } + ) + ) do + %> + <%= t(:"backlogs.points", count: story_points) %> <% end %> <% end %> - <%= render(Primer::Beta::Truncate.new(color: :subtle, classes: "velocity")) do %> - <%= t(:"backlogs.points", count: story_points) %> + <% grid.with_area(:menu) do %> + <%= render(Backlogs::BacklogMenuComponent.new(backlog:, project: @project)) %> <% end %> - - <%= render(Backlogs::BacklogMenuComponent.new(backlog:, project: @project)) %> <% end %> <% else %> <%= diff --git a/modules/backlogs/app/components/backlogs/backlog_header_component.rb b/modules/backlogs/app/components/backlogs/backlog_header_component.rb index 0e73c686412c..fd81a433cb78 100644 --- a/modules/backlogs/app/components/backlogs/backlog_header_component.rb +++ b/modules/backlogs/app/components/backlogs/backlog_header_component.rb @@ -30,8 +30,8 @@ module Backlogs class BacklogHeaderComponent < ApplicationComponent + include OpPrimer::ComponentHelpers include OpTurbo::Streamable - include Primer::ComponentHelpers include Redmine::I18n include RbCommonHelper diff --git a/modules/backlogs/app/components/backlogs/collapsible_component.html.erb b/modules/backlogs/app/components/backlogs/collapsible_component.html.erb new file mode 100644 index 000000000000..78e27123b5dd --- /dev/null +++ b/modules/backlogs/app/components/backlogs/collapsible_component.html.erb @@ -0,0 +1,61 @@ +<%# -- 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. + +++# %> + +<%= render(Primer::BaseComponent.new(**@system_arguments)) do %> + <%= render(Primer::BaseComponent.new(tag: :div, classes: "op-backlogs-collapsible")) do %> + <%= render(Primer::BaseComponent.new(tag: :div, classes: "op-backlogs-collapsible--title-line")) do %> + <%= title %> + <%= count %> + <%= + render( + Primer::BaseComponent.new( + tag: :div, + role: "button", + tabindex: 0, + classes: "op-backlogs-collapsible--toggle CollapsibleSection--triggerArea", + aria: { + label: @toggle_label, + controls: @collapsible_id, + expanded: !@collapsed + }, + data: { + target: "collapsible-header.triggerElement", + action: "click:collapsible-header#toggle keydown:collapsible-header#toggleViaKeyboard" + } + ) + ) do + %> + <%= render(Primer::Beta::Octicon.new(icon: "chevron-up", hidden: @collapsed, data: { target: "collapsible-header.arrowUp" })) %> + <%= render(Primer::Beta::Octicon.new(icon: "chevron-down", hidden: !@collapsed, data: { target: "collapsible-header.arrowDown" })) %> + <% end %> + <% end %> + + <%= description %> + <% end %> +<% end %> diff --git a/modules/backlogs/app/components/backlogs/collapsible_component.rb b/modules/backlogs/app/components/backlogs/collapsible_component.rb new file mode 100644 index 000000000000..3ab049402716 --- /dev/null +++ b/modules/backlogs/app/components/backlogs/collapsible_component.rb @@ -0,0 +1,90 @@ +# 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 Backlogs + class CollapsibleComponent < Primer::Component + include OpPrimer::ComponentHelpers + + renders_one :title, ->(**system_arguments) { + system_arguments[:classes] = class_names( + system_arguments[:classes], + "op-backlogs-collapsible--title", + "Box-title" + ) + + Primer::Beta::Truncate.new(tag: :h3, **system_arguments) + } + + renders_one :count, ->(**system_arguments) { + system_arguments[:mr] ||= 2 + system_arguments[:scheme] ||= :primary + system_arguments[:classes] = class_names( + system_arguments[:classes], + "op-backlogs-collapsible--count" + ) + + Primer::Beta::Counter.new(**system_arguments) + } + + renders_one :description, ->(**system_arguments) { + system_arguments[:color] ||= :subtle + system_arguments[:hidden] = @collapsed + system_arguments[:classes] = class_names( + system_arguments[:classes], + "op-backlogs-collapsible--description" + ) + + Primer::Beta::Text.new(**system_arguments) + } + + def initialize(collapsible_id:, toggle_label:, collapsed: false, **system_arguments) + super() + + @collapsible_id = collapsible_id + @toggle_label = toggle_label + @collapsed = collapsed + + @system_arguments = deny_tag_argument(**system_arguments) + @system_arguments[:tag] = :"collapsible-header" + @system_arguments[:classes] = class_names( + system_arguments[:classes], + "CollapsibleHeader", + "CollapsibleHeader--collapsed" => @collapsed + ) + if @collapsed + @system_arguments[:data] = merge_data( + @system_arguments, { + data: { collapsed: @collapsed } + } + ) + end + end + end +end diff --git a/modules/backlogs/app/components/backlogs/sprint_page_header_component.rb b/modules/backlogs/app/components/backlogs/sprint_page_header_component.rb index 3fcba09e6188..c3e0fdb12046 100644 --- a/modules/backlogs/app/components/backlogs/sprint_page_header_component.rb +++ b/modules/backlogs/app/components/backlogs/sprint_page_header_component.rb @@ -30,7 +30,6 @@ module Backlogs class SprintPageHeaderComponent < ApplicationComponent - include Primer::ComponentHelpers include ApplicationHelper include RbCommonHelper diff --git a/modules/backlogs/app/components/backlogs/story_component.html.erb b/modules/backlogs/app/components/backlogs/story_component.html.erb index d9cbaf2a9bca..a71589f147b3 100644 --- a/modules/backlogs/app/components/backlogs/story_component.html.erb +++ b/modules/backlogs/app/components/backlogs/story_component.html.erb @@ -27,12 +27,13 @@ See COPYRIGHT and LICENSE files for more details. ++# %> -<%= stack(tag: :article, direction: :horizontal, justify: :space_between) do %> - <%= stack_item(classes: "hide-when-print") do %> +<%= grid_layout("op-backlogs-story", tag: :article) do |grid| %> + <% grid.with_area(:drag_handle, classes: "hide-when-print") do %> <%= render( Primer::OpenProject::DragHandle.new( role: "button", + classes: "op-backlogs-story--drag_handle_button", tabindex: 0, aria: { label: t(".label_drag_story", name: story.subject) @@ -42,16 +43,23 @@ See COPYRIGHT and LICENSE files for more details. %> <% end %> - <%= stack_item(grow: true) do %> + <% grid.with_area(:info_line) do %> <%= render(WorkPackages::InfoLineComponent.new(work_package: story)) %> - <%= render(Primer::Beta::Text.new(font_weight: :semibold)) do %> - <%= story.subject %> + <% end %> + + <% grid.with_area(:points) do %> + <%= render(Primer::Beta::Truncate.new(color: :subtle, mt: 1)) do %> + <%= t(:"backlogs.points", count: story_points) %> <% end %> <% end %> - <%= render(Primer::Beta::Truncate.new(color: :subtle, mt: 1)) do %> - <%= t(:"backlogs.points", count: story_points) %> + <% grid.with_area(:menu) do %> + <%= render(Backlogs::StoryMenuComponent.new(story:, sprint:, max_position:)) %> <% end %> - <%= render(Backlogs::StoryMenuComponent.new(story:, sprint:, max_position:)) %> + <% grid.with_area(:subject) do %> + <%= render(Primer::Beta::Text.new(font_weight: :semibold)) do %> + <%= story.subject %> + <% end %> + <% end %> <% end %> diff --git a/modules/backlogs/app/components/backlogs/story_component.rb b/modules/backlogs/app/components/backlogs/story_component.rb index 2a22543cdcd9..39c37f48a7f9 100644 --- a/modules/backlogs/app/components/backlogs/story_component.rb +++ b/modules/backlogs/app/components/backlogs/story_component.rb @@ -30,7 +30,7 @@ module Backlogs class StoryComponent < ApplicationComponent - include Primer::ComponentHelpers + include OpPrimer::ComponentHelpers attr_reader :story, :sprint, :max_position, :current_user diff --git a/modules/backlogs/config/locales/en.yml b/modules/backlogs/config/locales/en.yml index d6529e816a53..83f1013702e7 100644 --- a/modules/backlogs/config/locales/en.yml +++ b/modules/backlogs/config/locales/en.yml @@ -107,6 +107,10 @@ en: backlog_header_component: label_toggle_backlog: "Collapse/Expand %{name}" + label_story_count: + zero: "No stories in backlog" + one: "%{count} story in backlog" + other: "%{count} stories in backlog" backlog_menu_component: label_actions: "Backlog actions" diff --git a/modules/backlogs/spec/components/backlogs/backlog_component_spec.rb b/modules/backlogs/spec/components/backlogs/backlog_component_spec.rb index ed362f8dae1b..52951ec51b80 100644 --- a/modules/backlogs/spec/components/backlogs/backlog_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/backlog_component_spec.rb @@ -94,7 +94,7 @@ def render_component it "renders BacklogHeaderComponent in header" do render_component - expect(page).to have_css(".Box-header h4", text: "Sprint 1") + expect(page).to have_css(".Box-header h3", text: "Sprint 1") end it "renders StoryComponent for each story" do diff --git a/modules/backlogs/spec/components/backlogs/backlog_header_component_spec.rb b/modules/backlogs/spec/components/backlogs/backlog_header_component_spec.rb index 01023c788647..cd7e44a1d73c 100644 --- a/modules/backlogs/spec/components/backlogs/backlog_header_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/backlog_header_component_spec.rb @@ -91,7 +91,7 @@ def render_component(state: :show, folded: false) it "displays sprint name in h4" do render_component - expect(page).to have_css("h4", text: "Sprint 1") + expect(page).to have_css("h3", text: "Sprint 1") end it "shows story count via Primer::Beta::Counter" do From 8688dfd5c253e9f699a58d497cbfa4489423ff85 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 29 Jan 2026 14:09:44 -0300 Subject: [PATCH 04/57] Make Backlog Header form responsive Display on multiple lines on smaller viewports. --- .../assets/sass/backlogs/_master_backlog.sass | 6 ++++ .../backlog_header_component.html.erb | 3 +- .../app/forms/backlogs/backlog_header_form.rb | 28 ++++++++++--------- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/frontend/src/assets/sass/backlogs/_master_backlog.sass b/frontend/src/assets/sass/backlogs/_master_backlog.sass index a69e83b511d4..385f5e5c3438 100644 --- a/frontend/src/assets/sass/backlogs/_master_backlog.sass +++ b/frontend/src/assets/sass/backlogs/_master_backlog.sass @@ -114,3 +114,9 @@ $op-backlogs-header--points-min-width: 5rem align-self: start // Align to top of second row word-wrap: break-word overflow-wrap: break-word + +@media screen and (min-width: $breakpoint-sm) + .op-backlogs-header-form + .FormControl-spacingWrapper + flex-direction: row + column-gap: 0.5rem diff --git a/modules/backlogs/app/components/backlogs/backlog_header_component.html.erb b/modules/backlogs/app/components/backlogs/backlog_header_component.html.erb index 50cf85034798..b9939738804d 100644 --- a/modules/backlogs/app/components/backlogs/backlog_header_component.html.erb +++ b/modules/backlogs/app/components/backlogs/backlog_header_component.html.erb @@ -79,7 +79,8 @@ See COPYRIGHT and LICENSE files for more details. primer_form_with( url: backlogs_project_sprint_path(project, sprint), model: sprint, - method: :patch + method: :patch, + class: "op-backlogs-header-form" ) do |f| render(Backlogs::BacklogHeaderForm.new(f, cancel_path: show_name_backlogs_project_sprint_path(project, sprint))) end diff --git a/modules/backlogs/app/forms/backlogs/backlog_header_form.rb b/modules/backlogs/app/forms/backlogs/backlog_header_form.rb index 0a258dd8dd71..f8b298d9411e 100644 --- a/modules/backlogs/app/forms/backlogs/backlog_header_form.rb +++ b/modules/backlogs/app/forms/backlogs/backlog_header_form.rb @@ -33,17 +33,17 @@ class BacklogHeaderForm < ApplicationForm attr_reader :cancel_path form do |f| - f.group(layout: :horizontal) do |group| - group.text_field( - name: :name, - label: attribute_name(:name), - placeholder: attribute_name(:name), - visually_hide_label: true, - autofocus: true, - autocomplete: "off" - ) + f.text_field( + name: :name, + label: attribute_name(:name), + placeholder: attribute_name(:name), + visually_hide_label: true, + autofocus: true, + autocomplete: "off" + ) - group.single_date_picker( + f.group(layout: :horizontal) do |dates| + dates.single_date_picker( name: :start_date, label: attribute_name(:start_date), placeholder: attribute_name(:start_date), @@ -51,7 +51,7 @@ class BacklogHeaderForm < ApplicationForm leading_visual: { icon: :calendar }, datepicker_options: {} ) - group.single_date_picker( + dates.single_date_picker( name: :effective_date, label: attribute_name(:effective_date), placeholder: attribute_name(:effective_date), @@ -59,9 +59,11 @@ class BacklogHeaderForm < ApplicationForm leading_visual: { icon: :calendar }, datepicker_options: {} ) + end - group.submit(scheme: :primary, name: :submit, label: I18n.t(:button_save)) - group.button( + f.group(layout: :horizontal) do |buttons| + buttons.submit(scheme: :primary, name: :submit, label: I18n.t(:button_save)) + buttons.button( scheme: :secondary, name: :cancel, label: I18n.t(:button_cancel), From cb72e00d2b5f13d44b999f2faa8488c5ea699ad1 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 29 Jan 2026 15:17:08 -0300 Subject: [PATCH 05/57] Remove unused jquery.flot vendored/npm dependency --- frontend/package-lock.json | 11 - frontend/package.json | 1 - frontend/src/vendor/jquery.flot/LICENSE | 22 - frontend/src/vendor/jquery.flot/excanvas.js | 1427 --------- .../src/vendor/jquery.flot/excanvas.min.js | 1 - .../vendor/jquery.flot/jquery.colorhelpers.js | 179 -- .../jquery.flot/jquery.colorhelpers.min.js | 1 - .../jquery.flot/jquery.flot.crosshair.js | 167 -- .../jquery.flot/jquery.flot.crosshair.min.js | 1 - .../jquery.flot/jquery.flot.fillbetween.js | 183 -- .../jquery.flot.fillbetween.min.js | 1 - .../vendor/jquery.flot/jquery.flot.image.js | 238 -- .../jquery.flot/jquery.flot.image.min.js | 1 - .../src/vendor/jquery.flot/jquery.flot.js | 2599 ----------------- .../src/vendor/jquery.flot/jquery.flot.min.js | 6 - .../jquery.flot/jquery.flot.navigate.js | 336 --- .../jquery.flot/jquery.flot.navigate.min.js | 1 - .../src/vendor/jquery.flot/jquery.flot.pie.js | 748 ----- .../vendor/jquery.flot/jquery.flot.pie.min.js | 1 - .../vendor/jquery.flot/jquery.flot.resize.js | 60 - .../jquery.flot/jquery.flot.resize.min.js | 1 - .../jquery.flot/jquery.flot.selection.js | 344 --- .../jquery.flot/jquery.flot.selection.min.js | 1 - .../vendor/jquery.flot/jquery.flot.stack.js | 184 -- .../jquery.flot/jquery.flot.stack.min.js | 1 - .../vendor/jquery.flot/jquery.flot.symbol.js | 70 - .../jquery.flot/jquery.flot.symbol.min.js | 1 - .../jquery.flot/jquery.flot.threshold.js | 103 - .../jquery.flot/jquery.flot.threshold.min.js | 1 - 29 files changed, 6690 deletions(-) delete mode 100644 frontend/src/vendor/jquery.flot/LICENSE delete mode 100644 frontend/src/vendor/jquery.flot/excanvas.js delete mode 100644 frontend/src/vendor/jquery.flot/excanvas.min.js delete mode 100644 frontend/src/vendor/jquery.flot/jquery.colorhelpers.js delete mode 100644 frontend/src/vendor/jquery.flot/jquery.colorhelpers.min.js delete mode 100644 frontend/src/vendor/jquery.flot/jquery.flot.crosshair.js delete mode 100644 frontend/src/vendor/jquery.flot/jquery.flot.crosshair.min.js delete mode 100644 frontend/src/vendor/jquery.flot/jquery.flot.fillbetween.js delete mode 100644 frontend/src/vendor/jquery.flot/jquery.flot.fillbetween.min.js delete mode 100644 frontend/src/vendor/jquery.flot/jquery.flot.image.js delete mode 100644 frontend/src/vendor/jquery.flot/jquery.flot.image.min.js delete mode 100644 frontend/src/vendor/jquery.flot/jquery.flot.js delete mode 100644 frontend/src/vendor/jquery.flot/jquery.flot.min.js delete mode 100644 frontend/src/vendor/jquery.flot/jquery.flot.navigate.js delete mode 100644 frontend/src/vendor/jquery.flot/jquery.flot.navigate.min.js delete mode 100644 frontend/src/vendor/jquery.flot/jquery.flot.pie.js delete mode 100644 frontend/src/vendor/jquery.flot/jquery.flot.pie.min.js delete mode 100644 frontend/src/vendor/jquery.flot/jquery.flot.resize.js delete mode 100644 frontend/src/vendor/jquery.flot/jquery.flot.resize.min.js delete mode 100644 frontend/src/vendor/jquery.flot/jquery.flot.selection.js delete mode 100644 frontend/src/vendor/jquery.flot/jquery.flot.selection.min.js delete mode 100644 frontend/src/vendor/jquery.flot/jquery.flot.stack.js delete mode 100644 frontend/src/vendor/jquery.flot/jquery.flot.stack.min.js delete mode 100644 frontend/src/vendor/jquery.flot/jquery.flot.symbol.js delete mode 100644 frontend/src/vendor/jquery.flot/jquery.flot.symbol.min.js delete mode 100644 frontend/src/vendor/jquery.flot/jquery.flot.threshold.js delete mode 100644 frontend/src/vendor/jquery.flot/jquery.flot.threshold.min.js diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4dc0f2c2dbd5..1d4491fcc6a3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -92,7 +92,6 @@ "jquery": "^3.7.1", "jquery.caret": "^0.3.1", "jquery.cookie": "^1.4.1", - "jquery.flot": "^0.8.3", "json5": "^2.2.2", "lit-html": "^3.3.2", "lodash": "^4.17.23", @@ -16957,11 +16956,6 @@ "integrity": "sha512-c/hZOOL+8VSw/FkTVH637gS1/6YzMSCROpTZ2qBYwJ7s7sHajU7uBkSSiE5+GXWwrfCCyO+jsYjUQ7Hs2rIxAA==", "license": "MIT" }, - "node_modules/jquery.flot": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/jquery.flot/-/jquery.flot-0.8.3.tgz", - "integrity": "sha512-/tEE8J5NjwvStHDaCHkvTJpD7wDS4hE1OEL8xEmhgQfUe0gLUem923PIceNez1mz4yBNx6Hjv7pJcowLNd+nbg==" - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -36585,11 +36579,6 @@ "resolved": "https://registry.npmjs.org/jquery.cookie/-/jquery.cookie-1.4.1.tgz", "integrity": "sha512-c/hZOOL+8VSw/FkTVH637gS1/6YzMSCROpTZ2qBYwJ7s7sHajU7uBkSSiE5+GXWwrfCCyO+jsYjUQ7Hs2rIxAA==" }, - "jquery.flot": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/jquery.flot/-/jquery.flot-0.8.3.tgz", - "integrity": "sha512-/tEE8J5NjwvStHDaCHkvTJpD7wDS4hE1OEL8xEmhgQfUe0gLUem923PIceNez1mz4yBNx6Hjv7pJcowLNd+nbg==" - }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 18a4f4d79f6c..1987cf818319 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -147,7 +147,6 @@ "jquery": "^3.7.1", "jquery.caret": "^0.3.1", "jquery.cookie": "^1.4.1", - "jquery.flot": "^0.8.3", "json5": "^2.2.2", "lit-html": "^3.3.2", "lodash": "^4.17.23", diff --git a/frontend/src/vendor/jquery.flot/LICENSE b/frontend/src/vendor/jquery.flot/LICENSE deleted file mode 100644 index 07d5b2094d15..000000000000 --- a/frontend/src/vendor/jquery.flot/LICENSE +++ /dev/null @@ -1,22 +0,0 @@ -Copyright (c) 2007-2009 IOLA and Ole Laursen - -Permission is hereby granted, free of charge, to any person -obtaining a copy of this software and associated documentation -files (the "Software"), to deal in the Software without -restriction, including without limitation the rights to use, -copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -OTHER DEALINGS IN THE SOFTWARE. diff --git a/frontend/src/vendor/jquery.flot/excanvas.js b/frontend/src/vendor/jquery.flot/excanvas.js deleted file mode 100644 index c40d6f7014d8..000000000000 --- a/frontend/src/vendor/jquery.flot/excanvas.js +++ /dev/null @@ -1,1427 +0,0 @@ -// Copyright 2006 Google Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - - -// Known Issues: -// -// * Patterns only support repeat. -// * Radial gradient are not implemented. The VML version of these look very -// different from the canvas one. -// * Clipping paths are not implemented. -// * Coordsize. The width and height attribute have higher priority than the -// width and height style values which isn't correct. -// * Painting mode isn't implemented. -// * Canvas width/height should is using content-box by default. IE in -// Quirks mode will draw the canvas using border-box. Either change your -// doctype to HTML5 -// (http://www.whatwg.org/specs/web-apps/current-work/#the-doctype) -// or use Box Sizing Behavior from WebFX -// (http://webfx.eae.net/dhtml/boxsizing/boxsizing.html) -// * Non uniform scaling does not correctly scale strokes. -// * Filling very large shapes (above 5000 points) is buggy. -// * Optimize. There is always room for speed improvements. - -// Only add this code if we do not already have a canvas implementation -if (!document.createElement('canvas').getContext) { - -(function() { - - // alias some functions to make (compiled) code shorter - var m = Math; - var mr = m.round; - var ms = m.sin; - var mc = m.cos; - var abs = m.abs; - var sqrt = m.sqrt; - - // this is used for sub pixel precision - var Z = 10; - var Z2 = Z / 2; - - /** - * This funtion is assigned to the elements as element.getContext(). - * @this {HTMLElement} - * @return {CanvasRenderingContext2D_} - */ - function getContext() { - return this.context_ || - (this.context_ = new CanvasRenderingContext2D_(this)); - } - - var slice = Array.prototype.slice; - - /** - * Binds a function to an object. The returned function will always use the - * passed in {@code obj} as {@code this}. - * - * Example: - * - * g = bind(f, obj, a, b) - * g(c, d) // will do f.call(obj, a, b, c, d) - * - * @param {Function} f The function to bind the object to - * @param {Object} obj The object that should act as this when the function - * is called - * @param {*} var_args Rest arguments that will be used as the initial - * arguments when the function is called - * @return {Function} A new function that has bound this - */ - function bind(f, obj, var_args) { - var a = slice.call(arguments, 2); - return function() { - return f.apply(obj, a.concat(slice.call(arguments))); - }; - } - - function encodeHtmlAttribute(s) { - return String(s).replace(/&/g, '&').replace(/"/g, '"'); - } - - function addNamespacesAndStylesheet(doc) { - // create xmlns - if (!doc.namespaces['g_vml_']) { - doc.namespaces.add('g_vml_', 'urn:schemas-microsoft-com:vml', - '#default#VML'); - - } - if (!doc.namespaces['g_o_']) { - doc.namespaces.add('g_o_', 'urn:schemas-microsoft-com:office:office', - '#default#VML'); - } - - // Setup default CSS. Only add one style sheet per document - if (!doc.styleSheets['ex_canvas_']) { - var ss = doc.createStyleSheet(); - ss.owningElement.id = 'ex_canvas_'; - ss.cssText = 'canvas{display:inline-block;overflow:hidden;' + - // default size is 300x150 in Gecko and Opera - 'text-align:left;width:300px;height:150px}'; - } - } - - // Add namespaces and stylesheet at startup. - addNamespacesAndStylesheet(document); - - var G_vmlCanvasManager_ = { - init: function(opt_doc) { - if (/MSIE/.test(navigator.userAgent) && !window.opera) { - var doc = opt_doc || document; - // Create a dummy element so that IE will allow canvas elements to be - // recognized. - doc.createElement('canvas'); - doc.attachEvent('onreadystatechange', bind(this.init_, this, doc)); - } - }, - - init_: function(doc) { - // find all canvas elements - var els = doc.getElementsByTagName('canvas'); - for (var i = 0; i < els.length; i++) { - this.initElement(els[i]); - } - }, - - /** - * Public initializes a canvas element so that it can be used as canvas - * element from now on. This is called automatically before the page is - * loaded but if you are creating elements using createElement you need to - * make sure this is called on the element. - * @param {HTMLElement} el The canvas element to initialize. - * @return {HTMLElement} the element that was created. - */ - initElement: function(el) { - if (!el.getContext) { - el.getContext = getContext; - - // Add namespaces and stylesheet to document of the element. - addNamespacesAndStylesheet(el.ownerDocument); - - // Remove fallback content. There is no way to hide text nodes so we - // just remove all childNodes. We could hide all elements and remove - // text nodes but who really cares about the fallback content. - el.innerHTML = ''; - - // do not use inline function because that will leak memory - el.attachEvent('onpropertychange', onPropertyChange); - el.attachEvent('onresize', onResize); - - var attrs = el.attributes; - if (attrs.width && attrs.width.specified) { - // TODO: use runtimeStyle and coordsize - // el.getContext().setWidth_(attrs.width.nodeValue); - el.style.width = attrs.width.nodeValue + 'px'; - } else { - el.width = el.clientWidth; - } - if (attrs.height && attrs.height.specified) { - // TODO: use runtimeStyle and coordsize - // el.getContext().setHeight_(attrs.height.nodeValue); - el.style.height = attrs.height.nodeValue + 'px'; - } else { - el.height = el.clientHeight; - } - //el.getContext().setCoordsize_() - } - return el; - } - }; - - function onPropertyChange(e) { - var el = e.srcElement; - - switch (e.propertyName) { - case 'width': - el.getContext().clearRect(); - el.style.width = el.attributes.width.nodeValue + 'px'; - // In IE8 this does not trigger onresize. - el.firstChild.style.width = el.clientWidth + 'px'; - break; - case 'height': - el.getContext().clearRect(); - el.style.height = el.attributes.height.nodeValue + 'px'; - el.firstChild.style.height = el.clientHeight + 'px'; - break; - } - } - - function onResize(e) { - var el = e.srcElement; - if (el.firstChild) { - el.firstChild.style.width = el.clientWidth + 'px'; - el.firstChild.style.height = el.clientHeight + 'px'; - } - } - - G_vmlCanvasManager_.init(); - - // precompute "00" to "FF" - var decToHex = []; - for (var i = 0; i < 16; i++) { - for (var j = 0; j < 16; j++) { - decToHex[i * 16 + j] = i.toString(16) + j.toString(16); - } - } - - function createMatrixIdentity() { - return [ - [1, 0, 0], - [0, 1, 0], - [0, 0, 1] - ]; - } - - function matrixMultiply(m1, m2) { - var result = createMatrixIdentity(); - - for (var x = 0; x < 3; x++) { - for (var y = 0; y < 3; y++) { - var sum = 0; - - for (var z = 0; z < 3; z++) { - sum += m1[x][z] * m2[z][y]; - } - - result[x][y] = sum; - } - } - return result; - } - - function copyState(o1, o2) { - o2.fillStyle = o1.fillStyle; - o2.lineCap = o1.lineCap; - o2.lineJoin = o1.lineJoin; - o2.lineWidth = o1.lineWidth; - o2.miterLimit = o1.miterLimit; - o2.shadowBlur = o1.shadowBlur; - o2.shadowColor = o1.shadowColor; - o2.shadowOffsetX = o1.shadowOffsetX; - o2.shadowOffsetY = o1.shadowOffsetY; - o2.strokeStyle = o1.strokeStyle; - o2.globalAlpha = o1.globalAlpha; - o2.font = o1.font; - o2.textAlign = o1.textAlign; - o2.textBaseline = o1.textBaseline; - o2.arcScaleX_ = o1.arcScaleX_; - o2.arcScaleY_ = o1.arcScaleY_; - o2.lineScale_ = o1.lineScale_; - } - - var colorData = { - aliceblue: '#F0F8FF', - antiquewhite: '#FAEBD7', - aquamarine: '#7FFFD4', - azure: '#F0FFFF', - beige: '#F5F5DC', - bisque: '#FFE4C4', - black: '#000000', - blanchedalmond: '#FFEBCD', - blueviolet: '#8A2BE2', - brown: '#A52A2A', - burlywood: '#DEB887', - cadetblue: '#5F9EA0', - chartreuse: '#7FFF00', - chocolate: '#D2691E', - coral: '#FF7F50', - cornflowerblue: '#6495ED', - cornsilk: '#FFF8DC', - crimson: '#DC143C', - cyan: '#00FFFF', - darkblue: '#00008B', - darkcyan: '#008B8B', - darkgoldenrod: '#B8860B', - darkgray: '#A9A9A9', - darkgreen: '#006400', - darkgrey: '#A9A9A9', - darkkhaki: '#BDB76B', - darkmagenta: '#8B008B', - darkolivegreen: '#556B2F', - darkorange: '#FF8C00', - darkorchid: '#9932CC', - darkred: '#8B0000', - darksalmon: '#E9967A', - darkseagreen: '#8FBC8F', - darkslateblue: '#483D8B', - darkslategray: '#2F4F4F', - darkslategrey: '#2F4F4F', - darkturquoise: '#00CED1', - darkviolet: '#9400D3', - deeppink: '#FF1493', - deepskyblue: '#00BFFF', - dimgray: '#696969', - dimgrey: '#696969', - dodgerblue: '#1E90FF', - firebrick: '#B22222', - floralwhite: '#FFFAF0', - forestgreen: '#228B22', - gainsboro: '#DCDCDC', - ghostwhite: '#F8F8FF', - gold: '#FFD700', - goldenrod: '#DAA520', - grey: '#808080', - greenyellow: '#ADFF2F', - honeydew: '#F0FFF0', - hotpink: '#FF69B4', - indianred: '#CD5C5C', - indigo: '#4B0082', - ivory: '#FFFFF0', - khaki: '#F0E68C', - lavender: '#E6E6FA', - lavenderblush: '#FFF0F5', - lawngreen: '#7CFC00', - lemonchiffon: '#FFFACD', - lightblue: '#ADD8E6', - lightcoral: '#F08080', - lightcyan: '#E0FFFF', - lightgoldenrodyellow: '#FAFAD2', - lightgreen: '#90EE90', - lightgrey: '#D3D3D3', - lightpink: '#FFB6C1', - lightsalmon: '#FFA07A', - lightseagreen: '#20B2AA', - lightskyblue: '#87CEFA', - lightslategray: '#778899', - lightslategrey: '#778899', - lightsteelblue: '#B0C4DE', - lightyellow: '#FFFFE0', - limegreen: '#32CD32', - linen: '#FAF0E6', - magenta: '#FF00FF', - mediumaquamarine: '#66CDAA', - mediumblue: '#0000CD', - mediumorchid: '#BA55D3', - mediumpurple: '#9370DB', - mediumseagreen: '#3CB371', - mediumslateblue: '#7B68EE', - mediumspringgreen: '#00FA9A', - mediumturquoise: '#48D1CC', - mediumvioletred: '#C71585', - midnightblue: '#191970', - mintcream: '#F5FFFA', - mistyrose: '#FFE4E1', - moccasin: '#FFE4B5', - navajowhite: '#FFDEAD', - oldlace: '#FDF5E6', - olivedrab: '#6B8E23', - orange: '#FFA500', - orangered: '#FF4500', - orchid: '#DA70D6', - palegoldenrod: '#EEE8AA', - palegreen: '#98FB98', - paleturquoise: '#AFEEEE', - palevioletred: '#DB7093', - papayawhip: '#FFEFD5', - peachpuff: '#FFDAB9', - peru: '#CD853F', - pink: '#FFC0CB', - plum: '#DDA0DD', - powderblue: '#B0E0E6', - rosybrown: '#BC8F8F', - royalblue: '#4169E1', - saddlebrown: '#8B4513', - salmon: '#FA8072', - sandybrown: '#F4A460', - seagreen: '#2E8B57', - seashell: '#FFF5EE', - sienna: '#A0522D', - skyblue: '#87CEEB', - slateblue: '#6A5ACD', - slategray: '#708090', - slategrey: '#708090', - snow: '#FFFAFA', - springgreen: '#00FF7F', - steelblue: '#4682B4', - tan: '#D2B48C', - thistle: '#D8BFD8', - tomato: '#FF6347', - turquoise: '#40E0D0', - violet: '#EE82EE', - wheat: '#F5DEB3', - whitesmoke: '#F5F5F5', - yellowgreen: '#9ACD32' - }; - - - function getRgbHslContent(styleString) { - var start = styleString.indexOf('(', 3); - var end = styleString.indexOf(')', start + 1); - var parts = styleString.substring(start + 1, end).split(','); - // add alpha if needed - if (parts.length == 4 && styleString.substr(3, 1) == 'a') { - alpha = Number(parts[3]); - } else { - parts[3] = 1; - } - return parts; - } - - function percent(s) { - return parseFloat(s) / 100; - } - - function clamp(v, min, max) { - return Math.min(max, Math.max(min, v)); - } - - function hslToRgb(parts){ - var r, g, b; - h = parseFloat(parts[0]) / 360 % 360; - if (h < 0) - h++; - s = clamp(percent(parts[1]), 0, 1); - l = clamp(percent(parts[2]), 0, 1); - if (s == 0) { - r = g = b = l; // achromatic - } else { - var q = l < 0.5 ? l * (1 + s) : l + s - l * s; - var p = 2 * l - q; - r = hueToRgb(p, q, h + 1 / 3); - g = hueToRgb(p, q, h); - b = hueToRgb(p, q, h - 1 / 3); - } - - return '#' + decToHex[Math.floor(r * 255)] + - decToHex[Math.floor(g * 255)] + - decToHex[Math.floor(b * 255)]; - } - - function hueToRgb(m1, m2, h) { - if (h < 0) - h++; - if (h > 1) - h--; - - if (6 * h < 1) - return m1 + (m2 - m1) * 6 * h; - else if (2 * h < 1) - return m2; - else if (3 * h < 2) - return m1 + (m2 - m1) * (2 / 3 - h) * 6; - else - return m1; - } - - function processStyle(styleString) { - var str, alpha = 1; - - styleString = String(styleString); - if (styleString.charAt(0) == '#') { - str = styleString; - } else if (/^rgb/.test(styleString)) { - var parts = getRgbHslContent(styleString); - var str = '#', n; - for (var i = 0; i < 3; i++) { - if (parts[i].indexOf('%') != -1) { - n = Math.floor(percent(parts[i]) * 255); - } else { - n = Number(parts[i]); - } - str += decToHex[clamp(n, 0, 255)]; - } - alpha = parts[3]; - } else if (/^hsl/.test(styleString)) { - var parts = getRgbHslContent(styleString); - str = hslToRgb(parts); - alpha = parts[3]; - } else { - str = colorData[styleString] || styleString; - } - return {color: str, alpha: alpha}; - } - - var DEFAULT_STYLE = { - style: 'normal', - variant: 'normal', - weight: 'normal', - size: 10, - family: 'sans-serif' - }; - - // Internal text style cache - var fontStyleCache = {}; - - function processFontStyle(styleString) { - if (fontStyleCache[styleString]) { - return fontStyleCache[styleString]; - } - - var el = document.createElement('div'); - var style = el.style; - try { - style.font = styleString; - } catch (ex) { - // Ignore failures to set to invalid font. - } - - return fontStyleCache[styleString] = { - style: style.fontStyle || DEFAULT_STYLE.style, - variant: style.fontVariant || DEFAULT_STYLE.variant, - weight: style.fontWeight || DEFAULT_STYLE.weight, - size: style.fontSize || DEFAULT_STYLE.size, - family: style.fontFamily || DEFAULT_STYLE.family - }; - } - - function getComputedStyle(style, element) { - var computedStyle = {}; - - for (var p in style) { - computedStyle[p] = style[p]; - } - - // Compute the size - var canvasFontSize = parseFloat(element.currentStyle.fontSize), - fontSize = parseFloat(style.size); - - if (typeof style.size == 'number') { - computedStyle.size = style.size; - } else if (style.size.indexOf('px') != -1) { - computedStyle.size = fontSize; - } else if (style.size.indexOf('em') != -1) { - computedStyle.size = canvasFontSize * fontSize; - } else if(style.size.indexOf('%') != -1) { - computedStyle.size = (canvasFontSize / 100) * fontSize; - } else if (style.size.indexOf('pt') != -1) { - computedStyle.size = fontSize / .75; - } else { - computedStyle.size = canvasFontSize; - } - - // Different scaling between normal text and VML text. This was found using - // trial and error to get the same size as non VML text. - computedStyle.size *= 0.981; - - return computedStyle; - } - - function buildStyle(style) { - return style.style + ' ' + style.variant + ' ' + style.weight + ' ' + - style.size + 'px ' + style.family; - } - - function processLineCap(lineCap) { - switch (lineCap) { - case 'butt': - return 'flat'; - case 'round': - return 'round'; - case 'square': - default: - return 'square'; - } - } - - /** - * This class implements CanvasRenderingContext2D interface as described by - * the WHATWG. - * @param {HTMLElement} surfaceElement The element that the 2D context should - * be associated with - */ - function CanvasRenderingContext2D_(surfaceElement) { - this.m_ = createMatrixIdentity(); - - this.mStack_ = []; - this.aStack_ = []; - this.currentPath_ = []; - - // Canvas context properties - this.strokeStyle = '#000'; - this.fillStyle = '#000'; - - this.lineWidth = 1; - this.lineJoin = 'miter'; - this.lineCap = 'butt'; - this.miterLimit = Z * 1; - this.globalAlpha = 1; - this.font = '10px sans-serif'; - this.textAlign = 'left'; - this.textBaseline = 'alphabetic'; - this.canvas = surfaceElement; - - var el = surfaceElement.ownerDocument.createElement('div'); - el.style.width = surfaceElement.clientWidth + 'px'; - el.style.height = surfaceElement.clientHeight + 'px'; - el.style.overflow = 'hidden'; - el.style.position = 'absolute'; - surfaceElement.appendChild(el); - - this.element_ = el; - this.arcScaleX_ = 1; - this.arcScaleY_ = 1; - this.lineScale_ = 1; - } - - var contextPrototype = CanvasRenderingContext2D_.prototype; - contextPrototype.clearRect = function() { - if (this.textMeasureEl_) { - this.textMeasureEl_.removeNode(true); - this.textMeasureEl_ = null; - } - this.element_.innerHTML = ''; - }; - - contextPrototype.beginPath = function() { - // TODO: Branch current matrix so that save/restore has no effect - // as per safari docs. - this.currentPath_ = []; - }; - - contextPrototype.moveTo = function(aX, aY) { - var p = this.getCoords_(aX, aY); - this.currentPath_.push({type: 'moveTo', x: p.x, y: p.y}); - this.currentX_ = p.x; - this.currentY_ = p.y; - }; - - contextPrototype.lineTo = function(aX, aY) { - var p = this.getCoords_(aX, aY); - this.currentPath_.push({type: 'lineTo', x: p.x, y: p.y}); - - this.currentX_ = p.x; - this.currentY_ = p.y; - }; - - contextPrototype.bezierCurveTo = function(aCP1x, aCP1y, - aCP2x, aCP2y, - aX, aY) { - var p = this.getCoords_(aX, aY); - var cp1 = this.getCoords_(aCP1x, aCP1y); - var cp2 = this.getCoords_(aCP2x, aCP2y); - bezierCurveTo(this, cp1, cp2, p); - }; - - // Helper function that takes the already fixed cordinates. - function bezierCurveTo(self, cp1, cp2, p) { - self.currentPath_.push({ - type: 'bezierCurveTo', - cp1x: cp1.x, - cp1y: cp1.y, - cp2x: cp2.x, - cp2y: cp2.y, - x: p.x, - y: p.y - }); - self.currentX_ = p.x; - self.currentY_ = p.y; - } - - contextPrototype.quadraticCurveTo = function(aCPx, aCPy, aX, aY) { - // the following is lifted almost directly from - // http://developer.mozilla.org/en/docs/Canvas_tutorial:Drawing_shapes - - var cp = this.getCoords_(aCPx, aCPy); - var p = this.getCoords_(aX, aY); - - var cp1 = { - x: this.currentX_ + 2.0 / 3.0 * (cp.x - this.currentX_), - y: this.currentY_ + 2.0 / 3.0 * (cp.y - this.currentY_) - }; - var cp2 = { - x: cp1.x + (p.x - this.currentX_) / 3.0, - y: cp1.y + (p.y - this.currentY_) / 3.0 - }; - - bezierCurveTo(this, cp1, cp2, p); - }; - - contextPrototype.arc = function(aX, aY, aRadius, - aStartAngle, aEndAngle, aClockwise) { - aRadius *= Z; - var arcType = aClockwise ? 'at' : 'wa'; - - var xStart = aX + mc(aStartAngle) * aRadius - Z2; - var yStart = aY + ms(aStartAngle) * aRadius - Z2; - - var xEnd = aX + mc(aEndAngle) * aRadius - Z2; - var yEnd = aY + ms(aEndAngle) * aRadius - Z2; - - // IE won't render arches drawn counter clockwise if xStart == xEnd. - if (xStart == xEnd && !aClockwise) { - xStart += 0.125; // Offset xStart by 1/80 of a pixel. Use something - // that can be represented in binary - } - - var p = this.getCoords_(aX, aY); - var pStart = this.getCoords_(xStart, yStart); - var pEnd = this.getCoords_(xEnd, yEnd); - - this.currentPath_.push({type: arcType, - x: p.x, - y: p.y, - radius: aRadius, - xStart: pStart.x, - yStart: pStart.y, - xEnd: pEnd.x, - yEnd: pEnd.y}); - - }; - - contextPrototype.rect = function(aX, aY, aWidth, aHeight) { - this.moveTo(aX, aY); - this.lineTo(aX + aWidth, aY); - this.lineTo(aX + aWidth, aY + aHeight); - this.lineTo(aX, aY + aHeight); - this.closePath(); - }; - - contextPrototype.strokeRect = function(aX, aY, aWidth, aHeight) { - var oldPath = this.currentPath_; - this.beginPath(); - - this.moveTo(aX, aY); - this.lineTo(aX + aWidth, aY); - this.lineTo(aX + aWidth, aY + aHeight); - this.lineTo(aX, aY + aHeight); - this.closePath(); - this.stroke(); - - this.currentPath_ = oldPath; - }; - - contextPrototype.fillRect = function(aX, aY, aWidth, aHeight) { - var oldPath = this.currentPath_; - this.beginPath(); - - this.moveTo(aX, aY); - this.lineTo(aX + aWidth, aY); - this.lineTo(aX + aWidth, aY + aHeight); - this.lineTo(aX, aY + aHeight); - this.closePath(); - this.fill(); - - this.currentPath_ = oldPath; - }; - - contextPrototype.createLinearGradient = function(aX0, aY0, aX1, aY1) { - var gradient = new CanvasGradient_('gradient'); - gradient.x0_ = aX0; - gradient.y0_ = aY0; - gradient.x1_ = aX1; - gradient.y1_ = aY1; - return gradient; - }; - - contextPrototype.createRadialGradient = function(aX0, aY0, aR0, - aX1, aY1, aR1) { - var gradient = new CanvasGradient_('gradientradial'); - gradient.x0_ = aX0; - gradient.y0_ = aY0; - gradient.r0_ = aR0; - gradient.x1_ = aX1; - gradient.y1_ = aY1; - gradient.r1_ = aR1; - return gradient; - }; - - contextPrototype.drawImage = function(image, var_args) { - var dx, dy, dw, dh, sx, sy, sw, sh; - - // to find the original width we overide the width and height - var oldRuntimeWidth = image.runtimeStyle.width; - var oldRuntimeHeight = image.runtimeStyle.height; - image.runtimeStyle.width = 'auto'; - image.runtimeStyle.height = 'auto'; - - // get the original size - var w = image.width; - var h = image.height; - - // and remove overides - image.runtimeStyle.width = oldRuntimeWidth; - image.runtimeStyle.height = oldRuntimeHeight; - - if (arguments.length == 3) { - dx = arguments[1]; - dy = arguments[2]; - sx = sy = 0; - sw = dw = w; - sh = dh = h; - } else if (arguments.length == 5) { - dx = arguments[1]; - dy = arguments[2]; - dw = arguments[3]; - dh = arguments[4]; - sx = sy = 0; - sw = w; - sh = h; - } else if (arguments.length == 9) { - sx = arguments[1]; - sy = arguments[2]; - sw = arguments[3]; - sh = arguments[4]; - dx = arguments[5]; - dy = arguments[6]; - dw = arguments[7]; - dh = arguments[8]; - } else { - throw Error('Invalid number of arguments'); - } - - var d = this.getCoords_(dx, dy); - - var w2 = sw / 2; - var h2 = sh / 2; - - var vmlStr = []; - - var W = 10; - var H = 10; - - // For some reason that I've now forgotten, using divs didn't work - vmlStr.push(' ' , - '', - ''); - - this.element_.insertAdjacentHTML('BeforeEnd', vmlStr.join('')); - }; - - contextPrototype.stroke = function(aFill) { - var W = 10; - var H = 10; - // Divide the shape into chunks if it's too long because IE has a limit - // somewhere for how long a VML shape can be. This simple division does - // not work with fills, only strokes, unfortunately. - var chunkSize = 5000; - - var min = {x: null, y: null}; - var max = {x: null, y: null}; - - for (var j = 0; j < this.currentPath_.length; j += chunkSize) { - var lineStr = []; - var lineOpen = false; - - lineStr.push(''); - - if (!aFill) { - appendStroke(this, lineStr); - } else { - appendFill(this, lineStr, min, max); - } - - lineStr.push(''); - - this.element_.insertAdjacentHTML('beforeEnd', lineStr.join('')); - } - }; - - function appendStroke(ctx, lineStr) { - var a = processStyle(ctx.strokeStyle); - var color = a.color; - var opacity = a.alpha * ctx.globalAlpha; - var lineWidth = ctx.lineScale_ * ctx.lineWidth; - - // VML cannot correctly render a line if the width is less than 1px. - // In that case, we dilute the color to make the line look thinner. - if (lineWidth < 1) { - opacity *= lineWidth; - } - - lineStr.push( - '' - ); - } - - function appendFill(ctx, lineStr, min, max) { - var fillStyle = ctx.fillStyle; - var arcScaleX = ctx.arcScaleX_; - var arcScaleY = ctx.arcScaleY_; - var width = max.x - min.x; - var height = max.y - min.y; - if (fillStyle instanceof CanvasGradient_) { - // TODO: Gradients transformed with the transformation matrix. - var angle = 0; - var focus = {x: 0, y: 0}; - - // additional offset - var shift = 0; - // scale factor for offset - var expansion = 1; - - if (fillStyle.type_ == 'gradient') { - var x0 = fillStyle.x0_ / arcScaleX; - var y0 = fillStyle.y0_ / arcScaleY; - var x1 = fillStyle.x1_ / arcScaleX; - var y1 = fillStyle.y1_ / arcScaleY; - var p0 = ctx.getCoords_(x0, y0); - var p1 = ctx.getCoords_(x1, y1); - var dx = p1.x - p0.x; - var dy = p1.y - p0.y; - angle = Math.atan2(dx, dy) * 180 / Math.PI; - - // The angle should be a non-negative number. - if (angle < 0) { - angle += 360; - } - - // Very small angles produce an unexpected result because they are - // converted to a scientific notation string. - if (angle < 1e-6) { - angle = 0; - } - } else { - var p0 = ctx.getCoords_(fillStyle.x0_, fillStyle.y0_); - focus = { - x: (p0.x - min.x) / width, - y: (p0.y - min.y) / height - }; - - width /= arcScaleX * Z; - height /= arcScaleY * Z; - var dimension = m.max(width, height); - shift = 2 * fillStyle.r0_ / dimension; - expansion = 2 * fillStyle.r1_ / dimension - shift; - } - - // We need to sort the color stops in ascending order by offset, - // otherwise IE won't interpret it correctly. - var stops = fillStyle.colors_; - stops.sort(function(cs1, cs2) { - return cs1.offset - cs2.offset; - }); - - var length = stops.length; - var color1 = stops[0].color; - var color2 = stops[length - 1].color; - var opacity1 = stops[0].alpha * ctx.globalAlpha; - var opacity2 = stops[length - 1].alpha * ctx.globalAlpha; - - var colors = []; - for (var i = 0; i < length; i++) { - var stop = stops[i]; - colors.push(stop.offset * expansion + shift + ' ' + stop.color); - } - - // When colors attribute is used, the meanings of opacity and o:opacity2 - // are reversed. - lineStr.push(''); - } else if (fillStyle instanceof CanvasPattern_) { - if (width && height) { - var deltaLeft = -min.x; - var deltaTop = -min.y; - lineStr.push(''); - } - } else { - var a = processStyle(ctx.fillStyle); - var color = a.color; - var opacity = a.alpha * ctx.globalAlpha; - lineStr.push(''); - } - } - - contextPrototype.fill = function() { - this.stroke(true); - }; - - contextPrototype.closePath = function() { - this.currentPath_.push({type: 'close'}); - }; - - /** - * @private - */ - contextPrototype.getCoords_ = function(aX, aY) { - var m = this.m_; - return { - x: Z * (aX * m[0][0] + aY * m[1][0] + m[2][0]) - Z2, - y: Z * (aX * m[0][1] + aY * m[1][1] + m[2][1]) - Z2 - }; - }; - - contextPrototype.save = function() { - var o = {}; - copyState(this, o); - this.aStack_.push(o); - this.mStack_.push(this.m_); - this.m_ = matrixMultiply(createMatrixIdentity(), this.m_); - }; - - contextPrototype.restore = function() { - if (this.aStack_.length) { - copyState(this.aStack_.pop(), this); - this.m_ = this.mStack_.pop(); - } - }; - - function matrixIsFinite(m) { - return isFinite(m[0][0]) && isFinite(m[0][1]) && - isFinite(m[1][0]) && isFinite(m[1][1]) && - isFinite(m[2][0]) && isFinite(m[2][1]); - } - - function setM(ctx, m, updateLineScale) { - if (!matrixIsFinite(m)) { - return; - } - ctx.m_ = m; - - if (updateLineScale) { - // Get the line scale. - // Determinant of this.m_ means how much the area is enlarged by the - // transformation. So its square root can be used as a scale factor - // for width. - var det = m[0][0] * m[1][1] - m[0][1] * m[1][0]; - ctx.lineScale_ = sqrt(abs(det)); - } - } - - contextPrototype.translate = function(aX, aY) { - var m1 = [ - [1, 0, 0], - [0, 1, 0], - [aX, aY, 1] - ]; - - setM(this, matrixMultiply(m1, this.m_), false); - }; - - contextPrototype.rotate = function(aRot) { - var c = mc(aRot); - var s = ms(aRot); - - var m1 = [ - [c, s, 0], - [-s, c, 0], - [0, 0, 1] - ]; - - setM(this, matrixMultiply(m1, this.m_), false); - }; - - contextPrototype.scale = function(aX, aY) { - this.arcScaleX_ *= aX; - this.arcScaleY_ *= aY; - var m1 = [ - [aX, 0, 0], - [0, aY, 0], - [0, 0, 1] - ]; - - setM(this, matrixMultiply(m1, this.m_), true); - }; - - contextPrototype.transform = function(m11, m12, m21, m22, dx, dy) { - var m1 = [ - [m11, m12, 0], - [m21, m22, 0], - [dx, dy, 1] - ]; - - setM(this, matrixMultiply(m1, this.m_), true); - }; - - contextPrototype.setTransform = function(m11, m12, m21, m22, dx, dy) { - var m = [ - [m11, m12, 0], - [m21, m22, 0], - [dx, dy, 1] - ]; - - setM(this, m, true); - }; - - /** - * The text drawing function. - * The maxWidth argument isn't taken in account, since no browser supports - * it yet. - */ - contextPrototype.drawText_ = function(text, x, y, maxWidth, stroke) { - var m = this.m_, - delta = 1000, - left = 0, - right = delta, - offset = {x: 0, y: 0}, - lineStr = []; - - var fontStyle = getComputedStyle(processFontStyle(this.font), - this.element_); - - var fontStyleString = buildStyle(fontStyle); - - var elementStyle = this.element_.currentStyle; - var textAlign = this.textAlign.toLowerCase(); - switch (textAlign) { - case 'left': - case 'center': - case 'right': - break; - case 'end': - textAlign = elementStyle.direction == 'ltr' ? 'right' : 'left'; - break; - case 'start': - textAlign = elementStyle.direction == 'rtl' ? 'right' : 'left'; - break; - default: - textAlign = 'left'; - } - - // 1.75 is an arbitrary number, as there is no info about the text baseline - switch (this.textBaseline) { - case 'hanging': - case 'top': - offset.y = fontStyle.size / 1.75; - break; - case 'middle': - break; - default: - case null: - case 'alphabetic': - case 'ideographic': - case 'bottom': - offset.y = -fontStyle.size / 2.25; - break; - } - - switch(textAlign) { - case 'right': - left = delta; - right = 0.05; - break; - case 'center': - left = right = delta / 2; - break; - } - - var d = this.getCoords_(x + offset.x, y + offset.y); - - lineStr.push(''); - - if (stroke) { - appendStroke(this, lineStr); - } else { - // TODO: Fix the min and max params. - appendFill(this, lineStr, {x: -left, y: 0}, - {x: right, y: fontStyle.size}); - } - - var skewM = m[0][0].toFixed(3) + ',' + m[1][0].toFixed(3) + ',' + - m[0][1].toFixed(3) + ',' + m[1][1].toFixed(3) + ',0,0'; - - var skewOffset = mr(d.x / Z) + ',' + mr(d.y / Z); - - lineStr.push('', - '', - ''); - - this.element_.insertAdjacentHTML('beforeEnd', lineStr.join('')); - }; - - contextPrototype.fillText = function(text, x, y, maxWidth) { - this.drawText_(text, x, y, maxWidth, false); - }; - - contextPrototype.strokeText = function(text, x, y, maxWidth) { - this.drawText_(text, x, y, maxWidth, true); - }; - - contextPrototype.measureText = function(text) { - if (!this.textMeasureEl_) { - var s = ''; - this.element_.insertAdjacentHTML('beforeEnd', s); - this.textMeasureEl_ = this.element_.lastChild; - } - var doc = this.element_.ownerDocument; - this.textMeasureEl_.innerHTML = ''; - this.textMeasureEl_.style.font = this.font; - // Don't use innerHTML or innerText because they allow markup/whitespace. - this.textMeasureEl_.appendChild(doc.createTextNode(text)); - return {width: this.textMeasureEl_.offsetWidth}; - }; - - /******** STUBS ********/ - contextPrototype.clip = function() { - // TODO: Implement - }; - - contextPrototype.arcTo = function() { - // TODO: Implement - }; - - contextPrototype.createPattern = function(image, repetition) { - return new CanvasPattern_(image, repetition); - }; - - // Gradient / Pattern Stubs - function CanvasGradient_(aType) { - this.type_ = aType; - this.x0_ = 0; - this.y0_ = 0; - this.r0_ = 0; - this.x1_ = 0; - this.y1_ = 0; - this.r1_ = 0; - this.colors_ = []; - } - - CanvasGradient_.prototype.addColorStop = function(aOffset, aColor) { - aColor = processStyle(aColor); - this.colors_.push({offset: aOffset, - color: aColor.color, - alpha: aColor.alpha}); - }; - - function CanvasPattern_(image, repetition) { - assertImageIsValid(image); - switch (repetition) { - case 'repeat': - case null: - case '': - this.repetition_ = 'repeat'; - break - case 'repeat-x': - case 'repeat-y': - case 'no-repeat': - this.repetition_ = repetition; - break; - default: - throwException('SYNTAX_ERR'); - } - - this.src_ = image.src; - this.width_ = image.width; - this.height_ = image.height; - } - - function throwException(s) { - throw new DOMException_(s); - } - - function assertImageIsValid(img) { - if (!img || img.nodeType != 1 || img.tagName != 'IMG') { - throwException('TYPE_MISMATCH_ERR'); - } - if (img.readyState != 'complete') { - throwException('INVALID_STATE_ERR'); - } - } - - function DOMException_(s) { - this.code = this[s]; - this.message = s +': DOM Exception ' + this.code; - } - var p = DOMException_.prototype = new Error; - p.INDEX_SIZE_ERR = 1; - p.DOMSTRING_SIZE_ERR = 2; - p.HIERARCHY_REQUEST_ERR = 3; - p.WRONG_DOCUMENT_ERR = 4; - p.INVALID_CHARACTER_ERR = 5; - p.NO_DATA_ALLOWED_ERR = 6; - p.NO_MODIFICATION_ALLOWED_ERR = 7; - p.NOT_FOUND_ERR = 8; - p.NOT_SUPPORTED_ERR = 9; - p.INUSE_ATTRIBUTE_ERR = 10; - p.INVALID_STATE_ERR = 11; - p.SYNTAX_ERR = 12; - p.INVALID_MODIFICATION_ERR = 13; - p.NAMESPACE_ERR = 14; - p.INVALID_ACCESS_ERR = 15; - p.VALIDATION_ERR = 16; - p.TYPE_MISMATCH_ERR = 17; - - // set up externs - G_vmlCanvasManager = G_vmlCanvasManager_; - CanvasRenderingContext2D = CanvasRenderingContext2D_; - CanvasGradient = CanvasGradient_; - CanvasPattern = CanvasPattern_; - DOMException = DOMException_; -})(); - -} // if diff --git a/frontend/src/vendor/jquery.flot/excanvas.min.js b/frontend/src/vendor/jquery.flot/excanvas.min.js deleted file mode 100644 index 12c74f7bea84..000000000000 --- a/frontend/src/vendor/jquery.flot/excanvas.min.js +++ /dev/null @@ -1 +0,0 @@ -if(!document.createElement("canvas").getContext){(function(){var z=Math;var K=z.round;var J=z.sin;var U=z.cos;var b=z.abs;var k=z.sqrt;var D=10;var F=D/2;function T(){return this.context_||(this.context_=new W(this))}var O=Array.prototype.slice;function G(i,j,m){var Z=O.call(arguments,2);return function(){return i.apply(j,Z.concat(O.call(arguments)))}}function AD(Z){return String(Z).replace(/&/g,"&").replace(/"/g,""")}function r(i){if(!i.namespaces.g_vml_){i.namespaces.add("g_vml_","urn:schemas-microsoft-com:vml","#default#VML")}if(!i.namespaces.g_o_){i.namespaces.add("g_o_","urn:schemas-microsoft-com:office:office","#default#VML")}if(!i.styleSheets.ex_canvas_){var Z=i.createStyleSheet();Z.owningElement.id="ex_canvas_";Z.cssText="canvas{display:inline-block;overflow:hidden;text-align:left;width:300px;height:150px}"}}r(document);var E={init:function(Z){if(/MSIE/.test(navigator.userAgent)&&!window.opera){var i=Z||document;i.createElement("canvas");i.attachEvent("onreadystatechange",G(this.init_,this,i))}},init_:function(m){var j=m.getElementsByTagName("canvas");for(var Z=0;Z1){j--}if(6*j<1){return i+(Z-i)*6*j}else{if(2*j<1){return Z}else{if(3*j<2){return i+(Z-i)*(2/3-j)*6}else{return i}}}}function Y(Z){var AE,p=1;Z=String(Z);if(Z.charAt(0)=="#"){AE=Z}else{if(/^rgb/.test(Z)){var m=g(Z);var AE="#",AF;for(var j=0;j<3;j++){if(m[j].indexOf("%")!=-1){AF=Math.floor(C(m[j])*255)}else{AF=Number(m[j])}AE+=I[N(AF,0,255)]}p=m[3]}else{if(/^hsl/.test(Z)){var m=g(Z);AE=c(m);p=m[3]}else{AE=B[Z]||Z}}}return{color:AE,alpha:p}}var L={style:"normal",variant:"normal",weight:"normal",size:10,family:"sans-serif"};var f={};function X(Z){if(f[Z]){return f[Z]}var m=document.createElement("div");var j=m.style;try{j.font=Z}catch(i){}return f[Z]={style:j.fontStyle||L.style,variant:j.fontVariant||L.variant,weight:j.fontWeight||L.weight,size:j.fontSize||L.size,family:j.fontFamily||L.family}}function P(j,i){var Z={};for(var AF in j){Z[AF]=j[AF]}var AE=parseFloat(i.currentStyle.fontSize),m=parseFloat(j.size);if(typeof j.size=="number"){Z.size=j.size}else{if(j.size.indexOf("px")!=-1){Z.size=m}else{if(j.size.indexOf("em")!=-1){Z.size=AE*m}else{if(j.size.indexOf("%")!=-1){Z.size=(AE/100)*m}else{if(j.size.indexOf("pt")!=-1){Z.size=m/0.75}else{Z.size=AE}}}}}Z.size*=0.981;return Z}function AA(Z){return Z.style+" "+Z.variant+" "+Z.weight+" "+Z.size+"px "+Z.family}function t(Z){switch(Z){case"butt":return"flat";case"round":return"round";case"square":default:return"square"}}function W(i){this.m_=V();this.mStack_=[];this.aStack_=[];this.currentPath_=[];this.strokeStyle="#000";this.fillStyle="#000";this.lineWidth=1;this.lineJoin="miter";this.lineCap="butt";this.miterLimit=D*1;this.globalAlpha=1;this.font="10px sans-serif";this.textAlign="left";this.textBaseline="alphabetic";this.canvas=i;var Z=i.ownerDocument.createElement("div");Z.style.width=i.clientWidth+"px";Z.style.height=i.clientHeight+"px";Z.style.overflow="hidden";Z.style.position="absolute";i.appendChild(Z);this.element_=Z;this.arcScaleX_=1;this.arcScaleY_=1;this.lineScale_=1}var M=W.prototype;M.clearRect=function(){if(this.textMeasureEl_){this.textMeasureEl_.removeNode(true);this.textMeasureEl_=null}this.element_.innerHTML=""};M.beginPath=function(){this.currentPath_=[]};M.moveTo=function(i,Z){var j=this.getCoords_(i,Z);this.currentPath_.push({type:"moveTo",x:j.x,y:j.y});this.currentX_=j.x;this.currentY_=j.y};M.lineTo=function(i,Z){var j=this.getCoords_(i,Z);this.currentPath_.push({type:"lineTo",x:j.x,y:j.y});this.currentX_=j.x;this.currentY_=j.y};M.bezierCurveTo=function(j,i,AI,AH,AG,AE){var Z=this.getCoords_(AG,AE);var AF=this.getCoords_(j,i);var m=this.getCoords_(AI,AH);e(this,AF,m,Z)};function e(Z,m,j,i){Z.currentPath_.push({type:"bezierCurveTo",cp1x:m.x,cp1y:m.y,cp2x:j.x,cp2y:j.y,x:i.x,y:i.y});Z.currentX_=i.x;Z.currentY_=i.y}M.quadraticCurveTo=function(AG,j,i,Z){var AF=this.getCoords_(AG,j);var AE=this.getCoords_(i,Z);var AH={x:this.currentX_+2/3*(AF.x-this.currentX_),y:this.currentY_+2/3*(AF.y-this.currentY_)};var m={x:AH.x+(AE.x-this.currentX_)/3,y:AH.y+(AE.y-this.currentY_)/3};e(this,AH,m,AE)};M.arc=function(AJ,AH,AI,AE,i,j){AI*=D;var AN=j?"at":"wa";var AK=AJ+U(AE)*AI-F;var AM=AH+J(AE)*AI-F;var Z=AJ+U(i)*AI-F;var AL=AH+J(i)*AI-F;if(AK==Z&&!j){AK+=0.125}var m=this.getCoords_(AJ,AH);var AG=this.getCoords_(AK,AM);var AF=this.getCoords_(Z,AL);this.currentPath_.push({type:AN,x:m.x,y:m.y,radius:AI,xStart:AG.x,yStart:AG.y,xEnd:AF.x,yEnd:AF.y})};M.rect=function(j,i,Z,m){this.moveTo(j,i);this.lineTo(j+Z,i);this.lineTo(j+Z,i+m);this.lineTo(j,i+m);this.closePath()};M.strokeRect=function(j,i,Z,m){var p=this.currentPath_;this.beginPath();this.moveTo(j,i);this.lineTo(j+Z,i);this.lineTo(j+Z,i+m);this.lineTo(j,i+m);this.closePath();this.stroke();this.currentPath_=p};M.fillRect=function(j,i,Z,m){var p=this.currentPath_;this.beginPath();this.moveTo(j,i);this.lineTo(j+Z,i);this.lineTo(j+Z,i+m);this.lineTo(j,i+m);this.closePath();this.fill();this.currentPath_=p};M.createLinearGradient=function(i,m,Z,j){var p=new v("gradient");p.x0_=i;p.y0_=m;p.x1_=Z;p.y1_=j;return p};M.createRadialGradient=function(m,AE,j,i,p,Z){var AF=new v("gradientradial");AF.x0_=m;AF.y0_=AE;AF.r0_=j;AF.x1_=i;AF.y1_=p;AF.r1_=Z;return AF};M.drawImage=function(AO,j){var AH,AF,AJ,AV,AM,AK,AQ,AX;var AI=AO.runtimeStyle.width;var AN=AO.runtimeStyle.height;AO.runtimeStyle.width="auto";AO.runtimeStyle.height="auto";var AG=AO.width;var AT=AO.height;AO.runtimeStyle.width=AI;AO.runtimeStyle.height=AN;if(arguments.length==3){AH=arguments[1];AF=arguments[2];AM=AK=0;AQ=AJ=AG;AX=AV=AT}else{if(arguments.length==5){AH=arguments[1];AF=arguments[2];AJ=arguments[3];AV=arguments[4];AM=AK=0;AQ=AG;AX=AT}else{if(arguments.length==9){AM=arguments[1];AK=arguments[2];AQ=arguments[3];AX=arguments[4];AH=arguments[5];AF=arguments[6];AJ=arguments[7];AV=arguments[8]}else{throw Error("Invalid number of arguments")}}}var AW=this.getCoords_(AH,AF);var m=AQ/2;var i=AX/2;var AU=[];var Z=10;var AE=10;AU.push(" ','","");this.element_.insertAdjacentHTML("BeforeEnd",AU.join(""))};M.stroke=function(AM){var m=10;var AN=10;var AE=5000;var AG={x:null,y:null};var AL={x:null,y:null};for(var AH=0;AHAL.x){AL.x=Z.x}if(AG.y==null||Z.yAL.y){AL.y=Z.y}}}AK.push(' ">');if(!AM){R(this,AK)}else{a(this,AK,AG,AL)}AK.push("");this.element_.insertAdjacentHTML("beforeEnd",AK.join(""))}};function R(j,AE){var i=Y(j.strokeStyle);var m=i.color;var p=i.alpha*j.globalAlpha;var Z=j.lineScale_*j.lineWidth;if(Z<1){p*=Z}AE.push("')}function a(AO,AG,Ah,AP){var AH=AO.fillStyle;var AY=AO.arcScaleX_;var AX=AO.arcScaleY_;var Z=AP.x-Ah.x;var m=AP.y-Ah.y;if(AH instanceof v){var AL=0;var Ac={x:0,y:0};var AU=0;var AK=1;if(AH.type_=="gradient"){var AJ=AH.x0_/AY;var j=AH.y0_/AX;var AI=AH.x1_/AY;var Aj=AH.y1_/AX;var Ag=AO.getCoords_(AJ,j);var Af=AO.getCoords_(AI,Aj);var AE=Af.x-Ag.x;var p=Af.y-Ag.y;AL=Math.atan2(AE,p)*180/Math.PI;if(AL<0){AL+=360}if(AL<0.000001){AL=0}}else{var Ag=AO.getCoords_(AH.x0_,AH.y0_);Ac={x:(Ag.x-Ah.x)/Z,y:(Ag.y-Ah.y)/m};Z/=AY*D;m/=AX*D;var Aa=z.max(Z,m);AU=2*AH.r0_/Aa;AK=2*AH.r1_/Aa-AU}var AS=AH.colors_;AS.sort(function(Ak,i){return Ak.offset-i.offset});var AN=AS.length;var AR=AS[0].color;var AQ=AS[AN-1].color;var AW=AS[0].alpha*AO.globalAlpha;var AV=AS[AN-1].alpha*AO.globalAlpha;var Ab=[];for(var Ae=0;Ae')}else{if(AH instanceof u){if(Z&&m){var AF=-Ah.x;var AZ=-Ah.y;AG.push("')}}else{var Ai=Y(AO.fillStyle);var AT=Ai.color;var Ad=Ai.alpha*AO.globalAlpha;AG.push('')}}}M.fill=function(){this.stroke(true)};M.closePath=function(){this.currentPath_.push({type:"close"})};M.getCoords_=function(j,i){var Z=this.m_;return{x:D*(j*Z[0][0]+i*Z[1][0]+Z[2][0])-F,y:D*(j*Z[0][1]+i*Z[1][1]+Z[2][1])-F}};M.save=function(){var Z={};Q(this,Z);this.aStack_.push(Z);this.mStack_.push(this.m_);this.m_=d(V(),this.m_)};M.restore=function(){if(this.aStack_.length){Q(this.aStack_.pop(),this);this.m_=this.mStack_.pop()}};function H(Z){return isFinite(Z[0][0])&&isFinite(Z[0][1])&&isFinite(Z[1][0])&&isFinite(Z[1][1])&&isFinite(Z[2][0])&&isFinite(Z[2][1])}function y(i,Z,j){if(!H(Z)){return }i.m_=Z;if(j){var p=Z[0][0]*Z[1][1]-Z[0][1]*Z[1][0];i.lineScale_=k(b(p))}}M.translate=function(j,i){var Z=[[1,0,0],[0,1,0],[j,i,1]];y(this,d(Z,this.m_),false)};M.rotate=function(i){var m=U(i);var j=J(i);var Z=[[m,j,0],[-j,m,0],[0,0,1]];y(this,d(Z,this.m_),false)};M.scale=function(j,i){this.arcScaleX_*=j;this.arcScaleY_*=i;var Z=[[j,0,0],[0,i,0],[0,0,1]];y(this,d(Z,this.m_),true)};M.transform=function(p,m,AF,AE,i,Z){var j=[[p,m,0],[AF,AE,0],[i,Z,1]];y(this,d(j,this.m_),true)};M.setTransform=function(AE,p,AG,AF,j,i){var Z=[[AE,p,0],[AG,AF,0],[j,i,1]];y(this,Z,true)};M.drawText_=function(AK,AI,AH,AN,AG){var AM=this.m_,AQ=1000,i=0,AP=AQ,AF={x:0,y:0},AE=[];var Z=P(X(this.font),this.element_);var j=AA(Z);var AR=this.element_.currentStyle;var p=this.textAlign.toLowerCase();switch(p){case"left":case"center":case"right":break;case"end":p=AR.direction=="ltr"?"right":"left";break;case"start":p=AR.direction=="rtl"?"right":"left";break;default:p="left"}switch(this.textBaseline){case"hanging":case"top":AF.y=Z.size/1.75;break;case"middle":break;default:case null:case"alphabetic":case"ideographic":case"bottom":AF.y=-Z.size/2.25;break}switch(p){case"right":i=AQ;AP=0.05;break;case"center":i=AP=AQ/2;break}var AO=this.getCoords_(AI+AF.x,AH+AF.y);AE.push('');if(AG){R(this,AE)}else{a(this,AE,{x:-i,y:0},{x:AP,y:Z.size})}var AL=AM[0][0].toFixed(3)+","+AM[1][0].toFixed(3)+","+AM[0][1].toFixed(3)+","+AM[1][1].toFixed(3)+",0,0";var AJ=K(AO.x/D)+","+K(AO.y/D);AE.push('','','');this.element_.insertAdjacentHTML("beforeEnd",AE.join(""))};M.fillText=function(j,Z,m,i){this.drawText_(j,Z,m,i,false)};M.strokeText=function(j,Z,m,i){this.drawText_(j,Z,m,i,true)};M.measureText=function(j){if(!this.textMeasureEl_){var Z='';this.element_.insertAdjacentHTML("beforeEnd",Z);this.textMeasureEl_=this.element_.lastChild}var i=this.element_.ownerDocument;this.textMeasureEl_.innerHTML="";this.textMeasureEl_.style.font=this.font;this.textMeasureEl_.appendChild(i.createTextNode(j));return{width:this.textMeasureEl_.offsetWidth}};M.clip=function(){};M.arcTo=function(){};M.createPattern=function(i,Z){return new u(i,Z)};function v(Z){this.type_=Z;this.x0_=0;this.y0_=0;this.r0_=0;this.x1_=0;this.y1_=0;this.r1_=0;this.colors_=[]}v.prototype.addColorStop=function(i,Z){Z=Y(Z);this.colors_.push({offset:i,color:Z.color,alpha:Z.alpha})};function u(i,Z){q(i);switch(Z){case"repeat":case null:case"":this.repetition_="repeat";break;case"repeat-x":case"repeat-y":case"no-repeat":this.repetition_=Z;break;default:n("SYNTAX_ERR")}this.src_=i.src;this.width_=i.width;this.height_=i.height}function n(Z){throw new o(Z)}function q(Z){if(!Z||Z.nodeType!=1||Z.tagName!="IMG"){n("TYPE_MISMATCH_ERR")}if(Z.readyState!="complete"){n("INVALID_STATE_ERR")}}function o(Z){this.code=this[Z];this.message=Z+": DOM Exception "+this.code}var x=o.prototype=new Error;x.INDEX_SIZE_ERR=1;x.DOMSTRING_SIZE_ERR=2;x.HIERARCHY_REQUEST_ERR=3;x.WRONG_DOCUMENT_ERR=4;x.INVALID_CHARACTER_ERR=5;x.NO_DATA_ALLOWED_ERR=6;x.NO_MODIFICATION_ALLOWED_ERR=7;x.NOT_FOUND_ERR=8;x.NOT_SUPPORTED_ERR=9;x.INUSE_ATTRIBUTE_ERR=10;x.INVALID_STATE_ERR=11;x.SYNTAX_ERR=12;x.INVALID_MODIFICATION_ERR=13;x.NAMESPACE_ERR=14;x.INVALID_ACCESS_ERR=15;x.VALIDATION_ERR=16;x.TYPE_MISMATCH_ERR=17;G_vmlCanvasManager=E;CanvasRenderingContext2D=W;CanvasGradient=v;CanvasPattern=u;DOMException=o})()}; \ No newline at end of file diff --git a/frontend/src/vendor/jquery.flot/jquery.colorhelpers.js b/frontend/src/vendor/jquery.flot/jquery.colorhelpers.js deleted file mode 100644 index d3524d786f0a..000000000000 --- a/frontend/src/vendor/jquery.flot/jquery.colorhelpers.js +++ /dev/null @@ -1,179 +0,0 @@ -/* Plugin for jQuery for working with colors. - * - * Version 1.1. - * - * Inspiration from jQuery color animation plugin by John Resig. - * - * Released under the MIT license by Ole Laursen, October 2009. - * - * Examples: - * - * $.color.parse("#fff").scale('rgb', 0.25).add('a', -0.5).toString() - * var c = $.color.extract($("#mydiv"), 'background-color'); - * console.log(c.r, c.g, c.b, c.a); - * $.color.make(100, 50, 25, 0.4).toString() // returns "rgba(100,50,25,0.4)" - * - * Note that .scale() and .add() return the same modified object - * instead of making a new one. - * - * V. 1.1: Fix error handling so e.g. parsing an empty string does - * produce a color rather than just crashing. - */ - -(function($) { - $.color = {}; - - // construct color object with some convenient chainable helpers - $.color.make = function (r, g, b, a) { - var o = {}; - o.r = r || 0; - o.g = g || 0; - o.b = b || 0; - o.a = a != null ? a : 1; - - o.add = function (c, d) { - for (var i = 0; i < c.length; ++i) - o[c.charAt(i)] += d; - return o.normalize(); - }; - - o.scale = function (c, f) { - for (var i = 0; i < c.length; ++i) - o[c.charAt(i)] *= f; - return o.normalize(); - }; - - o.toString = function () { - if (o.a >= 1.0) { - return "rgb("+[o.r, o.g, o.b].join(",")+")"; - } else { - return "rgba("+[o.r, o.g, o.b, o.a].join(",")+")"; - } - }; - - o.normalize = function () { - function clamp(min, value, max) { - return value < min ? min: (value > max ? max: value); - } - - o.r = clamp(0, parseInt(o.r), 255); - o.g = clamp(0, parseInt(o.g), 255); - o.b = clamp(0, parseInt(o.b), 255); - o.a = clamp(0, o.a, 1); - return o; - }; - - o.clone = function () { - return $.color.make(o.r, o.b, o.g, o.a); - }; - - return o.normalize(); - } - - // extract CSS color property from element, going up in the DOM - // if it's "transparent" - $.color.extract = function (elem, css) { - var c; - do { - c = elem.css(css).toLowerCase(); - // keep going until we find an element that has color, or - // we hit the body - if (c != '' && c != 'transparent') - break; - elem = elem.parent(); - } while (!$.nodeName(elem.get(0), "body")); - - // catch Safari's way of signalling transparent - if (c == "rgba(0, 0, 0, 0)") - c = "transparent"; - - return $.color.parse(c); - } - - // parse CSS color string (like "rgb(10, 32, 43)" or "#fff"), - // returns color object, if parsing failed, you get black (0, 0, - // 0) out - $.color.parse = function (str) { - var res, m = $.color.make; - - // Look for rgb(num,num,num) - if (res = /rgb\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)/.exec(str)) - return m(parseInt(res[1], 10), parseInt(res[2], 10), parseInt(res[3], 10)); - - // Look for rgba(num,num,num,num) - if (res = /rgba\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)/.exec(str)) - return m(parseInt(res[1], 10), parseInt(res[2], 10), parseInt(res[3], 10), parseFloat(res[4])); - - // Look for rgb(num%,num%,num%) - if (res = /rgb\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*\)/.exec(str)) - return m(parseFloat(res[1])*2.55, parseFloat(res[2])*2.55, parseFloat(res[3])*2.55); - - // Look for rgba(num%,num%,num%,num) - if (res = /rgba\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)/.exec(str)) - return m(parseFloat(res[1])*2.55, parseFloat(res[2])*2.55, parseFloat(res[3])*2.55, parseFloat(res[4])); - - // Look for #a0b1c2 - if (res = /#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})/.exec(str)) - return m(parseInt(res[1], 16), parseInt(res[2], 16), parseInt(res[3], 16)); - - // Look for #fff - if (res = /#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])/.exec(str)) - return m(parseInt(res[1]+res[1], 16), parseInt(res[2]+res[2], 16), parseInt(res[3]+res[3], 16)); - - // Otherwise, we're most likely dealing with a named color - var name = $.trim(str).toLowerCase(); - if (name == "transparent") - return m(255, 255, 255, 0); - else { - // default to black - res = lookupColors[name] || [0, 0, 0]; - return m(res[0], res[1], res[2]); - } - } - - var lookupColors = { - aqua:[0,255,255], - azure:[240,255,255], - beige:[245,245,220], - black:[0,0,0], - blue:[0,0,255], - brown:[165,42,42], - cyan:[0,255,255], - darkblue:[0,0,139], - darkcyan:[0,139,139], - darkgrey:[169,169,169], - darkgreen:[0,100,0], - darkkhaki:[189,183,107], - darkmagenta:[139,0,139], - darkolivegreen:[85,107,47], - darkorange:[255,140,0], - darkorchid:[153,50,204], - darkred:[139,0,0], - darksalmon:[233,150,122], - darkviolet:[148,0,211], - fuchsia:[255,0,255], - gold:[255,215,0], - green:[0,128,0], - indigo:[75,0,130], - khaki:[240,230,140], - lightblue:[173,216,230], - lightcyan:[224,255,255], - lightgreen:[144,238,144], - lightgrey:[211,211,211], - lightpink:[255,182,193], - lightyellow:[255,255,224], - lime:[0,255,0], - magenta:[255,0,255], - maroon:[128,0,0], - navy:[0,0,128], - olive:[128,128,0], - orange:[255,165,0], - pink:[255,192,203], - purple:[128,0,128], - violet:[128,0,128], - red:[255,0,0], - silver:[192,192,192], - white:[255,255,255], - yellow:[255,255,0] - }; -})(jQuery); diff --git a/frontend/src/vendor/jquery.flot/jquery.colorhelpers.min.js b/frontend/src/vendor/jquery.flot/jquery.colorhelpers.min.js deleted file mode 100644 index 7f44c57b560c..000000000000 --- a/frontend/src/vendor/jquery.flot/jquery.colorhelpers.min.js +++ /dev/null @@ -1 +0,0 @@ -(function(b){b.color={};b.color.make=function(f,e,c,d){var h={};h.r=f||0;h.g=e||0;h.b=c||0;h.a=d!=null?d:1;h.add=function(k,j){for(var g=0;g=1){return"rgb("+[h.r,h.g,h.b].join(",")+")"}else{return"rgba("+[h.r,h.g,h.b,h.a].join(",")+")"}};h.normalize=function(){function g(j,k,i){return ki?i:k)}h.r=g(0,parseInt(h.r),255);h.g=g(0,parseInt(h.g),255);h.b=g(0,parseInt(h.b),255);h.a=g(0,h.a,1);return h};h.clone=function(){return b.color.make(h.r,h.b,h.g,h.a)};return h.normalize()};b.color.extract=function(e,d){var f;do{f=e.css(d).toLowerCase();if(f!=""&&f!="transparent"){break}e=e.parent()}while(!b.nodeName(e.get(0),"body"));if(f=="rgba(0, 0, 0, 0)"){f="transparent"}return b.color.parse(f)};b.color.parse=function(f){var e,c=b.color.make;if(e=/rgb\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)/.exec(f)){return c(parseInt(e[1],10),parseInt(e[2],10),parseInt(e[3],10))}if(e=/rgba\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)/.exec(f)){return c(parseInt(e[1],10),parseInt(e[2],10),parseInt(e[3],10),parseFloat(e[4]))}if(e=/rgb\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*\)/.exec(f)){return c(parseFloat(e[1])*2.55,parseFloat(e[2])*2.55,parseFloat(e[3])*2.55)}if(e=/rgba\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)/.exec(f)){return c(parseFloat(e[1])*2.55,parseFloat(e[2])*2.55,parseFloat(e[3])*2.55,parseFloat(e[4]))}if(e=/#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})/.exec(f)){return c(parseInt(e[1],16),parseInt(e[2],16),parseInt(e[3],16))}if(e=/#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])/.exec(f)){return c(parseInt(e[1]+e[1],16),parseInt(e[2]+e[2],16),parseInt(e[3]+e[3],16))}var d=b.trim(f).toLowerCase();if(d=="transparent"){return c(255,255,255,0)}else{e=a[d]||[0,0,0];return c(e[0],e[1],e[2])}};var a={aqua:[0,255,255],azure:[240,255,255],beige:[245,245,220],black:[0,0,0],blue:[0,0,255],brown:[165,42,42],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgrey:[169,169,169],darkgreen:[0,100,0],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkviolet:[148,0,211],fuchsia:[255,0,255],gold:[255,215,0],green:[0,128,0],indigo:[75,0,130],khaki:[240,230,140],lightblue:[173,216,230],lightcyan:[224,255,255],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightyellow:[255,255,224],lime:[0,255,0],magenta:[255,0,255],maroon:[128,0,0],navy:[0,0,128],olive:[128,128,0],orange:[255,165,0],pink:[255,192,203],purple:[128,0,128],violet:[128,0,128],red:[255,0,0],silver:[192,192,192],white:[255,255,255],yellow:[255,255,0]}})(jQuery); \ No newline at end of file diff --git a/frontend/src/vendor/jquery.flot/jquery.flot.crosshair.js b/frontend/src/vendor/jquery.flot/jquery.flot.crosshair.js deleted file mode 100644 index 1d433f0074d1..000000000000 --- a/frontend/src/vendor/jquery.flot/jquery.flot.crosshair.js +++ /dev/null @@ -1,167 +0,0 @@ -/* -Flot plugin for showing crosshairs, thin lines, when the mouse hovers -over the plot. - - crosshair: { - mode: null or "x" or "y" or "xy" - color: color - lineWidth: number - } - -Set the mode to one of "x", "y" or "xy". The "x" mode enables a -vertical crosshair that lets you trace the values on the x axis, "y" -enables a horizontal crosshair and "xy" enables them both. "color" is -the color of the crosshair (default is "rgba(170, 0, 0, 0.80)"), -"lineWidth" is the width of the drawn lines (default is 1). - -The plugin also adds four public methods: - - - setCrosshair(pos) - - Set the position of the crosshair. Note that this is cleared if - the user moves the mouse. "pos" is in coordinates of the plot and - should be on the form { x: xpos, y: ypos } (you can use x2/x3/... - if you're using multiple axes), which is coincidentally the same - format as what you get from a "plothover" event. If "pos" is null, - the crosshair is cleared. - - - clearCrosshair() - - Clear the crosshair. - - - lockCrosshair(pos) - - Cause the crosshair to lock to the current location, no longer - updating if the user moves the mouse. Optionally supply a position - (passed on to setCrosshair()) to move it to. - - Example usage: - var myFlot = $.plot( $("#graph"), ..., { crosshair: { mode: "x" } } }; - $("#graph").bind("plothover", function (evt, position, item) { - if (item) { - // Lock the crosshair to the data point being hovered - myFlot.lockCrosshair({ x: item.datapoint[0], y: item.datapoint[1] }); - } - else { - // Return normal crosshair operation - myFlot.unlockCrosshair(); - } - }); - - - unlockCrosshair() - - Free the crosshair to move again after locking it. -*/ - -(function ($) { - var options = { - crosshair: { - mode: null, // one of null, "x", "y" or "xy", - color: "rgba(170, 0, 0, 0.80)", - lineWidth: 1 - } - }; - - function init(plot) { - // position of crosshair in pixels - var crosshair = { x: -1, y: -1, locked: false }; - - plot.setCrosshair = function setCrosshair(pos) { - if (!pos) - crosshair.x = -1; - else { - var o = plot.p2c(pos); - crosshair.x = Math.max(0, Math.min(o.left, plot.width())); - crosshair.y = Math.max(0, Math.min(o.top, plot.height())); - } - - plot.triggerRedrawOverlay(); - }; - - plot.clearCrosshair = plot.setCrosshair; // passes null for pos - - plot.lockCrosshair = function lockCrosshair(pos) { - if (pos) - plot.setCrosshair(pos); - crosshair.locked = true; - } - - plot.unlockCrosshair = function unlockCrosshair() { - crosshair.locked = false; - } - - function onMouseOut(e) { - if (crosshair.locked) - return; - - if (crosshair.x != -1) { - crosshair.x = -1; - plot.triggerRedrawOverlay(); - } - } - - function onMouseMove(e) { - if (crosshair.locked) - return; - - if (plot.getSelection && plot.getSelection()) { - crosshair.x = -1; // hide the crosshair while selecting - return; - } - - var offset = plot.offset(); - crosshair.x = Math.max(0, Math.min(e.pageX - offset.left, plot.width())); - crosshair.y = Math.max(0, Math.min(e.pageY - offset.top, plot.height())); - plot.triggerRedrawOverlay(); - } - - plot.hooks.bindEvents.push(function (plot, eventHolder) { - if (!plot.getOptions().crosshair.mode) - return; - - eventHolder.mouseout(onMouseOut); - eventHolder.mousemove(onMouseMove); - }); - - plot.hooks.drawOverlay.push(function (plot, ctx) { - var c = plot.getOptions().crosshair; - if (!c.mode) - return; - - var plotOffset = plot.getPlotOffset(); - - ctx.save(); - ctx.translate(plotOffset.left, plotOffset.top); - - if (crosshair.x != -1) { - ctx.strokeStyle = c.color; - ctx.lineWidth = c.lineWidth; - ctx.lineJoin = "round"; - - ctx.beginPath(); - if (c.mode.indexOf("x") != -1) { - ctx.moveTo(crosshair.x, 0); - ctx.lineTo(crosshair.x, plot.height()); - } - if (c.mode.indexOf("y") != -1) { - ctx.moveTo(0, crosshair.y); - ctx.lineTo(plot.width(), crosshair.y); - } - ctx.stroke(); - } - ctx.restore(); - }); - - plot.hooks.shutdown.push(function (plot, eventHolder) { - eventHolder.unbind("mouseout", onMouseOut); - eventHolder.unbind("mousemove", onMouseMove); - }); - } - - $.plot.plugins.push({ - init: init, - options: options, - name: 'crosshair', - version: '1.0' - }); -})(jQuery); diff --git a/frontend/src/vendor/jquery.flot/jquery.flot.crosshair.min.js b/frontend/src/vendor/jquery.flot/jquery.flot.crosshair.min.js deleted file mode 100644 index ccaf240366ac..000000000000 --- a/frontend/src/vendor/jquery.flot/jquery.flot.crosshair.min.js +++ /dev/null @@ -1 +0,0 @@ -(function(b){var a={crosshair:{mode:null,color:"rgba(170, 0, 0, 0.80)",lineWidth:1}};function c(h){var j={x:-1,y:-1,locked:false};h.setCrosshair=function e(l){if(!l){j.x=-1}else{var k=h.p2c(l);j.x=Math.max(0,Math.min(k.left,h.width()));j.y=Math.max(0,Math.min(k.top,h.height()))}h.triggerRedrawOverlay()};h.clearCrosshair=h.setCrosshair;h.lockCrosshair=function f(k){if(k){h.setCrosshair(k)}j.locked=true};h.unlockCrosshair=function g(){j.locked=false};function d(k){if(j.locked){return}if(j.x!=-1){j.x=-1;h.triggerRedrawOverlay()}}function i(k){if(j.locked){return}if(h.getSelection&&h.getSelection()){j.x=-1;return}var l=h.offset();j.x=Math.max(0,Math.min(k.pageX-l.left,h.width()));j.y=Math.max(0,Math.min(k.pageY-l.top,h.height()));h.triggerRedrawOverlay()}h.hooks.bindEvents.push(function(l,k){if(!l.getOptions().crosshair.mode){return}k.mouseout(d);k.mousemove(i)});h.hooks.drawOverlay.push(function(m,k){var n=m.getOptions().crosshair;if(!n.mode){return}var l=m.getPlotOffset();k.save();k.translate(l.left,l.top);if(j.x!=-1){k.strokeStyle=n.color;k.lineWidth=n.lineWidth;k.lineJoin="round";k.beginPath();if(n.mode.indexOf("x")!=-1){k.moveTo(j.x,0);k.lineTo(j.x,m.height())}if(n.mode.indexOf("y")!=-1){k.moveTo(0,j.y);k.lineTo(m.width(),j.y)}k.stroke()}k.restore()});h.hooks.shutdown.push(function(l,k){k.unbind("mouseout",d);k.unbind("mousemove",i)})}b.plot.plugins.push({init:c,options:a,name:"crosshair",version:"1.0"})})(jQuery); \ No newline at end of file diff --git a/frontend/src/vendor/jquery.flot/jquery.flot.fillbetween.js b/frontend/src/vendor/jquery.flot/jquery.flot.fillbetween.js deleted file mode 100644 index 69700e79ce66..000000000000 --- a/frontend/src/vendor/jquery.flot/jquery.flot.fillbetween.js +++ /dev/null @@ -1,183 +0,0 @@ -/* -Flot plugin for computing bottoms for filled line and bar charts. - -The case: you've got two series that you want to fill the area -between. In Flot terms, you need to use one as the fill bottom of the -other. You can specify the bottom of each data point as the third -coordinate manually, or you can use this plugin to compute it for you. - -In order to name the other series, you need to give it an id, like this - - var dataset = [ - { data: [ ... ], id: "foo" } , // use default bottom - { data: [ ... ], fillBetween: "foo" }, // use first dataset as bottom - ]; - - $.plot($("#placeholder"), dataset, { line: { show: true, fill: true }}); - -As a convenience, if the id given is a number that doesn't appear as -an id in the series, it is interpreted as the index in the array -instead (so fillBetween: 0 can also mean the first series). - -Internally, the plugin modifies the datapoints in each series. For -line series, extra data points might be inserted through -interpolation. Note that at points where the bottom line is not -defined (due to a null point or start/end of line), the current line -will show a gap too. The algorithm comes from the jquery.flot.stack.js -plugin, possibly some code could be shared. -*/ - -(function ($) { - var options = { - series: { fillBetween: null } // or number - }; - - function init(plot) { - function findBottomSeries(s, allseries) { - var i; - for (i = 0; i < allseries.length; ++i) { - if (allseries[i].id == s.fillBetween) - return allseries[i]; - } - - if (typeof s.fillBetween == "number") { - i = s.fillBetween; - - if (i < 0 || i >= allseries.length) - return null; - - return allseries[i]; - } - - return null; - } - - function computeFillBottoms(plot, s, datapoints) { - if (s.fillBetween == null) - return; - - var other = findBottomSeries(s, plot.getData()); - if (!other) - return; - - var ps = datapoints.pointsize, - points = datapoints.points, - otherps = other.datapoints.pointsize, - otherpoints = other.datapoints.points, - newpoints = [], - px, py, intery, qx, qy, bottom, - withlines = s.lines.show, - withbottom = ps > 2 && datapoints.format[2].y, - withsteps = withlines && s.lines.steps, - fromgap = true, - i = 0, j = 0, l; - - while (true) { - if (i >= points.length) - break; - - l = newpoints.length; - - if (points[i] == null) { - // copy gaps - for (m = 0; m < ps; ++m) - newpoints.push(points[i + m]); - i += ps; - } - else if (j >= otherpoints.length) { - // for lines, we can't use the rest of the points - if (!withlines) { - for (m = 0; m < ps; ++m) - newpoints.push(points[i + m]); - } - i += ps; - } - else if (otherpoints[j] == null) { - // oops, got a gap - for (m = 0; m < ps; ++m) - newpoints.push(null); - fromgap = true; - j += otherps; - } - else { - // cases where we actually got two points - px = points[i]; - py = points[i + 1]; - qx = otherpoints[j]; - qy = otherpoints[j + 1]; - bottom = 0; - - if (px == qx) { - for (m = 0; m < ps; ++m) - newpoints.push(points[i + m]); - - //newpoints[l + 1] += qy; - bottom = qy; - - i += ps; - j += otherps; - } - else if (px > qx) { - // we got past point below, might need to - // insert interpolated extra point - if (withlines && i > 0 && points[i - ps] != null) { - intery = py + (points[i - ps + 1] - py) * (qx - px) / (points[i - ps] - px); - newpoints.push(qx); - newpoints.push(intery) - for (m = 2; m < ps; ++m) - newpoints.push(points[i + m]); - bottom = qy; - } - - j += otherps; - } - else { // px < qx - if (fromgap && withlines) { - // if we come from a gap, we just skip this point - i += ps; - continue; - } - - for (m = 0; m < ps; ++m) - newpoints.push(points[i + m]); - - // we might be able to interpolate a point below, - // this can give us a better y - if (withlines && j > 0 && otherpoints[j - otherps] != null) - bottom = qy + (otherpoints[j - otherps + 1] - qy) * (px - qx) / (otherpoints[j - otherps] - qx); - - //newpoints[l + 1] += bottom; - - i += ps; - } - - fromgap = false; - - if (l != newpoints.length && withbottom) - newpoints[l + 2] = bottom; - } - - // maintain the line steps invariant - if (withsteps && l != newpoints.length && l > 0 - && newpoints[l] != null - && newpoints[l] != newpoints[l - ps] - && newpoints[l + 1] != newpoints[l - ps + 1]) { - for (m = 0; m < ps; ++m) - newpoints[l + ps + m] = newpoints[l + m]; - newpoints[l + 1] = newpoints[l - ps + 1]; - } - } - - datapoints.points = newpoints; - } - - plot.hooks.processDatapoints.push(computeFillBottoms); - } - - $.plot.plugins.push({ - init: init, - options: options, - name: 'fillbetween', - version: '1.0' - }); -})(jQuery); diff --git a/frontend/src/vendor/jquery.flot/jquery.flot.fillbetween.min.js b/frontend/src/vendor/jquery.flot/jquery.flot.fillbetween.min.js deleted file mode 100644 index 47f3dfb6de04..000000000000 --- a/frontend/src/vendor/jquery.flot/jquery.flot.fillbetween.min.js +++ /dev/null @@ -1 +0,0 @@ -(function(b){var a={series:{fillBetween:null}};function c(f){function d(j,h){var g;for(g=0;g=h.length){return null}return h[g]}return null}function e(B,u,g){if(u.fillBetween==null){return}var p=d(u,B.getData());if(!p){return}var y=g.pointsize,E=g.points,h=p.datapoints.pointsize,x=p.datapoints.points,r=[],w,v,k,G,F,q,t=u.lines.show,o=y>2&&g.format[2].y,n=t&&u.lines.steps,D=true,C=0,A=0,z;while(true){if(C>=E.length){break}z=r.length;if(E[C]==null){for(m=0;m=x.length){if(!t){for(m=0;mG){if(t&&C>0&&E[C-y]!=null){k=v+(E[C-y+1]-v)*(G-w)/(E[C-y]-w);r.push(G);r.push(k);for(m=2;m0&&x[A-h]!=null){q=F+(x[A-h+1]-F)*(w-G)/(x[A-h]-G)}C+=y}}D=false;if(z!=r.length&&o){r[z+2]=q}}}}if(n&&z!=r.length&&z>0&&r[z]!=null&&r[z]!=r[z-y]&&r[z+1]!=r[z-y+1]){for(m=0;m').load(handler).error(handler).attr('src', url); - }); - } - - function drawSeries(plot, ctx, series) { - var plotOffset = plot.getPlotOffset(); - - if (!series.images || !series.images.show) - return; - - var points = series.datapoints.points, - ps = series.datapoints.pointsize; - - for (var i = 0; i < points.length; i += ps) { - var img = points[i], - x1 = points[i + 1], y1 = points[i + 2], - x2 = points[i + 3], y2 = points[i + 4], - xaxis = series.xaxis, yaxis = series.yaxis, - tmp; - - // actually we should check img.complete, but it - // appears to be a somewhat unreliable indicator in - // IE6 (false even after load event) - if (!img || img.width <= 0 || img.height <= 0) - continue; - - if (x1 > x2) { - tmp = x2; - x2 = x1; - x1 = tmp; - } - if (y1 > y2) { - tmp = y2; - y2 = y1; - y1 = tmp; - } - - // if the anchor is at the center of the pixel, expand the - // image by 1/2 pixel in each direction - if (series.images.anchor == "center") { - tmp = 0.5 * (x2-x1) / (img.width - 1); - x1 -= tmp; - x2 += tmp; - tmp = 0.5 * (y2-y1) / (img.height - 1); - y1 -= tmp; - y2 += tmp; - } - - // clip - if (x1 == x2 || y1 == y2 || - x1 >= xaxis.max || x2 <= xaxis.min || - y1 >= yaxis.max || y2 <= yaxis.min) - continue; - - var sx1 = 0, sy1 = 0, sx2 = img.width, sy2 = img.height; - if (x1 < xaxis.min) { - sx1 += (sx2 - sx1) * (xaxis.min - x1) / (x2 - x1); - x1 = xaxis.min; - } - - if (x2 > xaxis.max) { - sx2 += (sx2 - sx1) * (xaxis.max - x2) / (x2 - x1); - x2 = xaxis.max; - } - - if (y1 < yaxis.min) { - sy2 += (sy1 - sy2) * (yaxis.min - y1) / (y2 - y1); - y1 = yaxis.min; - } - - if (y2 > yaxis.max) { - sy1 += (sy1 - sy2) * (yaxis.max - y2) / (y2 - y1); - y2 = yaxis.max; - } - - x1 = xaxis.p2c(x1); - x2 = xaxis.p2c(x2); - y1 = yaxis.p2c(y1); - y2 = yaxis.p2c(y2); - - // the transformation may have swapped us - if (x1 > x2) { - tmp = x2; - x2 = x1; - x1 = tmp; - } - if (y1 > y2) { - tmp = y2; - y2 = y1; - y1 = tmp; - } - - tmp = ctx.globalAlpha; - ctx.globalAlpha *= series.images.alpha; - ctx.drawImage(img, - sx1, sy1, sx2 - sx1, sy2 - sy1, - x1 + plotOffset.left, y1 + plotOffset.top, - x2 - x1, y2 - y1); - ctx.globalAlpha = tmp; - } - } - - function processRawData(plot, series, data, datapoints) { - if (!series.images.show) - return; - - // format is Image, x1, y1, x2, y2 (opposite corners) - datapoints.format = [ - { required: true }, - { x: true, number: true, required: true }, - { y: true, number: true, required: true }, - { x: true, number: true, required: true }, - { y: true, number: true, required: true } - ]; - } - - function init(plot) { - plot.hooks.processRawData.push(processRawData); - plot.hooks.drawSeries.push(drawSeries); - } - - $.plot.plugins.push({ - init: init, - options: options, - name: 'image', - version: '1.1' - }); -})(jQuery); diff --git a/frontend/src/vendor/jquery.flot/jquery.flot.image.min.js b/frontend/src/vendor/jquery.flot/jquery.flot.image.min.js deleted file mode 100644 index 9480c1e7a319..000000000000 --- a/frontend/src/vendor/jquery.flot/jquery.flot.image.min.js +++ /dev/null @@ -1 +0,0 @@ -(function(c){var a={series:{images:{show:false,alpha:1,anchor:"corner"}}};c.plot.image={};c.plot.image.loadDataImages=function(g,f,k){var j=[],h=[];var i=f.series.images.show;c.each(g,function(l,m){if(!(i||m.images.show)){return}if(m.data){m=m.data}c.each(m,function(n,o){if(typeof o[0]=="string"){j.push(o[0]);h.push(o)}})});c.plot.image.load(j,function(l){c.each(h,function(n,o){var m=o[0];if(l[m]){o[0]=l[m]}});k()})};c.plot.image.load=function(h,i){var g=h.length,f={};if(g==0){i({})}c.each(h,function(k,j){var l=function(){--g;f[j]=this;if(g==0){i(f)}};c("").load(l).error(l).attr("src",j)})};function d(q,o,l){var m=q.getPlotOffset();if(!l.images||!l.images.show){return}var r=l.datapoints.points,n=l.datapoints.pointsize;for(var t=0;tv){x=v;v=w;w=x}if(g>f){x=f;f=g;g=x}if(l.images.anchor=="center"){x=0.5*(v-w)/(y.width-1);w-=x;v+=x;x=0.5*(f-g)/(y.height-1);g-=x;f+=x}if(w==v||g==f||w>=h.max||v<=h.min||g>=u.max||f<=u.min){continue}var k=0,s=0,j=y.width,p=y.height;if(wh.max){j+=(j-k)*(h.max-v)/(v-w);v=h.max}if(gu.max){s+=(s-p)*(u.max-f)/(f-g);f=u.max}w=h.p2c(w);v=h.p2c(v);g=u.p2c(g);f=u.p2c(f);if(w>v){x=v;v=w;w=x}if(g>f){x=f;f=g;g=x}x=o.globalAlpha;o.globalAlpha*=l.images.alpha;o.drawImage(y,k,s,j-k,p-s,w+m.left,g+m.top,v-w,f-g);o.globalAlpha=x}}function b(i,f,g,h){if(!f.images.show){return}h.format=[{required:true},{x:true,number:true,required:true},{y:true,number:true,required:true},{x:true,number:true,required:true},{y:true,number:true,required:true}]}function e(f){f.hooks.processRawData.push(b);f.hooks.drawSeries.push(d)}c.plot.plugins.push({init:e,options:a,name:"image",version:"1.1"})})(jQuery); \ No newline at end of file diff --git a/frontend/src/vendor/jquery.flot/jquery.flot.js b/frontend/src/vendor/jquery.flot/jquery.flot.js deleted file mode 100644 index 28abf7f5c8a2..000000000000 --- a/frontend/src/vendor/jquery.flot/jquery.flot.js +++ /dev/null @@ -1,2599 +0,0 @@ -/*! Javascript plotting library for jQuery, v. 0.7. - * - * Released under the MIT license by IOLA, December 2007. - * - */ - -// first an inline dependency, jquery.colorhelpers.js, we inline it here -// for convenience - -/* Plugin for jQuery for working with colors. - * - * Version 1.1. - * - * Inspiration from jQuery color animation plugin by John Resig. - * - * Released under the MIT license by Ole Laursen, October 2009. - * - * Examples: - * - * $.color.parse("#fff").scale('rgb', 0.25).add('a', -0.5).toString() - * var c = $.color.extract($("#mydiv"), 'background-color'); - * console.log(c.r, c.g, c.b, c.a); - * $.color.make(100, 50, 25, 0.4).toString() // returns "rgba(100,50,25,0.4)" - * - * Note that .scale() and .add() return the same modified object - * instead of making a new one. - * - * V. 1.1: Fix error handling so e.g. parsing an empty string does - * produce a color rather than just crashing. - */ -(function(B){B.color={};B.color.make=function(F,E,C,D){var G={};G.r=F||0;G.g=E||0;G.b=C||0;G.a=D!=null?D:1;G.add=function(J,I){for(var H=0;H=1){return"rgb("+[G.r,G.g,G.b].join(",")+")"}else{return"rgba("+[G.r,G.g,G.b,G.a].join(",")+")"}};G.normalize=function(){function H(J,K,I){return KI?I:K)}G.r=H(0,parseInt(G.r),255);G.g=H(0,parseInt(G.g),255);G.b=H(0,parseInt(G.b),255);G.a=H(0,G.a,1);return G};G.clone=function(){return B.color.make(G.r,G.b,G.g,G.a)};return G.normalize()};B.color.extract=function(D,C){var E;do{E=D.css(C).toLowerCase();if(E!=""&&E!="transparent"){break}D=D.parent()}while(!B.nodeName(D.get(0),"body"));if(E=="rgba(0, 0, 0, 0)"){E="transparent"}return B.color.parse(E)};B.color.parse=function(F){var E,C=B.color.make;if(E=/rgb\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)/.exec(F)){return C(parseInt(E[1],10),parseInt(E[2],10),parseInt(E[3],10))}if(E=/rgba\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)/.exec(F)){return C(parseInt(E[1],10),parseInt(E[2],10),parseInt(E[3],10),parseFloat(E[4]))}if(E=/rgb\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*\)/.exec(F)){return C(parseFloat(E[1])*2.55,parseFloat(E[2])*2.55,parseFloat(E[3])*2.55)}if(E=/rgba\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)/.exec(F)){return C(parseFloat(E[1])*2.55,parseFloat(E[2])*2.55,parseFloat(E[3])*2.55,parseFloat(E[4]))}if(E=/#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})/.exec(F)){return C(parseInt(E[1],16),parseInt(E[2],16),parseInt(E[3],16))}if(E=/#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])/.exec(F)){return C(parseInt(E[1]+E[1],16),parseInt(E[2]+E[2],16),parseInt(E[3]+E[3],16))}var D=B.trim(F).toLowerCase();if(D=="transparent"){return C(255,255,255,0)}else{E=A[D]||[0,0,0];return C(E[0],E[1],E[2])}};var A={aqua:[0,255,255],azure:[240,255,255],beige:[245,245,220],black:[0,0,0],blue:[0,0,255],brown:[165,42,42],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgrey:[169,169,169],darkgreen:[0,100,0],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkviolet:[148,0,211],fuchsia:[255,0,255],gold:[255,215,0],green:[0,128,0],indigo:[75,0,130],khaki:[240,230,140],lightblue:[173,216,230],lightcyan:[224,255,255],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightyellow:[255,255,224],lime:[0,255,0],magenta:[255,0,255],maroon:[128,0,0],navy:[0,0,128],olive:[128,128,0],orange:[255,165,0],pink:[255,192,203],purple:[128,0,128],violet:[128,0,128],red:[255,0,0],silver:[192,192,192],white:[255,255,255],yellow:[255,255,0]}})(jQuery); - -// the actual Flot code -(function($) { - function Plot(placeholder, data_, options_, plugins) { - // data is on the form: - // [ series1, series2 ... ] - // where series is either just the data as [ [x1, y1], [x2, y2], ... ] - // or { data: [ [x1, y1], [x2, y2], ... ], label: "some label", ... } - - var series = [], - options = { - // the color theme used for graphs - colors: ["#edc240", "#afd8f8", "#cb4b4b", "#4da74d", "#9440ed"], - legend: { - show: true, - noColumns: 1, // number of colums in legend table - labelFormatter: null, // fn: string -> string - labelBoxBorderColor: "#ccc", // border color for the little label boxes - container: null, // container (as jQuery object) to put legend in, null means default on top of graph - position: "ne", // position of default legend container within plot - margin: 5, // distance from grid edge to default legend container within plot - backgroundColor: null, // null means auto-detect - backgroundOpacity: 0.85 // set to 0 to avoid background - }, - xaxis: { - show: null, // null = auto-detect, true = always, false = never - position: "bottom", // or "top" - mode: null, // null or "time" - color: null, // base color, labels, ticks - tickColor: null, // possibly different color of ticks, e.g. "rgba(0,0,0,0.15)" - transform: null, // null or f: number -> number to transform axis - inverseTransform: null, // if transform is set, this should be the inverse function - min: null, // min. value to show, null means set automatically - max: null, // max. value to show, null means set automatically - autoscaleMargin: null, // margin in % to add if auto-setting min/max - ticks: null, // either [1, 3] or [[1, "a"], 3] or (fn: axis info -> ticks) or app. number of ticks for auto-ticks - tickFormatter: null, // fn: number -> string - labelWidth: null, // size of tick labels in pixels - labelHeight: null, - reserveSpace: null, // whether to reserve space even if axis isn't shown - tickLength: null, // size in pixels of ticks, or "full" for whole line - alignTicksWithAxis: null, // axis number or null for no sync - - // mode specific options - tickDecimals: null, // no. of decimals, null means auto - tickSize: null, // number or [number, "unit"] - minTickSize: null, // number or [number, "unit"] - monthNames: null, // list of names of months - timeformat: null, // format string to use - twelveHourClock: false // 12 or 24 time in time mode - }, - yaxis: { - autoscaleMargin: 0.02, - position: "left" // or "right" - }, - xaxes: [], - yaxes: [], - series: { - points: { - show: false, - radius: 3, - lineWidth: 2, // in pixels - fill: true, - fillColor: "#ffffff", - symbol: "circle" // or callback - }, - lines: { - // we don't put in show: false so we can see - // whether lines were actively disabled - lineWidth: 2, // in pixels - fill: false, - fillColor: null, - steps: false - }, - bars: { - show: false, - lineWidth: 2, // in pixels - barWidth: 1, // in units of the x axis - fill: true, - fillColor: null, - align: "left", // or "center" - horizontal: false - }, - shadowSize: 3 - }, - grid: { - show: true, - aboveData: false, - color: "#545454", // primary color used for outline and labels - backgroundColor: null, // null for transparent, else color - borderColor: null, // set if different from the grid color - tickColor: null, // color for the ticks, e.g. "rgba(0,0,0,0.15)" - labelMargin: 5, // in pixels - axisMargin: 8, // in pixels - borderWidth: 2, // in pixels - minBorderMargin: null, // in pixels, null means taken from points radius - markings: null, // array of ranges or fn: axes -> array of ranges - markingsColor: "#f4f4f4", - markingsLineWidth: 2, - // interactive stuff - clickable: false, - hoverable: false, - autoHighlight: true, // highlight in case mouse is near - mouseActiveRadius: 10 // how far the mouse can be away to activate an item - }, - hooks: {} - }, - canvas = null, // the canvas for the plot itself - overlay = null, // canvas for interactive stuff on top of plot - eventHolder = null, // jQuery object that events should be bound to - ctx = null, octx = null, - xaxes = [], yaxes = [], - plotOffset = { left: 0, right: 0, top: 0, bottom: 0}, - canvasWidth = 0, canvasHeight = 0, - plotWidth = 0, plotHeight = 0, - hooks = { - processOptions: [], - processRawData: [], - processDatapoints: [], - drawSeries: [], - draw: [], - bindEvents: [], - drawOverlay: [], - shutdown: [] - }, - plot = this; - - // public functions - plot.setData = setData; - plot.setupGrid = setupGrid; - plot.draw = draw; - plot.getPlaceholder = function() { return placeholder; }; - plot.getCanvas = function() { return canvas; }; - plot.getPlotOffset = function() { return plotOffset; }; - plot.width = function () { return plotWidth; }; - plot.height = function () { return plotHeight; }; - plot.offset = function () { - var o = eventHolder.offset(); - o.left += plotOffset.left; - o.top += plotOffset.top; - return o; - }; - plot.getData = function () { return series; }; - plot.getAxes = function () { - var res = {}, i; - $.each(xaxes.concat(yaxes), function (_, axis) { - if (axis) - res[axis.direction + (axis.n != 1 ? axis.n : "") + "axis"] = axis; - }); - return res; - }; - plot.getXAxes = function () { return xaxes; }; - plot.getYAxes = function () { return yaxes; }; - plot.c2p = canvasToAxisCoords; - plot.p2c = axisToCanvasCoords; - plot.getOptions = function () { return options; }; - plot.highlight = highlight; - plot.unhighlight = unhighlight; - plot.triggerRedrawOverlay = triggerRedrawOverlay; - plot.pointOffset = function(point) { - return { - left: parseInt(xaxes[axisNumber(point, "x") - 1].p2c(+point.x) + plotOffset.left), - top: parseInt(yaxes[axisNumber(point, "y") - 1].p2c(+point.y) + plotOffset.top) - }; - }; - plot.shutdown = shutdown; - plot.resize = function () { - getCanvasDimensions(); - resizeCanvas(canvas); - resizeCanvas(overlay); - }; - - // public attributes - plot.hooks = hooks; - - // initialize - initPlugins(plot); - parseOptions(options_); - setupCanvases(); - setData(data_); - setupGrid(); - draw(); - bindEvents(); - - - function executeHooks(hook, args) { - args = [plot].concat(args); - for (var i = 0; i < hook.length; ++i) - hook[i].apply(this, args); - } - - function initPlugins() { - for (var i = 0; i < plugins.length; ++i) { - var p = plugins[i]; - p.init(plot); - if (p.options) - $.extend(true, options, p.options); - } - } - - function parseOptions(opts) { - var i; - - $.extend(true, options, opts); - - if (options.xaxis.color == null) - options.xaxis.color = options.grid.color; - if (options.yaxis.color == null) - options.yaxis.color = options.grid.color; - - if (options.xaxis.tickColor == null) // backwards-compatibility - options.xaxis.tickColor = options.grid.tickColor; - if (options.yaxis.tickColor == null) // backwards-compatibility - options.yaxis.tickColor = options.grid.tickColor; - - if (options.grid.borderColor == null) - options.grid.borderColor = options.grid.color; - if (options.grid.tickColor == null) - options.grid.tickColor = $.color.parse(options.grid.color).scale('a', 0.22).toString(); - - // fill in defaults in axes, copy at least always the - // first as the rest of the code assumes it'll be there - for (i = 0; i < Math.max(1, options.xaxes.length); ++i) - options.xaxes[i] = $.extend(true, {}, options.xaxis, options.xaxes[i]); - for (i = 0; i < Math.max(1, options.yaxes.length); ++i) - options.yaxes[i] = $.extend(true, {}, options.yaxis, options.yaxes[i]); - - // backwards compatibility, to be removed in future - if (options.xaxis.noTicks && options.xaxis.ticks == null) - options.xaxis.ticks = options.xaxis.noTicks; - if (options.yaxis.noTicks && options.yaxis.ticks == null) - options.yaxis.ticks = options.yaxis.noTicks; - if (options.x2axis) { - options.xaxes[1] = $.extend(true, {}, options.xaxis, options.x2axis); - options.xaxes[1].position = "top"; - } - if (options.y2axis) { - options.yaxes[1] = $.extend(true, {}, options.yaxis, options.y2axis); - options.yaxes[1].position = "right"; - } - if (options.grid.coloredAreas) - options.grid.markings = options.grid.coloredAreas; - if (options.grid.coloredAreasColor) - options.grid.markingsColor = options.grid.coloredAreasColor; - if (options.lines) - $.extend(true, options.series.lines, options.lines); - if (options.points) - $.extend(true, options.series.points, options.points); - if (options.bars) - $.extend(true, options.series.bars, options.bars); - if (options.shadowSize != null) - options.series.shadowSize = options.shadowSize; - - // save options on axes for future reference - for (i = 0; i < options.xaxes.length; ++i) - getOrCreateAxis(xaxes, i + 1).options = options.xaxes[i]; - for (i = 0; i < options.yaxes.length; ++i) - getOrCreateAxis(yaxes, i + 1).options = options.yaxes[i]; - - // add hooks from options - for (var n in hooks) - if (options.hooks[n] && options.hooks[n].length) - hooks[n] = hooks[n].concat(options.hooks[n]); - - executeHooks(hooks.processOptions, [options]); - } - - function setData(d) { - series = parseData(d); - fillInSeriesOptions(); - processData(); - } - - function parseData(d) { - var res = []; - for (var i = 0; i < d.length; ++i) { - var s = $.extend(true, {}, options.series); - - if (d[i].data != null) { - s.data = d[i].data; // move the data instead of deep-copy - delete d[i].data; - - $.extend(true, s, d[i]); - - d[i].data = s.data; - } - else - s.data = d[i]; - res.push(s); - } - - return res; - } - - function axisNumber(obj, coord) { - var a = obj[coord + "axis"]; - if (typeof a == "object") // if we got a real axis, extract number - a = a.n; - if (typeof a != "number") - a = 1; // default to first axis - return a; - } - - function allAxes() { - // return flat array without annoying null entries - return $.grep(xaxes.concat(yaxes), function (a) { return a; }); - } - - function canvasToAxisCoords(pos) { - // return an object with x/y corresponding to all used axes - var res = {}, i, axis; - for (i = 0; i < xaxes.length; ++i) { - axis = xaxes[i]; - if (axis && axis.used) - res["x" + axis.n] = axis.c2p(pos.left); - } - - for (i = 0; i < yaxes.length; ++i) { - axis = yaxes[i]; - if (axis && axis.used) - res["y" + axis.n] = axis.c2p(pos.top); - } - - if (res.x1 !== undefined) - res.x = res.x1; - if (res.y1 !== undefined) - res.y = res.y1; - - return res; - } - - function axisToCanvasCoords(pos) { - // get canvas coords from the first pair of x/y found in pos - var res = {}, i, axis, key; - - for (i = 0; i < xaxes.length; ++i) { - axis = xaxes[i]; - if (axis && axis.used) { - key = "x" + axis.n; - if (pos[key] == null && axis.n == 1) - key = "x"; - - if (pos[key] != null) { - res.left = axis.p2c(pos[key]); - break; - } - } - } - - for (i = 0; i < yaxes.length; ++i) { - axis = yaxes[i]; - if (axis && axis.used) { - key = "y" + axis.n; - if (pos[key] == null && axis.n == 1) - key = "y"; - - if (pos[key] != null) { - res.top = axis.p2c(pos[key]); - break; - } - } - } - - return res; - } - - function getOrCreateAxis(axes, number) { - if (!axes[number - 1]) - axes[number - 1] = { - n: number, // save the number for future reference - direction: axes == xaxes ? "x" : "y", - options: $.extend(true, {}, axes == xaxes ? options.xaxis : options.yaxis) - }; - - return axes[number - 1]; - } - - function fillInSeriesOptions() { - var i; - - // collect what we already got of colors - var neededColors = series.length, - usedColors = [], - assignedColors = []; - for (i = 0; i < series.length; ++i) { - var sc = series[i].color; - if (sc != null) { - --neededColors; - if (typeof sc == "number") - assignedColors.push(sc); - else - usedColors.push($.color.parse(series[i].color)); - } - } - - // we might need to generate more colors if higher indices - // are assigned - for (i = 0; i < assignedColors.length; ++i) { - neededColors = Math.max(neededColors, assignedColors[i] + 1); - } - - // produce colors as needed - var colors = [], variation = 0; - i = 0; - while (colors.length < neededColors) { - var c; - if (options.colors.length == i) // check degenerate case - c = $.color.make(100, 100, 100); - else - c = $.color.parse(options.colors[i]); - - // vary color if needed - var sign = variation % 2 == 1 ? -1 : 1; - c.scale('rgb', 1 + sign * Math.ceil(variation / 2) * 0.2) - - // FIXME: if we're getting to close to something else, - // we should probably skip this one - colors.push(c); - - ++i; - if (i >= options.colors.length) { - i = 0; - ++variation; - } - } - - // fill in the options - var colori = 0, s; - for (i = 0; i < series.length; ++i) { - s = series[i]; - - // assign colors - if (s.color == null) { - s.color = colors[colori].toString(); - ++colori; - } - else if (typeof s.color == "number") - s.color = colors[s.color].toString(); - - // turn on lines automatically in case nothing is set - if (s.lines.show == null) { - var v, show = true; - for (v in s) - if (s[v] && s[v].show) { - show = false; - break; - } - if (show) - s.lines.show = true; - } - - // setup axes - s.xaxis = getOrCreateAxis(xaxes, axisNumber(s, "x")); - s.yaxis = getOrCreateAxis(yaxes, axisNumber(s, "y")); - } - } - - function processData() { - var topSentry = Number.POSITIVE_INFINITY, - bottomSentry = Number.NEGATIVE_INFINITY, - fakeInfinity = Number.MAX_VALUE, - i, j, k, m, length, - s, points, ps, x, y, axis, val, f, p; - - function updateAxis(axis, min, max) { - if (min < axis.datamin && min != -fakeInfinity) - axis.datamin = min; - if (max > axis.datamax && max != fakeInfinity) - axis.datamax = max; - } - - $.each(allAxes(), function (_, axis) { - // init axis - axis.datamin = topSentry; - axis.datamax = bottomSentry; - axis.used = false; - }); - - for (i = 0; i < series.length; ++i) { - s = series[i]; - s.datapoints = { points: [] }; - - executeHooks(hooks.processRawData, [ s, s.data, s.datapoints ]); - } - - // first pass: clean and copy data - for (i = 0; i < series.length; ++i) { - s = series[i]; - - var data = s.data, format = s.datapoints.format; - - if (!format) { - format = []; - // find out how to copy - format.push({ x: true, number: true, required: true }); - format.push({ y: true, number: true, required: true }); - - if (s.bars.show || (s.lines.show && s.lines.fill)) { - format.push({ y: true, number: true, required: false, defaultValue: 0 }); - if (s.bars.horizontal) { - delete format[format.length - 1].y; - format[format.length - 1].x = true; - } - } - - s.datapoints.format = format; - } - - if (s.datapoints.pointsize != null) - continue; // already filled in - - s.datapoints.pointsize = format.length; - - ps = s.datapoints.pointsize; - points = s.datapoints.points; - - let insertSteps = s.lines.show && s.lines.steps; - s.xaxis.used = s.yaxis.used = true; - - for (j = k = 0; j < data.length; ++j, k += ps) { - p = data[j]; - - var nullify = p == null; - if (!nullify) { - for (m = 0; m < ps; ++m) { - val = p[m]; - f = format[m]; - - if (f) { - if (f.number && val != null) { - val = +val; // convert to number - if (isNaN(val)) - val = null; - else if (val == Infinity) - val = fakeInfinity; - else if (val == -Infinity) - val = -fakeInfinity; - } - - if (val == null) { - if (f.required) - nullify = true; - - if (f.defaultValue != null) - val = f.defaultValue; - } - } - - points[k + m] = val; - } - } - - if (nullify) { - for (m = 0; m < ps; ++m) { - val = points[k + m]; - if (val != null) { - f = format[m]; - // extract min/max info - if (f.x) - updateAxis(s.xaxis, val, val); - if (f.y) - updateAxis(s.yaxis, val, val); - } - points[k + m] = null; - } - } - else { - // a little bit of line specific stuff that - // perhaps shouldn't be here, but lacking - // better means... - if (insertSteps && k > 0 - && points[k - ps] != null - && points[k - ps] != points[k] - && points[k - ps + 1] != points[k + 1]) { - // copy the point to make room for a middle point - for (m = 0; m < ps; ++m) - points[k + ps + m] = points[k + m]; - - // middle point has same y - points[k + 1] = points[k - ps + 1]; - - // we've added a point, better reflect that - k += ps; - } - } - } - } - - // give the hooks a chance to run - for (i = 0; i < series.length; ++i) { - s = series[i]; - - executeHooks(hooks.processDatapoints, [ s, s.datapoints]); - } - - // second pass: find datamax/datamin for auto-scaling - for (i = 0; i < series.length; ++i) { - s = series[i]; - points = s.datapoints.points, - ps = s.datapoints.pointsize; - - var xmin = topSentry, ymin = topSentry, - xmax = bottomSentry, ymax = bottomSentry; - - for (j = 0; j < points.length; j += ps) { - if (points[j] == null) - continue; - - for (m = 0; m < ps; ++m) { - val = points[j + m]; - f = format[m]; - if (!f || val == fakeInfinity || val == -fakeInfinity) - continue; - - if (f.x) { - if (val < xmin) - xmin = val; - if (val > xmax) - xmax = val; - } - if (f.y) { - if (val < ymin) - ymin = val; - if (val > ymax) - ymax = val; - } - } - } - - if (s.bars.show) { - // make sure we got room for the bar on the dancing floor - var delta = s.bars.align == "left" ? 0 : -s.bars.barWidth/2; - if (s.bars.horizontal) { - ymin += delta; - ymax += delta + s.bars.barWidth; - } - else { - xmin += delta; - xmax += delta + s.bars.barWidth; - } - } - - updateAxis(s.xaxis, xmin, xmax); - updateAxis(s.yaxis, ymin, ymax); - } - - $.each(allAxes(), function (_, axis) { - if (axis.datamin == topSentry) - axis.datamin = null; - if (axis.datamax == bottomSentry) - axis.datamax = null; - }); - } - - function makeCanvas(skipPositioning, cls) { - var c = document.createElement('canvas'); - c.className = cls; - c.width = canvasWidth; - c.height = canvasHeight; - - if (!skipPositioning) - $(c).css({ position: 'absolute', left: 0, top: 0 }); - - $(c).appendTo(placeholder); - - if (!c.getContext) // excanvas hack - c = window.G_vmlCanvasManager.initElement(c); - - // used for resetting in case we get replotted - c.getContext("2d").save(); - - return c; - } - - function getCanvasDimensions() { - canvasWidth = placeholder.width(); - canvasHeight = placeholder.height(); - - if (canvasWidth <= 0 || canvasHeight <= 0) - throw new Error("Invalid dimensions for plot, width = " + canvasWidth + ", height = " + canvasHeight); - } - - function resizeCanvas(c) { - // resizing should reset the state (excanvas seems to be - // buggy though) - if (c.width != canvasWidth) - c.width = canvasWidth; - - if (c.height != canvasHeight) - c.height = canvasHeight; - - // so try to get back to the initial state (even if it's - // gone now, this should be safe according to the spec) - var cctx = c.getContext("2d"); - cctx.restore(); - - // and save again - cctx.save(); - } - - function setupCanvases() { - var reused, - existingCanvas = placeholder.children("canvas.base"), - existingOverlay = placeholder.children("canvas.overlay"); - - if (existingCanvas.length == 0 || existingOverlay == 0) { - // init everything - - placeholder.html(""); // make sure placeholder is clear - - placeholder.css({ padding: 0 }); // padding messes up the positioning - - if (placeholder.css("position") == 'static') - placeholder.css("position", "relative"); // for positioning labels and overlay - - getCanvasDimensions(); - - canvas = makeCanvas(true, "base"); - overlay = makeCanvas(false, "overlay"); // overlay canvas for interactive features - - reused = false; - } - else { - // reuse existing elements - - canvas = existingCanvas.get(0); - overlay = existingOverlay.get(0); - - reused = true; - } - - ctx = canvas.getContext("2d"); - octx = overlay.getContext("2d"); - - // we include the canvas in the event holder too, because IE 7 - // sometimes has trouble with the stacking order - eventHolder = $([overlay, canvas]); - - if (reused) { - // run shutdown in the old plot object - placeholder.data("plot").shutdown(); - - // reset reused canvases - plot.resize(); - - // make sure overlay pixels are cleared (canvas is cleared when we redraw) - octx.clearRect(0, 0, canvasWidth, canvasHeight); - - // then whack any remaining obvious garbage left - eventHolder.unbind(); - placeholder.children().not([canvas, overlay]).remove(); - } - - // save in case we get replotted - placeholder.data("plot", plot); - } - - function bindEvents() { - // bind events - if (options.grid.hoverable) { - eventHolder.mousemove(onMouseMove); - eventHolder.mouseleave(onMouseLeave); - } - - if (options.grid.clickable) - eventHolder.click(onClick); - - executeHooks(hooks.bindEvents, [eventHolder]); - } - - function shutdown() { - if (redrawTimeout) - clearTimeout(redrawTimeout); - - eventHolder.unbind("mousemove", onMouseMove); - eventHolder.unbind("mouseleave", onMouseLeave); - eventHolder.unbind("click", onClick); - - executeHooks(hooks.shutdown, [eventHolder]); - } - - function setTransformationHelpers(axis) { - // set helper functions on the axis, assumes plot area - // has been computed already - - function identity(x) { return x; } - - var s, m, t = axis.options.transform || identity, - it = axis.options.inverseTransform; - - // precompute how much the axis is scaling a point - // in canvas space - if (axis.direction == "x") { - s = axis.scale = plotWidth / Math.abs(t(axis.max) - t(axis.min)); - m = Math.min(t(axis.max), t(axis.min)); - } - else { - s = axis.scale = plotHeight / Math.abs(t(axis.max) - t(axis.min)); - s = -s; - m = Math.max(t(axis.max), t(axis.min)); - } - - // data point to canvas coordinate - if (t == identity) // slight optimization - axis.p2c = function (p) { return (p - m) * s; }; - else - axis.p2c = function (p) { return (t(p) - m) * s; }; - // canvas coordinate to data point - if (!it) - axis.c2p = function (c) { return m + c / s; }; - else - axis.c2p = function (c) { return it(m + c / s); }; - } - - function measureTickLabels(axis) { - var opts = axis.options, i, ticks = axis.ticks || [], labels = [], - l, w = opts.labelWidth, h = opts.labelHeight, dummyDiv; - - function makeDummyDiv(labels, width) { - return $('
    ' + - '
    ' - + labels.join("") + '
    ') - .appendTo(placeholder); - } - - if (axis.direction == "x") { - // to avoid measuring the widths of the labels (it's slow), we - // construct fixed-size boxes and put the labels inside - // them, we don't need the exact figures and the - // fixed-size box content is easy to center - if (w == null) - w = Math.floor(canvasWidth / (ticks.length > 0 ? ticks.length : 1)); - - // measure x label heights - if (h == null) { - labels = []; - for (i = 0; i < ticks.length; ++i) { - l = ticks[i].label; - if (l) - labels.push('
    ' + l + '
    '); - } - - if (labels.length > 0) { - // stick them all in the same div and measure - // collective height - labels.push('
    '); - dummyDiv = makeDummyDiv(labels, "width:10000px;"); - h = dummyDiv.height(); - dummyDiv.remove(); - } - } - } - else if (w == null || h == null) { - // calculate y label dimensions - for (i = 0; i < ticks.length; ++i) { - l = ticks[i].label; - if (l) - labels.push('
    ' + l + '
    '); - } - - if (labels.length > 0) { - dummyDiv = makeDummyDiv(labels, ""); - if (w == null) - w = dummyDiv.children().width(); - if (h == null) - h = dummyDiv.find("div.tickLabel").height(); - dummyDiv.remove(); - } - } - - if (w == null) - w = 0; - if (h == null) - h = 0; - - axis.labelWidth = w; - axis.labelHeight = h; - } - - function allocateAxisBoxFirstPhase(axis) { - // find the bounding box of the axis by looking at label - // widths/heights and ticks, make room by diminishing the - // plotOffset - - var lw = axis.labelWidth, - lh = axis.labelHeight, - pos = axis.options.position, - tickLength = axis.options.tickLength, - axismargin = options.grid.axisMargin, - padding = options.grid.labelMargin, - all = axis.direction == "x" ? xaxes : yaxes, - index; - - // determine axis margin - var samePosition = $.grep(all, function (a) { - return a && a.options.position == pos && a.reserveSpace; - }); - if ($.inArray(axis, samePosition) == samePosition.length - 1) - axismargin = 0; // outermost - - // determine tick length - if we're innermost, we can use "full" - if (tickLength == null) - tickLength = "full"; - - var sameDirection = $.grep(all, function (a) { - return a && a.reserveSpace; - }); - - var innermost = $.inArray(axis, sameDirection) == 0; - if (!innermost && tickLength == "full") - tickLength = 5; - - if (!isNaN(+tickLength)) - padding += +tickLength; - - // compute box - if (axis.direction == "x") { - lh += padding; - - if (pos == "bottom") { - plotOffset.bottom += lh + axismargin; - axis.box = { top: canvasHeight - plotOffset.bottom, height: lh }; - } - else { - axis.box = { top: plotOffset.top + axismargin, height: lh }; - plotOffset.top += lh + axismargin; - } - } - else { - lw += padding; - - if (pos == "left") { - axis.box = { left: plotOffset.left + axismargin, width: lw }; - plotOffset.left += lw + axismargin; - } - else { - plotOffset.right += lw + axismargin; - axis.box = { left: canvasWidth - plotOffset.right, width: lw }; - } - } - - // save for future reference - axis.position = pos; - axis.tickLength = tickLength; - axis.box.padding = padding; - axis.innermost = innermost; - } - - function allocateAxisBoxSecondPhase(axis) { - // set remaining bounding box coordinates - if (axis.direction == "x") { - axis.box.left = plotOffset.left; - axis.box.width = plotWidth; - } - else { - axis.box.top = plotOffset.top; - axis.box.height = plotHeight; - } - } - - function setupGrid() { - var i, axes = allAxes(); - - // first calculate the plot and axis box dimensions - - $.each(axes, function (_, axis) { - axis.show = axis.options.show; - if (axis.show == null) - axis.show = axis.used; // by default an axis is visible if it's got data - - axis.reserveSpace = axis.show || axis.options.reserveSpace; - - setRange(axis); - }); - - let allocatedAxes = $.grep(axes, function (axis) { return axis.reserveSpace; }); - - plotOffset.left = plotOffset.right = plotOffset.top = plotOffset.bottom = 0; - if (options.grid.show) { - $.each(allocatedAxes, function (_, axis) { - // make the ticks - setupTickGeneration(axis); - setTicks(axis); - snapRangeToTicks(axis, axis.ticks); - - // find labelWidth/Height for axis - measureTickLabels(axis); - }); - - // with all dimensions in house, we can compute the - // axis boxes, start from the outside (reverse order) - for (i = allocatedAxes.length - 1; i >= 0; --i) - allocateAxisBoxFirstPhase(allocatedAxes[i]); - - // make sure we've got enough space for things that - // might stick out - var minMargin = options.grid.minBorderMargin; - if (minMargin == null) { - minMargin = 0; - for (i = 0; i < series.length; ++i) - minMargin = Math.max(minMargin, series[i].points.radius + series[i].points.lineWidth/2); - } - - for (var a in plotOffset) { - plotOffset[a] += options.grid.borderWidth; - plotOffset[a] = Math.max(minMargin, plotOffset[a]); - } - } - - plotWidth = canvasWidth - plotOffset.left - plotOffset.right; - plotHeight = canvasHeight - plotOffset.bottom - plotOffset.top; - - // now we got the proper plotWidth/Height, we can compute the scaling - $.each(axes, function (_, axis) { - setTransformationHelpers(axis); - }); - - if (options.grid.show) { - $.each(allocatedAxes, function (_, axis) { - allocateAxisBoxSecondPhase(axis); - }); - - insertAxisLabels(); - } - - insertLegend(); - } - - function setRange(axis) { - var opts = axis.options, - min = +(opts.min != null ? opts.min : axis.datamin), - max = +(opts.max != null ? opts.max : axis.datamax), - delta = max - min; - - if (delta == 0.0) { - // degenerate case - var widen = max == 0 ? 1 : 0.01; - - if (opts.min == null) - min -= widen; - // always widen max if we couldn't widen min to ensure we - // don't fall into min == max which doesn't work - if (opts.max == null || opts.min != null) - max += widen; - } - else { - // consider autoscaling - var margin = opts.autoscaleMargin; - if (margin != null) { - if (opts.min == null) { - min -= delta * margin; - // make sure we don't go below zero if all values - // are positive - if (min < 0 && axis.datamin != null && axis.datamin >= 0) - min = 0; - } - if (opts.max == null) { - max += delta * margin; - if (max > 0 && axis.datamax != null && axis.datamax <= 0) - max = 0; - } - } - } - axis.min = min; - axis.max = max; - } - - function setupTickGeneration(axis) { - var opts = axis.options; - - // estimate number of ticks - var noTicks; - if (typeof opts.ticks == "number" && opts.ticks > 0) - noTicks = opts.ticks; - else - // heuristic based on the model a*sqrt(x) fitted to - // some data points that seemed reasonable - noTicks = 0.3 * Math.sqrt(axis.direction == "x" ? canvasWidth : canvasHeight); - - var delta = (axis.max - axis.min) / noTicks, - size, generator, unit, formatter, i, magn, norm; - - if (opts.mode == "time") { - // pretty handling of time - - // map of app. size of time units in milliseconds - var timeUnitSize = { - "second": 1000, - "minute": 60 * 1000, - "hour": 60 * 60 * 1000, - "day": 24 * 60 * 60 * 1000, - "month": 30 * 24 * 60 * 60 * 1000, - "year": 365.2425 * 24 * 60 * 60 * 1000 - }; - - - // the allowed tick sizes, after 1 year we use - // an integer algorithm - var spec = [ - [1, "second"], [2, "second"], [5, "second"], [10, "second"], - [30, "second"], - [1, "minute"], [2, "minute"], [5, "minute"], [10, "minute"], - [30, "minute"], - [1, "hour"], [2, "hour"], [4, "hour"], - [8, "hour"], [12, "hour"], - [1, "day"], [2, "day"], [3, "day"], - [0.25, "month"], [0.5, "month"], [1, "month"], - [2, "month"], [3, "month"], [6, "month"], - [1, "year"] - ]; - - var minSize = 0; - if (opts.minTickSize != null) { - if (typeof opts.tickSize == "number") - minSize = opts.tickSize; - else - minSize = opts.minTickSize[0] * timeUnitSize[opts.minTickSize[1]]; - } - - for (var i = 0; i < spec.length - 1; ++i) - if (delta < (spec[i][0] * timeUnitSize[spec[i][1]] - + spec[i + 1][0] * timeUnitSize[spec[i + 1][1]]) / 2 - && spec[i][0] * timeUnitSize[spec[i][1]] >= minSize) - break; - size = spec[i][0]; - unit = spec[i][1]; - - // special-case the possibility of several years - if (unit == "year") { - magn = Math.pow(10, Math.floor(Math.log(delta / timeUnitSize.year) / Math.LN10)); - norm = (delta / timeUnitSize.year) / magn; - if (norm < 1.5) - size = 1; - else if (norm < 3) - size = 2; - else if (norm < 7.5) - size = 5; - else - size = 10; - - size *= magn; - } - - axis.tickSize = opts.tickSize || [size, unit]; - - generator = function(axis) { - var ticks = [], - tickSize = axis.tickSize[0], unit = axis.tickSize[1], - d = new Date(axis.min); - - var step = tickSize * timeUnitSize[unit]; - - if (unit == "second") - d.setUTCSeconds(floorInBase(d.getUTCSeconds(), tickSize)); - if (unit == "minute") - d.setUTCMinutes(floorInBase(d.getUTCMinutes(), tickSize)); - if (unit == "hour") - d.setUTCHours(floorInBase(d.getUTCHours(), tickSize)); - if (unit == "month") - d.setUTCMonth(floorInBase(d.getUTCMonth(), tickSize)); - if (unit == "year") - d.setUTCFullYear(floorInBase(d.getUTCFullYear(), tickSize)); - - // reset smaller components - d.setUTCMilliseconds(0); - if (step >= timeUnitSize.minute) - d.setUTCSeconds(0); - if (step >= timeUnitSize.hour) - d.setUTCMinutes(0); - if (step >= timeUnitSize.day) - d.setUTCHours(0); - if (step >= timeUnitSize.day * 4) - d.setUTCDate(1); - if (step >= timeUnitSize.year) - d.setUTCMonth(0); - - - var carry = 0, v = Number.NaN, prev; - do { - prev = v; - v = d.getTime(); - ticks.push(v); - if (unit == "month") { - if (tickSize < 1) { - // a bit complicated - we'll divide the month - // up but we need to take care of fractions - // so we don't end up in the middle of a day - d.setUTCDate(1); - var start = d.getTime(); - d.setUTCMonth(d.getUTCMonth() + 1); - var end = d.getTime(); - d.setTime(v + carry * timeUnitSize.hour + (end - start) * tickSize); - carry = d.getUTCHours(); - d.setUTCHours(0); - } - else - d.setUTCMonth(d.getUTCMonth() + tickSize); - } - else if (unit == "year") { - d.setUTCFullYear(d.getUTCFullYear() + tickSize); - } - else - d.setTime(v + step); - } while (v < axis.max && v != prev); - - return ticks; - }; - - formatter = function (v, axis) { - var d = new Date(v); - - // first check global format - if (opts.timeformat != null) - return $.plot.formatDate(d, opts.timeformat, opts.monthNames); - - var t = axis.tickSize[0] * timeUnitSize[axis.tickSize[1]]; - var span = axis.max - axis.min; - var suffix = (opts.twelveHourClock) ? " %p" : ""; - - if (t < timeUnitSize.minute) - fmt = "%h:%M:%S" + suffix; - else if (t < timeUnitSize.day) { - if (span < 2 * timeUnitSize.day) - fmt = "%h:%M" + suffix; - else - fmt = "%b %d %h:%M" + suffix; - } - else if (t < timeUnitSize.month) - fmt = "%b %d"; - else if (t < timeUnitSize.year) { - if (span < timeUnitSize.year) - fmt = "%b"; - else - fmt = "%b %y"; - } - else - fmt = "%y"; - - return $.plot.formatDate(d, fmt, opts.monthNames); - }; - } - else { - // pretty rounding of base-10 numbers - var maxDec = opts.tickDecimals; - var dec = -Math.floor(Math.log(delta) / Math.LN10); - if (maxDec != null && dec > maxDec) - dec = maxDec; - - magn = Math.pow(10, -dec); - norm = delta / magn; // norm is between 1.0 and 10.0 - - if (norm < 1.5) - size = 1; - else if (norm < 3) { - size = 2; - // special case for 2.5, requires an extra decimal - if (norm > 2.25 && (maxDec == null || dec + 1 <= maxDec)) { - size = 2.5; - ++dec; - } - } - else if (norm < 7.5) - size = 5; - else - size = 10; - - size *= magn; - - if (opts.minTickSize != null && size < opts.minTickSize) - size = opts.minTickSize; - - axis.tickDecimals = Math.max(0, maxDec != null ? maxDec : dec); - axis.tickSize = opts.tickSize || size; - - generator = function (axis) { - var ticks = []; - - // spew out all possible ticks - var start = floorInBase(axis.min, axis.tickSize), - i = 0, v = Number.NaN, prev; - do { - prev = v; - v = start + i * axis.tickSize; - ticks.push(v); - ++i; - } while (v < axis.max && v != prev); - return ticks; - }; - - formatter = function (v, axis) { - return v.toFixed(axis.tickDecimals); - }; - } - - if (opts.alignTicksWithAxis != null) { - var otherAxis = (axis.direction == "x" ? xaxes : yaxes)[opts.alignTicksWithAxis - 1]; - if (otherAxis && otherAxis.used && otherAxis != axis) { - // consider snapping min/max to outermost nice ticks - var niceTicks = generator(axis); - if (niceTicks.length > 0) { - if (opts.min == null) - axis.min = Math.min(axis.min, niceTicks[0]); - if (opts.max == null && niceTicks.length > 1) - axis.max = Math.max(axis.max, niceTicks[niceTicks.length - 1]); - } - - generator = function (axis) { - // copy ticks, scaled to this axis - var ticks = [], v, i; - for (i = 0; i < otherAxis.ticks.length; ++i) { - v = (otherAxis.ticks[i].v - otherAxis.min) / (otherAxis.max - otherAxis.min); - v = axis.min + v * (axis.max - axis.min); - ticks.push(v); - } - return ticks; - }; - - // we might need an extra decimal since forced - // ticks don't necessarily fit naturally - if (axis.mode != "time" && opts.tickDecimals == null) { - var extraDec = Math.max(0, -Math.floor(Math.log(delta) / Math.LN10) + 1), - ts = generator(axis); - - // only proceed if the tick interval rounded - // with an extra decimal doesn't give us a - // zero at end - if (!(ts.length > 1 && /\..*0$/.test((ts[1] - ts[0]).toFixed(extraDec)))) - axis.tickDecimals = extraDec; - } - } - } - - axis.tickGenerator = generator; - if ($.isFunction(opts.tickFormatter)) - axis.tickFormatter = function (v, axis) { return "" + opts.tickFormatter(v, axis); }; - else - axis.tickFormatter = formatter; - } - - function setTicks(axis) { - var oticks = axis.options.ticks, ticks = []; - if (oticks == null || (typeof oticks == "number" && oticks > 0)) - ticks = axis.tickGenerator(axis); - else if (oticks) { - if ($.isFunction(oticks)) - // generate the ticks - ticks = oticks({ min: axis.min, max: axis.max }); - else - ticks = oticks; - } - - // clean up/labelify the supplied ticks, copy them over - var i, v; - axis.ticks = []; - for (i = 0; i < ticks.length; ++i) { - var label = null; - var t = ticks[i]; - if (typeof t == "object") { - v = +t[0]; - if (t.length > 1) - label = t[1]; - } - else - v = +t; - if (label == null) - label = axis.tickFormatter(v, axis); - if (!isNaN(v)) - axis.ticks.push({ v: v, label: label }); - } - } - - function snapRangeToTicks(axis, ticks) { - if (axis.options.autoscaleMargin && ticks.length > 0) { - // snap to ticks - if (axis.options.min == null) - axis.min = Math.min(axis.min, ticks[0].v); - if (axis.options.max == null && ticks.length > 1) - axis.max = Math.max(axis.max, ticks[ticks.length - 1].v); - } - } - - function draw() { - ctx.clearRect(0, 0, canvasWidth, canvasHeight); - - var grid = options.grid; - - // draw background, if any - if (grid.show && grid.backgroundColor) - drawBackground(); - - if (grid.show && !grid.aboveData) - drawGrid(); - - for (var i = 0; i < series.length; ++i) { - executeHooks(hooks.drawSeries, [ctx, series[i]]); - drawSeries(series[i]); - } - - executeHooks(hooks.draw, [ctx]); - - if (grid.show && grid.aboveData) - drawGrid(); - } - - function extractRange(ranges, coord) { - var axis, from, to, key, axes = allAxes(); - - for (i = 0; i < axes.length; ++i) { - axis = axes[i]; - if (axis.direction == coord) { - key = coord + axis.n + "axis"; - if (!ranges[key] && axis.n == 1) - key = coord + "axis"; // support x1axis as xaxis - if (ranges[key]) { - from = ranges[key].from; - to = ranges[key].to; - break; - } - } - } - - // backwards-compat stuff - to be removed in future - if (!ranges[key]) { - axis = coord == "x" ? xaxes[0] : yaxes[0]; - from = ranges[coord + "1"]; - to = ranges[coord + "2"]; - } - - // auto-reverse as an added bonus - if (from != null && to != null && from > to) { - var tmp = from; - from = to; - to = tmp; - } - - return { from: from, to: to, axis: axis }; - } - - function drawBackground() { - ctx.save(); - ctx.translate(plotOffset.left, plotOffset.top); - - ctx.fillStyle = getColorOrGradient(options.grid.backgroundColor, plotHeight, 0, "rgba(255, 255, 255, 0)"); - ctx.fillRect(0, 0, plotWidth, plotHeight); - ctx.restore(); - } - - function drawGrid() { - var i; - - ctx.save(); - ctx.translate(plotOffset.left, plotOffset.top); - - // draw markings - var markings = options.grid.markings; - if (markings) { - if ($.isFunction(markings)) { - var axes = plot.getAxes(); - // xmin etc. is backwards compatibility, to be - // removed in the future - axes.xmin = axes.xaxis.min; - axes.xmax = axes.xaxis.max; - axes.ymin = axes.yaxis.min; - axes.ymax = axes.yaxis.max; - - markings = markings(axes); - } - - for (i = 0; i < markings.length; ++i) { - var m = markings[i], - xrange = extractRange(m, "x"), - yrange = extractRange(m, "y"); - - // fill in missing - if (xrange.from == null) - xrange.from = xrange.axis.min; - if (xrange.to == null) - xrange.to = xrange.axis.max; - if (yrange.from == null) - yrange.from = yrange.axis.min; - if (yrange.to == null) - yrange.to = yrange.axis.max; - - // clip - if (xrange.to < xrange.axis.min || xrange.from > xrange.axis.max || - yrange.to < yrange.axis.min || yrange.from > yrange.axis.max) - continue; - - xrange.from = Math.max(xrange.from, xrange.axis.min); - xrange.to = Math.min(xrange.to, xrange.axis.max); - yrange.from = Math.max(yrange.from, yrange.axis.min); - yrange.to = Math.min(yrange.to, yrange.axis.max); - - if (xrange.from == xrange.to && yrange.from == yrange.to) - continue; - - // then draw - xrange.from = xrange.axis.p2c(xrange.from); - xrange.to = xrange.axis.p2c(xrange.to); - yrange.from = yrange.axis.p2c(yrange.from); - yrange.to = yrange.axis.p2c(yrange.to); - - if (xrange.from == xrange.to || yrange.from == yrange.to) { - // draw line - ctx.beginPath(); - ctx.strokeStyle = m.color || options.grid.markingsColor; - ctx.lineWidth = m.lineWidth || options.grid.markingsLineWidth; - ctx.moveTo(xrange.from, yrange.from); - ctx.lineTo(xrange.to, yrange.to); - ctx.stroke(); - } - else { - // fill area - ctx.fillStyle = m.color || options.grid.markingsColor; - ctx.fillRect(xrange.from, yrange.to, - xrange.to - xrange.from, - yrange.from - yrange.to); - } - } - } - - // draw the ticks - var axes = allAxes(), bw = options.grid.borderWidth; - - for (var j = 0; j < axes.length; ++j) { - var axis = axes[j], box = axis.box, - t = axis.tickLength, x, y, xoff, yoff; - if (!axis.show || axis.ticks.length == 0) - continue - - ctx.strokeStyle = axis.options.tickColor || $.color.parse(axis.options.color).scale('a', 0.22).toString(); - ctx.lineWidth = 1; - - // find the edges - if (axis.direction == "x") { - x = 0; - if (t == "full") - y = (axis.position == "top" ? 0 : plotHeight); - else - y = box.top - plotOffset.top + (axis.position == "top" ? box.height : 0); - } - else { - y = 0; - if (t == "full") - x = (axis.position == "left" ? 0 : plotWidth); - else - x = box.left - plotOffset.left + (axis.position == "left" ? box.width : 0); - } - - // draw tick bar - if (!axis.innermost) { - ctx.beginPath(); - xoff = yoff = 0; - if (axis.direction == "x") - xoff = plotWidth; - else - yoff = plotHeight; - - if (ctx.lineWidth == 1) { - x = Math.floor(x) + 0.5; - y = Math.floor(y) + 0.5; - } - - ctx.moveTo(x, y); - ctx.lineTo(x + xoff, y + yoff); - ctx.stroke(); - } - - // draw ticks - ctx.beginPath(); - for (i = 0; i < axis.ticks.length; ++i) { - var v = axis.ticks[i].v; - - xoff = yoff = 0; - - if (v < axis.min || v > axis.max - // skip those lying on the axes if we got a border - || (t == "full" && bw > 0 - && (v == axis.min || v == axis.max))) - continue; - - if (axis.direction == "x") { - x = axis.p2c(v); - yoff = t == "full" ? -plotHeight : t; - - if (axis.position == "top") - yoff = -yoff; - } - else { - y = axis.p2c(v); - xoff = t == "full" ? -plotWidth : t; - - if (axis.position == "left") - xoff = -xoff; - } - - if (ctx.lineWidth == 1) { - if (axis.direction == "x") - x = Math.floor(x) + 0.5; - else - y = Math.floor(y) + 0.5; - } - - ctx.moveTo(x, y); - ctx.lineTo(x + xoff, y + yoff); - } - - ctx.stroke(); - } - - - // draw border - if (bw) { - ctx.lineWidth = bw; - ctx.strokeStyle = options.grid.borderColor; - ctx.strokeRect(-bw/2, -bw/2, plotWidth + bw, plotHeight + bw); - } - - ctx.restore(); - } - - function insertAxisLabels() { - placeholder.find(".tickLabels").remove(); - - var html = ['
    ']; - - var axes = allAxes(); - for (var j = 0; j < axes.length; ++j) { - var axis = axes[j], box = axis.box; - if (!axis.show) - continue; - //debug: html.push('
    ') - html.push('
    '); - for (var i = 0; i < axis.ticks.length; ++i) { - var tick = axis.ticks[i]; - if (!tick.label || tick.v < axis.min || tick.v > axis.max) - continue; - - var pos = {}, align; - - if (axis.direction == "x") { - align = "center"; - pos.left = Math.round(plotOffset.left + axis.p2c(tick.v) - axis.labelWidth/2); - if (axis.position == "bottom") - pos.top = box.top + box.padding; - else - pos.bottom = canvasHeight - (box.top + box.height - box.padding); - } - else { - pos.top = Math.round(plotOffset.top + axis.p2c(tick.v) - axis.labelHeight/2); - if (axis.position == "left") { - pos.right = canvasWidth - (box.left + box.width - box.padding) - align = "right"; - } - else { - pos.left = box.left + box.padding; - align = "left"; - } - } - - pos.width = axis.labelWidth; - - var style = ["position:absolute", "text-align:" + align ]; - for (var a in pos) - style.push(a + ":" + pos[a] + "px") - - html.push('
    ' + tick.label + '
    '); - } - html.push('
    '); - } - - html.push('
    '); - - placeholder.append(html.join("")); - } - - function drawSeries(series) { - if (series.lines.show) - drawSeriesLines(series); - if (series.bars.show) - drawSeriesBars(series); - if (series.points.show) - drawSeriesPoints(series); - } - - function drawSeriesLines(series) { - function plotLine(datapoints, xoffset, yoffset, axisx, axisy) { - var points = datapoints.points, - ps = datapoints.pointsize, - prevx = null, prevy = null; - - ctx.beginPath(); - for (var i = ps; i < points.length; i += ps) { - var x1 = points[i - ps], y1 = points[i - ps + 1], - x2 = points[i], y2 = points[i + 1]; - - if (x1 == null || x2 == null) - continue; - - // clip with ymin - if (y1 <= y2 && y1 < axisy.min) { - if (y2 < axisy.min) - continue; // line segment is outside - // compute new intersection point - x1 = (axisy.min - y1) / (y2 - y1) * (x2 - x1) + x1; - y1 = axisy.min; - } - else if (y2 <= y1 && y2 < axisy.min) { - if (y1 < axisy.min) - continue; - x2 = (axisy.min - y1) / (y2 - y1) * (x2 - x1) + x1; - y2 = axisy.min; - } - - // clip with ymax - if (y1 >= y2 && y1 > axisy.max) { - if (y2 > axisy.max) - continue; - x1 = (axisy.max - y1) / (y2 - y1) * (x2 - x1) + x1; - y1 = axisy.max; - } - else if (y2 >= y1 && y2 > axisy.max) { - if (y1 > axisy.max) - continue; - x2 = (axisy.max - y1) / (y2 - y1) * (x2 - x1) + x1; - y2 = axisy.max; - } - - // clip with xmin - if (x1 <= x2 && x1 < axisx.min) { - if (x2 < axisx.min) - continue; - y1 = (axisx.min - x1) / (x2 - x1) * (y2 - y1) + y1; - x1 = axisx.min; - } - else if (x2 <= x1 && x2 < axisx.min) { - if (x1 < axisx.min) - continue; - y2 = (axisx.min - x1) / (x2 - x1) * (y2 - y1) + y1; - x2 = axisx.min; - } - - // clip with xmax - if (x1 >= x2 && x1 > axisx.max) { - if (x2 > axisx.max) - continue; - y1 = (axisx.max - x1) / (x2 - x1) * (y2 - y1) + y1; - x1 = axisx.max; - } - else if (x2 >= x1 && x2 > axisx.max) { - if (x1 > axisx.max) - continue; - y2 = (axisx.max - x1) / (x2 - x1) * (y2 - y1) + y1; - x2 = axisx.max; - } - - if (x1 != prevx || y1 != prevy) - ctx.moveTo(axisx.p2c(x1) + xoffset, axisy.p2c(y1) + yoffset); - - prevx = x2; - prevy = y2; - ctx.lineTo(axisx.p2c(x2) + xoffset, axisy.p2c(y2) + yoffset); - } - ctx.stroke(); - } - - function plotLineArea(datapoints, axisx, axisy) { - var points = datapoints.points, - ps = datapoints.pointsize, - bottom = Math.min(Math.max(0, axisy.min), axisy.max), - i = 0, top, areaOpen = false, - ypos = 1, segmentStart = 0, segmentEnd = 0; - - // we process each segment in two turns, first forward - // direction to sketch out top, then once we hit the - // end we go backwards to sketch the bottom - while (true) { - if (ps > 0 && i > points.length + ps) - break; - - i += ps; // ps is negative if going backwards - - var x1 = points[i - ps], - y1 = points[i - ps + ypos], - x2 = points[i], y2 = points[i + ypos]; - - if (areaOpen) { - if (ps > 0 && x1 != null && x2 == null) { - // at turning point - segmentEnd = i; - ps = -ps; - ypos = 2; - continue; - } - - if (ps < 0 && i == segmentStart + ps) { - // done with the reverse sweep - ctx.fill(); - areaOpen = false; - ps = -ps; - ypos = 1; - i = segmentStart = segmentEnd + ps; - continue; - } - } - - if (x1 == null || x2 == null) - continue; - - // clip x values - - // clip with xmin - if (x1 <= x2 && x1 < axisx.min) { - if (x2 < axisx.min) - continue; - y1 = (axisx.min - x1) / (x2 - x1) * (y2 - y1) + y1; - x1 = axisx.min; - } - else if (x2 <= x1 && x2 < axisx.min) { - if (x1 < axisx.min) - continue; - y2 = (axisx.min - x1) / (x2 - x1) * (y2 - y1) + y1; - x2 = axisx.min; - } - - // clip with xmax - if (x1 >= x2 && x1 > axisx.max) { - if (x2 > axisx.max) - continue; - y1 = (axisx.max - x1) / (x2 - x1) * (y2 - y1) + y1; - x1 = axisx.max; - } - else if (x2 >= x1 && x2 > axisx.max) { - if (x1 > axisx.max) - continue; - y2 = (axisx.max - x1) / (x2 - x1) * (y2 - y1) + y1; - x2 = axisx.max; - } - - if (!areaOpen) { - // open area - ctx.beginPath(); - ctx.moveTo(axisx.p2c(x1), axisy.p2c(bottom)); - areaOpen = true; - } - - // now first check the case where both is outside - if (y1 >= axisy.max && y2 >= axisy.max) { - ctx.lineTo(axisx.p2c(x1), axisy.p2c(axisy.max)); - ctx.lineTo(axisx.p2c(x2), axisy.p2c(axisy.max)); - continue; - } - else if (y1 <= axisy.min && y2 <= axisy.min) { - ctx.lineTo(axisx.p2c(x1), axisy.p2c(axisy.min)); - ctx.lineTo(axisx.p2c(x2), axisy.p2c(axisy.min)); - continue; - } - - // else it's a bit more complicated, there might - // be a flat maxed out rectangle first, then a - // triangular cutout or reverse; to find these - // keep track of the current x values - var x1old = x1, x2old = x2; - - // clip the y values, without shortcutting, we - // go through all cases in turn - - // clip with ymin - if (y1 <= y2 && y1 < axisy.min && y2 >= axisy.min) { - x1 = (axisy.min - y1) / (y2 - y1) * (x2 - x1) + x1; - y1 = axisy.min; - } - else if (y2 <= y1 && y2 < axisy.min && y1 >= axisy.min) { - x2 = (axisy.min - y1) / (y2 - y1) * (x2 - x1) + x1; - y2 = axisy.min; - } - - // clip with ymax - if (y1 >= y2 && y1 > axisy.max && y2 <= axisy.max) { - x1 = (axisy.max - y1) / (y2 - y1) * (x2 - x1) + x1; - y1 = axisy.max; - } - else if (y2 >= y1 && y2 > axisy.max && y1 <= axisy.max) { - x2 = (axisy.max - y1) / (y2 - y1) * (x2 - x1) + x1; - y2 = axisy.max; - } - - // if the x value was changed we got a rectangle - // to fill - if (x1 != x1old) { - ctx.lineTo(axisx.p2c(x1old), axisy.p2c(y1)); - // it goes to (x1, y1), but we fill that below - } - - // fill triangular section, this sometimes result - // in redundant points if (x1, y1) hasn't changed - // from previous line to, but we just ignore that - ctx.lineTo(axisx.p2c(x1), axisy.p2c(y1)); - ctx.lineTo(axisx.p2c(x2), axisy.p2c(y2)); - - // fill the other rectangle if it's there - if (x2 != x2old) { - ctx.lineTo(axisx.p2c(x2), axisy.p2c(y2)); - ctx.lineTo(axisx.p2c(x2old), axisy.p2c(y2)); - } - } - } - - ctx.save(); - ctx.translate(plotOffset.left, plotOffset.top); - ctx.lineJoin = "round"; - - var lw = series.lines.lineWidth, - sw = series.shadowSize; - // FIXME: consider another form of shadow when filling is turned on - if (lw > 0 && sw > 0) { - // draw shadow as a thick and thin line with transparency - ctx.lineWidth = sw; - ctx.strokeStyle = "rgba(0,0,0,0.1)"; - // position shadow at angle from the mid of line - var angle = Math.PI/18; - plotLine(series.datapoints, Math.sin(angle) * (lw/2 + sw/2), Math.cos(angle) * (lw/2 + sw/2), series.xaxis, series.yaxis); - ctx.lineWidth = sw/2; - plotLine(series.datapoints, Math.sin(angle) * (lw/2 + sw/4), Math.cos(angle) * (lw/2 + sw/4), series.xaxis, series.yaxis); - } - - ctx.lineWidth = lw; - ctx.strokeStyle = series.color; - var fillStyle = getFillStyle(series.lines, series.color, 0, plotHeight); - if (fillStyle) { - ctx.fillStyle = fillStyle; - plotLineArea(series.datapoints, series.xaxis, series.yaxis); - } - - if (lw > 0) - plotLine(series.datapoints, 0, 0, series.xaxis, series.yaxis); - ctx.restore(); - } - - function drawSeriesPoints(series) { - function plotPoints(datapoints, radius, fillStyle, offset, shadow, axisx, axisy, symbol) { - var points = datapoints.points, ps = datapoints.pointsize; - - for (var i = 0; i < points.length; i += ps) { - var x = points[i], y = points[i + 1]; - if (x == null || x < axisx.min || x > axisx.max || y < axisy.min || y > axisy.max) - continue; - - ctx.beginPath(); - x = axisx.p2c(x); - y = axisy.p2c(y) + offset; - if (symbol == "circle") - ctx.arc(x, y, radius, 0, shadow ? Math.PI : Math.PI * 2, false); - else - symbol(ctx, x, y, radius, shadow); - ctx.closePath(); - - if (fillStyle) { - ctx.fillStyle = fillStyle; - ctx.fill(); - } - ctx.stroke(); - } - } - - ctx.save(); - ctx.translate(plotOffset.left, plotOffset.top); - - var lw = series.points.lineWidth, - sw = series.shadowSize, - radius = series.points.radius, - symbol = series.points.symbol; - if (lw > 0 && sw > 0) { - // draw shadow in two steps - var w = sw / 2; - ctx.lineWidth = w; - ctx.strokeStyle = "rgba(0,0,0,0.1)"; - plotPoints(series.datapoints, radius, null, w + w/2, true, - series.xaxis, series.yaxis, symbol); - - ctx.strokeStyle = "rgba(0,0,0,0.2)"; - plotPoints(series.datapoints, radius, null, w/2, true, - series.xaxis, series.yaxis, symbol); - } - - ctx.lineWidth = lw; - ctx.strokeStyle = series.color; - plotPoints(series.datapoints, radius, - getFillStyle(series.points, series.color), 0, false, - series.xaxis, series.yaxis, symbol); - ctx.restore(); - } - - function drawBar(x, y, b, barLeft, barRight, offset, fillStyleCallback, axisx, axisy, c, horizontal, lineWidth) { - var left, right, bottom, top, - drawLeft, drawRight, drawTop, drawBottom, - tmp; - - // in horizontal mode, we start the bar from the left - // instead of from the bottom so it appears to be - // horizontal rather than vertical - if (horizontal) { - drawBottom = drawRight = drawTop = true; - drawLeft = false; - left = b; - right = x; - top = y + barLeft; - bottom = y + barRight; - - // account for negative bars - if (right < left) { - tmp = right; - right = left; - left = tmp; - drawLeft = true; - drawRight = false; - } - } - else { - drawLeft = drawRight = drawTop = true; - drawBottom = false; - left = x + barLeft; - right = x + barRight; - bottom = b; - top = y; - - // account for negative bars - if (top < bottom) { - tmp = top; - top = bottom; - bottom = tmp; - drawBottom = true; - drawTop = false; - } - } - - // clip - if (right < axisx.min || left > axisx.max || - top < axisy.min || bottom > axisy.max) - return; - - if (left < axisx.min) { - left = axisx.min; - drawLeft = false; - } - - if (right > axisx.max) { - right = axisx.max; - drawRight = false; - } - - if (bottom < axisy.min) { - bottom = axisy.min; - drawBottom = false; - } - - if (top > axisy.max) { - top = axisy.max; - drawTop = false; - } - - left = axisx.p2c(left); - bottom = axisy.p2c(bottom); - right = axisx.p2c(right); - top = axisy.p2c(top); - - // fill the bar - if (fillStyleCallback) { - c.beginPath(); - c.moveTo(left, bottom); - c.lineTo(left, top); - c.lineTo(right, top); - c.lineTo(right, bottom); - c.fillStyle = fillStyleCallback(bottom, top); - c.fill(); - } - - // draw outline - if (lineWidth > 0 && (drawLeft || drawRight || drawTop || drawBottom)) { - c.beginPath(); - - // FIXME: inline moveTo is buggy with excanvas - c.moveTo(left, bottom + offset); - if (drawLeft) - c.lineTo(left, top + offset); - else - c.moveTo(left, top + offset); - if (drawTop) - c.lineTo(right, top + offset); - else - c.moveTo(right, top + offset); - if (drawRight) - c.lineTo(right, bottom + offset); - else - c.moveTo(right, bottom + offset); - if (drawBottom) - c.lineTo(left, bottom + offset); - else - c.moveTo(left, bottom + offset); - c.stroke(); - } - } - - function drawSeriesBars(series) { - function plotBars(datapoints, barLeft, barRight, offset, fillStyleCallback, axisx, axisy) { - var points = datapoints.points, ps = datapoints.pointsize; - - for (var i = 0; i < points.length; i += ps) { - if (points[i] == null) - continue; - drawBar(points[i], points[i + 1], points[i + 2], barLeft, barRight, offset, fillStyleCallback, axisx, axisy, ctx, series.bars.horizontal, series.bars.lineWidth); - } - } - - ctx.save(); - ctx.translate(plotOffset.left, plotOffset.top); - - // FIXME: figure out a way to add shadows (for instance along the right edge) - ctx.lineWidth = series.bars.lineWidth; - ctx.strokeStyle = series.color; - var barLeft = series.bars.align == "left" ? 0 : -series.bars.barWidth/2; - var fillStyleCallback = series.bars.fill ? function (bottom, top) { return getFillStyle(series.bars, series.color, bottom, top); } : null; - plotBars(series.datapoints, barLeft, barLeft + series.bars.barWidth, 0, fillStyleCallback, series.xaxis, series.yaxis); - ctx.restore(); - } - - function getFillStyle(filloptions, seriesColor, bottom, top) { - var fill = filloptions.fill; - if (!fill) - return null; - - if (filloptions.fillColor) - return getColorOrGradient(filloptions.fillColor, bottom, top, seriesColor); - - var c = $.color.parse(seriesColor); - c.a = typeof fill == "number" ? fill : 0.4; - c.normalize(); - return c.toString(); - } - - function insertLegend() { - placeholder.find(".legend").remove(); - - if (!options.legend.show) - return; - - var fragments = [], rowStarted = false, - lf = options.legend.labelFormatter, s, label; - for (var i = 0; i < series.length; ++i) { - s = series[i]; - label = s.label; - if (!label) - continue; - - if (i % options.legend.noColumns == 0) { - if (rowStarted) - fragments.push(''); - fragments.push('
    '); - rowStarted = true; - } - - if (lf) - label = lf(label, s); - - fragments.push( - '' + - ''); - } - if (rowStarted) - fragments.push(''); - - if (fragments.length == 0) - return; - - var table = '
    ' + label + '
    ' + fragments.join("") + '
    '; - if (options.legend.container != null) - $(options.legend.container).html(table); - else { - var pos = "", - p = options.legend.position, - m = options.legend.margin; - if (m[0] == null) - m = [m, m]; - if (p.charAt(0) == "n") - pos += 'top:' + (m[1] + plotOffset.top) + 'px;'; - else if (p.charAt(0) == "s") - pos += 'bottom:' + (m[1] + plotOffset.bottom) + 'px;'; - if (p.charAt(1) == "e") - pos += 'right:' + (m[0] + plotOffset.right) + 'px;'; - else if (p.charAt(1) == "w") - pos += 'left:' + (m[0] + plotOffset.left) + 'px;'; - var legend = $('
    ' + table.replace('style="', 'style="position:absolute;' + pos +';') + '
    ').appendTo(placeholder); - if (options.legend.backgroundOpacity != 0.0) { - // put in the transparent background - // separately to avoid blended labels and - // label boxes - var c = options.legend.backgroundColor; - if (c == null) { - c = options.grid.backgroundColor; - if (c && typeof c == "string") - c = $.color.parse(c); - else - c = $.color.extract(legend, 'background-color'); - c.a = 1; - c = c.toString(); - } - var div = legend.children(); - $('
    ').prependTo(legend).css('opacity', options.legend.backgroundOpacity); - } - } - } - - - // interactive features - - var highlights = [], - redrawTimeout = null; - - // returns the data item the mouse is over, or null if none is found - function findNearbyItem(mouseX, mouseY, seriesFilter) { - var maxDistance = options.grid.mouseActiveRadius, - smallestDistance = maxDistance * maxDistance + 1, - item = null, foundPoint = false, i, j; - - for (i = series.length - 1; i >= 0; --i) { - if (!seriesFilter(series[i])) - continue; - - var s = series[i], - axisx = s.xaxis, - axisy = s.yaxis, - points = s.datapoints.points, - ps = s.datapoints.pointsize, - mx = axisx.c2p(mouseX), // precompute some stuff to make the loop faster - my = axisy.c2p(mouseY), - maxx = maxDistance / axisx.scale, - maxy = maxDistance / axisy.scale; - - // with inverse transforms, we can't use the maxx/maxy - // optimization, sadly - if (axisx.options.inverseTransform) - maxx = Number.MAX_VALUE; - if (axisy.options.inverseTransform) - maxy = Number.MAX_VALUE; - - if (s.lines.show || s.points.show) { - for (j = 0; j < points.length; j += ps) { - var x = points[j], y = points[j + 1]; - if (x == null) - continue; - - // For points and lines, the cursor must be within a - // certain distance to the data point - if (x - mx > maxx || x - mx < -maxx || - y - my > maxy || y - my < -maxy) - continue; - - // We have to calculate distances in pixels, not in - // data units, because the scales of the axes may be different - var dx = Math.abs(axisx.p2c(x) - mouseX), - dy = Math.abs(axisy.p2c(y) - mouseY), - dist = dx * dx + dy * dy; // we save the sqrt - - // use <= to ensure last point takes precedence - // (last generally means on top of) - if (dist < smallestDistance) { - smallestDistance = dist; - item = [i, j / ps]; - } - } - } - - if (s.bars.show && !item) { // no other point can be nearby - var barLeft = s.bars.align == "left" ? 0 : -s.bars.barWidth/2, - barRight = barLeft + s.bars.barWidth; - - for (j = 0; j < points.length; j += ps) { - var x = points[j], y = points[j + 1], b = points[j + 2]; - if (x == null) - continue; - - // for a bar graph, the cursor must be inside the bar - if (series[i].bars.horizontal ? - (mx <= Math.max(b, x) && mx >= Math.min(b, x) && - my >= y + barLeft && my <= y + barRight) : - (mx >= x + barLeft && mx <= x + barRight && - my >= Math.min(b, y) && my <= Math.max(b, y))) - item = [i, j / ps]; - } - } - } - - if (item) { - i = item[0]; - j = item[1]; - ps = series[i].datapoints.pointsize; - - return { datapoint: series[i].datapoints.points.slice(j * ps, (j + 1) * ps), - dataIndex: j, - series: series[i], - seriesIndex: i }; - } - - return null; - } - - function onMouseMove(e) { - if (options.grid.hoverable) - triggerClickHoverEvent("plothover", e, - function (s) { return s["hoverable"] != false; }); - } - - function onMouseLeave(e) { - if (options.grid.hoverable) - triggerClickHoverEvent("plothover", e, - function (s) { return false; }); - } - - function onClick(e) { - triggerClickHoverEvent("plotclick", e, - function (s) { return s["clickable"] != false; }); - } - - // trigger click or hover event (they send the same parameters - // so we share their code) - function triggerClickHoverEvent(eventname, event, seriesFilter) { - var offset = eventHolder.offset(), - canvasX = event.pageX - offset.left - plotOffset.left, - canvasY = event.pageY - offset.top - plotOffset.top, - pos = canvasToAxisCoords({ left: canvasX, top: canvasY }); - - pos.pageX = event.pageX; - pos.pageY = event.pageY; - - var item = findNearbyItem(canvasX, canvasY, seriesFilter); - - if (item) { - // fill in mouse pos for any listeners out there - item.pageX = parseInt(item.series.xaxis.p2c(item.datapoint[0]) + offset.left + plotOffset.left); - item.pageY = parseInt(item.series.yaxis.p2c(item.datapoint[1]) + offset.top + plotOffset.top); - } - - if (options.grid.autoHighlight) { - // clear auto-highlights - for (var i = 0; i < highlights.length; ++i) { - var h = highlights[i]; - if (h.auto == eventname && - !(item && h.series == item.series && - h.point[0] == item.datapoint[0] && - h.point[1] == item.datapoint[1])) - unhighlight(h.series, h.point); - } - - if (item) - highlight(item.series, item.datapoint, eventname); - } - - placeholder.trigger(eventname, [ pos, item ]); - } - - function triggerRedrawOverlay() { - if (!redrawTimeout) - redrawTimeout = setTimeout(drawOverlay, 30); - } - - function drawOverlay() { - redrawTimeout = null; - - // draw highlights - octx.save(); - octx.clearRect(0, 0, canvasWidth, canvasHeight); - octx.translate(plotOffset.left, plotOffset.top); - - var i, hi; - for (i = 0; i < highlights.length; ++i) { - hi = highlights[i]; - - if (hi.series.bars.show) - drawBarHighlight(hi.series, hi.point); - else - drawPointHighlight(hi.series, hi.point); - } - octx.restore(); - - executeHooks(hooks.drawOverlay, [octx]); - } - - function highlight(s, point, auto) { - if (typeof s == "number") - s = series[s]; - - if (typeof point == "number") { - var ps = s.datapoints.pointsize; - point = s.datapoints.points.slice(ps * point, ps * (point + 1)); - } - - var i = indexOfHighlight(s, point); - if (i == -1) { - highlights.push({ series: s, point: point, auto: auto }); - - triggerRedrawOverlay(); - } - else if (!auto) - highlights[i].auto = false; - } - - function unhighlight(s, point) { - if (s == null && point == null) { - highlights = []; - triggerRedrawOverlay(); - } - - if (typeof s == "number") - s = series[s]; - - if (typeof point == "number") - point = s.data[point]; - - var i = indexOfHighlight(s, point); - if (i != -1) { - highlights.splice(i, 1); - - triggerRedrawOverlay(); - } - } - - function indexOfHighlight(s, p) { - for (var i = 0; i < highlights.length; ++i) { - var h = highlights[i]; - if (h.series == s && h.point[0] == p[0] - && h.point[1] == p[1]) - return i; - } - return -1; - } - - function drawPointHighlight(series, point) { - var x = point[0], y = point[1], - axisx = series.xaxis, axisy = series.yaxis; - - if (x < axisx.min || x > axisx.max || y < axisy.min || y > axisy.max) - return; - - var pointRadius = series.points.radius + series.points.lineWidth / 2; - octx.lineWidth = pointRadius; - octx.strokeStyle = $.color.parse(series.color).scale('a', 0.5).toString(); - var radius = 1.5 * pointRadius, - x = axisx.p2c(x), - y = axisy.p2c(y); - - octx.beginPath(); - if (series.points.symbol == "circle") - octx.arc(x, y, radius, 0, 2 * Math.PI, false); - else - series.points.symbol(octx, x, y, radius, false); - octx.closePath(); - octx.stroke(); - } - - function drawBarHighlight(series, point) { - octx.lineWidth = series.bars.lineWidth; - octx.strokeStyle = $.color.parse(series.color).scale('a', 0.5).toString(); - var fillStyle = $.color.parse(series.color).scale('a', 0.5).toString(); - var barLeft = series.bars.align == "left" ? 0 : -series.bars.barWidth/2; - drawBar(point[0], point[1], point[2] || 0, barLeft, barLeft + series.bars.barWidth, - 0, function () { return fillStyle; }, series.xaxis, series.yaxis, octx, series.bars.horizontal, series.bars.lineWidth); - } - - function getColorOrGradient(spec, bottom, top, defaultColor) { - if (typeof spec == "string") - return spec; - else { - // assume this is a gradient spec; IE currently only - // supports a simple vertical gradient properly, so that's - // what we support too - var gradient = ctx.createLinearGradient(0, top, 0, bottom); - - for (var i = 0, l = spec.colors.length; i < l; ++i) { - var c = spec.colors[i]; - if (typeof c != "string") { - var co = $.color.parse(defaultColor); - if (c.brightness != null) - co = co.scale('rgb', c.brightness) - if (c.opacity != null) - co.a *= c.opacity; - c = co.toString(); - } - gradient.addColorStop(i / (l - 1), c); - } - - return gradient; - } - } - } - - $.plot = function(placeholder, data, options) { - //var t0 = new Date(); - var plot = new Plot($(placeholder), data, options, $.plot.plugins); - //(window.console ? console.log : alert)("time used (msecs): " + ((new Date()).getTime() - t0.getTime())); - return plot; - }; - - $.plot.version = "0.7"; - - $.plot.plugins = []; - - // returns a string with the date d formatted according to fmt - $.plot.formatDate = function(d, fmt, monthNames) { - var leftPad = function(n) { - n = "" + n; - return n.length == 1 ? "0" + n : n; - }; - - var r = []; - var escape = false, padNext = false; - var hours = d.getUTCHours(); - var isAM = hours < 12; - if (monthNames == null) - monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; - - if (fmt.search(/%p|%P/) != -1) { - if (hours > 12) { - hours = hours - 12; - } else if (hours == 0) { - hours = 12; - } - } - for (var i = 0; i < fmt.length; ++i) { - var c = fmt.charAt(i); - - if (escape) { - switch (c) { - case 'h': c = "" + hours; break; - case 'H': c = leftPad(hours); break; - case 'M': c = leftPad(d.getUTCMinutes()); break; - case 'S': c = leftPad(d.getUTCSeconds()); break; - case 'd': c = "" + d.getUTCDate(); break; - case 'm': c = "" + (d.getUTCMonth() + 1); break; - case 'y': c = "" + d.getUTCFullYear(); break; - case 'b': c = "" + monthNames[d.getUTCMonth()]; break; - case 'p': c = (isAM) ? ("" + "am") : ("" + "pm"); break; - case 'P': c = (isAM) ? ("" + "AM") : ("" + "PM"); break; - case '0': c = ""; padNext = true; break; - } - if (c && padNext) { - c = leftPad(c); - padNext = false; - } - r.push(c); - if (!padNext) - escape = false; - } - else { - if (c == "%") - escape = true; - else - r.push(c); - } - } - return r.join(""); - }; - - // round to nearby lower multiple of base - function floorInBase(n, base) { - return base * Math.floor(n / base); - } - -})(jQuery); diff --git a/frontend/src/vendor/jquery.flot/jquery.flot.min.js b/frontend/src/vendor/jquery.flot/jquery.flot.min.js deleted file mode 100644 index 4467fc5d8cd3..000000000000 --- a/frontend/src/vendor/jquery.flot/jquery.flot.min.js +++ /dev/null @@ -1,6 +0,0 @@ -/* Javascript plotting library for jQuery, v. 0.7. - * - * Released under the MIT license by IOLA, December 2007. - * - */ -(function(b){b.color={};b.color.make=function(d,e,g,f){var c={};c.r=d||0;c.g=e||0;c.b=g||0;c.a=f!=null?f:1;c.add=function(h,j){for(var k=0;k=1){return"rgb("+[c.r,c.g,c.b].join(",")+")"}else{return"rgba("+[c.r,c.g,c.b,c.a].join(",")+")"}};c.normalize=function(){function h(k,j,l){return jl?l:j)}c.r=h(0,parseInt(c.r),255);c.g=h(0,parseInt(c.g),255);c.b=h(0,parseInt(c.b),255);c.a=h(0,c.a,1);return c};c.clone=function(){return b.color.make(c.r,c.b,c.g,c.a)};return c.normalize()};b.color.extract=function(d,e){var c;do{c=d.css(e).toLowerCase();if(c!=""&&c!="transparent"){break}d=d.parent()}while(!b.nodeName(d.get(0),"body"));if(c=="rgba(0, 0, 0, 0)"){c="transparent"}return b.color.parse(c)};b.color.parse=function(c){var d,f=b.color.make;if(d=/rgb\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)/.exec(c)){return f(parseInt(d[1],10),parseInt(d[2],10),parseInt(d[3],10))}if(d=/rgba\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)/.exec(c)){return f(parseInt(d[1],10),parseInt(d[2],10),parseInt(d[3],10),parseFloat(d[4]))}if(d=/rgb\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*\)/.exec(c)){return f(parseFloat(d[1])*2.55,parseFloat(d[2])*2.55,parseFloat(d[3])*2.55)}if(d=/rgba\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)/.exec(c)){return f(parseFloat(d[1])*2.55,parseFloat(d[2])*2.55,parseFloat(d[3])*2.55,parseFloat(d[4]))}if(d=/#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})/.exec(c)){return f(parseInt(d[1],16),parseInt(d[2],16),parseInt(d[3],16))}if(d=/#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])/.exec(c)){return f(parseInt(d[1]+d[1],16),parseInt(d[2]+d[2],16),parseInt(d[3]+d[3],16))}var e=b.trim(c).toLowerCase();if(e=="transparent"){return f(255,255,255,0)}else{d=a[e]||[0,0,0];return f(d[0],d[1],d[2])}};var a={aqua:[0,255,255],azure:[240,255,255],beige:[245,245,220],black:[0,0,0],blue:[0,0,255],brown:[165,42,42],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgrey:[169,169,169],darkgreen:[0,100,0],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkviolet:[148,0,211],fuchsia:[255,0,255],gold:[255,215,0],green:[0,128,0],indigo:[75,0,130],khaki:[240,230,140],lightblue:[173,216,230],lightcyan:[224,255,255],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightyellow:[255,255,224],lime:[0,255,0],magenta:[255,0,255],maroon:[128,0,0],navy:[0,0,128],olive:[128,128,0],orange:[255,165,0],pink:[255,192,203],purple:[128,0,128],violet:[128,0,128],red:[255,0,0],silver:[192,192,192],white:[255,255,255],yellow:[255,255,0]}})(jQuery);(function(c){function b(av,ai,J,af){var Q=[],O={colors:["#edc240","#afd8f8","#cb4b4b","#4da74d","#9440ed"],legend:{show:true,noColumns:1,labelFormatter:null,labelBoxBorderColor:"#ccc",container:null,position:"ne",margin:5,backgroundColor:null,backgroundOpacity:0.85},xaxis:{show:null,position:"bottom",mode:null,color:null,tickColor:null,transform:null,inverseTransform:null,min:null,max:null,autoscaleMargin:null,ticks:null,tickFormatter:null,labelWidth:null,labelHeight:null,reserveSpace:null,tickLength:null,alignTicksWithAxis:null,tickDecimals:null,tickSize:null,minTickSize:null,monthNames:null,timeformat:null,twelveHourClock:false},yaxis:{autoscaleMargin:0.02,position:"left"},xaxes:[],yaxes:[],series:{points:{show:false,radius:3,lineWidth:2,fill:true,fillColor:"#ffffff",symbol:"circle"},lines:{lineWidth:2,fill:false,fillColor:null,steps:false},bars:{show:false,lineWidth:2,barWidth:1,fill:true,fillColor:null,align:"left",horizontal:false},shadowSize:3},grid:{show:true,aboveData:false,color:"#545454",backgroundColor:null,borderColor:null,tickColor:null,labelMargin:5,axisMargin:8,borderWidth:2,minBorderMargin:null,markings:null,markingsColor:"#f4f4f4",markingsLineWidth:2,clickable:false,hoverable:false,autoHighlight:true,mouseActiveRadius:10},hooks:{}},az=null,ad=null,y=null,H=null,A=null,p=[],aw=[],q={left:0,right:0,top:0,bottom:0},G=0,I=0,h=0,w=0,ak={processOptions:[],processRawData:[],processDatapoints:[],drawSeries:[],draw:[],bindEvents:[],drawOverlay:[],shutdown:[]},aq=this;aq.setData=aj;aq.setupGrid=t;aq.draw=W;aq.getPlaceholder=function(){return av};aq.getCanvas=function(){return az};aq.getPlotOffset=function(){return q};aq.width=function(){return h};aq.height=function(){return w};aq.offset=function(){var aB=y.offset();aB.left+=q.left;aB.top+=q.top;return aB};aq.getData=function(){return Q};aq.getAxes=function(){var aC={},aB;c.each(p.concat(aw),function(aD,aE){if(aE){aC[aE.direction+(aE.n!=1?aE.n:"")+"axis"]=aE}});return aC};aq.getXAxes=function(){return p};aq.getYAxes=function(){return aw};aq.c2p=C;aq.p2c=ar;aq.getOptions=function(){return O};aq.highlight=x;aq.unhighlight=T;aq.triggerRedrawOverlay=f;aq.pointOffset=function(aB){return{left:parseInt(p[aA(aB,"x")-1].p2c(+aB.x)+q.left),top:parseInt(aw[aA(aB,"y")-1].p2c(+aB.y)+q.top)}};aq.shutdown=ag;aq.resize=function(){B();g(az);g(ad)};aq.hooks=ak;F(aq);Z(J);X();aj(ai);t();W();ah();function an(aD,aB){aB=[aq].concat(aB);for(var aC=0;aC=O.colors.length){aG=0;++aF}}var aH=0,aN;for(aG=0;aGa3.datamax&&a1!=aB){a3.datamax=a1}}c.each(m(),function(a1,a2){a2.datamin=aO;a2.datamax=aI;a2.used=false});for(aU=0;aU0&&aT[aR-aP]!=null&&aT[aR-aP]!=aT[aR]&&aT[aR-aP+1]!=aT[aR+1]){for(aN=0;aNaM){aM=a0}}if(aX.y){if(a0aV){aV=a0}}}}if(aJ.bars.show){var aY=aJ.bars.align=="left"?0:-aJ.bars.barWidth/2;if(aJ.bars.horizontal){aQ+=aY;aV+=aY+aJ.bars.barWidth}else{aK+=aY;aM+=aY+aJ.bars.barWidth}}aF(aJ.xaxis,aK,aM);aF(aJ.yaxis,aQ,aV)}c.each(m(),function(a1,a2){if(a2.datamin==aO){a2.datamin=null}if(a2.datamax==aI){a2.datamax=null}})}function j(aB,aC){var aD=document.createElement("canvas");aD.className=aC;aD.width=G;aD.height=I;if(!aB){c(aD).css({position:"absolute",left:0,top:0})}c(aD).appendTo(av);if(!aD.getContext){aD=window.G_vmlCanvasManager.initElement(aD)}aD.getContext("2d").save();return aD}function B(){G=av.width();I=av.height();if(G<=0||I<=0){throw"Invalid dimensions for plot, width = "+G+", height = "+I}}function g(aC){if(aC.width!=G){aC.width=G}if(aC.height!=I){aC.height=I}var aB=aC.getContext("2d");aB.restore();aB.save()}function X(){var aC,aB=av.children("canvas.base"),aD=av.children("canvas.overlay");if(aB.length==0||aD==0){av.html("");av.css({padding:0});if(av.css("position")=="static"){av.css("position","relative")}B();az=j(true,"base");ad=j(false,"overlay");aC=false}else{az=aB.get(0);ad=aD.get(0);aC=true}H=az.getContext("2d");A=ad.getContext("2d");y=c([ad,az]);if(aC){av.data("plot").shutdown();aq.resize();A.clearRect(0,0,G,I);y.unbind();av.children().not([az,ad]).remove()}av.data("plot",aq)}function ah(){if(O.grid.hoverable){y.mousemove(aa);y.mouseleave(l)}if(O.grid.clickable){y.click(R)}an(ak.bindEvents,[y])}function ag(){if(M){clearTimeout(M)}y.unbind("mousemove",aa);y.unbind("mouseleave",l);y.unbind("click",R);an(ak.shutdown,[y])}function r(aG){function aC(aH){return aH}var aF,aB,aD=aG.options.transform||aC,aE=aG.options.inverseTransform;if(aG.direction=="x"){aF=aG.scale=h/Math.abs(aD(aG.max)-aD(aG.min));aB=Math.min(aD(aG.max),aD(aG.min))}else{aF=aG.scale=w/Math.abs(aD(aG.max)-aD(aG.min));aF=-aF;aB=Math.max(aD(aG.max),aD(aG.min))}if(aD==aC){aG.p2c=function(aH){return(aH-aB)*aF}}else{aG.p2c=function(aH){return(aD(aH)-aB)*aF}}if(!aE){aG.c2p=function(aH){return aB+aH/aF}}else{aG.c2p=function(aH){return aE(aB+aH/aF)}}}function L(aD){var aB=aD.options,aF,aJ=aD.ticks||[],aI=[],aE,aK=aB.labelWidth,aG=aB.labelHeight,aC;function aH(aM,aL){return c('
    '+aM.join("")+"
    ").appendTo(av)}if(aD.direction=="x"){if(aK==null){aK=Math.floor(G/(aJ.length>0?aJ.length:1))}if(aG==null){aI=[];for(aF=0;aF'+aE+"
    ")}}if(aI.length>0){aI.push('
    ');aC=aH(aI,"width:10000px;");aG=aC.height();aC.remove()}}}else{if(aK==null||aG==null){for(aF=0;aF'+aE+"
    ")}}if(aI.length>0){aC=aH(aI,"");if(aK==null){aK=aC.children().width()}if(aG==null){aG=aC.find("div.tickLabel").height()}aC.remove()}}}if(aK==null){aK=0}if(aG==null){aG=0}aD.labelWidth=aK;aD.labelHeight=aG}function au(aD){var aC=aD.labelWidth,aL=aD.labelHeight,aH=aD.options.position,aF=aD.options.tickLength,aG=O.grid.axisMargin,aJ=O.grid.labelMargin,aK=aD.direction=="x"?p:aw,aE;var aB=c.grep(aK,function(aN){return aN&&aN.options.position==aH&&aN.reserveSpace});if(c.inArray(aD,aB)==aB.length-1){aG=0}if(aF==null){aF="full"}var aI=c.grep(aK,function(aN){return aN&&aN.reserveSpace});var aM=c.inArray(aD,aI)==0;if(!aM&&aF=="full"){aF=5}if(!isNaN(+aF)){aJ+=+aF}if(aD.direction=="x"){aL+=aJ;if(aH=="bottom"){q.bottom+=aL+aG;aD.box={top:I-q.bottom,height:aL}}else{aD.box={top:q.top+aG,height:aL};q.top+=aL+aG}}else{aC+=aJ;if(aH=="left"){aD.box={left:q.left+aG,width:aC};q.left+=aC+aG}else{q.right+=aC+aG;aD.box={left:G-q.right,width:aC}}}aD.position=aH;aD.tickLength=aF;aD.box.padding=aJ;aD.innermost=aM}function U(aB){if(aB.direction=="x"){aB.box.left=q.left;aB.box.width=h}else{aB.box.top=q.top;aB.box.height=w}}function t(){var aC,aE=m();c.each(aE,function(aF,aG){aG.show=aG.options.show;if(aG.show==null){aG.show=aG.used}aG.reserveSpace=aG.show||aG.options.reserveSpace;n(aG)});allocatedAxes=c.grep(aE,function(aF){return aF.reserveSpace});q.left=q.right=q.top=q.bottom=0;if(O.grid.show){c.each(allocatedAxes,function(aF,aG){S(aG);P(aG);ap(aG,aG.ticks);L(aG)});for(aC=allocatedAxes.length-1;aC>=0;--aC){au(allocatedAxes[aC])}var aD=O.grid.minBorderMargin;if(aD==null){aD=0;for(aC=0;aC=0){aD=0}}if(aF.max==null){aB+=aH*aG;if(aB>0&&aE.datamax!=null&&aE.datamax<=0){aB=0}}}}aE.min=aD;aE.max=aB}function S(aG){var aM=aG.options;var aH;if(typeof aM.ticks=="number"&&aM.ticks>0){aH=aM.ticks}else{aH=0.3*Math.sqrt(aG.direction=="x"?G:I)}var aT=(aG.max-aG.min)/aH,aO,aB,aN,aR,aS,aQ,aI;if(aM.mode=="time"){var aJ={second:1000,minute:60*1000,hour:60*60*1000,day:24*60*60*1000,month:30*24*60*60*1000,year:365.2425*24*60*60*1000};var aK=[[1,"second"],[2,"second"],[5,"second"],[10,"second"],[30,"second"],[1,"minute"],[2,"minute"],[5,"minute"],[10,"minute"],[30,"minute"],[1,"hour"],[2,"hour"],[4,"hour"],[8,"hour"],[12,"hour"],[1,"day"],[2,"day"],[3,"day"],[0.25,"month"],[0.5,"month"],[1,"month"],[2,"month"],[3,"month"],[6,"month"],[1,"year"]];var aC=0;if(aM.minTickSize!=null){if(typeof aM.tickSize=="number"){aC=aM.tickSize}else{aC=aM.minTickSize[0]*aJ[aM.minTickSize[1]]}}for(var aS=0;aS=aC){break}}aO=aK[aS][0];aN=aK[aS][1];if(aN=="year"){aQ=Math.pow(10,Math.floor(Math.log(aT/aJ.year)/Math.LN10));aI=(aT/aJ.year)/aQ;if(aI<1.5){aO=1}else{if(aI<3){aO=2}else{if(aI<7.5){aO=5}else{aO=10}}}aO*=aQ}aG.tickSize=aM.tickSize||[aO,aN];aB=function(aX){var a2=[],a0=aX.tickSize[0],a3=aX.tickSize[1],a1=new Date(aX.min);var aW=a0*aJ[a3];if(a3=="second"){a1.setUTCSeconds(a(a1.getUTCSeconds(),a0))}if(a3=="minute"){a1.setUTCMinutes(a(a1.getUTCMinutes(),a0))}if(a3=="hour"){a1.setUTCHours(a(a1.getUTCHours(),a0))}if(a3=="month"){a1.setUTCMonth(a(a1.getUTCMonth(),a0))}if(a3=="year"){a1.setUTCFullYear(a(a1.getUTCFullYear(),a0))}a1.setUTCMilliseconds(0);if(aW>=aJ.minute){a1.setUTCSeconds(0)}if(aW>=aJ.hour){a1.setUTCMinutes(0)}if(aW>=aJ.day){a1.setUTCHours(0)}if(aW>=aJ.day*4){a1.setUTCDate(1)}if(aW>=aJ.year){a1.setUTCMonth(0)}var a5=0,a4=Number.NaN,aY;do{aY=a4;a4=a1.getTime();a2.push(a4);if(a3=="month"){if(a0<1){a1.setUTCDate(1);var aV=a1.getTime();a1.setUTCMonth(a1.getUTCMonth()+1);var aZ=a1.getTime();a1.setTime(a4+a5*aJ.hour+(aZ-aV)*a0);a5=a1.getUTCHours();a1.setUTCHours(0)}else{a1.setUTCMonth(a1.getUTCMonth()+a0)}}else{if(a3=="year"){a1.setUTCFullYear(a1.getUTCFullYear()+a0)}else{a1.setTime(a4+aW)}}}while(a4aU){aP=aU}aQ=Math.pow(10,-aP);aI=aT/aQ;if(aI<1.5){aO=1}else{if(aI<3){aO=2;if(aI>2.25&&(aU==null||aP+1<=aU)){aO=2.5;++aP}}else{if(aI<7.5){aO=5}else{aO=10}}}aO*=aQ;if(aM.minTickSize!=null&&aO0){if(aM.min==null){aG.min=Math.min(aG.min,aL[0])}if(aM.max==null&&aL.length>1){aG.max=Math.max(aG.max,aL[aL.length-1])}}aB=function(aX){var aY=[],aV,aW;for(aW=0;aW1&&/\..*0$/.test((aD[1]-aD[0]).toFixed(aE)))){aG.tickDecimals=aE}}}}aG.tickGenerator=aB;if(c.isFunction(aM.tickFormatter)){aG.tickFormatter=function(aV,aW){return""+aM.tickFormatter(aV,aW)}}else{aG.tickFormatter=aR}}function P(aF){var aH=aF.options.ticks,aG=[];if(aH==null||(typeof aH=="number"&&aH>0)){aG=aF.tickGenerator(aF)}else{if(aH){if(c.isFunction(aH)){aG=aH({min:aF.min,max:aF.max})}else{aG=aH}}}var aE,aB;aF.ticks=[];for(aE=0;aE1){aC=aD[1]}}else{aB=+aD}if(aC==null){aC=aF.tickFormatter(aB,aF)}if(!isNaN(aB)){aF.ticks.push({v:aB,label:aC})}}}function ap(aB,aC){if(aB.options.autoscaleMargin&&aC.length>0){if(aB.options.min==null){aB.min=Math.min(aB.min,aC[0].v)}if(aB.options.max==null&&aC.length>1){aB.max=Math.max(aB.max,aC[aC.length-1].v)}}}function W(){H.clearRect(0,0,G,I);var aC=O.grid;if(aC.show&&aC.backgroundColor){N()}if(aC.show&&!aC.aboveData){ac()}for(var aB=0;aBaG){var aC=aH;aH=aG;aG=aC}return{from:aH,to:aG,axis:aE}}function N(){H.save();H.translate(q.left,q.top);H.fillStyle=am(O.grid.backgroundColor,w,0,"rgba(255, 255, 255, 0)");H.fillRect(0,0,h,w);H.restore()}function ac(){var aF;H.save();H.translate(q.left,q.top);var aH=O.grid.markings;if(aH){if(c.isFunction(aH)){var aK=aq.getAxes();aK.xmin=aK.xaxis.min;aK.xmax=aK.xaxis.max;aK.ymin=aK.yaxis.min;aK.ymax=aK.yaxis.max;aH=aH(aK)}for(aF=0;aFaC.axis.max||aI.toaI.axis.max){continue}aC.from=Math.max(aC.from,aC.axis.min);aC.to=Math.min(aC.to,aC.axis.max);aI.from=Math.max(aI.from,aI.axis.min);aI.to=Math.min(aI.to,aI.axis.max);if(aC.from==aC.to&&aI.from==aI.to){continue}aC.from=aC.axis.p2c(aC.from);aC.to=aC.axis.p2c(aC.to);aI.from=aI.axis.p2c(aI.from);aI.to=aI.axis.p2c(aI.to);if(aC.from==aC.to||aI.from==aI.to){H.beginPath();H.strokeStyle=aD.color||O.grid.markingsColor;H.lineWidth=aD.lineWidth||O.grid.markingsLineWidth;H.moveTo(aC.from,aI.from);H.lineTo(aC.to,aI.to);H.stroke()}else{H.fillStyle=aD.color||O.grid.markingsColor;H.fillRect(aC.from,aI.to,aC.to-aC.from,aI.from-aI.to)}}}var aK=m(),aM=O.grid.borderWidth;for(var aE=0;aEaB.max||(aQ=="full"&&aM>0&&(aO==aB.min||aO==aB.max))){continue}if(aB.direction=="x"){aN=aB.p2c(aO);aJ=aQ=="full"?-w:aQ;if(aB.position=="top"){aJ=-aJ}}else{aL=aB.p2c(aO);aP=aQ=="full"?-h:aQ;if(aB.position=="left"){aP=-aP}}if(H.lineWidth==1){if(aB.direction=="x"){aN=Math.floor(aN)+0.5}else{aL=Math.floor(aL)+0.5}}H.moveTo(aN,aL);H.lineTo(aN+aP,aL+aJ)}H.stroke()}if(aM){H.lineWidth=aM;H.strokeStyle=O.grid.borderColor;H.strokeRect(-aM/2,-aM/2,h+aM,w+aM)}H.restore()}function k(){av.find(".tickLabels").remove();var aG=['
    '];var aJ=m();for(var aD=0;aD');for(var aE=0;aEaC.max){continue}var aK={},aI;if(aC.direction=="x"){aI="center";aK.left=Math.round(q.left+aC.p2c(aH.v)-aC.labelWidth/2);if(aC.position=="bottom"){aK.top=aF.top+aF.padding}else{aK.bottom=I-(aF.top+aF.height-aF.padding)}}else{aK.top=Math.round(q.top+aC.p2c(aH.v)-aC.labelHeight/2);if(aC.position=="left"){aK.right=G-(aF.left+aF.width-aF.padding);aI="right"}else{aK.left=aF.left+aF.padding;aI="left"}}aK.width=aC.labelWidth;var aB=["position:absolute","text-align:"+aI];for(var aL in aK){aB.push(aL+":"+aK[aL]+"px")}aG.push('
    '+aH.label+"
    ")}aG.push("
    ")}aG.push("
    ");av.append(aG.join(""))}function d(aB){if(aB.lines.show){at(aB)}if(aB.bars.show){e(aB)}if(aB.points.show){ao(aB)}}function at(aE){function aD(aP,aQ,aI,aU,aT){var aV=aP.points,aJ=aP.pointsize,aN=null,aM=null;H.beginPath();for(var aO=aJ;aO=aR&&aS>aT.max){if(aR>aT.max){continue}aL=(aT.max-aS)/(aR-aS)*(aK-aL)+aL;aS=aT.max}else{if(aR>=aS&&aR>aT.max){if(aS>aT.max){continue}aK=(aT.max-aS)/(aR-aS)*(aK-aL)+aL;aR=aT.max}}if(aL<=aK&&aL=aK&&aL>aU.max){if(aK>aU.max){continue}aS=(aU.max-aL)/(aK-aL)*(aR-aS)+aS;aL=aU.max}else{if(aK>=aL&&aK>aU.max){if(aL>aU.max){continue}aR=(aU.max-aL)/(aK-aL)*(aR-aS)+aS;aK=aU.max}}if(aL!=aN||aS!=aM){H.moveTo(aU.p2c(aL)+aQ,aT.p2c(aS)+aI)}aN=aK;aM=aR;H.lineTo(aU.p2c(aK)+aQ,aT.p2c(aR)+aI)}H.stroke()}function aF(aI,aQ,aP){var aW=aI.points,aV=aI.pointsize,aN=Math.min(Math.max(0,aP.min),aP.max),aX=0,aU,aT=false,aM=1,aL=0,aR=0;while(true){if(aV>0&&aX>aW.length+aV){break}aX+=aV;var aZ=aW[aX-aV],aK=aW[aX-aV+aM],aY=aW[aX],aJ=aW[aX+aM];if(aT){if(aV>0&&aZ!=null&&aY==null){aR=aX;aV=-aV;aM=2;continue}if(aV<0&&aX==aL+aV){H.fill();aT=false;aV=-aV;aM=1;aX=aL=aR+aV;continue}}if(aZ==null||aY==null){continue}if(aZ<=aY&&aZ=aY&&aZ>aQ.max){if(aY>aQ.max){continue}aK=(aQ.max-aZ)/(aY-aZ)*(aJ-aK)+aK;aZ=aQ.max}else{if(aY>=aZ&&aY>aQ.max){if(aZ>aQ.max){continue}aJ=(aQ.max-aZ)/(aY-aZ)*(aJ-aK)+aK;aY=aQ.max}}if(!aT){H.beginPath();H.moveTo(aQ.p2c(aZ),aP.p2c(aN));aT=true}if(aK>=aP.max&&aJ>=aP.max){H.lineTo(aQ.p2c(aZ),aP.p2c(aP.max));H.lineTo(aQ.p2c(aY),aP.p2c(aP.max));continue}else{if(aK<=aP.min&&aJ<=aP.min){H.lineTo(aQ.p2c(aZ),aP.p2c(aP.min));H.lineTo(aQ.p2c(aY),aP.p2c(aP.min));continue}}var aO=aZ,aS=aY;if(aK<=aJ&&aK=aP.min){aZ=(aP.min-aK)/(aJ-aK)*(aY-aZ)+aZ;aK=aP.min}else{if(aJ<=aK&&aJ=aP.min){aY=(aP.min-aK)/(aJ-aK)*(aY-aZ)+aZ;aJ=aP.min}}if(aK>=aJ&&aK>aP.max&&aJ<=aP.max){aZ=(aP.max-aK)/(aJ-aK)*(aY-aZ)+aZ;aK=aP.max}else{if(aJ>=aK&&aJ>aP.max&&aK<=aP.max){aY=(aP.max-aK)/(aJ-aK)*(aY-aZ)+aZ;aJ=aP.max}}if(aZ!=aO){H.lineTo(aQ.p2c(aO),aP.p2c(aK))}H.lineTo(aQ.p2c(aZ),aP.p2c(aK));H.lineTo(aQ.p2c(aY),aP.p2c(aJ));if(aY!=aS){H.lineTo(aQ.p2c(aY),aP.p2c(aJ));H.lineTo(aQ.p2c(aS),aP.p2c(aJ))}}}H.save();H.translate(q.left,q.top);H.lineJoin="round";var aG=aE.lines.lineWidth,aB=aE.shadowSize;if(aG>0&&aB>0){H.lineWidth=aB;H.strokeStyle="rgba(0,0,0,0.1)";var aH=Math.PI/18;aD(aE.datapoints,Math.sin(aH)*(aG/2+aB/2),Math.cos(aH)*(aG/2+aB/2),aE.xaxis,aE.yaxis);H.lineWidth=aB/2;aD(aE.datapoints,Math.sin(aH)*(aG/2+aB/4),Math.cos(aH)*(aG/2+aB/4),aE.xaxis,aE.yaxis)}H.lineWidth=aG;H.strokeStyle=aE.color;var aC=ae(aE.lines,aE.color,0,w);if(aC){H.fillStyle=aC;aF(aE.datapoints,aE.xaxis,aE.yaxis)}if(aG>0){aD(aE.datapoints,0,0,aE.xaxis,aE.yaxis)}H.restore()}function ao(aE){function aH(aN,aM,aU,aK,aS,aT,aQ,aJ){var aR=aN.points,aI=aN.pointsize;for(var aL=0;aLaT.max||aOaQ.max){continue}H.beginPath();aP=aT.p2c(aP);aO=aQ.p2c(aO)+aK;if(aJ=="circle"){H.arc(aP,aO,aM,0,aS?Math.PI:Math.PI*2,false)}else{aJ(H,aP,aO,aM,aS)}H.closePath();if(aU){H.fillStyle=aU;H.fill()}H.stroke()}}H.save();H.translate(q.left,q.top);var aG=aE.points.lineWidth,aC=aE.shadowSize,aB=aE.points.radius,aF=aE.points.symbol;if(aG>0&&aC>0){var aD=aC/2;H.lineWidth=aD;H.strokeStyle="rgba(0,0,0,0.1)";aH(aE.datapoints,aB,null,aD+aD/2,true,aE.xaxis,aE.yaxis,aF);H.strokeStyle="rgba(0,0,0,0.2)";aH(aE.datapoints,aB,null,aD/2,true,aE.xaxis,aE.yaxis,aF)}H.lineWidth=aG;H.strokeStyle=aE.color;aH(aE.datapoints,aB,ae(aE.points,aE.color),0,false,aE.xaxis,aE.yaxis,aF);H.restore()}function E(aN,aM,aV,aI,aQ,aF,aD,aL,aK,aU,aR,aC){var aE,aT,aJ,aP,aG,aB,aO,aH,aS;if(aR){aH=aB=aO=true;aG=false;aE=aV;aT=aN;aP=aM+aI;aJ=aM+aQ;if(aTaL.max||aPaK.max){return}if(aEaL.max){aT=aL.max;aB=false}if(aJaK.max){aP=aK.max;aO=false}aE=aL.p2c(aE);aJ=aK.p2c(aJ);aT=aL.p2c(aT);aP=aK.p2c(aP);if(aD){aU.beginPath();aU.moveTo(aE,aJ);aU.lineTo(aE,aP);aU.lineTo(aT,aP);aU.lineTo(aT,aJ);aU.fillStyle=aD(aJ,aP);aU.fill()}if(aC>0&&(aG||aB||aO||aH)){aU.beginPath();aU.moveTo(aE,aJ+aF);if(aG){aU.lineTo(aE,aP+aF)}else{aU.moveTo(aE,aP+aF)}if(aO){aU.lineTo(aT,aP+aF)}else{aU.moveTo(aT,aP+aF)}if(aB){aU.lineTo(aT,aJ+aF)}else{aU.moveTo(aT,aJ+aF)}if(aH){aU.lineTo(aE,aJ+aF)}else{aU.moveTo(aE,aJ+aF)}aU.stroke()}}function e(aD){function aC(aJ,aI,aL,aG,aK,aN,aM){var aO=aJ.points,aF=aJ.pointsize;for(var aH=0;aH")}aH.push("");aF=true}if(aN){aJ=aN(aJ,aM)}aH.push('
    '+aJ+"")}if(aF){aH.push("")}if(aH.length==0){return}var aL=''+aH.join("")+"
    ";if(O.legend.container!=null){c(O.legend.container).html(aL)}else{var aI="",aC=O.legend.position,aD=O.legend.margin;if(aD[0]==null){aD=[aD,aD]}if(aC.charAt(0)=="n"){aI+="top:"+(aD[1]+q.top)+"px;"}else{if(aC.charAt(0)=="s"){aI+="bottom:"+(aD[1]+q.bottom)+"px;"}}if(aC.charAt(1)=="e"){aI+="right:"+(aD[0]+q.right)+"px;"}else{if(aC.charAt(1)=="w"){aI+="left:"+(aD[0]+q.left)+"px;"}}var aK=c('
    '+aL.replace('style="','style="position:absolute;'+aI+";")+"
    ").appendTo(av);if(O.legend.backgroundOpacity!=0){var aG=O.legend.backgroundColor;if(aG==null){aG=O.grid.backgroundColor;if(aG&&typeof aG=="string"){aG=c.color.parse(aG)}else{aG=c.color.extract(aK,"background-color")}aG.a=1;aG=aG.toString()}var aB=aK.children();c('
    ').prependTo(aK).css("opacity",O.legend.backgroundOpacity)}}}var ab=[],M=null;function K(aI,aG,aD){var aO=O.grid.mouseActiveRadius,a0=aO*aO+1,aY=null,aR=false,aW,aU;for(aW=Q.length-1;aW>=0;--aW){if(!aD(Q[aW])){continue}var aP=Q[aW],aH=aP.xaxis,aF=aP.yaxis,aV=aP.datapoints.points,aT=aP.datapoints.pointsize,aQ=aH.c2p(aI),aN=aF.c2p(aG),aC=aO/aH.scale,aB=aO/aF.scale;if(aH.options.inverseTransform){aC=Number.MAX_VALUE}if(aF.options.inverseTransform){aB=Number.MAX_VALUE}if(aP.lines.show||aP.points.show){for(aU=0;aUaC||aK-aQ<-aC||aJ-aN>aB||aJ-aN<-aB){continue}var aM=Math.abs(aH.p2c(aK)-aI),aL=Math.abs(aF.p2c(aJ)-aG),aS=aM*aM+aL*aL;if(aS=Math.min(aZ,aK)&&aN>=aJ+aE&&aN<=aJ+aX):(aQ>=aK+aE&&aQ<=aK+aX&&aN>=Math.min(aZ,aJ)&&aN<=Math.max(aZ,aJ))){aY=[aW,aU/aT]}}}}if(aY){aW=aY[0];aU=aY[1];aT=Q[aW].datapoints.pointsize;return{datapoint:Q[aW].datapoints.points.slice(aU*aT,(aU+1)*aT),dataIndex:aU,series:Q[aW],seriesIndex:aW}}return null}function aa(aB){if(O.grid.hoverable){u("plothover",aB,function(aC){return aC.hoverable!=false})}}function l(aB){if(O.grid.hoverable){u("plothover",aB,function(aC){return false})}}function R(aB){u("plotclick",aB,function(aC){return aC.clickable!=false})}function u(aC,aB,aD){var aE=y.offset(),aH=aB.pageX-aE.left-q.left,aF=aB.pageY-aE.top-q.top,aJ=C({left:aH,top:aF});aJ.pageX=aB.pageX;aJ.pageY=aB.pageY;var aK=K(aH,aF,aD);if(aK){aK.pageX=parseInt(aK.series.xaxis.p2c(aK.datapoint[0])+aE.left+q.left);aK.pageY=parseInt(aK.series.yaxis.p2c(aK.datapoint[1])+aE.top+q.top)}if(O.grid.autoHighlight){for(var aG=0;aGaH.max||aIaG.max){return}var aF=aE.points.radius+aE.points.lineWidth/2;A.lineWidth=aF;A.strokeStyle=c.color.parse(aE.color).scale("a",0.5).toString();var aB=1.5*aF,aC=aH.p2c(aC),aI=aG.p2c(aI);A.beginPath();if(aE.points.symbol=="circle"){A.arc(aC,aI,aB,0,2*Math.PI,false)}else{aE.points.symbol(A,aC,aI,aB,false)}A.closePath();A.stroke()}function v(aE,aB){A.lineWidth=aE.bars.lineWidth;A.strokeStyle=c.color.parse(aE.color).scale("a",0.5).toString();var aD=c.color.parse(aE.color).scale("a",0.5).toString();var aC=aE.bars.align=="left"?0:-aE.bars.barWidth/2;E(aB[0],aB[1],aB[2]||0,aC,aC+aE.bars.barWidth,0,function(){return aD},aE.xaxis,aE.yaxis,A,aE.bars.horizontal,aE.bars.lineWidth)}function am(aJ,aB,aH,aC){if(typeof aJ=="string"){return aJ}else{var aI=H.createLinearGradient(0,aH,0,aB);for(var aE=0,aD=aJ.colors.length;aE12){n=n-12}else{if(n==0){n=12}}}for(var g=0;g0&&L.which!=M.which)||E(L.target).is(M.not)){return }}switch(L.type){case"mousedown":E.extend(M,E(K).offset(),{elem:K,target:L.target,pageX:L.pageX,pageY:L.pageY});A.add(document,"mousemove mouseup",H,M);G(K,false);F.dragging=null;return false;case !F.dragging&&"mousemove":if(I(L.pageX-M.pageX)+I(L.pageY-M.pageY) max) { - // make sure min < max - var tmp = min; - min = max; - max = tmp; - } - - var range = max - min; - if (zr && - ((zr[0] != null && range < zr[0]) || - (zr[1] != null && range > zr[1]))) - return; - - opts.min = min; - opts.max = max; - }); - - plot.setupGrid(); - plot.draw(); - - if (!args.preventEvent) - plot.getPlaceholder().trigger("plotzoom", [ plot ]); - } - - plot.pan = function (args) { - var delta = { - x: +args.left, - y: +args.top - }; - - if (isNaN(delta.x)) - delta.x = 0; - if (isNaN(delta.y)) - delta.y = 0; - - $.each(plot.getAxes(), function (_, axis) { - var opts = axis.options, - min, max, d = delta[axis.direction]; - - min = axis.c2p(axis.p2c(axis.min) + d), - max = axis.c2p(axis.p2c(axis.max) + d); - - var pr = opts.panRange; - if (pr === false) // no panning on this axis - return; - - if (pr) { - // check whether we hit the wall - if (pr[0] != null && pr[0] > min) { - d = pr[0] - min; - min += d; - max += d; - } - - if (pr[1] != null && pr[1] < max) { - d = pr[1] - max; - min += d; - max += d; - } - } - - opts.min = min; - opts.max = max; - }); - - plot.setupGrid(); - plot.draw(); - - if (!args.preventEvent) - plot.getPlaceholder().trigger("plotpan", [ plot ]); - } - - function shutdown(plot, eventHolder) { - eventHolder.unbind(plot.getOptions().zoom.trigger, onZoomClick); - eventHolder.unbind("mousewheel", onMouseWheel); - eventHolder.unbind("dragstart", onDragStart); - eventHolder.unbind("drag", onDrag); - eventHolder.unbind("dragend", onDragEnd); - if (panTimeout) - clearTimeout(panTimeout); - } - - plot.hooks.bindEvents.push(bindEvents); - plot.hooks.shutdown.push(shutdown); - } - - $.plot.plugins.push({ - init: init, - options: options, - name: 'navigate', - version: '1.3' - }); -})(jQuery); diff --git a/frontend/src/vendor/jquery.flot/jquery.flot.navigate.min.js b/frontend/src/vendor/jquery.flot/jquery.flot.navigate.min.js deleted file mode 100644 index ecf63c93ba5b..000000000000 --- a/frontend/src/vendor/jquery.flot/jquery.flot.navigate.min.js +++ /dev/null @@ -1 +0,0 @@ -(function(i){i.fn.drag=function(j,k,l){if(k){this.bind("dragstart",j)}if(l){this.bind("dragend",l)}return !j?this.trigger("drag"):this.bind("drag",k?k:j)};var d=i.event,c=d.special,h=c.drag={not:":input",distance:0,which:1,dragging:false,setup:function(j){j=i.extend({distance:h.distance,which:h.which,not:h.not},j||{});j.distance=e(j.distance);d.add(this,"mousedown",f,j);if(this.attachEvent){this.attachEvent("ondragstart",a)}},teardown:function(){d.remove(this,"mousedown",f);if(this===h.dragging){h.dragging=h.proxy=false}g(this,true);if(this.detachEvent){this.detachEvent("ondragstart",a)}}};c.dragstart=c.dragend={setup:function(){},teardown:function(){}};function f(j){var k=this,l,m=j.data||{};if(m.elem){k=j.dragTarget=m.elem;j.dragProxy=h.proxy||k;j.cursorOffsetX=m.pageX-m.left;j.cursorOffsetY=m.pageY-m.top;j.offsetX=j.pageX-j.cursorOffsetX;j.offsetY=j.pageY-j.cursorOffsetY}else{if(h.dragging||(m.which>0&&j.which!=m.which)||i(j.target).is(m.not)){return}}switch(j.type){case"mousedown":i.extend(m,i(k).offset(),{elem:k,target:j.target,pageX:j.pageX,pageY:j.pageY});d.add(document,"mousemove mouseup",f,m);g(k,false);h.dragging=null;return false;case !h.dragging&&"mousemove":if(e(j.pageX-m.pageX)+e(j.pageY-m.pageY)w){var A=B;B=w;w=A}var y=w-B;if(E&&((E[0]!=null&&yE[1]))){return}D.min=B;D.max=w});o.setupGrid();o.draw();if(!q.preventEvent){o.getPlaceholder().trigger("plotzoom",[o])}};o.pan=function(p){var q={x:+p.left,y:+p.top};if(isNaN(q.x)){q.x=0}if(isNaN(q.y)){q.y=0}b.each(o.getAxes(),function(s,u){var v=u.options,t,r,w=q[u.direction];t=u.c2p(u.p2c(u.min)+w),r=u.c2p(u.p2c(u.max)+w);var x=v.panRange;if(x===false){return}if(x){if(x[0]!=null&&x[0]>t){w=x[0]-t;t+=w;r+=w}if(x[1]!=null&&x[1]1) - options.series.pie.tilt=1; - if (options.series.pie.tilt<0) - options.series.pie.tilt=0; - - // add processData hook to do transformations on the data - plot.hooks.processDatapoints.push(processDatapoints); - plot.hooks.drawOverlay.push(drawOverlay); - - // add draw hook - plot.hooks.draw.push(draw); - } - } - - // bind hoverable events - function bindEvents(plot, eventHolder) - { - var options = plot.getOptions(); - - if (options.series.pie.show && options.grid.hoverable) - eventHolder.unbind('mousemove').mousemove(onMouseMove); - - if (options.series.pie.show && options.grid.clickable) - eventHolder.unbind('click').click(onClick); - } - - - // debugging function that prints out an object - function alertObject(obj) - { - var msg = ''; - function traverse(obj, depth) - { - if (!depth) - depth = 0; - for (var i = 0; i < obj.length; ++i) - { - for (var j=0; jcanvas.width-maxRadius) - centerLeft = canvas.width-maxRadius; - } - - function fixData(data) - { - for (var i = 0; i < data.length; ++i) - { - if (typeof(data[i].data)=='number') - data[i].data = [[1,data[i].data]]; - else if (typeof(data[i].data)=='undefined' || typeof(data[i].data[0])=='undefined') - { - if (typeof(data[i].data)!='undefined' && typeof(data[i].data.label)!='undefined') - data[i].label = data[i].data.label; // fix weirdness coming from flot - data[i].data = [[1,0]]; - - } - } - return data; - } - - function combine(data) - { - data = fixData(data); - calcTotal(data); - var combined = 0; - var numCombined = 0; - var color = options.series.pie.combine.color; - - var newdata = []; - for (var i = 0; i < data.length; ++i) - { - // make sure its a number - data[i].data[0][1] = parseFloat(data[i].data[0][1]); - if (!data[i].data[0][1]) - data[i].data[0][1] = 0; - - if (data[i].data[0][1]/total<=options.series.pie.combine.threshold) - { - combined += data[i].data[0][1]; - numCombined++; - if (!color) - color = data[i].color; - } - else - { - newdata.push({ - data: [[1,data[i].data[0][1]]], - color: data[i].color, - label: data[i].label, - angle: (data[i].data[0][1]*(Math.PI*2))/total, - percent: (data[i].data[0][1]/total*100) - }); - } - } - if (numCombined>0) - newdata.push({ - data: [[1,combined]], - color: color, - label: options.series.pie.combine.label, - angle: (combined*(Math.PI*2))/total, - percent: (combined/total*100) - }); - return newdata; - } - - function draw(plot, newCtx) - { - if (!target) return; // if no series were passed - ctx = newCtx; - - setupPie(); - var slices = plot.getData(); - - var attempts = 0; - while (redraw && attempts0) - maxRadius *= shrink; - attempts += 1; - clear(); - if (options.series.pie.tilt<=0.8) - drawShadow(); - drawPie(); - } - if (attempts >= redrawAttempts) { - clear(); - target.prepend('
    Could not draw pie with labels contained inside canvas
    '); - } - - if ( plot.setSeries && plot.insertLegend ) - { - plot.setSeries(slices); - plot.insertLegend(); - } - - // we're actually done at this point, just defining internal functions at this point - - function clear() - { - ctx.clearRect(0,0,canvas.width,canvas.height); - target.children().filter('.pieLabel, .pieLabelBackground').remove(); - } - - function drawShadow() - { - var shadowLeft = 5; - var shadowTop = 15; - var edge = 10; - var alpha = 0.02; - - // set radius - if (options.series.pie.radius>1) - var radius = options.series.pie.radius; - else - var radius = maxRadius * options.series.pie.radius; - - if (radius>=(canvas.width/2)-shadowLeft || radius*options.series.pie.tilt>=(canvas.height/2)-shadowTop || radius<=edge) - return; // shadow would be outside canvas, so don't draw it - - ctx.save(); - ctx.translate(shadowLeft,shadowTop); - ctx.globalAlpha = alpha; - ctx.fillStyle = '#000'; - - // center and rotate to starting position - ctx.translate(centerLeft,centerTop); - ctx.scale(1, options.series.pie.tilt); - - //radius -= edge; - for (var i=1; i<=edge; i++) - { - ctx.beginPath(); - ctx.arc(0,0,radius,0,Math.PI*2,false); - ctx.fill(); - radius -= i; - } - - ctx.restore(); - } - - function drawPie() - { - startAngle = Math.PI*options.series.pie.startAngle; - - // set radius - if (options.series.pie.radius>1) - var radius = options.series.pie.radius; - else - var radius = maxRadius * options.series.pie.radius; - - // center and rotate to starting position - ctx.save(); - ctx.translate(centerLeft,centerTop); - ctx.scale(1, options.series.pie.tilt); - //ctx.rotate(startAngle); // start at top; -- This doesn't work properly in Opera - - // draw slices - ctx.save(); - var currentAngle = startAngle; - for (var i = 0; i < slices.length; ++i) - { - slices[i].startAngle = currentAngle; - drawSlice(slices[i].angle, slices[i].color, true); - } - ctx.restore(); - - // draw slice outlines - ctx.save(); - ctx.lineWidth = options.series.pie.stroke.width; - currentAngle = startAngle; - for (var i = 0; i < slices.length; ++i) - drawSlice(slices[i].angle, options.series.pie.stroke.color, false); - ctx.restore(); - - // draw donut hole - drawDonutHole(ctx); - - // draw labels - if (options.series.pie.label.show) - drawLabels(); - - // restore to original state - ctx.restore(); - - function drawSlice(angle, color, fill) - { - if (angle<=0) - return; - - if (fill) - ctx.fillStyle = color; - else - { - ctx.strokeStyle = color; - ctx.lineJoin = 'round'; - } - - ctx.beginPath(); - if (Math.abs(angle - Math.PI*2) > 0.000000001) - ctx.moveTo(0,0); // Center of the pie - //ctx.arc(0,0,radius,0,angle,false); // This doesn't work properly in Opera - ctx.arc(0,0,radius,currentAngle,currentAngle+angle,false); - ctx.closePath(); - //ctx.rotate(angle); // This doesn't work properly in Opera - currentAngle += angle; - - if (fill) - ctx.fill(); - else - ctx.stroke(); - } - - function drawLabels() - { - var currentAngle = startAngle; - - // set radius - if (options.series.pie.label.radius>1) - var radius = options.series.pie.label.radius; - else - var radius = maxRadius * options.series.pie.label.radius; - - for (var i = 0; i < slices.length; ++i) - { - if (slices[i].percent >= options.series.pie.label.threshold*100) - drawLabel(slices[i], currentAngle, i); - currentAngle += slices[i].angle; - } - - function drawLabel(slice, startAngle, index) - { - if (slice.data[0][1]==0) - return; - - // format label text - var lf = options.legend.labelFormatter, text, plf = options.series.pie.label.formatter; - if (lf) - text = lf(slice.label, slice); - else - text = slice.label; - if (plf) - text = plf(text, slice); - - var halfAngle = ((startAngle+slice.angle) + startAngle)/2; - var x = centerLeft + Math.round(Math.cos(halfAngle) * radius); - var y = centerTop + Math.round(Math.sin(halfAngle) * radius) * options.series.pie.tilt; - - var html = '' + text + ""; - target.append(html); - var label = target.children('#pieLabel'+index); - var labelTop = (y - label.height()/2); - var labelLeft = (x - label.width()/2); - label.css('top', labelTop); - label.css('left', labelLeft); - - // check to make sure that the label is not outside the canvas - if (0-labelTop>0 || 0-labelLeft>0 || canvas.height-(labelTop+label.height())<0 || canvas.width-(labelLeft+label.width())<0) - redraw = true; - - if (options.series.pie.label.background.opacity != 0) { - // put in the transparent background separately to avoid blended labels and label boxes - var c = options.series.pie.label.background.color; - if (c == null) { - c = slice.color; - } - var pos = 'top:'+labelTop+'px;left:'+labelLeft+'px;'; - $('
    ').insertBefore(label).css('opacity', options.series.pie.label.background.opacity); - } - } // end individual label function - } // end drawLabels function - } // end drawPie function - } // end draw function - - // Placed here because it needs to be accessed from multiple locations - function drawDonutHole(layer) - { - // draw donut hole - if(options.series.pie.innerRadius > 0) - { - // subtract the center - layer.save(); - innerRadius = options.series.pie.innerRadius > 1 ? options.series.pie.innerRadius : maxRadius * options.series.pie.innerRadius; - layer.globalCompositeOperation = 'destination-out'; // this does not work with excanvas, but it will fall back to using the stroke color - layer.beginPath(); - layer.fillStyle = options.series.pie.stroke.color; - layer.arc(0,0,innerRadius,0,Math.PI*2,false); - layer.fill(); - layer.closePath(); - layer.restore(); - - // add inner stroke - layer.save(); - layer.beginPath(); - layer.strokeStyle = options.series.pie.stroke.color; - layer.arc(0,0,innerRadius,0,Math.PI*2,false); - layer.stroke(); - layer.closePath(); - layer.restore(); - // TODO: add extra shadow inside hole (with a mask) if the pie is tilted. - } - } - - //-- Additional Interactive related functions -- - - function isPointInPoly(poly, pt) - { - for(var c = false, i = -1, l = poly.length, j = l - 1; ++i < l; j = i) - ((poly[i][1] <= pt[1] && pt[1] < poly[j][1]) || (poly[j][1] <= pt[1] && pt[1]< poly[i][1])) - && (pt[0] < (poly[j][0] - poly[i][0]) * (pt[1] - poly[i][1]) / (poly[j][1] - poly[i][1]) + poly[i][0]) - && (c = !c); - return c; - } - - function findNearbySlice(mouseX, mouseY) - { - var slices = plot.getData(), - options = plot.getOptions(), - radius = options.series.pie.radius > 1 ? options.series.pie.radius : maxRadius * options.series.pie.radius; - - for (var i = 0; i < slices.length; ++i) - { - var s = slices[i]; - - if(s.pie.show) - { - ctx.save(); - ctx.beginPath(); - ctx.moveTo(0,0); // Center of the pie - //ctx.scale(1, options.series.pie.tilt); // this actually seems to break everything when here. - ctx.arc(0,0,radius,s.startAngle,s.startAngle+s.angle,false); - ctx.closePath(); - x = mouseX-centerLeft; - y = mouseY-centerTop; - if(ctx.isPointInPath) - { - if (ctx.isPointInPath(mouseX-centerLeft, mouseY-centerTop)) - { - //alert('found slice!'); - ctx.restore(); - return {datapoint: [s.percent, s.data], dataIndex: 0, series: s, seriesIndex: i}; - } - } - else - { - // excanvas for IE doesn;t support isPointInPath, this is a workaround. - p1X = (radius * Math.cos(s.startAngle)); - p1Y = (radius * Math.sin(s.startAngle)); - p2X = (radius * Math.cos(s.startAngle+(s.angle/4))); - p2Y = (radius * Math.sin(s.startAngle+(s.angle/4))); - p3X = (radius * Math.cos(s.startAngle+(s.angle/2))); - p3Y = (radius * Math.sin(s.startAngle+(s.angle/2))); - p4X = (radius * Math.cos(s.startAngle+(s.angle/1.5))); - p4Y = (radius * Math.sin(s.startAngle+(s.angle/1.5))); - p5X = (radius * Math.cos(s.startAngle+s.angle)); - p5Y = (radius * Math.sin(s.startAngle+s.angle)); - arrPoly = [[0,0],[p1X,p1Y],[p2X,p2Y],[p3X,p3Y],[p4X,p4Y],[p5X,p5Y]]; - arrPoint = [x,y]; - // TODO: perhaps do some mathmatical trickery here with the Y-coordinate to compensate for pie tilt? - if(isPointInPoly(arrPoly, arrPoint)) - { - ctx.restore(); - return {datapoint: [s.percent, s.data], dataIndex: 0, series: s, seriesIndex: i}; - } - } - ctx.restore(); - } - } - - return null; - } - - function onMouseMove(e) - { - triggerClickHoverEvent('plothover', e); - } - - function onClick(e) - { - triggerClickHoverEvent('plotclick', e); - } - - // trigger click or hover event (they send the same parameters so we share their code) - function triggerClickHoverEvent(eventname, e) - { - var offset = plot.offset(), - canvasX = parseInt(e.pageX - offset.left), - canvasY = parseInt(e.pageY - offset.top), - item = findNearbySlice(canvasX, canvasY); - - if (options.grid.autoHighlight) - { - // clear auto-highlights - for (var i = 0; i < highlights.length; ++i) - { - var h = highlights[i]; - if (h.auto == eventname && !(item && h.series == item.series)) - unhighlight(h.series); - } - } - - // highlight the slice - if (item) - highlight(item.series, eventname); - - // trigger any hover bind events - var pos = { pageX: e.pageX, pageY: e.pageY }; - target.trigger(eventname, [ pos, item ]); - } - - function highlight(s, auto) - { - if (typeof s == "number") - s = series[s]; - - var i = indexOfHighlight(s); - if (i == -1) - { - highlights.push({ series: s, auto: auto }); - plot.triggerRedrawOverlay(); - } - else if (!auto) - highlights[i].auto = false; - } - - function unhighlight(s) - { - if (s == null) - { - highlights = []; - plot.triggerRedrawOverlay(); - } - - if (typeof s == "number") - s = series[s]; - - var i = indexOfHighlight(s); - if (i != -1) - { - highlights.splice(i, 1); - plot.triggerRedrawOverlay(); - } - } - - function indexOfHighlight(s) - { - for (var i = 0; i < highlights.length; ++i) - { - var h = highlights[i]; - if (h.series == s) - return i; - } - return -1; - } - - function drawOverlay(plot, octx) - { - //alert(options.series.pie.radius); - var options = plot.getOptions(); - //alert(options.series.pie.radius); - - var radius = options.series.pie.radius > 1 ? options.series.pie.radius : maxRadius * options.series.pie.radius; - - octx.save(); - octx.translate(centerLeft, centerTop); - octx.scale(1, options.series.pie.tilt); - - for (i = 0; i < highlights.length; ++i) - drawHighlight(highlights[i].series); - - drawDonutHole(octx); - - octx.restore(); - - function drawHighlight(series) - { - if (series.angle < 0) return; - - //octx.fillStyle = parseColor(options.series.pie.highlight.color).scale(null, null, null, options.series.pie.highlight.opacity).toString(); - octx.fillStyle = "rgba(255, 255, 255, "+options.series.pie.highlight.opacity+")"; // this is temporary until we have access to parseColor - - octx.beginPath(); - if (Math.abs(series.angle - Math.PI*2) > 0.000000001) - octx.moveTo(0,0); // Center of the pie - octx.arc(0,0,radius,series.startAngle,series.startAngle+series.angle,false); - octx.closePath(); - octx.fill(); - } - - } - - } // end init (plugin body) - - // define pie specific options and their default values - var options = { - series: { - pie: { - show: false, - radius: 'auto', // actual radius of the visible pie (based on full calculated radius if <=1, or hard pixel value) - innerRadius:0, /* for donut */ - startAngle: 3/2, - tilt: 1, - offset: { - top: 0, - left: 'auto' - }, - stroke: { - color: '#FFF', - width: 1 - }, - label: { - show: 'auto', - formatter: function(label, slice){ - return '
    '+label+'
    '+Math.round(slice.percent)+'%
    '; - }, // formatter function - radius: 1, // radius at which to place the labels (based on full calculated radius if <=1, or hard pixel value) - background: { - color: null, - opacity: 0 - }, - threshold: 0 // percentage at which to hide the label (i.e. the slice is too narrow) - }, - combine: { - threshold: -1, // percentage at which to combine little slices into one larger slice - color: null, // color to give the new slice (auto-generated if null) - label: 'Other' // label to give the new slice - }, - highlight: { - //color: '#FFF', // will add this functionality once parseColor is available - opacity: 0.5 - } - } - } - }; - - $.plot.plugins.push({ - init: init, - options: options, - name: "pie", - version: "1.0" - }); -})(jQuery); diff --git a/frontend/src/vendor/jquery.flot/jquery.flot.pie.min.js b/frontend/src/vendor/jquery.flot/jquery.flot.pie.min.js deleted file mode 100644 index b7bf870d759a..000000000000 --- a/frontend/src/vendor/jquery.flot/jquery.flot.pie.min.js +++ /dev/null @@ -1 +0,0 @@ -(function(b){function c(D){var h=null;var L=null;var n=null;var B=null;var p=null;var M=0;var F=true;var o=10;var w=0.95;var A=0;var d=false;var z=false;var j=[];D.hooks.processOptions.push(g);D.hooks.bindEvents.push(e);function g(O,N){if(N.series.pie.show){N.grid.show=false;if(N.series.pie.label.show=="auto"){if(N.legend.show){N.series.pie.label.show=false}else{N.series.pie.label.show=true}}if(N.series.pie.radius=="auto"){if(N.series.pie.label.show){N.series.pie.radius=3/4}else{N.series.pie.radius=1}}if(N.series.pie.tilt>1){N.series.pie.tilt=1}if(N.series.pie.tilt<0){N.series.pie.tilt=0}O.hooks.processDatapoints.push(E);O.hooks.drawOverlay.push(H);O.hooks.draw.push(r)}}function e(P,N){var O=P.getOptions();if(O.series.pie.show&&O.grid.hoverable){N.unbind("mousemove").mousemove(t)}if(O.series.pie.show&&O.grid.clickable){N.unbind("click").click(l)}}function G(O){var P="";function N(S,T){if(!T){T=0}for(var R=0;Rh.width-n){B=h.width-n}}}function v(O){for(var N=0;N0){R.push({data:[[1,P]],color:N,label:a.series.pie.combine.label,angle:(P*(Math.PI*2))/M,percent:(P/M*100)})}return R}function r(S,Q){if(!L){return}ctx=Q;I();var T=S.getData();var P=0;while(F&&P0){n*=w}P+=1;N();if(a.series.pie.tilt<=0.8){O()}R()}if(P>=o){N();L.prepend('
    Could not draw pie with labels contained inside canvas
    ')}if(S.setSeries&&S.insertLegend){S.setSeries(T);S.insertLegend()}function N(){ctx.clearRect(0,0,h.width,h.height);L.children().filter(".pieLabel, .pieLabelBackground").remove()}function O(){var Z=5;var Y=15;var W=10;var X=0.02;if(a.series.pie.radius>1){var U=a.series.pie.radius}else{var U=n*a.series.pie.radius}if(U>=(h.width/2)-Z||U*a.series.pie.tilt>=(h.height/2)-Y||U<=W){return}ctx.save();ctx.translate(Z,Y);ctx.globalAlpha=X;ctx.fillStyle="#000";ctx.translate(B,p);ctx.scale(1,a.series.pie.tilt);for(var V=1;V<=W;V++){ctx.beginPath();ctx.arc(0,0,U,0,Math.PI*2,false);ctx.fill();U-=V}ctx.restore()}function R(){startAngle=Math.PI*a.series.pie.startAngle;if(a.series.pie.radius>1){var U=a.series.pie.radius}else{var U=n*a.series.pie.radius}ctx.save();ctx.translate(B,p);ctx.scale(1,a.series.pie.tilt);ctx.save();var Y=startAngle;for(var W=0;W1e-9){ctx.moveTo(0,0)}else{if(b.browser.msie){ab-=0.0001}}ctx.arc(0,0,U,Y,Y+ab,false);ctx.closePath();Y+=ab;if(aa){ctx.fill()}else{ctx.stroke()}}function V(){var ac=startAngle;if(a.series.pie.label.radius>1){var Z=a.series.pie.label.radius}else{var Z=n*a.series.pie.label.radius}for(var ab=0;ab=a.series.pie.label.threshold*100){aa(T[ab],ac,ab)}ac+=T[ab].angle}function aa(ap,ai,ag){if(ap.data[0][1]==0){return}var ar=a.legend.labelFormatter,aq,ae=a.series.pie.label.formatter;if(ar){aq=ar(ap.label,ap)}else{aq=ap.label}if(ae){aq=ae(aq,ap)}var aj=((ai+ap.angle)+ai)/2;var ao=B+Math.round(Math.cos(aj)*Z);var am=p+Math.round(Math.sin(aj)*Z)*a.series.pie.tilt;var af=''+aq+"";L.append(af);var an=L.children("#pieLabel"+ag);var ad=(am-an.height()/2);var ah=(ao-an.width()/2);an.css("top",ad);an.css("left",ah);if(0-ad>0||0-ah>0||h.height-(ad+an.height())<0||h.width-(ah+an.width())<0){F=true}if(a.series.pie.label.background.opacity!=0){var ak=a.series.pie.label.background.color;if(ak==null){ak=ap.color}var al="top:"+ad+"px;left:"+ah+"px;";b('
    ').insertBefore(an).css("opacity",a.series.pie.label.background.opacity)}}}}}function J(N){if(a.series.pie.innerRadius>0){N.save();innerRadius=a.series.pie.innerRadius>1?a.series.pie.innerRadius:n*a.series.pie.innerRadius;N.globalCompositeOperation="destination-out";N.beginPath();N.fillStyle=a.series.pie.stroke.color;N.arc(0,0,innerRadius,0,Math.PI*2,false);N.fill();N.closePath();N.restore();N.save();N.beginPath();N.strokeStyle=a.series.pie.stroke.color;N.arc(0,0,innerRadius,0,Math.PI*2,false);N.stroke();N.closePath();N.restore()}}function s(Q,R){for(var S=false,P=-1,N=Q.length,O=N-1;++P1?O.series.pie.radius:n*O.series.pie.radius;for(var Q=0;Q1?P.series.pie.radius:n*P.series.pie.radius;R.save();R.translate(B,p);R.scale(1,P.series.pie.tilt);for(i=0;i1e-9){R.moveTo(0,0)}R.arc(0,0,N,S.startAngle,S.startAngle+S.angle,false);R.closePath();R.fill()}}}var a={series:{pie:{show:false,radius:"auto",innerRadius:0,startAngle:3/2,tilt:1,offset:{top:0,left:"auto"},stroke:{color:"#FFF",width:1},label:{show:"auto",formatter:function(d,e){return'
    '+d+"
    "+Math.round(e.percent)+"%
    "},radius:1,background:{color:null,opacity:0},threshold:0},combine:{threshold:-1,color:null,label:"Other"},highlight:{opacity:0.5}}}};b.plot.plugins.push({init:c,options:a,name:"pie",version:"1.0"})})(jQuery); \ No newline at end of file diff --git a/frontend/src/vendor/jquery.flot/jquery.flot.resize.js b/frontend/src/vendor/jquery.flot/jquery.flot.resize.js deleted file mode 100644 index 69dfb24f38e2..000000000000 --- a/frontend/src/vendor/jquery.flot/jquery.flot.resize.js +++ /dev/null @@ -1,60 +0,0 @@ -/* -Flot plugin for automatically redrawing plots when the placeholder -size changes, e.g. on window resizes. - -It works by listening for changes on the placeholder div (through the -jQuery resize event plugin) - if the size changes, it will redraw the -plot. - -There are no options. If you need to disable the plugin for some -plots, you can just fix the size of their placeholders. -*/ - - -/* Inline dependency: - * jQuery resize event - v1.1 - 3/14/2010 - * http://benalman.com/projects/jquery-resize-plugin/ - * - * Copyright (c) 2010 "Cowboy" Ben Alman - * Dual licensed under the MIT and GPL licenses. - * http://benalman.com/about/license/ - */ -(function($,h,c){var a=$([]),e=$.resize=$.extend($.resize,{}),i,k="setTimeout",j="resize",d=j+"-special-event",b="delay",f="throttleWindow";e[b]=250;e[f]=true;$.event.special[j]={setup:function(){if(!e[f]&&this[k]){return false}var l=$(this);a=a.add(l);$.data(this,d,{w:l.width(),h:l.height()});if(a.length===1){g()}},teardown:function(){if(!e[f]&&this[k]){return false}var l=$(this);a=a.not(l);l.removeData(d);if(!a.length){clearTimeout(i)}},add:function(l){if(!e[f]&&this[k]){return false}var n;function m(s,o,p){var q=$(this),r=$.data(this,d);r.w=o!==c?o:q.width();r.h=p!==c?p:q.height();n.apply(this,arguments)}if($.isFunction(l)){n=l;return m}else{n=l.handler;l.handler=m}}};function g(){i=h[k](function(){a.each(function(){var n=$(this),m=n.width(),l=n.height(),o=$.data(this,d);if(m!==o.w||l!==o.h){n.trigger(j,[o.w=m,o.h=l])}});g()},e[b])}})(jQuery,this); - - -(function ($) { - var options = { }; // no options - - function init(plot) { - function onResize() { - var placeholder = plot.getPlaceholder(); - - // somebody might have hidden us and we can't plot - // when we don't have the dimensions - if (placeholder.width() == 0 || placeholder.height() == 0) - return; - - plot.resize(); - plot.setupGrid(); - plot.draw(); - } - - function bindEvents(plot, eventHolder) { - plot.getPlaceholder().resize(onResize); - } - - function shutdown(plot, eventHolder) { - plot.getPlaceholder().unbind("resize", onResize); - } - - plot.hooks.bindEvents.push(bindEvents); - plot.hooks.shutdown.push(shutdown); - } - - $.plot.plugins.push({ - init: init, - options: options, - name: 'resize', - version: '1.0' - }); -})(jQuery); diff --git a/frontend/src/vendor/jquery.flot/jquery.flot.resize.min.js b/frontend/src/vendor/jquery.flot/jquery.flot.resize.min.js deleted file mode 100644 index 1fa0771f570e..000000000000 --- a/frontend/src/vendor/jquery.flot/jquery.flot.resize.min.js +++ /dev/null @@ -1 +0,0 @@ -(function(n,p,u){var w=n([]),s=n.resize=n.extend(n.resize,{}),o,l="setTimeout",m="resize",t=m+"-special-event",v="delay",r="throttleWindow";s[v]=250;s[r]=true;n.event.special[m]={setup:function(){if(!s[r]&&this[l]){return false}var a=n(this);w=w.add(a);n.data(this,t,{w:a.width(),h:a.height()});if(w.length===1){q()}},teardown:function(){if(!s[r]&&this[l]){return false}var a=n(this);w=w.not(a);a.removeData(t);if(!w.length){clearTimeout(o)}},add:function(b){if(!s[r]&&this[l]){return false}var c;function a(d,h,g){var f=n(this),e=n.data(this,t);e.w=h!==u?h:f.width();e.h=g!==u?g:f.height();c.apply(this,arguments)}if(n.isFunction(b)){c=b;return a}else{c=b.handler;b.handler=a}}};function q(){o=p[l](function(){w.each(function(){var d=n(this),a=d.width(),b=d.height(),c=n.data(this,t);if(a!==c.w||b!==c.h){d.trigger(m,[c.w=a,c.h=b])}});q()},s[v])}})(jQuery,this);(function(b){var a={};function c(f){function e(){var h=f.getPlaceholder();if(h.width()==0||h.height()==0){return}f.resize();f.setupGrid();f.draw()}function g(i,h){i.getPlaceholder().resize(e)}function d(i,h){i.getPlaceholder().unbind("resize",e)}f.hooks.bindEvents.push(g);f.hooks.shutdown.push(d)}b.plot.plugins.push({init:c,options:a,name:"resize",version:"1.0"})})(jQuery); \ No newline at end of file diff --git a/frontend/src/vendor/jquery.flot/jquery.flot.selection.js b/frontend/src/vendor/jquery.flot/jquery.flot.selection.js deleted file mode 100644 index 7f7b32694bda..000000000000 --- a/frontend/src/vendor/jquery.flot/jquery.flot.selection.js +++ /dev/null @@ -1,344 +0,0 @@ -/* -Flot plugin for selecting regions. - -The plugin defines the following options: - - selection: { - mode: null or "x" or "y" or "xy", - color: color - } - -Selection support is enabled by setting the mode to one of "x", "y" or -"xy". In "x" mode, the user will only be able to specify the x range, -similarly for "y" mode. For "xy", the selection becomes a rectangle -where both ranges can be specified. "color" is color of the selection -(if you need to change the color later on, you can get to it with -plot.getOptions().selection.color). - -When selection support is enabled, a "plotselected" event will be -emitted on the DOM element you passed into the plot function. The -event handler gets a parameter with the ranges selected on the axes, -like this: - - placeholder.bind("plotselected", function(event, ranges) { - alert("You selected " + ranges.xaxis.from + " to " + ranges.xaxis.to) - // similar for yaxis - with multiple axes, the extra ones are in - // x2axis, x3axis, ... - }); - -The "plotselected" event is only fired when the user has finished -making the selection. A "plotselecting" event is fired during the -process with the same parameters as the "plotselected" event, in case -you want to know what's happening while it's happening, - -A "plotunselected" event with no arguments is emitted when the user -clicks the mouse to remove the selection. - -The plugin allso adds the following methods to the plot object: - -- setSelection(ranges, preventEvent) - - Set the selection rectangle. The passed in ranges is on the same - form as returned in the "plotselected" event. If the selection mode - is "x", you should put in either an xaxis range, if the mode is "y" - you need to put in an yaxis range and both xaxis and yaxis if the - selection mode is "xy", like this: - - setSelection({ xaxis: { from: 0, to: 10 }, yaxis: { from: 40, to: 60 } }); - - setSelection will trigger the "plotselected" event when called. If - you don't want that to happen, e.g. if you're inside a - "plotselected" handler, pass true as the second parameter. If you - are using multiple axes, you can specify the ranges on any of those, - e.g. as x2axis/x3axis/... instead of xaxis, the plugin picks the - first one it sees. - -- clearSelection(preventEvent) - - Clear the selection rectangle. Pass in true to avoid getting a - "plotunselected" event. - -- getSelection() - - Returns the current selection in the same format as the - "plotselected" event. If there's currently no selection, the - function returns null. - -*/ - -(function ($) { - function init(plot) { - var selection = { - first: { x: -1, y: -1}, second: { x: -1, y: -1}, - show: false, - active: false - }; - - // FIXME: The drag handling implemented here should be - // abstracted out, there's some similar code from a library in - // the navigation plugin, this should be massaged a bit to fit - // the Flot cases here better and reused. Doing this would - // make this plugin much slimmer. - var savedhandlers = {}; - - var mouseUpHandler = null; - - function onMouseMove(e) { - if (selection.active) { - updateSelection(e); - - plot.getPlaceholder().trigger("plotselecting", [ getSelection() ]); - } - } - - function onMouseDown(e) { - if (e.which != 1) // only accept left-click - return; - - // cancel out any text selections - document.body.focus(); - - // prevent text selection and drag in old-school browsers - if (document.onselectstart !== undefined && savedhandlers.onselectstart == null) { - savedhandlers.onselectstart = document.onselectstart; - document.onselectstart = function () { return false; }; - } - if (document.ondrag !== undefined && savedhandlers.ondrag == null) { - savedhandlers.ondrag = document.ondrag; - document.ondrag = function () { return false; }; - } - - setSelectionPos(selection.first, e); - - selection.active = true; - - // this is a bit silly, but we have to use a closure to be - // able to whack the same handler again - mouseUpHandler = function (e) { onMouseUp(e); }; - - $(document).one("mouseup", mouseUpHandler); - } - - function onMouseUp(e) { - mouseUpHandler = null; - - // revert drag stuff for old-school browsers - if (document.onselectstart !== undefined) - document.onselectstart = savedhandlers.onselectstart; - if (document.ondrag !== undefined) - document.ondrag = savedhandlers.ondrag; - - // no more dragging - selection.active = false; - updateSelection(e); - - if (selectionIsSane()) - triggerSelectedEvent(); - else { - // this counts as a clear - plot.getPlaceholder().trigger("plotunselected", [ ]); - plot.getPlaceholder().trigger("plotselecting", [ null ]); - } - - return false; - } - - function getSelection() { - if (!selectionIsSane()) - return null; - - var r = {}, c1 = selection.first, c2 = selection.second; - $.each(plot.getAxes(), function (name, axis) { - if (axis.used) { - var p1 = axis.c2p(c1[axis.direction]), p2 = axis.c2p(c2[axis.direction]); - r[name] = { from: Math.min(p1, p2), to: Math.max(p1, p2) }; - } - }); - return r; - } - - function triggerSelectedEvent() { - var r = getSelection(); - - plot.getPlaceholder().trigger("plotselected", [ r ]); - - // backwards-compat stuff, to be removed in future - if (r.xaxis && r.yaxis) - plot.getPlaceholder().trigger("selected", [ { x1: r.xaxis.from, y1: r.yaxis.from, x2: r.xaxis.to, y2: r.yaxis.to } ]); - } - - function clamp(min, value, max) { - return value < min ? min: (value > max ? max: value); - } - - function setSelectionPos(pos, e) { - var o = plot.getOptions(); - var offset = plot.getPlaceholder().offset(); - var plotOffset = plot.getPlotOffset(); - pos.x = clamp(0, e.pageX - offset.left - plotOffset.left, plot.width()); - pos.y = clamp(0, e.pageY - offset.top - plotOffset.top, plot.height()); - - if (o.selection.mode == "y") - pos.x = pos == selection.first ? 0 : plot.width(); - - if (o.selection.mode == "x") - pos.y = pos == selection.first ? 0 : plot.height(); - } - - function updateSelection(pos) { - if (pos.pageX == null) - return; - - setSelectionPos(selection.second, pos); - if (selectionIsSane()) { - selection.show = true; - plot.triggerRedrawOverlay(); - } - else - clearSelection(true); - } - - function clearSelection(preventEvent) { - if (selection.show) { - selection.show = false; - plot.triggerRedrawOverlay(); - if (!preventEvent) - plot.getPlaceholder().trigger("plotunselected", [ ]); - } - } - - // function taken from markings support in Flot - function extractRange(ranges, coord) { - var axis, from, to, key, axes = plot.getAxes(); - - for (var k in axes) { - axis = axes[k]; - if (axis.direction == coord) { - key = coord + axis.n + "axis"; - if (!ranges[key] && axis.n == 1) - key = coord + "axis"; // support x1axis as xaxis - if (ranges[key]) { - from = ranges[key].from; - to = ranges[key].to; - break; - } - } - } - - // backwards-compat stuff - to be removed in future - if (!ranges[key]) { - axis = coord == "x" ? plot.getXAxes()[0] : plot.getYAxes()[0]; - from = ranges[coord + "1"]; - to = ranges[coord + "2"]; - } - - // auto-reverse as an added bonus - if (from != null && to != null && from > to) { - var tmp = from; - from = to; - to = tmp; - } - - return { from: from, to: to, axis: axis }; - } - - function setSelection(ranges, preventEvent) { - var axis, range, o = plot.getOptions(); - - if (o.selection.mode == "y") { - selection.first.x = 0; - selection.second.x = plot.width(); - } - else { - range = extractRange(ranges, "x"); - - selection.first.x = range.axis.p2c(range.from); - selection.second.x = range.axis.p2c(range.to); - } - - if (o.selection.mode == "x") { - selection.first.y = 0; - selection.second.y = plot.height(); - } - else { - range = extractRange(ranges, "y"); - - selection.first.y = range.axis.p2c(range.from); - selection.second.y = range.axis.p2c(range.to); - } - - selection.show = true; - plot.triggerRedrawOverlay(); - if (!preventEvent && selectionIsSane()) - triggerSelectedEvent(); - } - - function selectionIsSane() { - var minSize = 5; - return Math.abs(selection.second.x - selection.first.x) >= minSize && - Math.abs(selection.second.y - selection.first.y) >= minSize; - } - - plot.clearSelection = clearSelection; - plot.setSelection = setSelection; - plot.getSelection = getSelection; - - plot.hooks.bindEvents.push(function(plot, eventHolder) { - var o = plot.getOptions(); - if (o.selection.mode != null) { - eventHolder.mousemove(onMouseMove); - eventHolder.mousedown(onMouseDown); - } - }); - - - plot.hooks.drawOverlay.push(function (plot, ctx) { - // draw selection - if (selection.show && selectionIsSane()) { - var plotOffset = plot.getPlotOffset(); - var o = plot.getOptions(); - - ctx.save(); - ctx.translate(plotOffset.left, plotOffset.top); - - var c = $.color.parse(o.selection.color); - - ctx.strokeStyle = c.scale('a', 0.8).toString(); - ctx.lineWidth = 1; - ctx.lineJoin = "round"; - ctx.fillStyle = c.scale('a', 0.4).toString(); - - var x = Math.min(selection.first.x, selection.second.x), - y = Math.min(selection.first.y, selection.second.y), - w = Math.abs(selection.second.x - selection.first.x), - h = Math.abs(selection.second.y - selection.first.y); - - ctx.fillRect(x, y, w, h); - ctx.strokeRect(x, y, w, h); - - ctx.restore(); - } - }); - - plot.hooks.shutdown.push(function (plot, eventHolder) { - eventHolder.unbind("mousemove", onMouseMove); - eventHolder.unbind("mousedown", onMouseDown); - - if (mouseUpHandler) - $(document).unbind("mouseup", mouseUpHandler); - }); - - } - - $.plot.plugins.push({ - init: init, - options: { - selection: { - mode: null, // one of null, "x", "y" or "xy" - color: "#e8cfac" - } - }, - name: 'selection', - version: '1.1' - }); -})(jQuery); diff --git a/frontend/src/vendor/jquery.flot/jquery.flot.selection.min.js b/frontend/src/vendor/jquery.flot/jquery.flot.selection.min.js deleted file mode 100644 index badc0052dbe0..000000000000 --- a/frontend/src/vendor/jquery.flot/jquery.flot.selection.min.js +++ /dev/null @@ -1 +0,0 @@ -(function(a){function b(k){var p={first:{x:-1,y:-1},second:{x:-1,y:-1},show:false,active:false};var m={};var r=null;function e(s){if(p.active){l(s);k.getPlaceholder().trigger("plotselecting",[g()])}}function n(s){if(s.which!=1){return}document.body.focus();if(document.onselectstart!==undefined&&m.onselectstart==null){m.onselectstart=document.onselectstart;document.onselectstart=function(){return false}}if(document.ondrag!==undefined&&m.ondrag==null){m.ondrag=document.ondrag;document.ondrag=function(){return false}}d(p.first,s);p.active=true;r=function(t){j(t)};a(document).one("mouseup",r)}function j(s){r=null;if(document.onselectstart!==undefined){document.onselectstart=m.onselectstart}if(document.ondrag!==undefined){document.ondrag=m.ondrag}p.active=false;l(s);if(f()){i()}else{k.getPlaceholder().trigger("plotunselected",[]);k.getPlaceholder().trigger("plotselecting",[null])}return false}function g(){if(!f()){return null}var u={},t=p.first,s=p.second;a.each(k.getAxes(),function(v,w){if(w.used){var y=w.c2p(t[w.direction]),x=w.c2p(s[w.direction]);u[v]={from:Math.min(y,x),to:Math.max(y,x)}}});return u}function i(){var s=g();k.getPlaceholder().trigger("plotselected",[s]);if(s.xaxis&&s.yaxis){k.getPlaceholder().trigger("selected",[{x1:s.xaxis.from,y1:s.yaxis.from,x2:s.xaxis.to,y2:s.yaxis.to}])}}function h(t,u,s){return us?s:u)}function d(w,t){var v=k.getOptions();var u=k.getPlaceholder().offset();var s=k.getPlotOffset();w.x=h(0,t.pageX-u.left-s.left,k.width());w.y=h(0,t.pageY-u.top-s.top,k.height());if(v.selection.mode=="y"){w.x=w==p.first?0:k.width()}if(v.selection.mode=="x"){w.y=w==p.first?0:k.height()}}function l(s){if(s.pageX==null){return}d(p.second,s);if(f()){p.show=true;k.triggerRedrawOverlay()}else{q(true)}}function q(s){if(p.show){p.show=false;k.triggerRedrawOverlay();if(!s){k.getPlaceholder().trigger("plotunselected",[])}}}function c(s,w){var t,y,z,A,x=k.getAxes();for(var u in x){t=x[u];if(t.direction==w){A=w+t.n+"axis";if(!s[A]&&t.n==1){A=w+"axis"}if(s[A]){y=s[A].from;z=s[A].to;break}}}if(!s[A]){t=w=="x"?k.getXAxes()[0]:k.getYAxes()[0];y=s[w+"1"];z=s[w+"2"]}if(y!=null&&z!=null&&y>z){var v=y;y=z;z=v}return{from:y,to:z,axis:t}}function o(t,s){var v,u,w=k.getOptions();if(w.selection.mode=="y"){p.first.x=0;p.second.x=k.width()}else{u=c(t,"x");p.first.x=u.axis.p2c(u.from);p.second.x=u.axis.p2c(u.to)}if(w.selection.mode=="x"){p.first.y=0;p.second.y=k.height()}else{u=c(t,"y");p.first.y=u.axis.p2c(u.from);p.second.y=u.axis.p2c(u.to)}p.show=true;k.triggerRedrawOverlay();if(!s&&f()){i()}}function f(){var s=5;return Math.abs(p.second.x-p.first.x)>=s&&Math.abs(p.second.y-p.first.y)>=s}k.clearSelection=q;k.setSelection=o;k.getSelection=g;k.hooks.bindEvents.push(function(t,s){var u=t.getOptions();if(u.selection.mode!=null){s.mousemove(e);s.mousedown(n)}});k.hooks.drawOverlay.push(function(v,D){if(p.show&&f()){var t=v.getPlotOffset();var s=v.getOptions();D.save();D.translate(t.left,t.top);var z=a.color.parse(s.selection.color);D.strokeStyle=z.scale("a",0.8).toString();D.lineWidth=1;D.lineJoin="round";D.fillStyle=z.scale("a",0.4).toString();var B=Math.min(p.first.x,p.second.x),A=Math.min(p.first.y,p.second.y),C=Math.abs(p.second.x-p.first.x),u=Math.abs(p.second.y-p.first.y);D.fillRect(B,A,C,u);D.strokeRect(B,A,C,u);D.restore()}});k.hooks.shutdown.push(function(t,s){s.unbind("mousemove",e);s.unbind("mousedown",n);if(r){a(document).unbind("mouseup",r)}})}a.plot.plugins.push({init:b,options:{selection:{mode:null,color:"#e8cfac"}},name:"selection",version:"1.1"})})(jQuery); \ No newline at end of file diff --git a/frontend/src/vendor/jquery.flot/jquery.flot.stack.js b/frontend/src/vendor/jquery.flot/jquery.flot.stack.js deleted file mode 100644 index a31d5dc9b586..000000000000 --- a/frontend/src/vendor/jquery.flot/jquery.flot.stack.js +++ /dev/null @@ -1,184 +0,0 @@ -/* -Flot plugin for stacking data sets, i.e. putting them on top of each -other, for accumulative graphs. - -The plugin assumes the data is sorted on x (or y if stacking -horizontally). For line charts, it is assumed that if a line has an -undefined gap (from a null point), then the line above it should have -the same gap - insert zeros instead of "null" if you want another -behaviour. This also holds for the start and end of the chart. Note -that stacking a mix of positive and negative values in most instances -doesn't make sense (so it looks weird). - -Two or more series are stacked when their "stack" attribute is set to -the same key (which can be any number or string or just "true"). To -specify the default stack, you can set - - series: { - stack: null or true or key (number/string) - } - -or specify it for a specific series - - $.plot($("#placeholder"), [{ data: [ ... ], stack: true }]) - -The stacking order is determined by the order of the data series in -the array (later series end up on top of the previous). - -Internally, the plugin modifies the datapoints in each series, adding -an offset to the y value. For line series, extra data points are -inserted through interpolation. If there's a second y value, it's also -adjusted (e.g for bar charts or filled areas). -*/ - -(function ($) { - var options = { - series: { stack: null } // or number/string - }; - - function init(plot) { - function findMatchingSeries(s, allseries) { - var res = null - for (var i = 0; i < allseries.length; ++i) { - if (s == allseries[i]) - break; - - if (allseries[i].stack == s.stack) - res = allseries[i]; - } - - return res; - } - - function stackData(plot, s, datapoints) { - if (s.stack == null) - return; - - var other = findMatchingSeries(s, plot.getData()); - if (!other) - return; - - var ps = datapoints.pointsize, - points = datapoints.points, - otherps = other.datapoints.pointsize, - otherpoints = other.datapoints.points, - newpoints = [], - px, py, intery, qx, qy, bottom, - withlines = s.lines.show, - horizontal = s.bars.horizontal, - withbottom = ps > 2 && (horizontal ? datapoints.format[2].x : datapoints.format[2].y), - withsteps = withlines && s.lines.steps, - fromgap = true, - keyOffset = horizontal ? 1 : 0, - accumulateOffset = horizontal ? 0 : 1, - i = 0, j = 0, l; - - while (true) { - if (i >= points.length) - break; - - l = newpoints.length; - - if (points[i] == null) { - // copy gaps - for (m = 0; m < ps; ++m) - newpoints.push(points[i + m]); - i += ps; - } - else if (j >= otherpoints.length) { - // for lines, we can't use the rest of the points - if (!withlines) { - for (m = 0; m < ps; ++m) - newpoints.push(points[i + m]); - } - i += ps; - } - else if (otherpoints[j] == null) { - // oops, got a gap - for (m = 0; m < ps; ++m) - newpoints.push(null); - fromgap = true; - j += otherps; - } - else { - // cases where we actually got two points - px = points[i + keyOffset]; - py = points[i + accumulateOffset]; - qx = otherpoints[j + keyOffset]; - qy = otherpoints[j + accumulateOffset]; - bottom = 0; - - if (px == qx) { - for (m = 0; m < ps; ++m) - newpoints.push(points[i + m]); - - newpoints[l + accumulateOffset] += qy; - bottom = qy; - - i += ps; - j += otherps; - } - else if (px > qx) { - // we got past point below, might need to - // insert interpolated extra point - if (withlines && i > 0 && points[i - ps] != null) { - intery = py + (points[i - ps + accumulateOffset] - py) * (qx - px) / (points[i - ps + keyOffset] - px); - newpoints.push(qx); - newpoints.push(intery + qy); - for (m = 2; m < ps; ++m) - newpoints.push(points[i + m]); - bottom = qy; - } - - j += otherps; - } - else { // px < qx - if (fromgap && withlines) { - // if we come from a gap, we just skip this point - i += ps; - continue; - } - - for (m = 0; m < ps; ++m) - newpoints.push(points[i + m]); - - // we might be able to interpolate a point below, - // this can give us a better y - if (withlines && j > 0 && otherpoints[j - otherps] != null) - bottom = qy + (otherpoints[j - otherps + accumulateOffset] - qy) * (px - qx) / (otherpoints[j - otherps + keyOffset] - qx); - - newpoints[l + accumulateOffset] += bottom; - - i += ps; - } - - fromgap = false; - - if (l != newpoints.length && withbottom) - newpoints[l + 2] += bottom; - } - - // maintain the line steps invariant - if (withsteps && l != newpoints.length && l > 0 - && newpoints[l] != null - && newpoints[l] != newpoints[l - ps] - && newpoints[l + 1] != newpoints[l - ps + 1]) { - for (m = 0; m < ps; ++m) - newpoints[l + ps + m] = newpoints[l + m]; - newpoints[l + 1] = newpoints[l - ps + 1]; - } - } - - datapoints.points = newpoints; - } - - plot.hooks.processDatapoints.push(stackData); - } - - $.plot.plugins.push({ - init: init, - options: options, - name: 'stack', - version: '1.2' - }); -})(jQuery); diff --git a/frontend/src/vendor/jquery.flot/jquery.flot.stack.min.js b/frontend/src/vendor/jquery.flot/jquery.flot.stack.min.js deleted file mode 100644 index bba2a0e5ff7e..000000000000 --- a/frontend/src/vendor/jquery.flot/jquery.flot.stack.min.js +++ /dev/null @@ -1 +0,0 @@ -(function(b){var a={series:{stack:null}};function c(f){function d(k,j){var h=null;for(var g=0;g2&&(G?g.format[2].x:g.format[2].y),n=u&&v.lines.steps,E=true,q=G?1:0,H=G?0:1,D=0,B=0,A;while(true){if(D>=F.length){break}A=t.length;if(F[D]==null){for(m=0;m=y.length){if(!u){for(m=0;mJ){if(u&&D>0&&F[D-z]!=null){k=w+(F[D-z+H]-w)*(J-x)/(F[D-z+q]-x);t.push(J);t.push(k+I);for(m=2;m0&&y[B-h]!=null){r=I+(y[B-h+H]-I)*(x-J)/(y[B-h+q]-J)}t[A+H]+=r;D+=z}}E=false;if(A!=t.length&&o){t[A+2]+=r}}}}if(n&&A!=t.length&&A>0&&t[A]!=null&&t[A]!=t[A-z]&&t[A+1]!=t[A-z+1]){for(m=0;m s = r * sqrt(pi)/2 - var size = radius * Math.sqrt(Math.PI) / 2; - ctx.rect(x - size, y - size, size + size, size + size); - }, - diamond: function (ctx, x, y, radius, shadow) { - // pi * r^2 = 2s^2 => s = r * sqrt(pi/2) - var size = radius * Math.sqrt(Math.PI / 2); - ctx.moveTo(x - size, y); - ctx.lineTo(x, y - size); - ctx.lineTo(x + size, y); - ctx.lineTo(x, y + size); - ctx.lineTo(x - size, y); - }, - triangle: function (ctx, x, y, radius, shadow) { - // pi * r^2 = 1/2 * s^2 * sin (pi / 3) => s = r * sqrt(2 * pi / sin(pi / 3)) - var size = radius * Math.sqrt(2 * Math.PI / Math.sin(Math.PI / 3)); - var height = size * Math.sin(Math.PI / 3); - ctx.moveTo(x - size/2, y + height/2); - ctx.lineTo(x + size/2, y + height/2); - if (!shadow) { - ctx.lineTo(x, y - height/2); - ctx.lineTo(x - size/2, y + height/2); - } - }, - cross: function (ctx, x, y, radius, shadow) { - // pi * r^2 = (2s)^2 => s = r * sqrt(pi)/2 - var size = radius * Math.sqrt(Math.PI) / 2; - ctx.moveTo(x - size, y - size); - ctx.lineTo(x + size, y + size); - ctx.moveTo(x - size, y + size); - ctx.lineTo(x + size, y - size); - } - } - - var s = series.points.symbol; - if (handlers[s]) - series.points.symbol = handlers[s]; - } - - function init(plot) { - plot.hooks.processDatapoints.push(processRawData); - } - - $.plot.plugins.push({ - init: init, - name: 'symbols', - version: '1.0' - }); -})(jQuery); diff --git a/frontend/src/vendor/jquery.flot/jquery.flot.symbol.min.js b/frontend/src/vendor/jquery.flot/jquery.flot.symbol.min.js deleted file mode 100644 index 272e003ab49c..000000000000 --- a/frontend/src/vendor/jquery.flot/jquery.flot.symbol.min.js +++ /dev/null @@ -1 +0,0 @@ -(function(b){function a(h,e,g){var d={square:function(k,j,n,i,m){var l=i*Math.sqrt(Math.PI)/2;k.rect(j-l,n-l,l+l,l+l)},diamond:function(k,j,n,i,m){var l=i*Math.sqrt(Math.PI/2);k.moveTo(j-l,n);k.lineTo(j,n-l);k.lineTo(j+l,n);k.lineTo(j,n+l);k.lineTo(j-l,n)},triangle:function(l,k,o,j,n){var m=j*Math.sqrt(2*Math.PI/Math.sin(Math.PI/3));var i=m*Math.sin(Math.PI/3);l.moveTo(k-m/2,o+i/2);l.lineTo(k+m/2,o+i/2);if(!n){l.lineTo(k,o-i/2);l.lineTo(k-m/2,o+i/2)}},cross:function(k,j,n,i,m){var l=i*Math.sqrt(Math.PI)/2;k.moveTo(j-l,n-l);k.lineTo(j+l,n+l);k.moveTo(j-l,n+l);k.lineTo(j+l,n-l)}};var f=e.points.symbol;if(d[f]){e.points.symbol=d[f]}}function c(d){d.hooks.processDatapoints.push(a)}b.plot.plugins.push({init:c,name:"symbols",version:"1.0"})})(jQuery); \ No newline at end of file diff --git a/frontend/src/vendor/jquery.flot/jquery.flot.threshold.js b/frontend/src/vendor/jquery.flot/jquery.flot.threshold.js deleted file mode 100644 index 0b2e7ac82a73..000000000000 --- a/frontend/src/vendor/jquery.flot/jquery.flot.threshold.js +++ /dev/null @@ -1,103 +0,0 @@ -/* -Flot plugin for thresholding data. Controlled through the option -"threshold" in either the global series options - - series: { - threshold: { - below: number - color: colorspec - } - } - -or in a specific series - - $.plot($("#placeholder"), [{ data: [ ... ], threshold: { ... }}]) - -The data points below "below" are drawn with the specified color. This -makes it easy to mark points below 0, e.g. for budget data. - -Internally, the plugin works by splitting the data into two series, -above and below the threshold. The extra series below the threshold -will have its label cleared and the special "originSeries" attribute -set to the original series. You may need to check for this in hover -events. -*/ - -(function ($) { - var options = { - series: { threshold: null } // or { below: number, color: color spec} - }; - - function init(plot) { - function thresholdData(plot, s, datapoints) { - if (!s.threshold) - return; - - var ps = datapoints.pointsize, i, x, y, p, prevp, - thresholded = $.extend({}, s); // note: shallow copy - - thresholded.datapoints = { points: [], pointsize: ps }; - thresholded.label = null; - thresholded.color = s.threshold.color; - thresholded.threshold = null; - thresholded.originSeries = s; - thresholded.data = []; - - var below = s.threshold.below, - origpoints = datapoints.points, - addCrossingPoints = s.lines.show; - - threspoints = []; - newpoints = []; - - for (i = 0; i < origpoints.length; i += ps) { - x = origpoints[i] - y = origpoints[i + 1]; - - prevp = p; - if (y < below) - p = threspoints; - else - p = newpoints; - - if (addCrossingPoints && prevp != p && x != null - && i > 0 && origpoints[i - ps] != null) { - var interx = (x - origpoints[i - ps]) / (y - origpoints[i - ps + 1]) * (below - y) + x; - prevp.push(interx); - prevp.push(below); - for (m = 2; m < ps; ++m) - prevp.push(origpoints[i + m]); - - p.push(null); // start new segment - p.push(null); - for (m = 2; m < ps; ++m) - p.push(origpoints[i + m]); - p.push(interx); - p.push(below); - for (m = 2; m < ps; ++m) - p.push(origpoints[i + m]); - } - - p.push(x); - p.push(y); - } - - datapoints.points = newpoints; - thresholded.datapoints.points = threspoints; - - if (thresholded.datapoints.points.length > 0) - plot.getData().push(thresholded); - - // FIXME: there are probably some edge cases left in bars - } - - plot.hooks.processDatapoints.push(thresholdData); - } - - $.plot.plugins.push({ - init: init, - options: options, - name: 'threshold', - version: '1.0' - }); -})(jQuery); diff --git a/frontend/src/vendor/jquery.flot/jquery.flot.threshold.min.js b/frontend/src/vendor/jquery.flot/jquery.flot.threshold.min.js deleted file mode 100644 index d8b79dfc93c9..000000000000 --- a/frontend/src/vendor/jquery.flot/jquery.flot.threshold.min.js +++ /dev/null @@ -1 +0,0 @@ -(function(B){var A={series:{threshold:null}};function C(D){function E(L,S,M){if(!S.threshold){return }var F=M.pointsize,I,O,N,G,K,H=B.extend({},S);H.datapoints={points:[],pointsize:F};H.label=null;H.color=S.threshold.color;H.threshold=null;H.originSeries=S;H.data=[];var P=S.threshold.below,Q=M.points,R=S.lines.show;threspoints=[];newpoints=[];for(I=0;I0&&Q[I-F]!=null){var J=(O-Q[I-F])/(N-Q[I-F+1])*(P-N)+O;K.push(J);K.push(P);for(m=2;m0){L.getData().push(H)}}D.hooks.processDatapoints.push(E)}B.plot.plugins.push({init:C,options:A,name:"threshold",version:"1.0"})})(jQuery); \ No newline at end of file From 741d01644ab99670713c4b873d00cf40c549a87d Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 29 Jan 2026 17:38:21 -0300 Subject: [PATCH 06/57] Show action buttons in Taskboard/Burndown headers Updates `SprintPageHeaderComponent` to support `with_action_button`. --- .../sprint_page_header_component.html.erb | 17 ++++++----------- .../backlogs/sprint_page_header_component.rb | 4 ++++ .../app/views/rb_burndown_charts/show.html.erb | 15 ++++++++++++++- .../app/views/rb_taskboards/show.html.erb | 16 +++++++++++++++- 4 files changed, 39 insertions(+), 13 deletions(-) diff --git a/modules/backlogs/app/components/backlogs/sprint_page_header_component.html.erb b/modules/backlogs/app/components/backlogs/sprint_page_header_component.html.erb index 7ddc8a202644..7b8ba671461a 100644 --- a/modules/backlogs/app/components/backlogs/sprint_page_header_component.html.erb +++ b/modules/backlogs/app/components/backlogs/sprint_page_header_component.html.erb @@ -27,14 +27,9 @@ See COPYRIGHT and LICENSE files for more details. ++# %> -<%= - render Primer::OpenProject::PageHeader.new do |header| - header.with_title_content(@sprint.name) - - header.with_description do - format_date_range(date_range) - end - - header.with_breadcrumbs(breadcrumb_items) - end -%> +<%= render(@page_header) do |header| %> + <% header.with_title_content(@sprint.name) %> + <% header.with_description { format_date_range(date_range) } %> + <% header.with_breadcrumbs(breadcrumb_items) %> + <%= content %> +<% end %> diff --git a/modules/backlogs/app/components/backlogs/sprint_page_header_component.rb b/modules/backlogs/app/components/backlogs/sprint_page_header_component.rb index c3e0fdb12046..f0b54587d941 100644 --- a/modules/backlogs/app/components/backlogs/sprint_page_header_component.rb +++ b/modules/backlogs/app/components/backlogs/sprint_page_header_component.rb @@ -33,11 +33,15 @@ class SprintPageHeaderComponent < ApplicationComponent include ApplicationHelper include RbCommonHelper + delegate :with_action_button, to: :@page_header + def initialize(sprint:, project:) super @sprint = sprint @project = project + + @page_header = Primer::OpenProject::PageHeader.new end def breadcrumb_items diff --git a/modules/backlogs/app/views/rb_burndown_charts/show.html.erb b/modules/backlogs/app/views/rb_burndown_charts/show.html.erb index 86ea72309d8a..5d0524637451 100644 --- a/modules/backlogs/app/views/rb_burndown_charts/show.html.erb +++ b/modules/backlogs/app/views/rb_burndown_charts/show.html.erb @@ -33,7 +33,20 @@ See COPYRIGHT and LICENSE files for more details. <% end -%> -<%= render(Backlogs::SprintPageHeaderComponent.new(sprint: @sprint, project: @project)) %> +<%= + render(Backlogs::SprintPageHeaderComponent.new(sprint: @sprint, project: @project)) do |header| + header.with_action_button( + tag: :a, + href: backlogs_project_sprint_taskboard_path(@project, @sprint), + mobile_icon: :"op-view-cards", + mobile_label: t(:label_task_board), + aria: { label: t(:label_task_board) } + ) do |button| + button.with_leading_visual_icon(icon: :"op-view-cards") + t(:label_task_board) + end + end +%> <% if @burndown %> <%= render partial: "burndown", locals: { div: "burndown_", burndown: @burndown } %> diff --git a/modules/backlogs/app/views/rb_taskboards/show.html.erb b/modules/backlogs/app/views/rb_taskboards/show.html.erb index acae533782b2..23dc8eb7a2d9 100644 --- a/modules/backlogs/app/views/rb_taskboards/show.html.erb +++ b/modules/backlogs/app/views/rb_taskboards/show.html.erb @@ -35,7 +35,21 @@ See COPYRIGHT and LICENSE files for more details. <% html_title @sprint.name %> -<%= render(Backlogs::SprintPageHeaderComponent.new(sprint: @sprint, project: @project)) %> +<%= + render(Backlogs::SprintPageHeaderComponent.new(sprint: @sprint, project: @project)) do |header| + header.with_action_button( + tag: :a, + href: backlogs_project_sprint_burndown_chart_path(@project, @sprint), + mobile_icon: :graph, + mobile_label: t(:"backlogs.show_burndown_chart"), + aria: { label: t(:"backlogs.show_burndown_chart") }, + disabled: !@sprint.has_burndown? + ) do |button| + button.with_leading_visual_icon(icon: :graph) + t(:"backlogs.show_burndown_chart") + end + end +%> <%= render(Primer::OpenProject::SubHeader.new) do |component| %> <% component.with_filter_component(id: "col_width") do %> From 16ca9063a6e0e9e2a30b3faa1b07af70d61e8fb1 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 19:55:54 -0300 Subject: [PATCH 07/57] Use `fetch_or_fallback` for state validation in BacklogHeaderComponent (#21814) * Initial plan * Use fetch_or_fallback to validate state in BacklogHeaderComponent Co-authored-by: myabc <755+myabc@users.noreply.github.com> * Add test for state validation fallback behavior Co-authored-by: myabc <755+myabc@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: myabc <755+myabc@users.noreply.github.com> --- .../app/components/backlogs/backlog_header_component.rb | 3 ++- .../components/backlogs/backlog_header_component_spec.rb | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/modules/backlogs/app/components/backlogs/backlog_header_component.rb b/modules/backlogs/app/components/backlogs/backlog_header_component.rb index fd81a433cb78..6daeacba6ac2 100644 --- a/modules/backlogs/app/components/backlogs/backlog_header_component.rb +++ b/modules/backlogs/app/components/backlogs/backlog_header_component.rb @@ -32,6 +32,7 @@ module Backlogs class BacklogHeaderComponent < ApplicationComponent include OpPrimer::ComponentHelpers include OpTurbo::Streamable + include Primer::FetchOrFallbackHelper include Redmine::I18n include RbCommonHelper @@ -55,7 +56,7 @@ def initialize( @backlog = backlog @project = project - @state = ActiveSupport::StringInquirer.new(state.to_s) + @state = ActiveSupport::StringInquirer.new(fetch_or_fallback(STATE_OPTIONS, state, STATE_DEFAULT).to_s) @collapsed = folded @current_user = current_user end diff --git a/modules/backlogs/spec/components/backlogs/backlog_header_component_spec.rb b/modules/backlogs/spec/components/backlogs/backlog_header_component_spec.rb index cd7e44a1d73c..ded99a4092f4 100644 --- a/modules/backlogs/spec/components/backlogs/backlog_header_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/backlog_header_component_spec.rb @@ -211,4 +211,11 @@ def render_component(state: :show, folded: false) expect(page).to have_link(I18n.t(:button_cancel)) end end + + describe "state validation" do + it "raises an InvalidValueError for invalid state values" do + expect { render_component(state: :invalid) } + .to raise_error(Primer::FetchOrFallbackHelper::InvalidValueError) + end + end end From df2e2aa0628aaa9d1c5c0604a7f6166db7fd4668 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 30 Jan 2026 18:40:18 -0300 Subject: [PATCH 08/57] Refresh backlogs list on story update via spllt view - Moves the backlogs list into its own Turbo Frame. - Hooks into `HalEventsService` to subscribe to work package updates. - Refreshes the Turbo Frame (with morphing) on work package update. --- .../app/features/plugins/plugin-context.ts | 2 ++ .../dynamic/backlogs.controller.ts | 36 ++++++++++++++++++- .../rb_master_backlogs_controller.rb | 34 ++++++++++-------- .../views/rb_master_backlogs/_list.html.erb | 23 ++++++++++++ .../views/rb_master_backlogs/index.html.erb | 30 +++------------- modules/backlogs/config/routes.rb | 14 +++----- .../lib/open_project/backlogs/engine.rb | 2 +- .../rb_master_backlogs_controller_spec.rb | 27 +++++++++++--- .../rb_master_backlogs_routing_spec.rb | 2 +- 9 files changed, 114 insertions(+), 56 deletions(-) create mode 100644 modules/backlogs/app/views/rb_master_backlogs/_list.html.erb diff --git a/frontend/src/app/features/plugins/plugin-context.ts b/frontend/src/app/features/plugins/plugin-context.ts index 2dbd58f1d8f0..c51d0a50d798 100644 --- a/frontend/src/app/features/plugins/plugin-context.ts +++ b/frontend/src/app/features/plugins/plugin-context.ts @@ -31,6 +31,7 @@ import { HttpClient } from '@angular/common/http'; import { TimezoneService } from 'core-app/core/datetime/timezone.service'; import { TurboRequestsService } from 'core-app/core/turbo/turbo-requests.service'; import { CurrentProjectService } from 'core-app/core/current-project/current-project.service'; +import { HalEventsService } from '../hal/services/hal-events.service'; /** * Plugin context bridge for plugins outside the CLI compiler context * in order to access services and parts of the core application @@ -48,6 +49,7 @@ export class OpenProjectPluginContext { confirmDialog: this.injector.get(ConfirmDialogService), externalQueryConfiguration: this.injector.get(ExternalQueryConfigurationService), externalRelationQueryConfiguration: this.injector.get(ExternalRelationQueryConfigurationService), + halEvents: this.injector.get(HalEventsService), halResource: this.injector.get(HalResourceService), hooks: this.injector.get(HookService), i18n: this.injector.get(I18nService), diff --git a/frontend/src/stimulus/controllers/dynamic/backlogs.controller.ts b/frontend/src/stimulus/controllers/dynamic/backlogs.controller.ts index b2ed8978f20f..768dcb9a2587 100644 --- a/frontend/src/stimulus/controllers/dynamic/backlogs.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/backlogs.controller.ts @@ -27,6 +27,40 @@ //++ import { Controller } from '@hotwired/stimulus'; +import { FrameElement } from '@hotwired/turbo'; +import { HalEventsService } from 'core-app/features/hal/services/hal-events.service'; +import { filter, Subscription } from 'rxjs'; -export default class BacklogsController extends Controller { +export default class BacklogsController extends Controller { + static values = { + listUrl: String, + }; + + declare listUrlValue:string; + private service:HalEventsService|null = null; + private subscription:Subscription|null = null; + + // eslint-disable-next-line @typescript-eslint/no-misused-promises + async connect() { + const { services: { halEvents } } = await window.OpenProject.getPluginContext(); + + this.service = halEvents; + this.subscription = this.service.aggregated$('WorkPackage') + .pipe(filter((events) => events.some((event) => event.eventType === 'updated'))) + .subscribe(() => { this.refreshList(); }); + } + + disconnect() { + this.subscription?.unsubscribe(); + this.subscription = null; + this.service = null; + } + + private refreshList() { + this.listElement.src = this.listUrlValue; + } + + private get listElement() { + return this.element.querySelector('#backlogs_container')!; + } } diff --git a/modules/backlogs/app/controllers/rb_master_backlogs_controller.rb b/modules/backlogs/app/controllers/rb_master_backlogs_controller.rb index 9ee8e2882697..dd6838e553ba 100644 --- a/modules/backlogs/app/controllers/rb_master_backlogs_controller.rb +++ b/modules/backlogs/app/controllers/rb_master_backlogs_controller.rb @@ -33,25 +33,31 @@ class RbMasterBacklogsController < RbApplicationController menu_item :backlogs + before_action :load_backlogs, only: :index + def index - @owner_backlogs = Backlog.owner_backlogs(@project) - @sprint_backlogs = Backlog.sprint_backlogs(@project) + if turbo_frame_request? + render partial: "list", layout: false + else + render :index + end end - def split_view - @owner_backlogs = Backlog.owner_backlogs(@project) - @sprint_backlogs = Backlog.sprint_backlogs(@project) - - respond_to do |format| - format.html do - if turbo_frame_request? - render "work_packages/split_view", layout: false - else - render :index - end - end + def details + if turbo_frame_request? + render "work_packages/split_view", layout: false + else + load_backlogs + render :index end end def split_view_base_route = backlogs_project_backlogs_path(request.query_parameters) + + private + + def load_backlogs + @owner_backlogs = Backlog.owner_backlogs(@project) + @sprint_backlogs = Backlog.sprint_backlogs(@project) + end end diff --git a/modules/backlogs/app/views/rb_master_backlogs/_list.html.erb b/modules/backlogs/app/views/rb_master_backlogs/_list.html.erb new file mode 100644 index 000000000000..376cb8f1fa9a --- /dev/null +++ b/modules/backlogs/app/views/rb_master_backlogs/_list.html.erb @@ -0,0 +1,23 @@ + + <% if @owner_backlogs.empty? && @sprint_backlogs.empty? %> + <%= + render(Primer::Beta::Blankslate.new(border: true, spacious: true)) do |blankslate| + blankslate.with_visual_icon(icon: :versions) + blankslate.with_heading(tag: :h2).with_content(t(:backlogs_empty_title)) + + if current_user.allowed_in_project?(:manage_versions, @project) + blankslate.with_description_content(t(:backlogs_empty_action_text)) + end + end + %> + <% else %> +
    +
    + <%= render(Backlogs::BacklogComponent.with_collection(@sprint_backlogs, project: @project)) %> +
    +
    + <%= render(Backlogs::BacklogComponent.with_collection(@owner_backlogs, project: @project)) %> +
    +
    + <% end %> +
    diff --git a/modules/backlogs/app/views/rb_master_backlogs/index.html.erb b/modules/backlogs/app/views/rb_master_backlogs/index.html.erb index c4e99028ee11..5242488c3210 100644 --- a/modules/backlogs/app/views/rb_master_backlogs/index.html.erb +++ b/modules/backlogs/app/views/rb_master_backlogs/index.html.erb @@ -29,6 +29,9 @@ See COPYRIGHT and LICENSE files for more details. <% html_title t(:label_backlogs) %> +<% content_controller "backlogs", + "backlogs-list-url-value": backlogs_project_backlogs_path(@project) %> + <% content_for :content_header do %> <%= render Primer::OpenProject::PageHeader.new do |header| @@ -54,32 +57,7 @@ See COPYRIGHT and LICENSE files for more details. <% end %> <% content_for :content_body do %> - <% if (@owner_backlogs.empty? && @sprint_backlogs.empty?) %> - <%= - render(Primer::Beta::Blankslate.new(border: true, spacious: true)) do |blankslate| - blankslate.with_visual_icon(icon: :versions) - blankslate.with_heading(tag: :h2).with_content(t(:backlogs_empty_title)) - - if current_user.allowed_in_project?(:manage_versions, @project) - blankslate.with_description_content(t(:backlogs_empty_action_text)) - end - end - %> - <% end %> - -
    -
    - -
    - <%= render(Backlogs::BacklogComponent.with_collection(@sprint_backlogs, project: @project)) %> -
    -
    - <%= render(Backlogs::BacklogComponent.with_collection(@owner_backlogs, project: @project)) %> -
    -
    -
    + <%= render partial: "list" %> <% end %> <% content_for :content_body_right do %> diff --git a/modules/backlogs/config/routes.rb b/modules/backlogs/config/routes.rb index ee43551ae7d9..9aa4c7217ca2 100644 --- a/modules/backlogs/config/routes.rb +++ b/modules/backlogs/config/routes.rb @@ -27,19 +27,15 @@ #++ Rails.application.routes.draw do - concern :with_split_view do |options| - get "details/:work_package_id(/:tab)", - action: options.fetch(:action, :split_view), - defaults: { tab: :overview }, - as: :details, - work_package_split_view: true - end - scope "", as: "backlogs" do scope "projects/:project_id", as: "project" do resources :backlogs, controller: :rb_master_backlogs, only: :index do collection do - concerns :with_split_view, base_route: :backlogs_project_backlogs_path + get "details/:work_package_id(/:tab)", + action: :details, + as: :details, + work_package_split_view: true, + defaults: { tab: :overview } end end diff --git a/modules/backlogs/lib/open_project/backlogs/engine.rb b/modules/backlogs/lib/open_project/backlogs/engine.rb index 81308d001752..a70ef5705f5c 100644 --- a/modules/backlogs/lib/open_project/backlogs/engine.rb +++ b/modules/backlogs/lib/open_project/backlogs/engine.rb @@ -75,7 +75,7 @@ def self.settings project_module :backlogs, dependencies: :work_package_tracking do # Master backlog permissions permission :view_master_backlog, - { rb_master_backlogs: %i[index split_view], + { rb_master_backlogs: %i[index details], rb_sprints: %i[index show show_name], rb_wikis: :show, rb_stories: %i[index show], diff --git a/modules/backlogs/spec/controllers/rb_master_backlogs_controller_spec.rb b/modules/backlogs/spec/controllers/rb_master_backlogs_controller_spec.rb index 595d6f32d701..992a47b9d59c 100644 --- a/modules/backlogs/spec/controllers/rb_master_backlogs_controller_spec.rb +++ b/modules/backlogs/spec/controllers/rb_master_backlogs_controller_spec.rb @@ -48,16 +48,23 @@ end describe "GET #index" do - it do + it "is successful" do get :index, params: { project_id: project.id } expect(response).to be_successful end + + it "assigns @owner_backlogs and @sprint_backlogs" do + get :index, params: { project_id: project.id } + + expect(assigns(:owner_backlogs)).to be_an(Array) + expect(assigns(:sprint_backlogs)).to be_an(Array) + end end - describe "GET #split_view" do - it do - get :split_view, params: { + describe "GET #details" do + it "is successful" do + get :details, params: { project_id: project.id, tab: :overview, work_package_id: story.id, @@ -66,5 +73,17 @@ expect(response).to be_successful end + + it "assigns @owner_backlogs and @sprint_backlogs" do + get :details, params: { + project_id: project.id, + tab: :overview, + work_package_id: story.id, + work_package_split_view: true + } + + expect(assigns(:owner_backlogs)).to be_an(Array) + expect(assigns(:sprint_backlogs)).to be_an(Array) + end end end diff --git a/modules/backlogs/spec/routing/rb_master_backlogs_routing_spec.rb b/modules/backlogs/spec/routing/rb_master_backlogs_routing_spec.rb index b8ca73aab0a2..4ee4440bccce 100644 --- a/modules/backlogs/spec/routing/rb_master_backlogs_routing_spec.rb +++ b/modules/backlogs/spec/routing/rb_master_backlogs_routing_spec.rb @@ -39,7 +39,7 @@ it { expect(get("/projects/project_42/backlogs/details/33")).to route_to( controller: "rb_master_backlogs", - action: "split_view", + action: "details", project_id: "project_42", work_package_id: "33", tab: :overview, From 3458c7b1187129bdc09ae084390279eec835eb82 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 2 Feb 2026 19:24:25 -0300 Subject: [PATCH 09/57] Simplify DnD: remove empty list item CSS hiding --- .../assets/sass/backlogs/_master_backlog.sass | 6 ---- .../backlogs/backlog_component.html.erb | 32 ++++++++++--------- .../backlogs/backlog_component_spec.rb | 9 +----- 3 files changed, 18 insertions(+), 29 deletions(-) diff --git a/frontend/src/assets/sass/backlogs/_master_backlog.sass b/frontend/src/assets/sass/backlogs/_master_backlog.sass index 385f5e5c3438..675cdba86b34 100644 --- a/frontend/src/assets/sass/backlogs/_master_backlog.sass +++ b/frontend/src/assets/sass/backlogs/_master_backlog.sass @@ -26,12 +26,6 @@ * See COPYRIGHT and LICENSE files for more details. ++ */ -li[data-empty-list-item] - display: none - -li[data-empty-list-item]:only-child - display: list-item - $op-backlogs-header--points-min-width: 5rem .op-backlogs-header diff --git a/modules/backlogs/app/components/backlogs/backlog_component.html.erb b/modules/backlogs/app/components/backlogs/backlog_component.html.erb index 05f5c412acbd..6fbb014f602d 100644 --- a/modules/backlogs/app/components/backlogs/backlog_component.html.erb +++ b/modules/backlogs/app/components/backlogs/backlog_component.html.erb @@ -32,21 +32,23 @@ See COPYRIGHT and LICENSE files for more details. <% border_box.with_header do %> <%= render(Backlogs::BacklogHeaderComponent.new(backlog:, project: @project, folded: folded?)) %> <% end %> - <% border_box.with_row(data: { empty_list_item: true }) do %> - <% if backlog.sprint_backlog? %> - <%= - render Primer::Beta::Blankslate.new(role: "status", aria: { live: "polite" }) do |blankslate| - blankslate.with_heading(tag: :h4).with_content(t(".sprint_backlog.blankslate_title")) - blankslate.with_description_content(t(".sprint_backlog.blankslate_description")) - end - %> - <% else %> - <%= - render Primer::Beta::Blankslate.new(role: "status", aria: { live: "polite" }) do |blankslate| - blankslate.with_heading(tag: :h4).with_content(t(".product_backlog.blankslate_title")) - blankslate.with_description_content(t(".product_backlog.blankslate_description")) - end - %> + <% if backlog.stories.empty? %> + <% border_box.with_row(data: { empty_list_item: true }) do %> + <% if backlog.sprint_backlog? %> + <%= + render Primer::Beta::Blankslate.new(role: "status", aria: { live: "polite" }) do |blankslate| + blankslate.with_heading(tag: :h4).with_content(t(".sprint_backlog.blankslate_title")) + blankslate.with_description_content(t(".sprint_backlog.blankslate_description")) + end + %> + <% else %> + <%= + render Primer::Beta::Blankslate.new(role: "status", aria: { live: "polite" }) do |blankslate| + blankslate.with_heading(tag: :h4).with_content(t(".product_backlog.blankslate_title")) + blankslate.with_description_content(t(".product_backlog.blankslate_description")) + end + %> + <% end %> <% end %> <% end %> <% backlog.stories.each do |story| %> diff --git a/modules/backlogs/spec/components/backlogs/backlog_component_spec.rb b/modules/backlogs/spec/components/backlogs/backlog_component_spec.rb index 52951ec51b80..f3bab7fb4a20 100644 --- a/modules/backlogs/spec/components/backlogs/backlog_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/backlog_component_spec.rb @@ -100,18 +100,11 @@ def render_component it "renders StoryComponent for each story" do render_component - expect(page).to have_css(".Box-row", count: 3) # 2 stories + 1 empty list item + expect(page).to have_css(".Box-row", count: 2) # 2 stories expect(page).to have_text(story1.subject) expect(page).to have_text(story2.subject) end - it "has the empty blankslate row with data attribute" do - render_component - - # The empty row has data-empty-list-item attribute - expect(page).to have_css("[data-empty-list-item]", visible: :all) - end - it "has drop target data attributes" do render_component From f61614eb614137b28f68a1f7026ba5a1fdec2182 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 2 Feb 2026 20:07:28 -0300 Subject: [PATCH 10/57] Update Controller flash messages --- config/locales/en.yml | 1 + .../app/controllers/rb_stories_controller.rb | 24 +++++++------------ .../controllers/rb_stories_controller_spec.rb | 1 - 3 files changed, 9 insertions(+), 17 deletions(-) diff --git a/config/locales/en.yml b/config/locales/en.yml index 9117b6f8d169..bba5778aafa2 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -4129,6 +4129,7 @@ en: notice_successful_delete: "Successful deletion." notice_successful_cancel: "Successful cancellation." notice_successful_update: "Successful update." + notice_successful_move: "Successful move from %{from} to %{to}." notice_unsuccessful_create: "Creation failed." notice_unsuccessful_create_with_reason: "Creation failed: %{reason}" notice_unsuccessful_update: "Update failed." diff --git a/modules/backlogs/app/controllers/rb_stories_controller.rb b/modules/backlogs/app/controllers/rb_stories_controller.rb index 76439dd4184d..570222dbb222 100644 --- a/modules/backlogs/app/controllers/rb_stories_controller.rb +++ b/modules/backlogs/app/controllers/rb_stories_controller.rb @@ -70,14 +70,8 @@ def move # .new(user: current_user, story:) # .call(attributes: move_params) - if story.update(version_id: move_params[:target_id], **move_params.except(:target_id)) - render_success_flash_message_via_turbo_stream( - message: I18n.t(:enumeration_caption_order_changed) - ) - else - render_error_flash_message_via_turbo_stream( - message: I18n.t(:enumeration_could_not_be_moved) + call.errors.full_messages.to_sentence - ) + unless story.update(version_id: move_params[:target_id], **move_params.except(:target_id)) + render_error_flash_message_via_turbo_stream(message: I18n.t(:notice_unsuccessful_update)) #  TODO: display reason end backlog = Backlog.for(sprint: @sprint, project: @project) @@ -86,6 +80,10 @@ def move if story.saved_change_to_version_id? new_sprint = story.version.becomes(Sprint) new_backlog = Backlog.for(sprint: new_sprint, project: @project) + + render_success_flash_message_via_turbo_stream( + message: I18n.t(:notice_successful_move, from: @sprint.name, to: new_sprint.name) + ) replace_via_turbo_stream(component: Backlogs::BacklogComponent.new(backlog: new_backlog, project: @project)) end @@ -95,14 +93,8 @@ def move def reorder story = Story.find(params[:id]) - if story.update(move_to: reorder_param) - render_success_flash_message_via_turbo_stream( - message: I18n.t(:enumeration_caption_order_changed) - ) - else - render_error_flash_message_via_turbo_stream( - message: I18n.t(:enumeration_could_not_be_moved) + call.errors.full_messages.to_sentence - ) + unless story.update(move_to: reorder_param) + render_error_flash_message_via_turbo_stream(message: I18n.t(:notice_unsuccessful_update)) #  TODO: display reason end backlog = Backlog.for(sprint: @sprint, project: @project) diff --git a/modules/backlogs/spec/controllers/rb_stories_controller_spec.rb b/modules/backlogs/spec/controllers/rb_stories_controller_spec.rb index 096c4a0d6084..541d09de9655 100644 --- a/modules/backlogs/spec/controllers/rb_stories_controller_spec.rb +++ b/modules/backlogs/spec/controllers/rb_stories_controller_spec.rb @@ -86,7 +86,6 @@ expect(response).to be_successful expect(response).to have_http_status :ok expect(response).to have_turbo_stream action: "replace", target: "backlogs-backlog-component-#{sprint.id}" - expect(response).to have_turbo_stream action: "flash", target: "op-primer-flash-component" end end From 82f908bd6dfe8f3958f307d52dd1ae8570bf21a7 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 3 Feb 2026 11:06:19 -0300 Subject: [PATCH 11/57] Ensure Edit sprint form takes full width --- frontend/src/assets/sass/backlogs/_master_backlog.sass | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/assets/sass/backlogs/_master_backlog.sass b/frontend/src/assets/sass/backlogs/_master_backlog.sass index 675cdba86b34..c05c7a83ba58 100644 --- a/frontend/src/assets/sass/backlogs/_master_backlog.sass +++ b/frontend/src/assets/sass/backlogs/_master_backlog.sass @@ -114,3 +114,7 @@ $op-backlogs-header--points-min-width: 5rem .FormControl-spacingWrapper flex-direction: row column-gap: 0.5rem + + & > :first-child + flex: 1 1 auto + min-width: 33% From bd0518288049442b1f5a0c87dd0862c42e918984 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 3 Feb 2026 15:31:13 -0300 Subject: [PATCH 12/57] Always show drag handle --- frontend/src/global_styles/primer/_overrides.sass | 9 --------- 1 file changed, 9 deletions(-) diff --git a/frontend/src/global_styles/primer/_overrides.sass b/frontend/src/global_styles/primer/_overrides.sass index c79dc6a82b4c..77449bf785ce 100644 --- a/frontend/src/global_styles/primer/_overrides.sass +++ b/frontend/src/global_styles/primer/_overrides.sass @@ -162,12 +162,3 @@ ul.SegmentedControl, .Box-row:is(.Box-row--draggable) padding-left: 0 - - .DragHandle - visibility: hidden - - &:hover, - &:focus-visible, - &:focus-within - .DragHandle - visibility: visible From ed7f504690bff7e57b9f5f13e5527318ea112a93 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 3 Feb 2026 15:39:59 -0300 Subject: [PATCH 13/57] Update backlog menu: wording (case), item order --- .../backlogs/backlog_menu_component.html.erb | 36 ++++++++++--------- modules/backlogs/config/locales/en.yml | 9 +++-- .../backlogs/backlog_menu_component_spec.rb | 24 ++++++------- .../features/backlogs/context_menu_spec.rb | 34 +++++++++--------- 4 files changed, 55 insertions(+), 48 deletions(-) diff --git a/modules/backlogs/app/components/backlogs/backlog_menu_component.html.erb b/modules/backlogs/app/components/backlogs/backlog_menu_component.html.erb index 416e57ffdad5..520958c0ed1c 100644 --- a/modules/backlogs/app/components/backlogs/backlog_menu_component.html.erb +++ b/modules/backlogs/app/components/backlogs/backlog_menu_component.html.erb @@ -44,13 +44,11 @@ See COPYRIGHT and LICENSE files for more details. ) do |item| item.with_leading_visual_icon(icon: :pencil) end - - menu.with_divider end if user_allowed?(:add_work_packages) menu.with_item( - label: t(:"backlogs.add_new_story"), + label: t(".action_menu.new_story"), href: new_project_work_packages_dialog_path( project, version_id: sprint.id, @@ -62,28 +60,22 @@ See COPYRIGHT and LICENSE files for more details. end end + if user_allowed?(:update_sprints) || user_allowed?(:add_work_packages) + menu.with_divider + end + menu.with_item( - label: t(:label_stories_tasks), + label: t(".action_menu.stories_tasks"), tag: :a, href: backlogs_project_sprint_query_path(project, sprint) ) do |item| item.with_leading_visual_icon(icon: :"op-view-list") end - if user_allowed?(:manage_versions) - menu.with_item( - label: t(:"backlogs.properties"), - tag: :a, - href: edit_version_path(sprint, back_url: backlogs_project_backlogs_path(project), project_id: project.id) - ) do |item| - item.with_leading_visual_icon(icon: :gear) - end - end - if backlog.sprint_backlog? if user_allowed?(:view_taskboards) menu.with_item( - label: t(:label_task_board), + label: t(".action_menu.task_board"), tag: :a, href: backlogs_project_sprint_taskboard_path(project, sprint) ) do |item| @@ -92,7 +84,7 @@ See COPYRIGHT and LICENSE files for more details. end menu.with_item( - label: t(:"backlogs.show_burndown_chart"), + label: t(".action_menu.burndown_chart"), tag: :a, href: backlogs_project_sprint_burndown_chart_path(project, sprint), disabled: !sprint.has_burndown? @@ -102,7 +94,7 @@ See COPYRIGHT and LICENSE files for more details. if project.module_enabled? "wiki" menu.with_item( - label: t(:label_wiki), + label: t(".action_menu.wiki"), tag: :a, href: edit_backlogs_project_sprint_wiki_path(project, sprint) ) do |item| @@ -110,5 +102,15 @@ See COPYRIGHT and LICENSE files for more details. end end end + + if user_allowed?(:manage_versions) + menu.with_item( + label: t(".action_menu.properties"), + tag: :a, + href: edit_version_path(sprint, back_url: backlogs_project_backlogs_path(project), project_id: project.id) + ) do |item| + item.with_leading_visual_icon(icon: :gear) + end + end end %> diff --git a/modules/backlogs/config/locales/en.yml b/modules/backlogs/config/locales/en.yml index 83f1013702e7..fffbf10c753e 100644 --- a/modules/backlogs/config/locales/en.yml +++ b/modules/backlogs/config/locales/en.yml @@ -57,7 +57,6 @@ en: task_type: "Task type" backlogs: - add_new_story: "New story" any: "any" backlog_settings: "Backlogs settings" burndown_graph: "Burndown Graph" @@ -79,7 +78,6 @@ en: other: "%{count} points" positions_could_not_be_rebuilt: "Positions could not be rebuilt." positions_rebuilt_successfully: "Positions rebuilt successfully." - properties: "Properties" rebuild: "Rebuild" rebuild_positions: "Rebuild positions" remaining_hours: "Remaining work" @@ -116,6 +114,12 @@ en: label_actions: "Backlog actions" action_menu: edit_sprint: "Edit sprint" + new_story: "New story" + stories_tasks: "Stories/Tasks" + task_board: "Task board" + burndown_chart: "Burndown chart" + wiki: "Wiki" + properties: "Properties" story_component: label_drag_story: "Move %{name}" @@ -192,7 +196,6 @@ en: label_sprint_name: "Sprint \"%{name}\"" label_sprint_velocity: "Velocity %{velocity}, based on %{sprints} sprints with an average %{days} days" label_stories: "Stories" - label_stories_tasks: "Stories/Tasks" label_task_board: "Task board" label_version_setting: "Versions" label_version: 'Version' diff --git a/modules/backlogs/spec/components/backlogs/backlog_menu_component_spec.rb b/modules/backlogs/spec/components/backlogs/backlog_menu_component_spec.rb index 30f26b2ccf74..a5273fbf06d5 100644 --- a/modules/backlogs/spec/components/backlogs/backlog_menu_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/backlog_menu_component_spec.rb @@ -87,7 +87,7 @@ def render_component it "shows Add new story item with compose icon" do render_component - expect(page).to have_text(I18n.t(:"backlogs.add_new_story")) + expect(page).to have_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.new_story")) expect(page).to have_octicon(:compose) end end @@ -98,7 +98,7 @@ def render_component it "does not show Add new story item" do render_component - expect(page).to have_no_text(I18n.t(:"backlogs.add_new_story")) + expect(page).to have_no_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.new_story")) end end @@ -108,7 +108,7 @@ def render_component it "shows Properties item with gear icon" do render_component - expect(page).to have_text(I18n.t(:"backlogs.properties")) + expect(page).to have_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.properties")) expect(page).to have_octicon(:gear) end end @@ -119,7 +119,7 @@ def render_component it "does not show Properties item" do render_component - expect(page).to have_no_text(I18n.t(:"backlogs.properties")) + expect(page).to have_no_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.properties")) end end @@ -129,7 +129,7 @@ def render_component it "shows Task board item" do render_component - expect(page).to have_text(I18n.t(:label_task_board)) + expect(page).to have_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.task_board")) end end @@ -139,7 +139,7 @@ def render_component it "does not show Task board item" do render_component - expect(page).to have_no_text(I18n.t(:label_task_board)) + expect(page).to have_no_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.task_board")) end end end @@ -150,13 +150,13 @@ def render_component it "shows Stories/Tasks link" do render_component - expect(page).to have_text(I18n.t(:label_stories_tasks)) + expect(page).to have_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.stories_tasks")) end it "shows Burndown chart link" do render_component - expect(page).to have_text(I18n.t(:"backlogs.show_burndown_chart")) + expect(page).to have_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.burndown_chart")) end context "when sprint has no burndown (no dates)" do @@ -165,7 +165,7 @@ def render_component it "shows Burndown chart link as disabled" do render_component - burndown_item = page.find("li", text: I18n.t(:"backlogs.show_burndown_chart")) + burndown_item = page.find("li", text: I18n.t(:"backlogs.backlog_menu_component.action_menu.burndown_chart")) expect(burndown_item[:class]).to include("ActionListItem--disabled") end end @@ -174,7 +174,7 @@ def render_component it "shows Burndown chart link as enabled" do render_component - burndown_item = page.find("li", text: I18n.t(:"backlogs.show_burndown_chart")) + burndown_item = page.find("li", text: I18n.t(:"backlogs.backlog_menu_component.action_menu.burndown_chart")) expect(burndown_item[:class]).not_to include("ActionListItem--disabled") end end @@ -188,7 +188,7 @@ def render_component it "shows Wiki item" do render_component - expect(page).to have_text(I18n.t(:label_wiki)) + expect(page).to have_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.wiki")) expect(page).to have_octicon(:book) end end @@ -200,7 +200,7 @@ def render_component it "does not show Wiki item" do render_component - expect(page).to have_no_text(I18n.t(:label_wiki)) + expect(page).to have_no_text(I18n.t(:"backlogs.backlog_menu_component.action_menu.wiki")) end end end diff --git a/modules/backlogs/spec/features/backlogs/context_menu_spec.rb b/modules/backlogs/spec/features/backlogs/context_menu_spec.rb index 85ac48a307c0..f88bec9c30c6 100644 --- a/modules/backlogs/spec/features/backlogs/context_menu_spec.rb +++ b/modules/backlogs/spec/features/backlogs/context_menu_spec.rb @@ -80,11 +80,12 @@ def within_backlog_context_menu(&) context "when the backlog is a sprint backlog (displayed on the left, the default)" do it "displays all menu entries" do within_backlog_context_menu do |menu| - expect(menu).to have_link I18n.t("backlogs.add_new_story") - expect(menu).to have_link I18n.t("label_stories_tasks") - expect(menu).to have_link I18n.t("label_task_board") - expect(menu).to have_link I18n.t("backlogs.show_burndown_chart") - expect(menu).to have_link I18n.t("label_wiki") + expect(menu).to have_selector :menuitem, count: 5 + expect(menu).to have_selector :menuitem, "New story" + expect(menu).to have_selector :menuitem, "Stories/Tasks" + expect(menu).to have_selector :menuitem, "Task board" + expect(menu).to have_selector :menuitem, "Burndown chart" + expect(menu).to have_selector :menuitem, "Wiki" end end end @@ -97,13 +98,14 @@ def within_backlog_context_menu(&) display: VersionSetting::DISPLAY_RIGHT) end - it 'only displays the "New story" and "Stories/Tasks" menu entries' do + it "only displays 4 menu entries" do within_backlog_context_menu do |menu| - expect(menu).to have_link I18n.t("backlogs.add_new_story") - expect(menu).to have_link I18n.t("label_stories_tasks") - expect(menu).to have_no_link I18n.t("label_task_board") - expect(menu).to have_no_link I18n.t("backlogs.show_burndown_chart") - expect(menu).to have_no_link I18n.t("label_wiki") + expect(menu).to have_selector :menuitem, count: 2 + expect(menu).to have_selector :menuitem, "New story" + expect(menu).to have_selector :menuitem, "Stories/Tasks" + expect(menu).to have_no_selector :menuitem, "Task board" + expect(menu).to have_no_selector :menuitem, "Burndown chart" + expect(menu).to have_no_selector :menuitem, "Wiki" end end end @@ -115,7 +117,7 @@ def within_backlog_context_menu(&) it 'disables the "Burndown chart" menu entry' do within_backlog_context_menu do |menu| - expect(menu).to have_link I18n.t("backlogs.show_burndown_chart", aria: { disabled: true }) + expect(menu).to have_selector :menuitem, "Burndown chart", disabled: true end end end @@ -127,7 +129,7 @@ def within_backlog_context_menu(&) it 'disables the "Burndown chart" menu entry' do within_backlog_context_menu do |menu| - expect(menu).to have_link I18n.t("backlogs.show_burndown_chart", aria: { disabled: true }) + expect(menu).to have_selector :menuitem, "Burndown chart", disabled: true end end end @@ -139,7 +141,7 @@ def within_backlog_context_menu(&) it 'does not display the "New story" menu entry' do within_backlog_context_menu do |menu| - expect(menu).to have_no_link I18n.t("backlogs.add_new_story") + expect(menu).to have_no_selector :menuitem, "New story" end end end @@ -151,7 +153,7 @@ def within_backlog_context_menu(&) it 'does not display the "Task board" menu entry' do within_backlog_context_menu do |menu| - expect(menu).to have_no_link I18n.t("label_task_board") + expect(menu).to have_no_selector :menuitem, "Task board" end end end @@ -163,7 +165,7 @@ def within_backlog_context_menu(&) it 'does not display the "Wiki" menu entry' do within_backlog_context_menu do |menu| - expect(menu).to have_no_link I18n.t("label_wiki") + expect(menu).to have_no_selector :menuitem, "Wiki" end end end From b1c67cc9b761ed99e9ba7993db3c7ba4885167bc Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 3 Feb 2026 16:37:04 -0300 Subject: [PATCH 14/57] Move 'Open details view' button out of story menu --- .../assets/sass/backlogs/_master_backlog.sass | 7 +++++-- .../components/backlogs/story_component.html.erb | 16 ++++++++++++++++ .../app/components/backlogs/story_component.rb | 3 ++- .../backlogs/story_menu_component.html.erb | 9 --------- .../components/backlogs/story_component_spec.rb | 7 +++++++ .../backlogs/story_menu_component_spec.rb | 7 ------- 6 files changed, 30 insertions(+), 19 deletions(-) diff --git a/frontend/src/assets/sass/backlogs/_master_backlog.sass b/frontend/src/assets/sass/backlogs/_master_backlog.sass index c05c7a83ba58..919ccd75c09f 100644 --- a/frontend/src/assets/sass/backlogs/_master_backlog.sass +++ b/frontend/src/assets/sass/backlogs/_master_backlog.sass @@ -41,7 +41,7 @@ $op-backlogs-header--points-min-width: 5rem margin-left: var(--stack-gap-normal) .op-backlogs-header--menu - margin-left: var(--stack-gap-normal) + margin-left: calc(var(--stack-gap-normal) * 2 + var(--base-size-32)) .op-backlogs-collapsible display: flex @@ -82,7 +82,7 @@ $op-backlogs-header--points-min-width: 5rem display: grid grid-template-columns: var(--control-xsmall-size) 1fr minmax($op-backlogs-header--points-min-width, max-content) auto grid-template-rows: auto auto - grid-template-areas: "drag_handle info_line points menu" "drag_handle subject subject subject" + grid-template-areas: "drag_handle info_line points show_button menu" "drag_handle subject subject subject subject" align-items: center margin-bottom: var(--base-size-4) @@ -101,6 +101,9 @@ $op-backlogs-header--points-min-width: 5rem .op-backlogs-story--points margin-left: var(--stack-gap-normal) +.op-backlogs-story--show_button + margin-left: var(--stack-gap-normal) + .op-backlogs-story--menu margin-left: var(--stack-gap-normal) diff --git a/modules/backlogs/app/components/backlogs/story_component.html.erb b/modules/backlogs/app/components/backlogs/story_component.html.erb index a71589f147b3..43d9477cf847 100644 --- a/modules/backlogs/app/components/backlogs/story_component.html.erb +++ b/modules/backlogs/app/components/backlogs/story_component.html.erb @@ -53,6 +53,22 @@ See COPYRIGHT and LICENSE files for more details. <% end %> <% end %> + <% grid.with_area(:show_button) do %> + <%= + render( + Primer::Beta::IconButton.new( + tag: :a, + scheme: :invisible, + icon: :"op-view-split", + "aria-label": t(:"js.button_open_details"), + href: details_backlogs_project_backlogs_path(project, story), + data: { turbo_frame: "content-bodyRight", turbo_action: "advance" }, + tooltip_direction: :se + ) + ) + %> + <% end %> + <% grid.with_area(:menu) do %> <%= render(Backlogs::StoryMenuComponent.new(story:, sprint:, max_position:)) %> <% end %> diff --git a/modules/backlogs/app/components/backlogs/story_component.rb b/modules/backlogs/app/components/backlogs/story_component.rb index 39c37f48a7f9..c9fb2e4311d2 100644 --- a/modules/backlogs/app/components/backlogs/story_component.rb +++ b/modules/backlogs/app/components/backlogs/story_component.rb @@ -32,13 +32,14 @@ module Backlogs class StoryComponent < ApplicationComponent include OpPrimer::ComponentHelpers - attr_reader :story, :sprint, :max_position, :current_user + attr_reader :story, :sprint, :project, :max_position, :current_user def initialize(story:, sprint:, max_position:, current_user: User.current) super() @story = story @sprint = sprint + @project = sprint.project @max_position = max_position @current_user = current_user end diff --git a/modules/backlogs/app/components/backlogs/story_menu_component.html.erb b/modules/backlogs/app/components/backlogs/story_menu_component.html.erb index 424bb34efbdf..cfc4b4eb40ee 100644 --- a/modules/backlogs/app/components/backlogs/story_menu_component.html.erb +++ b/modules/backlogs/app/components/backlogs/story_menu_component.html.erb @@ -36,15 +36,6 @@ See COPYRIGHT and LICENSE files for more details. tooltip_direction: :se ) - menu.with_item( - tag: :a, - label: t(:"js.button_open_details"), - href: details_backlogs_project_backlogs_path(project, story), - content_arguments: { turbo_frame: "content-bodyRight", turbo_action: "advance" } - ) do |item| - item.with_leading_visual_icon(icon: :"op-view-split") - end - menu.with_item( tag: :a, label: t(:"js.button_open_fullscreen"), diff --git a/modules/backlogs/spec/components/backlogs/story_component_spec.rb b/modules/backlogs/spec/components/backlogs/story_component_spec.rb index e09562c2701d..ef289ebc9dbe 100644 --- a/modules/backlogs/spec/components/backlogs/story_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/story_component_spec.rb @@ -92,6 +92,13 @@ def render_component expect(page).to have_text("5 points") end + it "shows Open details link (split view)" do + render_component + + expect(page).to have_text(I18n.t(:"js.button_open_details")) + expect(page).to have_octicon(:"op-view-split") + end + it "renders StoryMenuComponent" do render_component diff --git a/modules/backlogs/spec/components/backlogs/story_menu_component_spec.rb b/modules/backlogs/spec/components/backlogs/story_menu_component_spec.rb index 7a77d94a9a9e..21daaba4a061 100644 --- a/modules/backlogs/spec/components/backlogs/story_menu_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/story_menu_component_spec.rb @@ -66,13 +66,6 @@ def render_component(position: 2, max_position: 3) end describe "standard items" do - it "shows Open details link (split view)" do - render_component - - expect(page).to have_text(I18n.t(:"js.button_open_details")) - expect(page).to have_octicon(:"op-view-split") - end - it "shows Open fullscreen link (full page)" do render_component From 5ad7d1d31533c28336328f37e9950382022071f6 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 3 Feb 2026 19:58:04 -0300 Subject: [PATCH 15/57] Update "No versions defined" Blankslate text --- modules/backlogs/config/locales/en.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/backlogs/config/locales/en.yml b/modules/backlogs/config/locales/en.yml index fffbf10c753e..fa5cbf7ead0f 100644 --- a/modules/backlogs/config/locales/en.yml +++ b/modules/backlogs/config/locales/en.yml @@ -145,8 +145,8 @@ en: backlogs_velocity_missing: "No velocity could be calculated for this project" backlogs_velocity_varies: "Velocity varies significantly over sprints" backlogs_wiki_template: "Template for sprint wiki page" - backlogs_empty_title: "No versions are defined to be used in backlogs" - backlogs_empty_action_text: "To get started with backlogs, create a new version and assign it to a backlogs column." + backlogs_empty_title: "No versions are defined yet" + backlogs_empty_action_text: "To start using backlogs, please create a version first" burndown: story_points: "Story points" From 03f3eb461a007175875ccc2cffb38dc72b769f19 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 3 Feb 2026 22:14:31 -0300 Subject: [PATCH 16/57] Remove unused backlogs translation keys Co-Authored-By: Claude Opus 4.5 --- modules/backlogs/config/locales/en.yml | 71 -------------------------- 1 file changed, 71 deletions(-) diff --git a/modules/backlogs/config/locales/en.yml b/modules/backlogs/config/locales/en.yml index fa5cbf7ead0f..7183466ffd3e 100644 --- a/modules/backlogs/config/locales/en.yml +++ b/modules/backlogs/config/locales/en.yml @@ -58,16 +58,8 @@ en: backlogs: any: "any" - backlog_settings: "Backlogs settings" - burndown_graph: "Burndown Graph" - card_paper_size: "Paper size for card printing" - chart_options: "Chart options" - close: "Close" column_width: "Column width" - date: "Day" definition_of_done: "Definition of Done" - generating_chart: "Generating Graph..." - hours: "Hours" impediment: "Impediment" label_versions_default_fold_state: "Show versions folded" caption_versions_default_fold_state: "Versions will not be expanded by default when viewing backlogs. Each one has to be manually expanded." @@ -81,7 +73,6 @@ en: rebuild: "Rebuild" rebuild_positions: "Rebuild positions" remaining_hours: "Remaining work" - remaining_hours_ideal: "Remaining work (ideal)" show_burndown_chart: "Burndown Chart" story: "Story" story_points: @@ -93,7 +84,6 @@ en: user_preference: header_backlogs: "Backlogs module" button_update_backlogs: "Update backlogs module" - x_more: "%{count} more..." backlog_component: sprint_backlog: @@ -127,23 +117,12 @@ en: story_menu_component: label_actions: "Story actions" - backlogs_active: "active" - backlogs_any: "any" - backlogs_inactive: "Project shows no activity" backlogs_points_burn_direction: "Points burn up/down" backlogs_product_backlog: "Product backlog" - backlogs_product_backlog_unsized: "The top of the product backlog has unsized stories" - backlogs_sizing_inconsistent: "Story sizes vary against their estimates" - backlogs_sprint_notes_missing: "Closed sprints without retrospective/review notes" - backlogs_sprint_unestimated: "Closed or active sprints with unestimated stories" - backlogs_sprint_unsized: "Project has stories on active or recently closed sprints that were not sized" - backlogs_sprints: "Sprints" backlogs_story: "Story" backlogs_story_type: "Story types" backlogs_task: "Task" backlogs_task_type: "Task type" - backlogs_velocity_missing: "No velocity could be calculated for this project" - backlogs_velocity_varies: "Velocity varies significantly over sprints" backlogs_wiki_template: "Template for sprint wiki page" backlogs_empty_title: "No versions are defined yet" backlogs_empty_action_text: "To start using backlogs, please create a version first" @@ -152,67 +131,29 @@ en: story_points: "Story points" story_points_ideal: "Story points (ideal)" - button_edit_wiki: "Edit wiki page" - errors: attributes: task_type: cannot_be_story_type: "can not also be a story type" - error_intro_plural: "The following errors were encountered:" - error_intro_singular: "The following error was encountered:" - error_outro: "Please correct the above errors before submitting again." - - event_sprint_description: "%{summary}: %{url}\n%{description}" - event_sprint_summary: "%{project}: %{summary}" - - ideal: "ideal" - - inclusion: "is not included in the list" - - label_back_to_project: "Back to project page" - label_backlog: "Backlog" label_backlogs: "Backlogs" label_backlogs_unconfigured: "You have not configured Backlogs yet. Please go to %{administration} > %{plugins}, then click on the %{configure} link for this plugin. Once you have set the fields, come back to this page to start using the tool." label_blocks_ids: "IDs of blocked work packages" - label_burndown: "Burndown" label_column_in_backlog: "Column in backlog" - label_hours: "hours" - label_work_package_hierarchy: "Work package Hierarchy" - label_master_backlog: "Master Backlog" - label_not_prioritized: "not prioritized" - label_points: "points" label_points_burn_down: "Down" label_points_burn_up: "Up" - label_product_backlog: "product backlog" - label_select_all: "Select all" label_select_type: "Select a type" label_select_types: "Select types" label_selected_type: "Selected type" label_selected_types: "Selected types" - label_sprint_backlog: "sprint backlog" - label_sprint_cards: "Export cards" label_sprint_impediments: "Sprint Impediments" - label_sprint_name: "Sprint \"%{name}\"" - label_sprint_velocity: "Velocity %{velocity}, based on %{sprints} sprints with an average %{days} days" - label_stories: "Stories" label_task_board: "Task board" - label_version_setting: "Versions" - label_version: 'Version' - label_webcal: "Webcal Feed" - label_wiki: "Wiki" permission_view_master_backlog: "View master backlog" permission_view_taskboards: "View taskboards" permission_select_done_statuses: "Select done statuses" permission_update_sprints: "Update sprints" - points_accepted: "points accepted" - points_committed: "points committed" - points_resolved: "points resolved" - points_to_accept: "points not accepted" - points_to_resolve: "points not resolved" - project_module_backlogs: "Backlogs" rb_burndown_charts: @@ -220,20 +161,8 @@ en: blankslate_title: "No burndown data available" blankslate_description: "Set start and end date for the sprint to generate a burndown chart." - rb_label_copy_tasks: "Copy work packages" - rb_label_copy_tasks_all: "All" - rb_label_copy_tasks_none: "None" - rb_label_copy_tasks_open: "Open" - rb_label_link_to_original: "Include link to original story" - remaining_hours: "remaining work" - required_burn_rate_hours: "required burn rate (hours)" - required_burn_rate_points: "required burn rate (points)" - - todo_work_package_description: "%{summary}: %{url}\n%{description}" - todo_work_package_summary: "%{type}: %{summary}" - version_settings_display_label: "Column in backlog" version_settings_display_option_left: "left" version_settings_display_option_none: "none" From 51a0a8b35b0e81238cfce3394677d51723f46dad Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Wed, 4 Feb 2026 20:23:10 +0200 Subject: [PATCH 17/57] Use the Stories::Update service for moving and reordering stories. --- .../work_packages/set_attributes_service.rb | 2 +- .../app/controllers/rb_stories_controller.rb | 21 ++++++++++++------- .../app/services/stories/update_service.rb | 9 ++------ .../backlogs/patches/base_contract_patch.rb | 1 + 4 files changed, 18 insertions(+), 15 deletions(-) diff --git a/app/services/work_packages/set_attributes_service.rb b/app/services/work_packages/set_attributes_service.rb index b0c40bef6df6..51b596476205 100644 --- a/app/services/work_packages/set_attributes_service.rb +++ b/app/services/work_packages/set_attributes_service.rb @@ -69,7 +69,7 @@ def set_custom_values_to_validate(attributes, validate_custom_fields = nil) def set_static_attributes(attributes) assignable_attributes = attributes.select do |key, _| - !CustomField.custom_field_attribute?(key) && work_package.respond_to?(key) + !CustomField.custom_field_attribute?(key) && work_package.respond_to?("#{key}=") end work_package.attributes = assignable_attributes diff --git a/modules/backlogs/app/controllers/rb_stories_controller.rb b/modules/backlogs/app/controllers/rb_stories_controller.rb index 570222dbb222..01d0288a4063 100644 --- a/modules/backlogs/app/controllers/rb_stories_controller.rb +++ b/modules/backlogs/app/controllers/rb_stories_controller.rb @@ -66,12 +66,15 @@ def update def move story = Story.find(params[:id]) - # call = Stories::UpdateService - # .new(user: current_user, story:) - # .call(attributes: move_params) + call = Stories::UpdateService + .new(user: current_user, story:) + .call(attributes: { + version_id: move_params[:target_id], + position: move_params[:position] + }) - unless story.update(version_id: move_params[:target_id], **move_params.except(:target_id)) - render_error_flash_message_via_turbo_stream(message: I18n.t(:notice_unsuccessful_update)) #  TODO: display reason + unless call.success? + render_error_flash_message_via_turbo_stream(message: I18n.t(:notice_unsuccessful_update)) # TODO: display reason end backlog = Backlog.for(sprint: @sprint, project: @project) @@ -93,8 +96,12 @@ def move def reorder story = Story.find(params[:id]) - unless story.update(move_to: reorder_param) - render_error_flash_message_via_turbo_stream(message: I18n.t(:notice_unsuccessful_update)) #  TODO: display reason + call = Stories::UpdateService + .new(user: current_user, story:) + .call(attributes: { move_to: reorder_param }) + + unless call.success? + render_error_flash_message_via_turbo_stream(message: I18n.t(:notice_unsuccessful_update)) # TODO: display reason end backlog = Backlog.for(sprint: @sprint, project: @project) diff --git a/modules/backlogs/app/services/stories/update_service.rb b/modules/backlogs/app/services/stories/update_service.rb index 02f4862078f0..5f29ceed5b59 100644 --- a/modules/backlogs/app/services/stories/update_service.rb +++ b/modules/backlogs/app/services/stories/update_service.rb @@ -34,16 +34,11 @@ def initialize(user:, story:) self.story = story end - def call(attributes: {}, prev: nil) + def call(attributes: {}) create_call = WorkPackages::UpdateService .new(user:, model: story) - .call(**attributes.symbolize_keys) - - if create_call.success? && prev - create_call.result.move_after prev - end - + .call(**attributes.to_h.symbolize_keys) create_call end end diff --git a/modules/backlogs/lib/open_project/backlogs/patches/base_contract_patch.rb b/modules/backlogs/lib/open_project/backlogs/patches/base_contract_patch.rb index 0da6bb57435c..70eb1d3aadba 100644 --- a/modules/backlogs/lib/open_project/backlogs/patches/base_contract_patch.rb +++ b/modules/backlogs/lib/open_project/backlogs/patches/base_contract_patch.rb @@ -31,5 +31,6 @@ module OpenProject::Backlogs::Patches::BaseContractPatch included do attribute :story_points + attribute :position end end From 099bc5a15ae684e4f0435a5749659eaba2d2260e Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Wed, 4 Feb 2026 21:19:57 +0200 Subject: [PATCH 18/57] Keep the Stories::UpdateService prev argument for the stories controller update action. --- modules/backlogs/app/services/stories/update_service.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/modules/backlogs/app/services/stories/update_service.rb b/modules/backlogs/app/services/stories/update_service.rb index 5f29ceed5b59..fb835a7dbcc4 100644 --- a/modules/backlogs/app/services/stories/update_service.rb +++ b/modules/backlogs/app/services/stories/update_service.rb @@ -34,11 +34,16 @@ def initialize(user:, story:) self.story = story end - def call(attributes: {}) + def call(attributes: {}, prev: nil) create_call = WorkPackages::UpdateService .new(user:, model: story) .call(**attributes.to_h.symbolize_keys) + + if create_call.success? + create_call.result.move_after prev + end + create_call end end From dc9d9cb68003a8de6c1bcc9484e756088d786d95 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 4 Feb 2026 15:59:15 -0300 Subject: [PATCH 19/57] Visually highlight selected story, set aria-current Uses URL for state / as source-of-truth. Changes highlight and selected color to blue, focus color to grey. --- .../src/global_styles/primer/_overrides.sass | 4 +++ .../dynamic/backlogs.controller.ts | 30 ++++++++++++++++++- .../dynamic/backlogs/story.controller.ts | 15 ++++++++++ .../backlogs/backlog_component.html.erb | 7 +++-- .../views/rb_master_backlogs/index.html.erb | 3 +- 5 files changed, 55 insertions(+), 4 deletions(-) diff --git a/frontend/src/global_styles/primer/_overrides.sass b/frontend/src/global_styles/primer/_overrides.sass index 77449bf785ce..0ca650eb6261 100644 --- a/frontend/src/global_styles/primer/_overrides.sass +++ b/frontend/src/global_styles/primer/_overrides.sass @@ -153,6 +153,10 @@ ul.SegmentedControl, .ActionListItem-label[class^="__hl_"], .ActionListItem-label[class*=" __hl_"] color: var(--control-fgColor-disabled) !important +.Box-row--focus-gray + &:focus-visible + background-color: var(--bgColor-muted) + .Box-row--focus-blue &:focus-visible background-color: var(--bgColor-accent-muted) diff --git a/frontend/src/stimulus/controllers/dynamic/backlogs.controller.ts b/frontend/src/stimulus/controllers/dynamic/backlogs.controller.ts index 768dcb9a2587..8837db871b48 100644 --- a/frontend/src/stimulus/controllers/dynamic/backlogs.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/backlogs.controller.ts @@ -27,21 +27,32 @@ //++ import { Controller } from '@hotwired/stimulus'; -import { FrameElement } from '@hotwired/turbo'; +import { FrameElement, TurboVisitEvent } from '@hotwired/turbo'; import { HalEventsService } from 'core-app/features/hal/services/hal-events.service'; import { filter, Subscription } from 'rxjs'; +import StoryController from './backlogs/story.controller'; export default class BacklogsController extends Controller { + static outlets = ['backlogs--story']; + declare backlogsStoryOutlets:StoryController[]; + static values = { listUrl: String, }; declare listUrlValue:string; + + private abortController:AbortController|null = null; private service:HalEventsService|null = null; private subscription:Subscription|null = null; // eslint-disable-next-line @typescript-eslint/no-misused-promises async connect() { + this.abortController = new AbortController(); + const { signal } = this.abortController; + + document.addEventListener('turbo:visit', this.updateSelection, { signal }); + const { services: { halEvents } } = await window.OpenProject.getPluginContext(); this.service = halEvents; @@ -54,8 +65,25 @@ export default class BacklogsController extends Controller { this.subscription?.unsubscribe(); this.subscription = null; this.service = null; + + this.abortController?.abort(); + this.abortController = null; } + private updateSelection = (event:TurboVisitEvent) => { + const url = new URL(event.detail.url, window.location.origin); + const match = /\/details\/(\d+)/.exec(url.pathname); + const selectedId = match ? Number(match[1]) : null; + + this.backlogsStoryOutlets.forEach((story) => { + if (selectedId !== null && story.idValue === selectedId) { + story.markAsSelected(event); + } else { + story.unmarkAsSelected(event); + } + }); + }; + private refreshList() { this.listElement.src = this.listUrlValue; } diff --git a/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.ts b/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.ts index 9340149f9fea..e9eb3aad6730 100644 --- a/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.ts @@ -31,13 +31,18 @@ import * as Turbo from '@hotwired/turbo'; export default class StoryController extends Controller implements EventListenerObject { static values = { + id: Number, splitUrl: String, fullUrl: String, }; + declare idValue:number; declare splitUrlValue:string; declare fullUrlValue:string; + static classes = ['selected']; + declare readonly selectedClass:string; + private abortController:AbortController|null = null; private clickTimeout:number|null = null; @@ -60,6 +65,16 @@ export default class StoryController extends Controller implements } } + markAsSelected(_event:Event) { + this.element.classList.add(this.selectedClass); + this.element.setAttribute('aria-current', 'true'); + } + + unmarkAsSelected(_event:Event) { + this.element.classList.remove(this.selectedClass); + this.element.removeAttribute('aria-current'); + } + handleEvent(event:Event):void { switch (event.type) { case 'click': diff --git a/modules/backlogs/app/components/backlogs/backlog_component.html.erb b/modules/backlogs/app/components/backlogs/backlog_component.html.erb index 6fbb014f602d..9ee1cf8577fa 100644 --- a/modules/backlogs/app/components/backlogs/backlog_component.html.erb +++ b/modules/backlogs/app/components/backlogs/backlog_component.html.erb @@ -54,11 +54,14 @@ See COPYRIGHT and LICENSE files for more details. <% backlog.stories.each do |story| %> <% border_box.with_row( id: dom_id(story), - classes: "Box-row--hover-gray Box-row--focus-blue Box-row--clickable Box-row--draggable", + classes: "Box-row--hover-blue Box-row--focus-gray Box-row--clickable Box-row--draggable", data: draggable_item_config(story).merge( + story: true, controller: "backlogs--story", + backlogs__story_id_value: story.id, backlogs__story_split_url_value: details_backlogs_project_backlogs_path(project, story), - backlogs__story_full_url_value: work_package_path(story) + backlogs__story_full_url_value: work_package_path(story), + backlogs__story_selected_class: "Box-row--blue" ), tabindex: 0 ) do %> diff --git a/modules/backlogs/app/views/rb_master_backlogs/index.html.erb b/modules/backlogs/app/views/rb_master_backlogs/index.html.erb index 5242488c3210..5f87d6dfd1b7 100644 --- a/modules/backlogs/app/views/rb_master_backlogs/index.html.erb +++ b/modules/backlogs/app/views/rb_master_backlogs/index.html.erb @@ -30,7 +30,8 @@ See COPYRIGHT and LICENSE files for more details. <% html_title t(:label_backlogs) %> <% content_controller "backlogs", - "backlogs-list-url-value": backlogs_project_backlogs_path(@project) %> + "backlogs-list-url-value": backlogs_project_backlogs_path(@project), + "backlogs-backlogs--story-outlet": "li[data-story]" %> <% content_for :content_header do %> <%= From b7bfbdded53d502e11aa25f241110c476feaa8c0 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 4 Feb 2026 20:32:42 -0300 Subject: [PATCH 20/57] Remove unused story create/update actions The new Primer-based backlogs UI uses the split view for story editing instead of inline editing. This removes the legacy create/update actions, routes, permissions, and associated frontend code. Co-Authored-By: Claude Opus 4.5 --- .../controllers/dynamic/backlogs/story.ts | 145 ------------------ .../backlogs/taskboard-legacy.controller.ts | 1 - .../app/controllers/rb_stories_controller.rb | 43 ------ .../app/views/shared/_server_variables.js.erb | 3 - modules/backlogs/config/routes.rb | 2 +- .../lib/open_project/backlogs/engine.rb | 6 - .../controllers/rb_stories_controller_spec.rb | 20 --- .../spec/routing/rb_stories_routing_spec.rb | 15 -- 8 files changed, 1 insertion(+), 234 deletions(-) delete mode 100644 frontend/src/stimulus/controllers/dynamic/backlogs/story.ts diff --git a/frontend/src/stimulus/controllers/dynamic/backlogs/story.ts b/frontend/src/stimulus/controllers/dynamic/backlogs/story.ts deleted file mode 100644 index a9f945adacf6..000000000000 --- a/frontend/src/stimulus/controllers/dynamic/backlogs/story.ts +++ /dev/null @@ -1,145 +0,0 @@ -//-- copyright -// OpenProject is an open source project management software. -// Copyright (C) the OpenProject GmbH -// -// This program is free software; you can redistribute it and/or -// modify it under the terms of the GNU General Public License version 3. -// -// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -// Copyright (C) 2006-2013 Jean-Philippe Lang -// Copyright (C) 2010-2013 the ChiliProject Team -// -// This program is free software; you can redistribute it and/or -// modify it under the terms of the GNU General Public License -// as published by the Free Software Foundation; either version 2 -// of the License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program; if not, write to the Free Software -// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -// -// See COPYRIGHT and LICENSE files for more details. -//++ - -import { FetchResponse } from '@rails/request.js'; - -/************************************** - STORY -***************************************/ -// @ts-expect-error TS(2304): Cannot find name 'RB'. -RB.Story = (function ($) { - // @ts-expect-error TS(2304): Cannot find name 'RB'. - return RB.Object.create(RB.WorkPackage, RB.EditableInplace, { - initialize(el:any) { - this.$ = $(el); - this.el = el; - - // Associate this object with the element for later retrieval - this.$.data('this', this); - this.$.on('click', '.editable', this.handleClick); - }, - - /** - * Callbacks from model.js - **/ - beforeSave() { - this.refreshStory(); - }, - - afterCreate(data:string, response:FetchResponse) { - this.refreshStory(); - }, - - afterUpdate(data:string, response:FetchResponse) { - this.refreshStory(); - }, - - refreshed() { - this.refreshStory(); - }, - /**/ - - editDialogTitle() { - return `Story #${this.getID()}`; - }, - - editorDisplayed(editor:any) { }, - - getPoints() { - const points = parseInt(this.$.find('.story_points').first().text(), 10); - return isNaN(points) ? 0 : points; - }, - - getType() { - return 'Story'; - }, - - markIfClosed() { - // Do nothing - }, - - newDialogTitle() { - return 'New Story'; - }, - - refreshStory() { - this.recalcVelocity(); - }, - - recalcVelocity() { - this.$.parents('.backlog').first().data('this').refresh(); - }, - - saveDirectives() { - let url; - let prev; - let sprintId; - - let data; - let method; - - prev = this.$.prev(); - sprintId = this.$.parents('.backlog').data('this').isSprintBacklog() - ? this.$.parents('.backlog').data('this').getSprint().data('this') -.getID() - : ''; - - data = `prev=${ - prev.length === 1 ? prev.data('this').getID() : '' - }&version_id=${sprintId}`; - - if (this.$.find('.editor').length > 0) { - data += `&${this.$.find('.editor').serialize()}`; - } - - //TODO: this might be unsave in case the parent of this story is not the - // sprint backlog, then we dont have a sprintId an cannot generate a - // valid url - one option might be to take RB.constants.sprint_id - // hoping it exists - if (this.isNew()) { - // @ts-expect-error TS(2304): Cannot find name 'RB'. - url = RB.urlFor('create_story', { sprint_id: sprintId }); - method = 'post'; - } else { - // @ts-expect-error TS(2304): Cannot find name 'RB'. - url = RB.urlFor('update_story', { id: this.getID(), sprint_id: sprintId }); - method = 'put'; - } - - return { - url, - method, - data, - }; - }, - - beforeSaveDragResult() { - // Do nothing - }, - }); -}(jQuery)); diff --git a/frontend/src/stimulus/controllers/dynamic/backlogs/taskboard-legacy.controller.ts b/frontend/src/stimulus/controllers/dynamic/backlogs/taskboard-legacy.controller.ts index ed66caeff516..3e3beebea8ed 100644 --- a/frontend/src/stimulus/controllers/dynamic/backlogs/taskboard-legacy.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/backlogs/taskboard-legacy.controller.ts @@ -8,7 +8,6 @@ import './model'; import './editable_inplace'; import './sprint'; import './work_package'; -import './story'; import './task'; import './impediment'; import './taskboard'; diff --git a/modules/backlogs/app/controllers/rb_stories_controller.rb b/modules/backlogs/app/controllers/rb_stories_controller.rb index 01d0288a4063..486e4eb24e53 100644 --- a/modules/backlogs/app/controllers/rb_stories_controller.rb +++ b/modules/backlogs/app/controllers/rb_stories_controller.rb @@ -31,38 +31,6 @@ class RbStoriesController < RbApplicationController include OpTurbo::ComponentStream - # This is a constant here because we will recruit it elsewhere to whitelist - # attributes. This is necessary for now as we still directly use `attributes=` - # in non-controller code. - PERMITTED_PARAMS = %i[id status_id version_id - story_points type_id subject author_id - sprint_id].freeze - - def create - call = Stories::CreateService - .new(user: current_user) - .call(attributes: story_params, - prev: params[:prev]) - - respond_with_story(call) - end - - def update - story = Story.find(params[:id]) - - call = Stories::UpdateService - .new(user: current_user, story:) - .call(attributes: story_params, - prev: params[:prev]) - - unless call.success? - # reload the story to be able to display it correctly - call.result.reload - end - - respond_with_story(call) - end - def move story = Story.find(params[:id]) @@ -113,13 +81,6 @@ def reorder private - def respond_with_story(call) - status = call.success? ? 200 : 400 - story = call.result - - respond_with_turbo_streams - end - def move_params params.require(%i[position target_id]) params.permit(:position, :target_id) @@ -128,8 +89,4 @@ def move_params def reorder_param params.expect(:direction) end - - def story_params - params.permit(PERMITTED_PARAMS).merge(project: @project).to_h - end end diff --git a/modules/backlogs/app/views/shared/_server_variables.js.erb b/modules/backlogs/app/views/shared/_server_variables.js.erb index c50138cfda95..06d9390d5869 100644 --- a/modules/backlogs/app/views/shared/_server_variables.js.erb +++ b/modules/backlogs/app/views/shared/_server_variables.js.erb @@ -40,9 +40,6 @@ RB.urlFor = (function () { const routes = { update_sprint: '<%= backlogs_project_sprint_path(project_id: @project.identifier, id: ":id") %>', - create_story: '<%= backlogs_project_sprint_stories_path(project_id: @project.identifier, sprint_id: ":sprint_id") %>', - update_story: '<%= backlogs_project_sprint_story_path(project_id: @project.identifier, sprint_id: ":sprint_id", id: ":id") %>', - create_task: '<%= backlogs_project_sprint_tasks_path(project_id: @project.identifier, sprint_id: ":sprint_id") %>', update_task: '<%= backlogs_project_sprint_task_path(project_id: @project.identifier, sprint_id: ":sprint_id", id: ":id") %>', diff --git a/modules/backlogs/config/routes.rb b/modules/backlogs/config/routes.rb index 9aa4c7217ca2..ee9d1aba9068 100644 --- a/modules/backlogs/config/routes.rb +++ b/modules/backlogs/config/routes.rb @@ -52,7 +52,7 @@ resources :tasks, controller: :rb_tasks, only: %i[create update] - resources :stories, controller: :rb_stories, only: %i[create update] do + resources :stories, controller: :rb_stories, only: [] do member do put :move post :reorder diff --git a/modules/backlogs/lib/open_project/backlogs/engine.rb b/modules/backlogs/lib/open_project/backlogs/engine.rb index a70ef5705f5c..18210c6e1e00 100644 --- a/modules/backlogs/lib/open_project/backlogs/engine.rb +++ b/modules/backlogs/lib/open_project/backlogs/engine.rb @@ -54,7 +54,6 @@ def self.settings settings:) do Rails.application.reloader.to_prepare do OpenProject::AccessControl.permission(:add_work_packages).tap do |add| - add.controller_actions << "rb_stories/create" add.controller_actions << "rb_tasks/create" add.controller_actions << "rb_impediments/create" end @@ -62,14 +61,9 @@ def self.settings OpenProject::AccessControl.permission(:edit_work_packages).tap do |edit| edit.controller_actions << "rb_stories/move" edit.controller_actions << "rb_stories/reorder" - edit.controller_actions << "rb_stories/update" edit.controller_actions << "rb_tasks/update" edit.controller_actions << "rb_impediments/update" end - - OpenProject::AccessControl.permission(:change_work_package_status).tap do |edit| - edit.controller_actions << "rb_stories/update" - end end project_module :backlogs, dependencies: :work_package_tracking do diff --git a/modules/backlogs/spec/controllers/rb_stories_controller_spec.rb b/modules/backlogs/spec/controllers/rb_stories_controller_spec.rb index 541d09de9655..ab73f04b839f 100644 --- a/modules/backlogs/spec/controllers/rb_stories_controller_spec.rb +++ b/modules/backlogs/spec/controllers/rb_stories_controller_spec.rb @@ -47,16 +47,6 @@ .and_return({ "story_types" => [type_feature.id], "task_type" => type_task.id }) end - describe "POST #create" do - it "responds with success", :aggregate_failures do - post :create, params: { project_id: project.id, sprint_id: sprint.id }, - format: :turbo_stream - - expect(response).to be_successful - expect(response).to have_http_status :ok - end - end - describe "PUT #move" do let(:other_sprint) { create(:sprint, name: "Sprint 2", project:) } @@ -88,14 +78,4 @@ expect(response).to have_turbo_stream action: "replace", target: "backlogs-backlog-component-#{sprint.id}" end end - - describe "PATCH #update" do - it "responds with success", :aggregate_failures do - patch :update, params: { project_id: project.id, sprint_id: sprint.id, id: story.id }, - format: :turbo_stream - - expect(response).to be_successful - expect(response).to have_http_status :ok - end - end end diff --git a/modules/backlogs/spec/routing/rb_stories_routing_spec.rb b/modules/backlogs/spec/routing/rb_stories_routing_spec.rb index c6dcb09166bd..d0af15bece99 100644 --- a/modules/backlogs/spec/routing/rb_stories_routing_spec.rb +++ b/modules/backlogs/spec/routing/rb_stories_routing_spec.rb @@ -30,13 +30,6 @@ RSpec.describe RbStoriesController do describe "routing" do - it { - expect(post("/projects/project_42/sprints/21/stories")).to route_to(controller: "rb_stories", - action: "create", - project_id: "project_42", - sprint_id: "21") - } - it { expect(put("/projects/project_42/sprints/21/stories/85/move")).to route_to( controller: "rb_stories", @@ -56,13 +49,5 @@ id: "85" ) } - - it { - expect(put("/projects/project_42/sprints/21/stories/85")).to route_to(controller: "rb_stories", - action: "update", - project_id: "project_42", - sprint_id: "21", - id: "85") - } end end From 4b3fb24e2718cdc3c3678670e46c71490ea8265e Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 4 Feb 2026 20:44:09 -0300 Subject: [PATCH 21/57] Use outlet callback for initial selection When story outlets connect, check current URL and mark as selected if the URL matches. This ensures proper selection state on initial page load or direct navigation to a /details/{id} URL. Co-Authored-By: Claude Opus 4.5 --- .../controllers/dynamic/backlogs.controller.ts | 15 +++++++++++++-- .../dynamic/backlogs/story.controller.ts | 4 ++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/backlogs.controller.ts b/frontend/src/stimulus/controllers/dynamic/backlogs.controller.ts index 8837db871b48..239409703ef3 100644 --- a/frontend/src/stimulus/controllers/dynamic/backlogs.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/backlogs.controller.ts @@ -70,10 +70,16 @@ export default class BacklogsController extends Controller { this.abortController = null; } + backlogsStoryOutletConnected(outlet:StoryController) { + const selectedId = this.getSelectedIdFromPathname(window.location.pathname); + if (selectedId !== null && outlet.idValue === selectedId) { + outlet.markAsSelected(); + } + } + private updateSelection = (event:TurboVisitEvent) => { const url = new URL(event.detail.url, window.location.origin); - const match = /\/details\/(\d+)/.exec(url.pathname); - const selectedId = match ? Number(match[1]) : null; + const selectedId = this.getSelectedIdFromPathname(url.pathname); this.backlogsStoryOutlets.forEach((story) => { if (selectedId !== null && story.idValue === selectedId) { @@ -84,6 +90,11 @@ export default class BacklogsController extends Controller { }); }; + private getSelectedIdFromPathname(pathname:string):number|null { + const match = /\/details\/(\d+)/.exec(pathname); + return match ? Number(match[1]) : null; + } + private refreshList() { this.listElement.src = this.listUrlValue; } diff --git a/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.ts b/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.ts index e9eb3aad6730..4a6f76bfcb3d 100644 --- a/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.ts @@ -65,12 +65,12 @@ export default class StoryController extends Controller implements } } - markAsSelected(_event:Event) { + markAsSelected(_event?:Event) { this.element.classList.add(this.selectedClass); this.element.setAttribute('aria-current', 'true'); } - unmarkAsSelected(_event:Event) { + unmarkAsSelected(_event?:Event) { this.element.classList.remove(this.selectedClass); this.element.removeAttribute('aria-current'); } From aaaa48f85a8ff7b2ca477b0449e7d8e6b7d0d4cd Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 4 Feb 2026 20:56:35 -0300 Subject: [PATCH 22/57] Hide unavailable story menu move items Instead of disabling "Move to top" / "Move up" for the first story and "Move down" / "Move to bottom" for the last story, hide them entirely. Co-Authored-By: Claude Opus 4.5 --- .../backlogs/story_menu_component.rb | 41 +++--------- .../backlogs/story_menu_component_spec.rb | 64 +++++++------------ 2 files changed, 32 insertions(+), 73 deletions(-) diff --git a/modules/backlogs/app/components/backlogs/story_menu_component.rb b/modules/backlogs/app/components/backlogs/story_menu_component.rb index c65d2ac91430..cfd474da13ea 100644 --- a/modules/backlogs/app/components/backlogs/story_menu_component.rb +++ b/modules/backlogs/app/components/backlogs/story_menu_component.rb @@ -45,43 +45,22 @@ def initialize(story:, sprint:, max_position:, current_user: User.current) private def build_move_menu(menu) - build_move_item( - menu, - label: I18n.t(:label_sort_highest), - direction: "highest", - icon: :"move-to-top", - disabled: first_item? - ) - build_move_item( - menu, - label: I18n.t(:label_sort_higher), - direction: "higher", - icon: :"chevron-up", - disabled: first_item? - ) - build_move_item( - menu, - label: I18n.t(:label_sort_lower), - direction: "lower", - icon: :"chevron-down", - disabled: last_item? - ) - build_move_item( - menu, - label: I18n.t(:label_sort_lowest), - direction: "lowest", - icon: :"move-to-bottom", - disabled: last_item? - ) + unless first_item? + build_move_item(menu, label: I18n.t(:label_sort_highest), direction: "highest", icon: :"move-to-top") + build_move_item(menu, label: I18n.t(:label_sort_higher), direction: "higher", icon: :"chevron-up") + end + unless last_item? + build_move_item(menu, label: I18n.t(:label_sort_lower), direction: "lower", icon: :"chevron-down") + build_move_item(menu, label: I18n.t(:label_sort_lowest), direction: "lowest", icon: :"move-to-bottom") + end end - def build_move_item(menu, label:, direction:, icon:, **) + def build_move_item(menu, label:, direction:, icon:) menu.with_item( label:, tag: :button, href: reorder_backlogs_project_sprint_story_path(project, sprint, story), - form_arguments: { method: :post, inputs: [{ name: "direction", value: direction }] }, - ** + form_arguments: { method: :post, inputs: [{ name: "direction", value: direction }] } ) do |item| item.with_leading_visual_icon(icon:) end diff --git a/modules/backlogs/spec/components/backlogs/story_menu_component_spec.rb b/modules/backlogs/spec/components/backlogs/story_menu_component_spec.rb index 21daaba4a061..ecd72ae492eb 100644 --- a/modules/backlogs/spec/components/backlogs/story_menu_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/story_menu_component_spec.rb @@ -112,76 +112,56 @@ def render_component(position: 2, max_position: 3) describe "position logic" do context "when item is first (position=1)" do - it "disables Move to top and Move up" do + it "hides Move to top and Move up" do render_component(position: 1, max_position: 3) - # Move to top should be disabled - move_to_top = page.find("li", text: I18n.t(:label_sort_highest)) - expect(move_to_top[:class]).to include("ActionListItem--disabled") - - # Move up should be disabled - move_up = page.find("li", text: I18n.t(:label_sort_higher)) - expect(move_up[:class]).to include("ActionListItem--disabled") + expect(page).to have_no_text(I18n.t(:label_sort_highest)) + expect(page).to have_no_text(I18n.t(:label_sort_higher)) end - it "enables Move down and Move to bottom" do + it "shows Move down and Move to bottom" do render_component(position: 1, max_position: 3) - # Move down should be enabled - move_down = page.find("li", text: I18n.t(:label_sort_lower)) - expect(move_down[:class]).not_to include("ActionListItem--disabled") - - # Move to bottom should be enabled - move_to_bottom = page.find("li", text: I18n.t(:label_sort_lowest)) - expect(move_to_bottom[:class]).not_to include("ActionListItem--disabled") + expect(page).to have_text(I18n.t(:label_sort_lower)) + expect(page).to have_text(I18n.t(:label_sort_lowest)) end end context "when item is last (position=max)" do - it "disables Move down and Move to bottom" do + it "hides Move down and Move to bottom" do render_component(position: 3, max_position: 3) - # Move down should be disabled - move_down = page.find("li", text: I18n.t(:label_sort_lower)) - expect(move_down[:class]).to include("ActionListItem--disabled") - - # Move to bottom should be disabled - move_to_bottom = page.find("li", text: I18n.t(:label_sort_lowest)) - expect(move_to_bottom[:class]).to include("ActionListItem--disabled") + expect(page).to have_no_text(I18n.t(:label_sort_lower)) + expect(page).to have_no_text(I18n.t(:label_sort_lowest)) end - it "enables Move to top and Move up" do + it "shows Move to top and Move up" do render_component(position: 3, max_position: 3) - # Move to top should be enabled - move_to_top = page.find("li", text: I18n.t(:label_sort_highest)) - expect(move_to_top[:class]).not_to include("ActionListItem--disabled") - - # Move up should be enabled - move_up = page.find("li", text: I18n.t(:label_sort_higher)) - expect(move_up[:class]).not_to include("ActionListItem--disabled") + expect(page).to have_text(I18n.t(:label_sort_highest)) + expect(page).to have_text(I18n.t(:label_sort_higher)) end end context "when item is in the middle" do - it "enables all move options" do + it "shows all move options" do render_component(position: 2, max_position: 3) - expect(page.find("li", text: I18n.t(:label_sort_highest))[:class]).not_to include("ActionListItem--disabled") - expect(page.find("li", text: I18n.t(:label_sort_higher))[:class]).not_to include("ActionListItem--disabled") - expect(page.find("li", text: I18n.t(:label_sort_lower))[:class]).not_to include("ActionListItem--disabled") - expect(page.find("li", text: I18n.t(:label_sort_lowest))[:class]).not_to include("ActionListItem--disabled") + expect(page).to have_text(I18n.t(:label_sort_highest)) + expect(page).to have_text(I18n.t(:label_sort_higher)) + expect(page).to have_text(I18n.t(:label_sort_lower)) + expect(page).to have_text(I18n.t(:label_sort_lowest)) end end context "when there is only one item (position=1, max=1)" do - it "disables all move options" do + it "hides all move options" do render_component(position: 1, max_position: 1) - expect(page.find("li", text: I18n.t(:label_sort_highest))[:class]).to include("ActionListItem--disabled") - expect(page.find("li", text: I18n.t(:label_sort_higher))[:class]).to include("ActionListItem--disabled") - expect(page.find("li", text: I18n.t(:label_sort_lower))[:class]).to include("ActionListItem--disabled") - expect(page.find("li", text: I18n.t(:label_sort_lowest))[:class]).to include("ActionListItem--disabled") + expect(page).to have_no_text(I18n.t(:label_sort_highest)) + expect(page).to have_no_text(I18n.t(:label_sort_higher)) + expect(page).to have_no_text(I18n.t(:label_sort_lower)) + expect(page).to have_no_text(I18n.t(:label_sort_lowest)) end end end From 2ceacbf7d56d0e26938127c91966483e4d86ebed Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 5 Feb 2026 06:51:30 -0300 Subject: [PATCH 23/57] Revert "Move 'Open details view' button out of story menu" This reverts commit b1c67cc9b761ed99e9ba7993db3c7ba4885167bc. --- .../assets/sass/backlogs/_master_backlog.sass | 7 ++----- .../components/backlogs/story_component.html.erb | 16 ---------------- .../app/components/backlogs/story_component.rb | 3 +-- .../backlogs/story_menu_component.html.erb | 9 +++++++++ .../components/backlogs/story_component_spec.rb | 7 ------- .../backlogs/story_menu_component_spec.rb | 7 +++++++ 6 files changed, 19 insertions(+), 30 deletions(-) diff --git a/frontend/src/assets/sass/backlogs/_master_backlog.sass b/frontend/src/assets/sass/backlogs/_master_backlog.sass index 919ccd75c09f..c05c7a83ba58 100644 --- a/frontend/src/assets/sass/backlogs/_master_backlog.sass +++ b/frontend/src/assets/sass/backlogs/_master_backlog.sass @@ -41,7 +41,7 @@ $op-backlogs-header--points-min-width: 5rem margin-left: var(--stack-gap-normal) .op-backlogs-header--menu - margin-left: calc(var(--stack-gap-normal) * 2 + var(--base-size-32)) + margin-left: var(--stack-gap-normal) .op-backlogs-collapsible display: flex @@ -82,7 +82,7 @@ $op-backlogs-header--points-min-width: 5rem display: grid grid-template-columns: var(--control-xsmall-size) 1fr minmax($op-backlogs-header--points-min-width, max-content) auto grid-template-rows: auto auto - grid-template-areas: "drag_handle info_line points show_button menu" "drag_handle subject subject subject subject" + grid-template-areas: "drag_handle info_line points menu" "drag_handle subject subject subject" align-items: center margin-bottom: var(--base-size-4) @@ -101,9 +101,6 @@ $op-backlogs-header--points-min-width: 5rem .op-backlogs-story--points margin-left: var(--stack-gap-normal) -.op-backlogs-story--show_button - margin-left: var(--stack-gap-normal) - .op-backlogs-story--menu margin-left: var(--stack-gap-normal) diff --git a/modules/backlogs/app/components/backlogs/story_component.html.erb b/modules/backlogs/app/components/backlogs/story_component.html.erb index 43d9477cf847..a71589f147b3 100644 --- a/modules/backlogs/app/components/backlogs/story_component.html.erb +++ b/modules/backlogs/app/components/backlogs/story_component.html.erb @@ -53,22 +53,6 @@ See COPYRIGHT and LICENSE files for more details. <% end %> <% end %> - <% grid.with_area(:show_button) do %> - <%= - render( - Primer::Beta::IconButton.new( - tag: :a, - scheme: :invisible, - icon: :"op-view-split", - "aria-label": t(:"js.button_open_details"), - href: details_backlogs_project_backlogs_path(project, story), - data: { turbo_frame: "content-bodyRight", turbo_action: "advance" }, - tooltip_direction: :se - ) - ) - %> - <% end %> - <% grid.with_area(:menu) do %> <%= render(Backlogs::StoryMenuComponent.new(story:, sprint:, max_position:)) %> <% end %> diff --git a/modules/backlogs/app/components/backlogs/story_component.rb b/modules/backlogs/app/components/backlogs/story_component.rb index c9fb2e4311d2..39c37f48a7f9 100644 --- a/modules/backlogs/app/components/backlogs/story_component.rb +++ b/modules/backlogs/app/components/backlogs/story_component.rb @@ -32,14 +32,13 @@ module Backlogs class StoryComponent < ApplicationComponent include OpPrimer::ComponentHelpers - attr_reader :story, :sprint, :project, :max_position, :current_user + attr_reader :story, :sprint, :max_position, :current_user def initialize(story:, sprint:, max_position:, current_user: User.current) super() @story = story @sprint = sprint - @project = sprint.project @max_position = max_position @current_user = current_user end diff --git a/modules/backlogs/app/components/backlogs/story_menu_component.html.erb b/modules/backlogs/app/components/backlogs/story_menu_component.html.erb index cfc4b4eb40ee..424bb34efbdf 100644 --- a/modules/backlogs/app/components/backlogs/story_menu_component.html.erb +++ b/modules/backlogs/app/components/backlogs/story_menu_component.html.erb @@ -36,6 +36,15 @@ See COPYRIGHT and LICENSE files for more details. tooltip_direction: :se ) + menu.with_item( + tag: :a, + label: t(:"js.button_open_details"), + href: details_backlogs_project_backlogs_path(project, story), + content_arguments: { turbo_frame: "content-bodyRight", turbo_action: "advance" } + ) do |item| + item.with_leading_visual_icon(icon: :"op-view-split") + end + menu.with_item( tag: :a, label: t(:"js.button_open_fullscreen"), diff --git a/modules/backlogs/spec/components/backlogs/story_component_spec.rb b/modules/backlogs/spec/components/backlogs/story_component_spec.rb index ef289ebc9dbe..e09562c2701d 100644 --- a/modules/backlogs/spec/components/backlogs/story_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/story_component_spec.rb @@ -92,13 +92,6 @@ def render_component expect(page).to have_text("5 points") end - it "shows Open details link (split view)" do - render_component - - expect(page).to have_text(I18n.t(:"js.button_open_details")) - expect(page).to have_octicon(:"op-view-split") - end - it "renders StoryMenuComponent" do render_component diff --git a/modules/backlogs/spec/components/backlogs/story_menu_component_spec.rb b/modules/backlogs/spec/components/backlogs/story_menu_component_spec.rb index ecd72ae492eb..99758863c030 100644 --- a/modules/backlogs/spec/components/backlogs/story_menu_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/story_menu_component_spec.rb @@ -66,6 +66,13 @@ def render_component(position: 2, max_position: 3) end describe "standard items" do + it "shows Open details link (split view)" do + render_component + + expect(page).to have_text(I18n.t(:"js.button_open_details")) + expect(page).to have_octicon(:"op-view-split") + end + it "shows Open fullscreen link (full page)" do render_component From a90207ca4ff3fe63c8e51795b6e15550cea31acd Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 5 Feb 2026 07:04:32 -0300 Subject: [PATCH 24/57] Vertically center align story top row --- frontend/src/assets/sass/backlogs/_master_backlog.sass | 8 +------- .../app/components/backlogs/story_component.html.erb | 2 +- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/frontend/src/assets/sass/backlogs/_master_backlog.sass b/frontend/src/assets/sass/backlogs/_master_backlog.sass index c05c7a83ba58..f18df999ef34 100644 --- a/frontend/src/assets/sass/backlogs/_master_backlog.sass +++ b/frontend/src/assets/sass/backlogs/_master_backlog.sass @@ -82,22 +82,16 @@ $op-backlogs-header--points-min-width: 5rem display: grid grid-template-columns: var(--control-xsmall-size) 1fr minmax($op-backlogs-header--points-min-width, max-content) auto grid-template-rows: auto auto - grid-template-areas: "drag_handle info_line points menu" "drag_handle subject subject subject" + grid-template-areas: "drag_handle info_line points menu" ". subject subject subject" align-items: center margin-bottom: var(--base-size-4) .op-backlogs-story--drag_handle - align-self: start display: flex - padding-top: var(--base-size-8) .op-backlogs-story--drag_handle_button padding: var(--base-size-4) -.op-backlogs-story--info_line - align-self: end - margin-bottom: var(--base-size-4) - .op-backlogs-story--points margin-left: var(--stack-gap-normal) diff --git a/modules/backlogs/app/components/backlogs/story_component.html.erb b/modules/backlogs/app/components/backlogs/story_component.html.erb index a71589f147b3..aff0f073f4c9 100644 --- a/modules/backlogs/app/components/backlogs/story_component.html.erb +++ b/modules/backlogs/app/components/backlogs/story_component.html.erb @@ -48,7 +48,7 @@ See COPYRIGHT and LICENSE files for more details. <% end %> <% grid.with_area(:points) do %> - <%= render(Primer::Beta::Truncate.new(color: :subtle, mt: 1)) do %> + <%= render(Primer::Beta::Truncate.new(color: :subtle)) do %> <%= t(:"backlogs.points", count: story_points) %> <% end %> <% end %> From 61ff200bdc74e3e7d458abd379fcd4afe9be12f3 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Thu, 5 Feb 2026 12:19:48 +0100 Subject: [PATCH 25/57] Add container query for better page layout responsiveness --- .../assets/sass/backlogs/_master_backlog.sass | 23 ++++++++++++++++++- .../views/rb_master_backlogs/_list.html.erb | 8 +++---- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/frontend/src/assets/sass/backlogs/_master_backlog.sass b/frontend/src/assets/sass/backlogs/_master_backlog.sass index f18df999ef34..ac2d8072d2e3 100644 --- a/frontend/src/assets/sass/backlogs/_master_backlog.sass +++ b/frontend/src/assets/sass/backlogs/_master_backlog.sass @@ -45,7 +45,7 @@ $op-backlogs-header--points-min-width: 5rem .op-backlogs-collapsible display: flex - flex-wrap: flex + flex-wrap: wrap align-items: center column-gap: var(--stack-gap-normal) row-gap: var(--base-size-4) @@ -112,3 +112,24 @@ $op-backlogs-header--points-min-width: 5rem & > :first-child flex: 1 1 auto min-width: 33% + +.op-backlogs-page + display: block + container-name: backlogsListsContainer + container-type: inline-size + +.op-backlogs-container + display: flex + flex-direction: row + gap: var(--stack-gap-normal) + +.op-backlogs-lists + display: flex + flex-direction: column + gap: var(--stack-gap-normal) + flex: 1 1 100% + overflow: hidden + +@container backlogsListsContainer (max-width: 543px) + .op-backlogs-container + flex-direction: column diff --git a/modules/backlogs/app/views/rb_master_backlogs/_list.html.erb b/modules/backlogs/app/views/rb_master_backlogs/_list.html.erb index 376cb8f1fa9a..4295b3a50ac1 100644 --- a/modules/backlogs/app/views/rb_master_backlogs/_list.html.erb +++ b/modules/backlogs/app/views/rb_master_backlogs/_list.html.erb @@ -1,4 +1,4 @@ - + <% if @owner_backlogs.empty? && @sprint_backlogs.empty? %> <%= render(Primer::Beta::Blankslate.new(border: true, spacious: true)) do |blankslate| @@ -11,11 +11,11 @@ end %> <% else %> -
    -
    +
    +
    <%= render(Backlogs::BacklogComponent.with_collection(@sprint_backlogs, project: @project)) %>
    -
    +
    <%= render(Backlogs::BacklogComponent.with_collection(@owner_backlogs, project: @project)) %>
    From 4e3f8121d15a854513fed61aa271f648a5a2c3b5 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 5 Feb 2026 11:28:40 -0300 Subject: [PATCH 26/57] Unify empty backlog blankslate messages Use a single blankslate message for all backlog types with dynamic sprint name interpolation, e.g., "Sprint 1 is empty" or "Product Backlog is empty". Co-Authored-By: Claude Opus 4.5 --- .../backlogs/backlog_component.html.erb | 21 ++++++------------- modules/backlogs/config/locales/en.yml | 8 ++----- .../backlogs/backlog_component_spec.rb | 2 +- 3 files changed, 9 insertions(+), 22 deletions(-) diff --git a/modules/backlogs/app/components/backlogs/backlog_component.html.erb b/modules/backlogs/app/components/backlogs/backlog_component.html.erb index 9ee1cf8577fa..cbbe2339740c 100644 --- a/modules/backlogs/app/components/backlogs/backlog_component.html.erb +++ b/modules/backlogs/app/components/backlogs/backlog_component.html.erb @@ -34,21 +34,12 @@ See COPYRIGHT and LICENSE files for more details. <% end %> <% if backlog.stories.empty? %> <% border_box.with_row(data: { empty_list_item: true }) do %> - <% if backlog.sprint_backlog? %> - <%= - render Primer::Beta::Blankslate.new(role: "status", aria: { live: "polite" }) do |blankslate| - blankslate.with_heading(tag: :h4).with_content(t(".sprint_backlog.blankslate_title")) - blankslate.with_description_content(t(".sprint_backlog.blankslate_description")) - end - %> - <% else %> - <%= - render Primer::Beta::Blankslate.new(role: "status", aria: { live: "polite" }) do |blankslate| - blankslate.with_heading(tag: :h4).with_content(t(".product_backlog.blankslate_title")) - blankslate.with_description_content(t(".product_backlog.blankslate_description")) - end - %> - <% end %> + <%= + render Primer::Beta::Blankslate.new(role: "status", aria: { live: "polite" }) do |blankslate| + blankslate.with_heading(tag: :h4).with_content(t(".blankslate_title", name: sprint.name)) + blankslate.with_description_content(t(".blankslate_description")) + end + %> <% end %> <% end %> <% backlog.stories.each do |story| %> diff --git a/modules/backlogs/config/locales/en.yml b/modules/backlogs/config/locales/en.yml index 7183466ffd3e..a44339c68313 100644 --- a/modules/backlogs/config/locales/en.yml +++ b/modules/backlogs/config/locales/en.yml @@ -86,12 +86,8 @@ en: button_update_backlogs: "Update backlogs module" backlog_component: - sprint_backlog: - blankslate_title: "Sprint Backlog is empty" - blankslate_description: "No items planned yet. Drag items here to add them to the Sprint." - product_backlog: - blankslate_title: "Product Backlog is empty" - blankslate_description: "There is no upcoming work defined in the Product Backlog" + blankslate_title: "%{name} is empty" + blankslate_description: "No items planned yet. Drag items here to add them." backlog_header_component: label_toggle_backlog: "Collapse/Expand %{name}" diff --git a/modules/backlogs/spec/components/backlogs/backlog_component_spec.rb b/modules/backlogs/spec/components/backlogs/backlog_component_spec.rb index f3bab7fb4a20..084b9f232dcd 100644 --- a/modules/backlogs/spec/components/backlogs/backlog_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/backlog_component_spec.rb @@ -136,7 +136,7 @@ def render_component let(:stories) { [] } let(:rendered_component) { render_component } - it_behaves_like "rendering Blank Slate", heading: "Sprint Backlog is empty" + it_behaves_like "rendering Blank Slate", heading: "Sprint 1 is empty" end end end From abcf52f6da3faf99d8c78f53abbea0520d10927d Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 5 Feb 2026 11:32:36 -0300 Subject: [PATCH 27/57] Fix story row classes test expectations Update test to match actual class names after hover/focus color changes in dc9d9cb6800. Co-Authored-By: Claude Opus 4.5 --- .../spec/components/backlogs/backlog_component_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/backlogs/spec/components/backlogs/backlog_component_spec.rb b/modules/backlogs/spec/components/backlogs/backlog_component_spec.rb index 084b9f232dcd..b6f759bf3ee8 100644 --- a/modules/backlogs/spec/components/backlogs/backlog_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/backlog_component_spec.rb @@ -126,8 +126,8 @@ def render_component render_component story_row = page.find(".Box-row[id='story_#{story1.id}']") - expect(story_row[:class]).to include("Box-row--hover-gray") - expect(story_row[:class]).to include("Box-row--focus-blue") + expect(story_row[:class]).to include("Box-row--hover-blue") + expect(story_row[:class]).to include("Box-row--focus-gray") expect(story_row[:class]).to include("Box-row--clickable") end end From 7d54e81b1cb1bcd7196f3490eef6a0c591bc4ac0 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 5 Feb 2026 12:04:36 -0300 Subject: [PATCH 28/57] Make story points display responsive Hide "points" label text when container width is restricted (reuses container query from 61ff200bdc7). Only the numeric value is shown in narrow viewports. - Replace Primer::Beta::Truncate with Primer::Beta::Text for points - Add points_label translation for just the label portion - Remove unused backlogs.points translation - Add font-variant-numeric: tabular-nums for numeric alignment - Override grid column min-width in narrow container query Co-Authored-By: Claude Opus 4.5 --- .../src/assets/sass/backlogs/_master_backlog.sass | 12 ++++++++++++ .../backlogs/backlog_header_component.html.erb | 5 +++-- .../app/components/backlogs/story_component.html.erb | 5 +++-- modules/backlogs/config/locales/en.yml | 6 +++--- .../backlogs/backlog_header_component_spec.rb | 4 ++-- .../spec/components/backlogs/story_component_spec.rb | 8 ++++---- 6 files changed, 27 insertions(+), 13 deletions(-) diff --git a/frontend/src/assets/sass/backlogs/_master_backlog.sass b/frontend/src/assets/sass/backlogs/_master_backlog.sass index ac2d8072d2e3..43d6a031d009 100644 --- a/frontend/src/assets/sass/backlogs/_master_backlog.sass +++ b/frontend/src/assets/sass/backlogs/_master_backlog.sass @@ -27,6 +27,7 @@ */ $op-backlogs-header--points-min-width: 5rem +$op-backlogs-header--points-min-width-narrow: 2rem .op-backlogs-header display: grid @@ -39,6 +40,7 @@ $op-backlogs-header--points-min-width: 5rem .op-backlogs-header--points margin-left: var(--stack-gap-normal) + font-variant-numeric: tabular-nums .op-backlogs-header--menu margin-left: var(--stack-gap-normal) @@ -94,6 +96,7 @@ $op-backlogs-header--points-min-width: 5rem .op-backlogs-story--points margin-left: var(--stack-gap-normal) + font-variant-numeric: tabular-nums .op-backlogs-story--menu margin-left: var(--stack-gap-normal) @@ -133,3 +136,12 @@ $op-backlogs-header--points-min-width: 5rem @container backlogsListsContainer (max-width: 543px) .op-backlogs-container flex-direction: column + + .op-backlogs-points-label + display: none + + .op-backlogs-header + grid-template-columns: 1fr minmax($op-backlogs-header--points-min-width-narrow, max-content) auto + + .op-backlogs-story + grid-template-columns: var(--control-xsmall-size) 1fr minmax($op-backlogs-header--points-min-width-narrow, max-content) auto diff --git a/modules/backlogs/app/components/backlogs/backlog_header_component.html.erb b/modules/backlogs/app/components/backlogs/backlog_header_component.html.erb index b9939738804d..9d6f47b15188 100644 --- a/modules/backlogs/app/components/backlogs/backlog_header_component.html.erb +++ b/modules/backlogs/app/components/backlogs/backlog_header_component.html.erb @@ -59,14 +59,15 @@ See COPYRIGHT and LICENSE files for more details. <% grid.with_area(:points) do %> <%= render( - Primer::Beta::Truncate.new( + Primer::Beta::Text.new( color: :subtle, classes: "velocity", aria: { live: "polite" } ) ) do %> - <%= t(:"backlogs.points", count: story_points) %> + <%= story_points %> + <%= t(:"backlogs.points_label", count: story_points) %> <% end %> <% end %> diff --git a/modules/backlogs/app/components/backlogs/story_component.html.erb b/modules/backlogs/app/components/backlogs/story_component.html.erb index aff0f073f4c9..d18cd643c735 100644 --- a/modules/backlogs/app/components/backlogs/story_component.html.erb +++ b/modules/backlogs/app/components/backlogs/story_component.html.erb @@ -48,8 +48,9 @@ See COPYRIGHT and LICENSE files for more details. <% end %> <% grid.with_area(:points) do %> - <%= render(Primer::Beta::Truncate.new(color: :subtle)) do %> - <%= t(:"backlogs.points", count: story_points) %> + <%= render(Primer::Beta::Text.new(color: :subtle)) do %> + <%= story_points %> + <%= t(:"backlogs.points_label", count: story_points) %> <% end %> <% end %> diff --git a/modules/backlogs/config/locales/en.yml b/modules/backlogs/config/locales/en.yml index a44339c68313..002b73f14c26 100644 --- a/modules/backlogs/config/locales/en.yml +++ b/modules/backlogs/config/locales/en.yml @@ -65,9 +65,9 @@ en: caption_versions_default_fold_state: "Versions will not be expanded by default when viewing backlogs. Each one has to be manually expanded." work_package_is_closed: "Work package is done, when" label_is_done_status: "Status %{status_name} means done" - points: - one: "%{count} point" - other: "%{count} points" + points_label: + one: "point" + other: "points" positions_could_not_be_rebuilt: "Positions could not be rebuilt." positions_rebuilt_successfully: "Positions rebuilt successfully." rebuild: "Rebuild" diff --git a/modules/backlogs/spec/components/backlogs/backlog_header_component_spec.rb b/modules/backlogs/spec/components/backlogs/backlog_header_component_spec.rb index ded99a4092f4..40c25c764c3c 100644 --- a/modules/backlogs/spec/components/backlogs/backlog_header_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/backlog_header_component_spec.rb @@ -111,7 +111,7 @@ def render_component(state: :show, folded: false) render_component # 5 + 3 + 0 = 8 points - expect(page).to have_text("8 points") + expect(page).to have_text("8 points", normalize_ws: true) end it "renders collapse/expand chevrons" do @@ -140,7 +140,7 @@ def render_component(state: :show, folded: false) it "shows 0 points" do render_component - expect(page).to have_text("0 points") + expect(page).to have_text("0 points", normalize_ws: true) end end diff --git a/modules/backlogs/spec/components/backlogs/story_component_spec.rb b/modules/backlogs/spec/components/backlogs/story_component_spec.rb index e09562c2701d..5481e6bfcbf1 100644 --- a/modules/backlogs/spec/components/backlogs/story_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/story_component_spec.rb @@ -89,7 +89,7 @@ def render_component it "shows story points" do render_component - expect(page).to have_text("5 points") + expect(page).to have_text("5 points", normalize_ws: true) end it "renders StoryMenuComponent" do @@ -106,7 +106,7 @@ def render_component it "shows 0 points" do render_component - expect(page).to have_text("0 points") + expect(page).to have_text("0 points", normalize_ws: true) end end @@ -116,7 +116,7 @@ def render_component it "shows 0 points" do render_component - expect(page).to have_text("0 points") + expect(page).to have_text("0 points", normalize_ws: true) end end @@ -126,7 +126,7 @@ def render_component it "shows 1 point (singular)" do render_component - expect(page).to have_text("1 point") + expect(page).to have_text("1 point", normalize_ws: true) end end end From 4d528a486ec904017a2f9d006eb6003f953eef52 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 5 Feb 2026 12:14:12 -0300 Subject: [PATCH 29/57] Replace media queries with container queries Convert remaining @media queries to @container queries for consistent responsive behavior that adapts to the backlogs container width rather than viewport width. - Move collapsible narrow styles into container query - Move header form wide styles into container query - Reorder rules to match main stylesheet order Co-Authored-By: Claude Opus 4.5 --- .../assets/sass/backlogs/_master_backlog.sass | 47 ++++++++++--------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/frontend/src/assets/sass/backlogs/_master_backlog.sass b/frontend/src/assets/sass/backlogs/_master_backlog.sass index 43d6a031d009..ec90b5c3055d 100644 --- a/frontend/src/assets/sass/backlogs/_master_backlog.sass +++ b/frontend/src/assets/sass/backlogs/_master_backlog.sass @@ -71,15 +71,6 @@ $op-backlogs-header--points-min-width-narrow: 2rem &[hidden] opacity: 0 -@media screen and (max-width: $breakpoint-sm) - .op-backlogs-collapsible - flex-direction: column - align-items: flex-start - - &--description - [data-collapsed] & - display: none - .op-backlogs-story display: grid grid-template-columns: var(--control-xsmall-size) 1fr minmax($op-backlogs-header--points-min-width, max-content) auto @@ -106,16 +97,6 @@ $op-backlogs-header--points-min-width-narrow: 2rem word-wrap: break-word overflow-wrap: break-word -@media screen and (min-width: $breakpoint-sm) - .op-backlogs-header-form - .FormControl-spacingWrapper - flex-direction: row - column-gap: 0.5rem - - & > :first-child - flex: 1 1 auto - min-width: 33% - .op-backlogs-page display: block container-name: backlogsListsContainer @@ -133,15 +114,35 @@ $op-backlogs-header--points-min-width-narrow: 2rem flex: 1 1 100% overflow: hidden +// Note: Using hardcoded values instead of $breakpoint-sm because +// Sass doesn't interpolate variables in @container query conditions +@container backlogsListsContainer (min-width: 544px) + .op-backlogs-header-form + .FormControl-spacingWrapper + flex-direction: row + column-gap: 0.5rem + + & > :first-child + flex: 1 1 auto + min-width: 33% + @container backlogsListsContainer (max-width: 543px) - .op-backlogs-container + .op-backlogs-header + grid-template-columns: 1fr minmax($op-backlogs-header--points-min-width-narrow, max-content) auto + + .op-backlogs-collapsible flex-direction: column + align-items: flex-start + + &--description + [data-collapsed] & + display: none .op-backlogs-points-label display: none - .op-backlogs-header - grid-template-columns: 1fr minmax($op-backlogs-header--points-min-width-narrow, max-content) auto - .op-backlogs-story grid-template-columns: var(--control-xsmall-size) 1fr minmax($op-backlogs-header--points-min-width-narrow, max-content) auto + + .op-backlogs-container + flex-direction: column From 1697cb50b8cc7476fc88f91fc78414505f419f06 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 5 Feb 2026 13:21:25 -0300 Subject: [PATCH 30/57] Optical adjustment of container query Co-Authored-By: Parimal Satyal <88370597+psatyal@users.noreply.github.com> --- frontend/src/assets/sass/backlogs/_master_backlog.sass | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend/src/assets/sass/backlogs/_master_backlog.sass b/frontend/src/assets/sass/backlogs/_master_backlog.sass index ec90b5c3055d..2dd58ba4ed87 100644 --- a/frontend/src/assets/sass/backlogs/_master_backlog.sass +++ b/frontend/src/assets/sass/backlogs/_master_backlog.sass @@ -114,9 +114,11 @@ $op-backlogs-header--points-min-width-narrow: 2rem flex: 1 1 100% overflow: hidden -// Note: Using hardcoded values instead of $breakpoint-sm because -// Sass doesn't interpolate variables in @container query conditions -@container backlogsListsContainer (min-width: 544px) +// Note: Using hardcoded values because Sass doesn't interpolate variables in +// @container query conditions. +// Note: 655px is between $breakpoint-sm and $breakpoint-md. This was found to +// be a sensible value after initial testing with different viewports. +@container backlogsListsContainer (min-width: 655px) .op-backlogs-header-form .FormControl-spacingWrapper flex-direction: row @@ -126,7 +128,7 @@ $op-backlogs-header--points-min-width-narrow: 2rem flex: 1 1 auto min-width: 33% -@container backlogsListsContainer (max-width: 543px) +@container backlogsListsContainer (max-width: 654px) .op-backlogs-header grid-template-columns: 1fr minmax($op-backlogs-header--points-min-width-narrow, max-content) auto From 8767a8aab72bb978c817e0405597d612013dc541 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 5 Feb 2026 13:26:19 -0300 Subject: [PATCH 31/57] Optical adjustment of story row top margin Co-Authored-By: Parimal Satyal <88370597+psatyal@users.noreply.github.com> --- frontend/src/assets/sass/backlogs/_master_backlog.sass | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/assets/sass/backlogs/_master_backlog.sass b/frontend/src/assets/sass/backlogs/_master_backlog.sass index 2dd58ba4ed87..1a39b92e1e16 100644 --- a/frontend/src/assets/sass/backlogs/_master_backlog.sass +++ b/frontend/src/assets/sass/backlogs/_master_backlog.sass @@ -77,6 +77,7 @@ $op-backlogs-header--points-min-width-narrow: 2rem grid-template-rows: auto auto grid-template-areas: "drag_handle info_line points menu" ". subject subject subject" align-items: center + margin-top: calc(-1 * var(--base-size-4)) margin-bottom: var(--base-size-4) .op-backlogs-story--drag_handle From 86cb8f2daacdf274480559ef4bb2ce28943f8217 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 5 Feb 2026 14:11:54 -0300 Subject: [PATCH 32/57] Fix collapsible header: make whole area clickable --- .../assets/sass/backlogs/_master_backlog.sass | 1 + .../backlogs/collapsible_component.html.erb | 40 +++++++++---------- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/frontend/src/assets/sass/backlogs/_master_backlog.sass b/frontend/src/assets/sass/backlogs/_master_backlog.sass index 1a39b92e1e16..884f9d3cb97a 100644 --- a/frontend/src/assets/sass/backlogs/_master_backlog.sass +++ b/frontend/src/assets/sass/backlogs/_master_backlog.sass @@ -51,6 +51,7 @@ $op-backlogs-header--points-min-width-narrow: 2rem align-items: center column-gap: var(--stack-gap-normal) row-gap: var(--base-size-4) + flex: 1 &--title-line display: flex diff --git a/modules/backlogs/app/components/backlogs/collapsible_component.html.erb b/modules/backlogs/app/components/backlogs/collapsible_component.html.erb index 78e27123b5dd..efe6818b35a8 100644 --- a/modules/backlogs/app/components/backlogs/collapsible_component.html.erb +++ b/modules/backlogs/app/components/backlogs/collapsible_component.html.erb @@ -28,29 +28,29 @@ See COPYRIGHT and LICENSE files for more details. ++# %> <%= render(Primer::BaseComponent.new(**@system_arguments)) do %> - <%= render(Primer::BaseComponent.new(tag: :div, classes: "op-backlogs-collapsible")) do %> + <%= + render( + Primer::BaseComponent.new( + tag: :div, + role: "button", + tabindex: 0, + classes: "op-backlogs-collapsible", + aria: { + label: @toggle_label, + controls: @collapsible_id, + expanded: !@collapsed + }, + data: { + target: "collapsible-header.triggerElement", + action: "click:collapsible-header#toggle keydown:collapsible-header#toggleViaKeyboard" + } + ) + ) do + %> <%= render(Primer::BaseComponent.new(tag: :div, classes: "op-backlogs-collapsible--title-line")) do %> <%= title %> <%= count %> - <%= - render( - Primer::BaseComponent.new( - tag: :div, - role: "button", - tabindex: 0, - classes: "op-backlogs-collapsible--toggle CollapsibleSection--triggerArea", - aria: { - label: @toggle_label, - controls: @collapsible_id, - expanded: !@collapsed - }, - data: { - target: "collapsible-header.triggerElement", - action: "click:collapsible-header#toggle keydown:collapsible-header#toggleViaKeyboard" - } - ) - ) do - %> + <%= render(Primer::BaseComponent.new(tag: :div, classes: "op-backlogs-collapsible--toggle")) do %> <%= render(Primer::Beta::Octicon.new(icon: "chevron-up", hidden: @collapsed, data: { target: "collapsible-header.arrowUp" })) %> <%= render(Primer::Beta::Octicon.new(icon: "chevron-down", hidden: !@collapsed, data: { target: "collapsible-header.arrowDown" })) %> <% end %> From 60e1a62fcf45c85946cfd8ff01b92175126bf1cb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Feb 2026 00:25:09 +0000 Subject: [PATCH 33/57] Fix N+1 queries, improve permission checks - Eager load status and type associations in Story.backlogs to prevent N+1 queries - Use Story.visible scope in RbStoriesController to ensure proper permission checks Co-authored-by: myabc <755+myabc@users.noreply.github.com> --- modules/backlogs/app/controllers/rb_stories_controller.rb | 4 ++-- modules/backlogs/app/models/story.rb | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/modules/backlogs/app/controllers/rb_stories_controller.rb b/modules/backlogs/app/controllers/rb_stories_controller.rb index 486e4eb24e53..41803ba9249a 100644 --- a/modules/backlogs/app/controllers/rb_stories_controller.rb +++ b/modules/backlogs/app/controllers/rb_stories_controller.rb @@ -32,7 +32,7 @@ class RbStoriesController < RbApplicationController include OpTurbo::ComponentStream def move - story = Story.find(params[:id]) + story = Story.visible.find(params[:id]) call = Stories::UpdateService .new(user: current_user, story:) @@ -62,7 +62,7 @@ def move end def reorder - story = Story.find(params[:id]) + story = Story.visible.find(params[:id]) call = Stories::UpdateService .new(user: current_user, story:) diff --git a/modules/backlogs/app/models/story.rb b/modules/backlogs/app/models/story.rb index d72ae19edf9a..4b0d738c6142 100644 --- a/modules/backlogs/app/models/story.rb +++ b/modules/backlogs/app/models/story.rb @@ -33,7 +33,9 @@ def self.backlogs(project_id, sprint_ids, options = {}) options.reverse_merge!(order: Story::ORDER, conditions: Story.condition(project_id, sprint_ids)) - candidates = Story.where(options[:conditions]).order(Arel.sql(options[:order])) + candidates = Story.where(options[:conditions]) + .includes(:status, :type) + .order(Arel.sql(options[:order])) stories_by_version = Hash.new do |hash, sprint_id| hash[sprint_id] = [] From caebb8c7f03f6e8c8439b453387532a16390db9b Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 5 Feb 2026 14:56:28 -0300 Subject: [PATCH 34/57] Fix burndown chart responsiveness Wrap the chart canvas in a positioned container with defined height so ChartJS can properly resize on window resize. Co-Authored-By: Claude Opus 4.5 --- .../features/backlogs/burndown-chart.component.html | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/features/backlogs/burndown-chart.component.html b/frontend/src/app/features/backlogs/burndown-chart.component.html index 7a1b592ddee3..07c46743f629 100644 --- a/frontend/src/app/features/backlogs/burndown-chart.component.html +++ b/frontend/src/app/features/backlogs/burndown-chart.component.html @@ -1,7 +1,9 @@ - +
    + +

    From 822784bf4c4f58087e2be033160ce6d2fd7583c3 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 5 Feb 2026 14:50:38 -0300 Subject: [PATCH 35/57] Hide burndown chart debug info in production The debug section showing raw chart data is now only rendered when not in production mode. Co-Authored-By: Claude Opus 4.5 --- .../backlogs/burndown-chart.component.html | 18 ++++++++++-------- .../backlogs/burndown-chart.component.ts | 2 ++ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/frontend/src/app/features/backlogs/burndown-chart.component.html b/frontend/src/app/features/backlogs/burndown-chart.component.html index 07c46743f629..fb72c70a9ac1 100644 --- a/frontend/src/app/features/backlogs/burndown-chart.component.html +++ b/frontend/src/app/features/backlogs/burndown-chart.component.html @@ -5,12 +5,14 @@ [type]="'line'">
    -
    +@if (isDevMode) { +
    -
    - Debug - -
    {{maxValue() }}
    -
    {{lineChartData() | json}}
    -
    -
    +
    + Debug + +
    {{maxValue() }}
    +
    {{lineChartData() | json}}
    +
    +
    +} diff --git a/frontend/src/app/features/backlogs/burndown-chart.component.ts b/frontend/src/app/features/backlogs/burndown-chart.component.ts index a07ae8b41e52..7989f667c4cf 100644 --- a/frontend/src/app/features/backlogs/burndown-chart.component.ts +++ b/frontend/src/app/features/backlogs/burndown-chart.component.ts @@ -32,6 +32,7 @@ import { ChartData, ChartOptions } from 'chart.js'; import { I18nService } from 'core-app/core/i18n/i18n.service'; import PrimerColorsPlugin from 'core-app/shared/components/work-package-graphs/plugin.primer-colors'; import { BaseChartDirective, provideCharts, withDefaultRegisterables } from 'ng2-charts'; +import { environment } from '../../../environments/environment'; const BURNDOWN_Y_SCALE_MIN = 25; @@ -43,6 +44,7 @@ const BURNDOWN_Y_SCALE_MIN = 25; changeDetection: ChangeDetectionStrategy.OnPush }) export class BurndownChartComponent { + readonly isDevMode = !environment.production; readonly i18n = inject(I18nService); readonly chartData = input.required(); From 9c3be80ae0bc550205ca81d1f7fdced04362fb53 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 5 Feb 2026 16:04:51 -0300 Subject: [PATCH 36/57] Fix context menu feature spec description Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- modules/backlogs/spec/features/backlogs/context_menu_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/backlogs/spec/features/backlogs/context_menu_spec.rb b/modules/backlogs/spec/features/backlogs/context_menu_spec.rb index f88bec9c30c6..540699b5aea3 100644 --- a/modules/backlogs/spec/features/backlogs/context_menu_spec.rb +++ b/modules/backlogs/spec/features/backlogs/context_menu_spec.rb @@ -98,7 +98,7 @@ def within_backlog_context_menu(&) display: VersionSetting::DISPLAY_RIGHT) end - it "only displays 4 menu entries" do + it "only displays 2 menu entries" do within_backlog_context_menu do |menu| expect(menu).to have_selector :menuitem, count: 2 expect(menu).to have_selector :menuitem, "New story" From c454b6c89bcec6420205c671010f321da4e63e05 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Thu, 5 Feb 2026 20:02:51 +0200 Subject: [PATCH 37/57] Fix error of nil positions when calculating max_position --- modules/backlogs/app/components/backlogs/backlog_component.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/backlogs/app/components/backlogs/backlog_component.rb b/modules/backlogs/app/components/backlogs/backlog_component.rb index 2b72005c4fca..a25366262a33 100644 --- a/modules/backlogs/app/components/backlogs/backlog_component.rb +++ b/modules/backlogs/app/components/backlogs/backlog_component.rb @@ -66,7 +66,7 @@ def folded? end def max_position - stories.map(&:position).max + stories.filter_map(&:position).max end def drop_target_config From df2b48b938e25f67fbedbebccce47af4bc3626b8 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Thu, 5 Feb 2026 21:35:20 +0200 Subject: [PATCH 38/57] Update the move_after method to handle both the old style prev_id and new position drag and drop args --- .../app/controllers/rb_stories_controller.rb | 13 ++++++---- .../app/controllers/rb_tasks_controller.rb | 4 +-- .../app/services/stories/update_service.rb | 6 ++--- .../app/services/tasks/create_service.rb | 4 +-- .../app/services/tasks/update_service.rb | 4 +-- .../lib/open_project/backlogs/list.rb | 26 +++++++++---------- 6 files changed, 30 insertions(+), 27 deletions(-) diff --git a/modules/backlogs/app/controllers/rb_stories_controller.rb b/modules/backlogs/app/controllers/rb_stories_controller.rb index 41803ba9249a..7a08893eae77 100644 --- a/modules/backlogs/app/controllers/rb_stories_controller.rb +++ b/modules/backlogs/app/controllers/rb_stories_controller.rb @@ -33,13 +33,16 @@ class RbStoriesController < RbApplicationController def move story = Story.visible.find(params[:id]) + # The #move_after called in update service required reloading the story, hence + # it is required to memoize the previous version_id. + version_id_was = story.version_id call = Stories::UpdateService .new(user: current_user, story:) - .call(attributes: { - version_id: move_params[:target_id], - position: move_params[:position] - }) + .call( + attributes: { version_id: move_params[:target_id] }, + position: move_params[:position].to_i + ) unless call.success? render_error_flash_message_via_turbo_stream(message: I18n.t(:notice_unsuccessful_update)) # TODO: display reason @@ -48,7 +51,7 @@ def move backlog = Backlog.for(sprint: @sprint, project: @project) replace_via_turbo_stream(component: Backlogs::BacklogComponent.new(backlog:, project: @project)) - if story.saved_change_to_version_id? + if story.version_id != version_id_was new_sprint = story.version.becomes(Sprint) new_backlog = Backlog.for(sprint: new_sprint, project: @project) diff --git a/modules/backlogs/app/controllers/rb_tasks_controller.rb b/modules/backlogs/app/controllers/rb_tasks_controller.rb index d1bd84f1559b..bab78c34bb59 100644 --- a/modules/backlogs/app/controllers/rb_tasks_controller.rb +++ b/modules/backlogs/app/controllers/rb_tasks_controller.rb @@ -36,7 +36,7 @@ class RbTasksController < RbApplicationController def create call = ::Tasks::CreateService .new(user: current_user) - .call(attributes: task_params.merge(project: @project), prev: params[:prev]) + .call(attributes: task_params.merge(project: @project), prev_id: params[:prev]) respond_with_task call end @@ -46,7 +46,7 @@ def update call = ::Tasks::UpdateService .new(user: current_user, task:) - .call(attributes: task_params, prev: params[:prev]) + .call(attributes: task_params, prev_id: params[:prev]) respond_with_task call end diff --git a/modules/backlogs/app/services/stories/update_service.rb b/modules/backlogs/app/services/stories/update_service.rb index fb835a7dbcc4..fd5f94f50d6e 100644 --- a/modules/backlogs/app/services/stories/update_service.rb +++ b/modules/backlogs/app/services/stories/update_service.rb @@ -34,14 +34,14 @@ def initialize(user:, story:) self.story = story end - def call(attributes: {}, prev: nil) + def call(attributes: {}, position: nil) create_call = WorkPackages::UpdateService .new(user:, model: story) .call(**attributes.to_h.symbolize_keys) - if create_call.success? - create_call.result.move_after prev + if create_call.success? && position + create_call.result.move_after(position:) end create_call diff --git a/modules/backlogs/app/services/tasks/create_service.rb b/modules/backlogs/app/services/tasks/create_service.rb index 47591c790f0a..fd34ec2bb97f 100644 --- a/modules/backlogs/app/services/tasks/create_service.rb +++ b/modules/backlogs/app/services/tasks/create_service.rb @@ -33,7 +33,7 @@ def initialize(user:) self.user = user end - def call(attributes: {}, prev: "") + def call(attributes: {}, prev_id: "") attributes[:type_id] = Task.type create_call = WorkPackages::CreateService @@ -41,7 +41,7 @@ def call(attributes: {}, prev: "") .call(**attributes) if create_call.success? - create_call.result.move_after prev + create_call.result.move_after prev_id: end create_call diff --git a/modules/backlogs/app/services/tasks/update_service.rb b/modules/backlogs/app/services/tasks/update_service.rb index b936db0344f4..a249626d024f 100644 --- a/modules/backlogs/app/services/tasks/update_service.rb +++ b/modules/backlogs/app/services/tasks/update_service.rb @@ -34,14 +34,14 @@ def initialize(user:, task:) self.task = task end - def call(attributes: {}, prev: "") + def call(attributes: {}, prev_id: "") create_call = WorkPackages::UpdateService .new(user:, model: task) .call(**attributes) if create_call.success? - create_call.result.move_after prev + create_call.result.move_after prev_id: end create_call diff --git a/modules/backlogs/lib/open_project/backlogs/list.rb b/modules/backlogs/lib/open_project/backlogs/list.rb index 0a593a33b80c..67aef13fe8d3 100644 --- a/modules/backlogs/lib/open_project/backlogs/list.rb +++ b/modules/backlogs/lib/open_project/backlogs/list.rb @@ -53,28 +53,28 @@ def scope_condition end module InstanceMethods - def move_after(prev_id) + def move_after(position: nil, prev_id: nil) + if acts_as_list_list.none?(:position) + # If no items have a position, create an order on position + # silently. This can happen when sorting inside a version for the first + # time after backlogs was activated and there have already been items + # inside the version at the time of backlogs activation + set_default_prev_positions_silently(acts_as_list_list.last) + end + # Remove so the potential 'prev' has a correct position remove_from_list reload + id_or_position = position ? { position: position - 1 } : { id: prev_id } - prev = self.class.find_by(id: prev_id.to_i) + prev = acts_as_list_list.find_by(**id_or_position) - # If it should be the first story, move it to the 1st position if prev.blank? + # If it should be the first story, move it to the 1st position insert_at move_to_top - - # If its predecessor has no position, create an order on position - # silently. This can happen when sorting inside a version for the first - # time after backlogs was activated and there have already been items - # inside the version at the time of backlogs activation - elsif !prev.in_list? - prev_pos = set_default_prev_positions_silently(prev) - insert_at(prev_pos += 1) - - # There's a valid predecessor else + # There's a valid predecessor insert_at(prev.position + 1) end end From 2382e371077a0c95ad33063b58bbbe0ee41357c5 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 5 Feb 2026 16:09:47 -0300 Subject: [PATCH 39/57] Hide divider in story menu when only one item When a backlog has only a single story, the move menu items are not shown. This change also hides the divider in that case to avoid an orphan separator at the bottom of the menu. Co-Authored-By: Claude Opus 4.5 --- .../app/components/backlogs/story_menu_component.html.erb | 7 ++++--- .../app/components/backlogs/story_menu_component.rb | 4 ++++ .../spec/components/backlogs/story_menu_component_spec.rb | 6 ++++++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/modules/backlogs/app/components/backlogs/story_menu_component.html.erb b/modules/backlogs/app/components/backlogs/story_menu_component.html.erb index 424bb34efbdf..617e70857335 100644 --- a/modules/backlogs/app/components/backlogs/story_menu_component.html.erb +++ b/modules/backlogs/app/components/backlogs/story_menu_component.html.erb @@ -54,8 +54,9 @@ See COPYRIGHT and LICENSE files for more details. item.with_leading_visual_icon(icon: :"screen-full") end - menu.with_divider - - build_move_menu(menu) + if show_move_items? + menu.with_divider + build_move_menu(menu) + end end %> diff --git a/modules/backlogs/app/components/backlogs/story_menu_component.rb b/modules/backlogs/app/components/backlogs/story_menu_component.rb index cfd474da13ea..99581a19047f 100644 --- a/modules/backlogs/app/components/backlogs/story_menu_component.rb +++ b/modules/backlogs/app/components/backlogs/story_menu_component.rb @@ -44,6 +44,10 @@ def initialize(story:, sprint:, max_position:, current_user: User.current) private + def show_move_items? + !(first_item? && last_item?) + end + def build_move_menu(menu) unless first_item? build_move_item(menu, label: I18n.t(:label_sort_highest), direction: "highest", icon: :"move-to-top") diff --git a/modules/backlogs/spec/components/backlogs/story_menu_component_spec.rb b/modules/backlogs/spec/components/backlogs/story_menu_component_spec.rb index 99758863c030..b9ac8feaac45 100644 --- a/modules/backlogs/spec/components/backlogs/story_menu_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/story_menu_component_spec.rb @@ -170,6 +170,12 @@ def render_component(position: 2, max_position: 3) expect(page).to have_no_text(I18n.t(:label_sort_lower)) expect(page).to have_no_text(I18n.t(:label_sort_lowest)) end + + it "hides the divider" do + render_component(position: 1, max_position: 1) + + expect(page).to have_no_css(".ActionList-sectionDivider") + end end end end From adc5b1b24a8d70777c351bfb5d1227486a31b79e Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 5 Feb 2026 16:36:51 -0300 Subject: [PATCH 40/57] Improve Backlogs "not configured" page Replace the plain text message with a proper PageHeader and Blankslate component when Backlogs is not configured. This provides a consistent UI with the rest of the application and a clear call-to-action to configure the module. Co-Authored-By: Claude Opus 4.5 --- .../app/views/shared/not_configured.html.erb | 40 ++++++++++++++----- modules/backlogs/config/locales/en.yml | 4 ++ 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/modules/backlogs/app/views/shared/not_configured.html.erb b/modules/backlogs/app/views/shared/not_configured.html.erb index 098b7924ebeb..c1475db39168 100644 --- a/modules/backlogs/app/views/shared/not_configured.html.erb +++ b/modules/backlogs/app/views/shared/not_configured.html.erb @@ -1,4 +1,4 @@ -<%#-- copyright +<%# -- copyright OpenProject is an open source project management software. Copyright (C) the OpenProject GmbH @@ -25,13 +25,31 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. See COPYRIGHT and LICENSE files for more details. -++#%> - -
    - <%= t( - :label_backlogs_unconfigured, - administration: t(:label_administration), - plugins: t(:label_plugins), - configure: t(:button_configure) - ) %> -
    +++# %> + +<% html_title t(:label_backlogs) %> + +<% content_for :content_header do %> + <%= + render Primer::OpenProject::PageHeader.new do |header| + header.with_title { t(:label_backlogs) } + header.with_breadcrumbs( + [{ href: project_overview_path(@project.identifier), text: @project.name }, + t(:label_backlogs)] + ) + end + %> +<% end %> + +<% content_for :content_body do %> + <%= + render(Primer::Beta::Blankslate.new(border: true, spacious: true)) do |blankslate| + blankslate.with_visual_icon(icon: :"op-backlogs") + blankslate.with_heading(tag: :h2).with_content(t(:backlogs_not_configured_title)) + blankslate.with_description_content(t(:backlogs_not_configured_description)) + blankslate.with_secondary_action(href: admin_backlogs_settings_path, scheme: :default) do + t(:backlogs_not_configured_action_text) + end + end + %> +<% end %> diff --git a/modules/backlogs/config/locales/en.yml b/modules/backlogs/config/locales/en.yml index 002b73f14c26..dbeee1707a24 100644 --- a/modules/backlogs/config/locales/en.yml +++ b/modules/backlogs/config/locales/en.yml @@ -123,6 +123,10 @@ en: backlogs_empty_title: "No versions are defined yet" backlogs_empty_action_text: "To start using backlogs, please create a version first" + backlogs_not_configured_title: "Backlogs not configured" + backlogs_not_configured_description: "Story and task types need to be set before using this module." + backlogs_not_configured_action_text: "Configure Backlogs" + burndown: story_points: "Story points" story_points_ideal: "Story points (ideal)" From e45313279549d8cfb175048bb6a317d81067f82c Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 5 Feb 2026 16:53:40 -0300 Subject: [PATCH 41/57] Remove unused RbCommonHelper#all_workflows method Co-Authored-By: Claude Opus 4.5 --- modules/backlogs/app/helpers/rb_common_helper.rb | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/modules/backlogs/app/helpers/rb_common_helper.rb b/modules/backlogs/app/helpers/rb_common_helper.rb index c59c635c974d..d93c81c7398c 100644 --- a/modules/backlogs/app/helpers/rb_common_helper.rb +++ b/modules/backlogs/app/helpers/rb_common_helper.rb @@ -142,18 +142,6 @@ def work_package_status_for_id(id) @all_work_package_status_by_id[id] end - # Returns all distinct virtual workflows for the roles the current user has in the project and the story types. - # Virtual workflow because not every instance of a workflow in the database will be returned but a representation - # distinct by type_id, old_status_id and new_status_id. This helps in case a lot of workflows are configured. - def all_workflows - Workflow - .includes(%i[new_status old_status]) - .where(role_id: User.current.roles_for_project(@project).map(&:id), - type_id: story_types.map(&:id)) - .group(:type_id, :old_status_id, :new_status_id) - .reselect(:type_id, :old_status_id, :new_status_id) - end - def all_work_package_status @all_work_package_status ||= Status.order(Arel.sql("position ASC")) end From b106a786dbaf7537defad693f24f581ecb1087a4 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 5 Feb 2026 16:57:46 -0300 Subject: [PATCH 42/57] Autocorrect Rubocop offenses in controllers --- .../backlogs/app/controllers/backlogs_settings_controller.rb | 2 ++ .../app/controllers/projects/settings/backlogs_controller.rb | 4 +++- modules/backlogs/app/controllers/rb_application_controller.rb | 2 ++ .../backlogs/app/controllers/rb_burndown_charts_controller.rb | 2 ++ modules/backlogs/app/controllers/rb_impediments_controller.rb | 2 ++ modules/backlogs/app/controllers/rb_queries_controller.rb | 2 ++ modules/backlogs/app/controllers/rb_taskboards_controller.rb | 2 ++ modules/backlogs/app/controllers/rb_tasks_controller.rb | 4 +++- modules/backlogs/app/controllers/rb_wikis_controller.rb | 2 ++ 9 files changed, 20 insertions(+), 2 deletions(-) diff --git a/modules/backlogs/app/controllers/backlogs_settings_controller.rb b/modules/backlogs/app/controllers/backlogs_settings_controller.rb index 09318d09c684..845c60b6b7f9 100644 --- a/modules/backlogs/app/controllers/backlogs_settings_controller.rb +++ b/modules/backlogs/app/controllers/backlogs_settings_controller.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH diff --git a/modules/backlogs/app/controllers/projects/settings/backlogs_controller.rb b/modules/backlogs/app/controllers/projects/settings/backlogs_controller.rb index 6fa3587d0fc8..b53f8be524b8 100644 --- a/modules/backlogs/app/controllers/projects/settings/backlogs_controller.rb +++ b/modules/backlogs/app/controllers/projects/settings/backlogs_controller.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -30,7 +32,7 @@ class Projects::Settings::BacklogsController < Projects::SettingsController menu_item :settings_backlogs def show - @statuses_done_for_project = @project.done_statuses.select(:id).map(&:id) + @statuses_done_for_project = @project.done_statuses.pluck(:id) end def update diff --git a/modules/backlogs/app/controllers/rb_application_controller.rb b/modules/backlogs/app/controllers/rb_application_controller.rb index b756ae04b643..944ce7c2452b 100644 --- a/modules/backlogs/app/controllers/rb_application_controller.rb +++ b/modules/backlogs/app/controllers/rb_application_controller.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH diff --git a/modules/backlogs/app/controllers/rb_burndown_charts_controller.rb b/modules/backlogs/app/controllers/rb_burndown_charts_controller.rb index de32109c5d48..914d1f0a2148 100644 --- a/modules/backlogs/app/controllers/rb_burndown_charts_controller.rb +++ b/modules/backlogs/app/controllers/rb_burndown_charts_controller.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH diff --git a/modules/backlogs/app/controllers/rb_impediments_controller.rb b/modules/backlogs/app/controllers/rb_impediments_controller.rb index d67c86b22dc7..1a95316f107c 100644 --- a/modules/backlogs/app/controllers/rb_impediments_controller.rb +++ b/modules/backlogs/app/controllers/rb_impediments_controller.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH diff --git a/modules/backlogs/app/controllers/rb_queries_controller.rb b/modules/backlogs/app/controllers/rb_queries_controller.rb index da11a8155037..eaf990c0fab1 100644 --- a/modules/backlogs/app/controllers/rb_queries_controller.rb +++ b/modules/backlogs/app/controllers/rb_queries_controller.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH diff --git a/modules/backlogs/app/controllers/rb_taskboards_controller.rb b/modules/backlogs/app/controllers/rb_taskboards_controller.rb index 73cda7f7b903..05196ec200e1 100644 --- a/modules/backlogs/app/controllers/rb_taskboards_controller.rb +++ b/modules/backlogs/app/controllers/rb_taskboards_controller.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH diff --git a/modules/backlogs/app/controllers/rb_tasks_controller.rb b/modules/backlogs/app/controllers/rb_tasks_controller.rb index bab78c34bb59..5182715d05ff 100644 --- a/modules/backlogs/app/controllers/rb_tasks_controller.rb +++ b/modules/backlogs/app/controllers/rb_tasks_controller.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -31,7 +33,7 @@ class RbTasksController < RbApplicationController # attributes. This is necessary for now as we still directly use `attributes=` # in non-controller code. PERMITTED_PARAMS = ["id", "subject", "assigned_to_id", "remaining_hours", "parent_id", - "estimated_hours", "status_id", "sprint_id"] + "estimated_hours", "status_id", "sprint_id"].freeze def create call = ::Tasks::CreateService diff --git a/modules/backlogs/app/controllers/rb_wikis_controller.rb b/modules/backlogs/app/controllers/rb_wikis_controller.rb index 4de3f05da697..edd31eef7c15 100644 --- a/modules/backlogs/app/controllers/rb_wikis_controller.rb +++ b/modules/backlogs/app/controllers/rb_wikis_controller.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH From 4351c539fa91876ff7094329ad7beadcfc148063 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 5 Feb 2026 16:59:20 -0300 Subject: [PATCH 43/57] Disable Metrics/AbcSize for RbStoriesController#move --- modules/backlogs/app/controllers/rb_stories_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/backlogs/app/controllers/rb_stories_controller.rb b/modules/backlogs/app/controllers/rb_stories_controller.rb index 7a08893eae77..b0a6c3db1a69 100644 --- a/modules/backlogs/app/controllers/rb_stories_controller.rb +++ b/modules/backlogs/app/controllers/rb_stories_controller.rb @@ -31,7 +31,7 @@ class RbStoriesController < RbApplicationController include OpTurbo::ComponentStream - def move + def move # rubocop:disable Metrics/AbcSize story = Story.visible.find(params[:id]) # The #move_after called in update service required reloading the story, hence # it is required to memoize the previous version_id. From 654d2564624e6912d4cf40e63c67adaaaeef3439 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 5 Feb 2026 17:12:42 -0300 Subject: [PATCH 44/57] Disable Metrics/AbcSize for Story.backlogs --- modules/backlogs/app/models/story.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/backlogs/app/models/story.rb b/modules/backlogs/app/models/story.rb index 4b0d738c6142..6948b8dfdbca 100644 --- a/modules/backlogs/app/models/story.rb +++ b/modules/backlogs/app/models/story.rb @@ -29,7 +29,7 @@ class Story < WorkPackage extend OpenProject::Backlogs::Mixins::PreventIssueSti - def self.backlogs(project_id, sprint_ids, options = {}) + def self.backlogs(project_id, sprint_ids, options = {}) # rubocop:disable Metrics/AbcSize options.reverse_merge!(order: Story::ORDER, conditions: Story.condition(project_id, sprint_ids)) From 4e104c4656f00c4f4ea37dfb6945956fb302bc94 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Thu, 5 Feb 2026 22:40:15 +0200 Subject: [PATCH 45/57] Fix Stories::CreateService prev argument --- modules/backlogs/app/services/stories/create_service.rb | 4 ++-- modules/backlogs/lib/open_project/backlogs/list.rb | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/modules/backlogs/app/services/stories/create_service.rb b/modules/backlogs/app/services/stories/create_service.rb index f15a00de3f2d..d8c523914b93 100644 --- a/modules/backlogs/app/services/stories/create_service.rb +++ b/modules/backlogs/app/services/stories/create_service.rb @@ -33,13 +33,13 @@ def initialize(user:) self.user = user end - def call(attributes: {}, prev: nil) + def call(attributes: {}, position: nil) create_call = WorkPackages::CreateService .new(user:) .call(**attributes.symbolize_keys) if create_call.success? - create_call.result.move_after prev + create_call.result.move_after position: end create_call diff --git a/modules/backlogs/lib/open_project/backlogs/list.rb b/modules/backlogs/lib/open_project/backlogs/list.rb index 67aef13fe8d3..e884a450b5e5 100644 --- a/modules/backlogs/lib/open_project/backlogs/list.rb +++ b/modules/backlogs/lib/open_project/backlogs/list.rb @@ -148,6 +148,8 @@ def fix_own_work_package_position end def set_default_prev_positions_silently(prev) + return if prev.nil? + if prev.is_task? prev.version.rebuild_task_positions(prev) else From 81d5cca2aa4d1c7f7d9e4275ad3d2445d40860e9 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Thu, 5 Feb 2026 22:52:29 +0200 Subject: [PATCH 46/57] Fix not configured spec --- modules/backlogs/spec/views/shared/not_configured_spec.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/modules/backlogs/spec/views/shared/not_configured_spec.rb b/modules/backlogs/spec/views/shared/not_configured_spec.rb index f3fa729b26d0..57d240e138ff 100644 --- a/modules/backlogs/spec/views/shared/not_configured_spec.rb +++ b/modules/backlogs/spec/views/shared/not_configured_spec.rb @@ -29,6 +29,8 @@ require "spec_helper" RSpec.describe "shared/not_configured" do + before { assign(:project, create(:project)) } + it "renders without errors" do render end From 3b3bdbdfb5f65e240b38ca98217a575d40b3ef82 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:32:58 +0200 Subject: [PATCH 47/57] Add visible scope to sprints controller --- .../app/controllers/rb_sprints_controller.rb | 4 ++-- .../controllers/rb_sprints_controller_spec.rb | 21 ++++++++++++++----- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/modules/backlogs/app/controllers/rb_sprints_controller.rb b/modules/backlogs/app/controllers/rb_sprints_controller.rb index 6abdcee3e023..089a85ad4adb 100644 --- a/modules/backlogs/app/controllers/rb_sprints_controller.rb +++ b/modules/backlogs/app/controllers/rb_sprints_controller.rb @@ -94,11 +94,11 @@ def update # :sprint_id def load_sprint_and_project if params[:id] - @sprint = Sprint.find(params[:id]) + @sprint = Sprint.visible.find(params[:id]) @project = @sprint.project end # This overrides sprint's project if we set another project, say a subproject - @project = Project.find(params[:project_id]) if params[:project_id] + @project = Project.visible.find(params[:project_id]) if params[:project_id] end private diff --git a/modules/backlogs/spec/controllers/rb_sprints_controller_spec.rb b/modules/backlogs/spec/controllers/rb_sprints_controller_spec.rb index cb1a310ac487..99cccddba667 100644 --- a/modules/backlogs/spec/controllers/rb_sprints_controller_spec.rb +++ b/modules/backlogs/spec/controllers/rb_sprints_controller_spec.rb @@ -36,20 +36,31 @@ shared_let(:user) { create(:admin) } current_user { user } + let(:visible_projects_scope) { instance_double(ActiveRecord::Relation) } + let(:visible_sprints_scope) { instance_double(ActiveRecord::Relation) } + before do allow(Setting) .to receive(:plugin_openproject_backlogs) .and_return({ "story_types" => [type_feature.id], "task_type" => type_task.id }) allow(Project) - .to receive(:find) - .with(project.identifier) - .and_return(project) + .to receive(:visible) + .and_return(visible_projects_scope) + + allow(visible_projects_scope) + .to receive(:find) + .with(project.identifier) + .and_return(project) allow(Sprint) + .to receive(:visible) + .and_return(visible_sprints_scope) + + allow(visible_sprints_scope) .to receive(:find) - .with(sprint.id.to_s) - .and_return(sprint) + .with(sprint.id.to_s) + .and_return(sprint) end describe "GET #edit_name" do From 4b93f5cee50142adf41872814ec9d52d34f4fe91 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 6 Feb 2026 15:23:33 -0300 Subject: [PATCH 48/57] Fix ambiguous display of single dates When only one of start/end date is set, show an en-dash placeholder for the missing date instead of displaying the single date alone (which looked like a milestone). Remove .compact from date_range so nils flow through to format_date_range, which now handles them explicitly. Co-Authored-By: Claude Opus 4.6 --- .../backlogs/backlog_header_component.rb | 2 +- .../backlogs/sprint_page_header_component.rb | 2 +- .../backlogs/app/helpers/rb_common_helper.rb | 7 +- .../spec/helpers/rb_common_helper_spec.rb | 75 +++++++++++++++++++ 4 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 modules/backlogs/spec/helpers/rb_common_helper_spec.rb diff --git a/modules/backlogs/app/components/backlogs/backlog_header_component.rb b/modules/backlogs/app/components/backlogs/backlog_header_component.rb index 6daeacba6ac2..d8c621ca19c6 100644 --- a/modules/backlogs/app/components/backlogs/backlog_header_component.rb +++ b/modules/backlogs/app/components/backlogs/backlog_header_component.rb @@ -76,7 +76,7 @@ def story_count end def date_range - [sprint.start_date, sprint.effective_date].compact + [sprint.start_date, sprint.effective_date] end end end diff --git a/modules/backlogs/app/components/backlogs/sprint_page_header_component.rb b/modules/backlogs/app/components/backlogs/sprint_page_header_component.rb index f0b54587d941..9923f29101c1 100644 --- a/modules/backlogs/app/components/backlogs/sprint_page_header_component.rb +++ b/modules/backlogs/app/components/backlogs/sprint_page_header_component.rb @@ -53,7 +53,7 @@ def breadcrumb_items private def date_range - [@sprint.start_date, @sprint.effective_date].compact + [@sprint.start_date, @sprint.effective_date] end end end diff --git a/modules/backlogs/app/helpers/rb_common_helper.rb b/modules/backlogs/app/helpers/rb_common_helper.rb index d93c81c7398c..b28a5891b700 100644 --- a/modules/backlogs/app/helpers/rb_common_helper.rb +++ b/modules/backlogs/app/helpers/rb_common_helper.rb @@ -28,9 +28,10 @@ module RbCommonHelper def format_date_range(dates) - dates - .map { |date| tag.time(datetime: date.iso8601) { format_date(date) } } - .then { |dates| safe_join(dates, " – ") } + return nil if dates.all?(&:nil?) + + from, to = dates.map { |date| tag.time(datetime: date.iso8601) { format_date(date) } if date } + safe_join([from, "–", to], " ") # – and   end def assignee_id_or_empty(story) diff --git a/modules/backlogs/spec/helpers/rb_common_helper_spec.rb b/modules/backlogs/spec/helpers/rb_common_helper_spec.rb new file mode 100644 index 000000000000..7e9b2ffe23bd --- /dev/null +++ b/modules/backlogs/spec/helpers/rb_common_helper_spec.rb @@ -0,0 +1,75 @@ +# 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 RbCommonHelper do + describe "#format_date_range" do + let(:from) { Date.new(2025, 1, 6) } + let(:to) { Date.new(2025, 1, 17) } + + context "with an Array" do + it "renders both dates separated by an en-dash" do + expected = + "" \ + "\u00A0\u2013\u00A0" \ + "" + + expect(helper.format_date_range([from, to])).to be_html_eql(expected) + end + end + + context "when both dates are nil" do + it "returns nil" do + expect(helper.format_date_range([nil, nil])).to be_nil + end + end + + context "when only the start date is present" do + it "renders the start date with an en-dash" do + expected = + "" \ + "\u00A0\u2013\u00A0" + + expect(helper.format_date_range([from, nil])).to be_html_eql(expected) + end + end + + context "when only the end date is present" do + it "renders the end date with an en-dash" do + expected = + "\u00A0\u2013\u00A0" \ + "" + + expect(helper.format_date_range([nil, to])).to be_html_eql(expected) + end + end + end +end From b4970a02cf11d4cd5aa31f850d0b64332fa4283a Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 6 Feb 2026 15:30:29 -0300 Subject: [PATCH 49/57] Remove broken toggle transition --- frontend/src/assets/sass/backlogs/_master_backlog.sass | 8 -------- 1 file changed, 8 deletions(-) diff --git a/frontend/src/assets/sass/backlogs/_master_backlog.sass b/frontend/src/assets/sass/backlogs/_master_backlog.sass index 884f9d3cb97a..cd43321616e5 100644 --- a/frontend/src/assets/sass/backlogs/_master_backlog.sass +++ b/frontend/src/assets/sass/backlogs/_master_backlog.sass @@ -64,14 +64,6 @@ $op-backlogs-header--points-min-width-narrow: 2rem display: inline white-space: nowrap - &--toggle - svg - transition: opacity 120ms ease - transform-origin: center - - &[hidden] - opacity: 0 - .op-backlogs-story display: grid grid-template-columns: var(--control-xsmall-size) 1fr minmax($op-backlogs-header--points-min-width, max-content) auto From eba6b1b775699a48806871fd7851ef4d0c0c2a95 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 6 Feb 2026 15:31:48 -0300 Subject: [PATCH 50/57] Remove superfluous drag handle styling --- frontend/src/assets/sass/backlogs/_master_backlog.sass | 3 --- 1 file changed, 3 deletions(-) diff --git a/frontend/src/assets/sass/backlogs/_master_backlog.sass b/frontend/src/assets/sass/backlogs/_master_backlog.sass index cd43321616e5..159b6dd9ae24 100644 --- a/frontend/src/assets/sass/backlogs/_master_backlog.sass +++ b/frontend/src/assets/sass/backlogs/_master_backlog.sass @@ -73,9 +73,6 @@ $op-backlogs-header--points-min-width-narrow: 2rem margin-top: calc(-1 * var(--base-size-4)) margin-bottom: var(--base-size-4) -.op-backlogs-story--drag_handle - display: flex - .op-backlogs-story--drag_handle_button padding: var(--base-size-4) From 21180208ccbef6670093740c531f7b29c0063061 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 6 Feb 2026 16:19:07 -0300 Subject: [PATCH 51/57] Show failure reason if Story cannot be updated --- modules/backlogs/app/controllers/rb_stories_controller.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/modules/backlogs/app/controllers/rb_stories_controller.rb b/modules/backlogs/app/controllers/rb_stories_controller.rb index b0a6c3db1a69..240708202ecb 100644 --- a/modules/backlogs/app/controllers/rb_stories_controller.rb +++ b/modules/backlogs/app/controllers/rb_stories_controller.rb @@ -45,7 +45,9 @@ def move # rubocop:disable Metrics/AbcSize ) unless call.success? - render_error_flash_message_via_turbo_stream(message: I18n.t(:notice_unsuccessful_update)) # TODO: display reason + render_error_flash_message_via_turbo_stream( + message: I18n.t(:notice_unsuccessful_update_with_reason, reason: call.message) + ) end backlog = Backlog.for(sprint: @sprint, project: @project) @@ -72,7 +74,9 @@ def reorder .call(attributes: { move_to: reorder_param }) unless call.success? - render_error_flash_message_via_turbo_stream(message: I18n.t(:notice_unsuccessful_update)) # TODO: display reason + render_error_flash_message_via_turbo_stream( + message: I18n.t(:notice_unsuccessful_update_with_reason, reason: call.message) + ) end backlog = Backlog.for(sprint: @sprint, project: @project) From 6dd4a22fe3644de1afb39c3a411e8f8fcfeb68de Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 6 Feb 2026 16:28:56 -0300 Subject: [PATCH 52/57] Flesh out backlogs controller specs Co-Authored-By: Claude Opus 4.6 --- .../rb_master_backlogs_controller_spec.rb | 48 +++++++++++------ .../controllers/rb_sprints_controller_spec.rb | 12 +++++ .../controllers/rb_stories_controller_spec.rb | 52 +++++++++++++++++++ 3 files changed, 97 insertions(+), 15 deletions(-) diff --git a/modules/backlogs/spec/controllers/rb_master_backlogs_controller_spec.rb b/modules/backlogs/spec/controllers/rb_master_backlogs_controller_spec.rb index 992a47b9d59c..3d1152148288 100644 --- a/modules/backlogs/spec/controllers/rb_master_backlogs_controller_spec.rb +++ b/modules/backlogs/spec/controllers/rb_master_backlogs_controller_spec.rb @@ -48,22 +48,31 @@ end describe "GET #index" do - it "is successful" do + it "is successful", :aggregate_failures do get :index, params: { project_id: project.id } expect(response).to be_successful + expect(assigns(:project)).to eq(project) + expect(assigns(:owner_backlogs)).to be_an(Array) + expect(assigns(:sprint_backlogs)).to be_an(Array) end - it "assigns @owner_backlogs and @sprint_backlogs" do - get :index, params: { project_id: project.id } + context "with a Turbo Frame request" do + before { request.headers["Turbo-Frame"] = "backlogs_container" } - expect(assigns(:owner_backlogs)).to be_an(Array) - expect(assigns(:sprint_backlogs)).to be_an(Array) + it "renders the list partial", :aggregate_failures do + get :index, params: { project_id: project.id } + + expect(response).to be_successful + expect(assigns(:project)).to eq(project) + expect(assigns(:owner_backlogs)).to be_an(Array) + expect(assigns(:sprint_backlogs)).to be_an(Array) + end end end describe "GET #details" do - it "is successful" do + it "is successful", :aggregate_failures do get :details, params: { project_id: project.id, tab: :overview, @@ -72,18 +81,27 @@ } expect(response).to be_successful + expect(assigns(:project)).to eq(project) + expect(assigns(:owner_backlogs)).to be_an(Array) + expect(assigns(:sprint_backlogs)).to be_an(Array) end - it "assigns @owner_backlogs and @sprint_backlogs" do - get :details, params: { - project_id: project.id, - tab: :overview, - work_package_id: story.id, - work_package_split_view: true - } + context "with a Turbo Frame request" do + before { request.headers["Turbo-Frame"] = "content-bodyRight" } - expect(assigns(:owner_backlogs)).to be_an(Array) - expect(assigns(:sprint_backlogs)).to be_an(Array) + it "renders the split view without loading backlogs", :aggregate_failures do + get :details, params: { + project_id: project.id, + tab: :overview, + work_package_id: story.id, + work_package_split_view: true + } + + expect(response).to be_successful + expect(assigns(:project)).to eq(project) + expect(assigns(:owner_backlogs)).to be_nil + expect(assigns(:sprint_backlogs)).to be_nil + end end end end diff --git a/modules/backlogs/spec/controllers/rb_sprints_controller_spec.rb b/modules/backlogs/spec/controllers/rb_sprints_controller_spec.rb index 99cccddba667..5513fdaff5e7 100644 --- a/modules/backlogs/spec/controllers/rb_sprints_controller_spec.rb +++ b/modules/backlogs/spec/controllers/rb_sprints_controller_spec.rb @@ -73,6 +73,9 @@ expect(response).to be_successful expect(response).to have_http_status :ok expect(response).to have_turbo_stream action: "update", target: "backlogs-backlog-header-component-#{sprint.id}" + expect(assigns(:project)).to eq(project) + expect(assigns(:sprint)).to eq(sprint) + expect(assigns(:backlog)).to be_a(Backlog) end end @@ -86,6 +89,9 @@ expect(response).to be_successful expect(response).to have_http_status :ok expect(response).to have_turbo_stream action: "update", target: "backlogs-backlog-header-component-#{sprint.id}" + expect(assigns(:project)).to eq(project) + expect(assigns(:sprint)).to eq(sprint) + expect(assigns(:backlog)).to be_a(Backlog) end end @@ -113,6 +119,9 @@ expect(response).to have_http_status :ok expect(response).to have_turbo_stream action: "update", target: "backlogs-backlog-header-component-#{sprint.id}" expect(response).to have_turbo_stream action: "flash", target: "op-primer-flash-component" + expect(assigns(:project)).to eq(project) + expect(assigns(:sprint)).to eq(sprint) + expect(assigns(:backlog)).to be_a(Backlog) end end @@ -131,6 +140,9 @@ expect(response).to have_http_status :unprocessable_entity expect(response).to have_turbo_stream action: "update", target: "backlogs-backlog-header-component-#{sprint.id}" expect(response).to have_turbo_stream action: "flash", target: "op-primer-flash-component" + expect(assigns(:project)).to eq(project) + expect(assigns(:sprint)).to eq(sprint) + expect(assigns(:backlog)).to be_a(Backlog) end end end diff --git a/modules/backlogs/spec/controllers/rb_stories_controller_spec.rb b/modules/backlogs/spec/controllers/rb_stories_controller_spec.rb index ab73f04b839f..dd18b71a314c 100644 --- a/modules/backlogs/spec/controllers/rb_stories_controller_spec.rb +++ b/modules/backlogs/spec/controllers/rb_stories_controller_spec.rb @@ -65,6 +65,35 @@ expect(response).to have_turbo_stream action: "replace", target: "backlogs-backlog-component-#{sprint.id}" expect(response).to have_turbo_stream action: "replace", target: "backlogs-backlog-component-#{other_sprint.id}" expect(response).to have_turbo_stream action: "flash", target: "op-primer-flash-component" + expect(assigns(:project)).to eq(project) + expect(assigns(:sprint)).to eq(sprint) + end + + context "when service call fails" do + let(:service_result) { ServiceResult.failure(message: "Something went wrong") } + + before do + update_service = instance_double(Stories::UpdateService, call: service_result) + + allow(Stories::UpdateService) + .to receive(:new) + .and_return(update_service) + end + + it "renders an error flash", :aggregate_failures do + put :move, params: { + project_id: project.id, + sprint_id: sprint.id, + id: story.id, + target_id: other_sprint.id, + position: 1 + }, + format: :turbo_stream + + expect(response).to be_successful + expect(response).to have_turbo_stream action: "replace", target: "backlogs-backlog-component-#{sprint.id}" + expect(response).to have_turbo_stream action: "flash", target: "op-primer-flash-component" + end end end @@ -76,6 +105,29 @@ expect(response).to be_successful expect(response).to have_http_status :ok expect(response).to have_turbo_stream action: "replace", target: "backlogs-backlog-component-#{sprint.id}" + expect(assigns(:project)).to eq(project) + expect(assigns(:sprint)).to eq(sprint) + end + + context "when service call fails" do + let(:service_result) { ServiceResult.failure(message: "Something went wrong") } + + before do + update_service = instance_double(Stories::UpdateService, call: service_result) + + allow(Stories::UpdateService) + .to receive(:new) + .and_return(update_service) + end + + it "renders an error flash", :aggregate_failures do + post :reorder, params: { project_id: project.id, sprint_id: sprint.id, id: story.id, direction: "highest" }, + format: :turbo_stream + + expect(response).to be_successful + expect(response).to have_turbo_stream action: "replace", target: "backlogs-backlog-component-#{sprint.id}" + expect(response).to have_turbo_stream action: "flash", target: "op-primer-flash-component" + end end end end From b956deb8e992dc0fab6d926440dfe458944582fa Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 6 Feb 2026 16:46:15 -0300 Subject: [PATCH 53/57] DRY up `RbSprintsController` Extract `update_header_component_via_turbo_stream` helper to remove repeated backlog-build + turbo-stream-update block from `edit_name`, `show_name`, and `update` actions. Co-Authored-By: Claude Opus 4.6 --- .../app/controllers/rb_sprints_controller.rb | 55 +++++++------------ 1 file changed, 20 insertions(+), 35 deletions(-) diff --git a/modules/backlogs/app/controllers/rb_sprints_controller.rb b/modules/backlogs/app/controllers/rb_sprints_controller.rb index 089a85ad4adb..32d1d4402ae2 100644 --- a/modules/backlogs/app/controllers/rb_sprints_controller.rb +++ b/modules/backlogs/app/controllers/rb_sprints_controller.rb @@ -32,61 +32,34 @@ class RbSprintsController < RbApplicationController include OpTurbo::ComponentStream def edit_name - @backlog = Backlog.for(sprint: @sprint, project: @project) - - update_via_turbo_stream( - component: Backlogs::BacklogHeaderComponent.new( - backlog: @backlog, - project: @project, - state: :edit - ) - ) - + update_header_component_via_turbo_stream(state: :edit) respond_with_turbo_streams end def show_name - @backlog = Backlog.for(sprint: @sprint, project: @project) - - update_via_turbo_stream( - component: Backlogs::BacklogHeaderComponent.new( - backlog: @backlog, - project: @project, - state: :show - ) - ) - + update_header_component_via_turbo_stream(state: :show) respond_with_turbo_streams end def update call = Versions::UpdateService - .new(user: current_user, model: @sprint) - .call(attributes: sprint_params) + .new(user: current_user, model: @sprint) + .call(attributes: sprint_params) if call.success? status = 200 state = :show - @sprint = call.result - render_success_flash_message_via_turbo_stream(message: I18n.t(:notice_successful_update)) else status = 422 state = :edit - - render_error_flash_message_via_turbo_stream(message: I18n.t(:notice_unsuccessful_update_with_reason, reason: call.message)) + render_error_flash_message_via_turbo_stream( + message: I18n.t(:notice_unsuccessful_update_with_reason, reason: call.message) + ) end - @backlog = Backlog.for(sprint: @sprint, project: @project) - - update_via_turbo_stream( - component: Backlogs::BacklogHeaderComponent.new( - backlog: @backlog, - project: @project, - state: - ) - ) + update_header_component_via_turbo_stream(state:) respond_with_turbo_streams(status:) end @@ -103,6 +76,18 @@ def load_sprint_and_project private + def update_header_component_via_turbo_stream(state: :show) + @backlog = Backlog.for(sprint: @sprint, project: @project) + + update_via_turbo_stream( + component: Backlogs::BacklogHeaderComponent.new( + backlog: @backlog, + project: @project, + state: + ) + ) + end + def sprint_params params.expect(sprint: %i[name start_date effective_date]) end From 6edc43a2573998efe973b20fe182e92da3593208 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 6 Feb 2026 16:52:02 -0300 Subject: [PATCH 54/57] Fix `load_sprint_and_project` visibility Make it private (matching the parent class) and remove unnecessary guards since params are always present in the routes. Co-Authored-By: Claude Opus 4.6 --- .../app/controllers/rb_sprints_controller.rb | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/modules/backlogs/app/controllers/rb_sprints_controller.rb b/modules/backlogs/app/controllers/rb_sprints_controller.rb index 32d1d4402ae2..60abfe686dc5 100644 --- a/modules/backlogs/app/controllers/rb_sprints_controller.rb +++ b/modules/backlogs/app/controllers/rb_sprints_controller.rb @@ -63,17 +63,6 @@ def update respond_with_turbo_streams(status:) end - # Overwrite load_sprint_and_project to load the sprint from the :id instead of - # :sprint_id - def load_sprint_and_project - if params[:id] - @sprint = Sprint.visible.find(params[:id]) - @project = @sprint.project - end - # This overrides sprint's project if we set another project, say a subproject - @project = Project.visible.find(params[:project_id]) if params[:project_id] - end - private def update_header_component_via_turbo_stream(state: :show) @@ -88,6 +77,14 @@ def update_header_component_via_turbo_stream(state: :show) ) end + # Overrides load_sprint_and_project to load the sprint from :id instead of :sprint_id + def load_sprint_and_project + @sprint = Sprint.visible.find(params[:id]) + @project = @sprint.project + # This overrides sprint's project if we set another project, say a subproject + @project = Project.visible.find(params[:project_id]) + end + def sprint_params params.expect(sprint: %i[name start_date effective_date]) end From 1fbd1f1d9f072036dc5866ad480bc36ec5ee2790 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 6 Feb 2026 16:57:10 -0300 Subject: [PATCH 55/57] DRY up `RbStoriesController` Extract `load_story` before_action and `replace_backlog_component_via_turbo_stream` helper to remove repeated story lookup and backlog component rendering. Co-Authored-By: Claude Opus 4.6 --- .../app/controllers/rb_stories_controller.rb | 48 ++++++++++--------- .../controllers/rb_stories_controller_spec.rb | 4 ++ 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/modules/backlogs/app/controllers/rb_stories_controller.rb b/modules/backlogs/app/controllers/rb_stories_controller.rb index 240708202ecb..0b1048a2349c 100644 --- a/modules/backlogs/app/controllers/rb_stories_controller.rb +++ b/modules/backlogs/app/controllers/rb_stories_controller.rb @@ -31,18 +31,19 @@ class RbStoriesController < RbApplicationController include OpTurbo::ComponentStream + before_action :load_story + def move # rubocop:disable Metrics/AbcSize - story = Story.visible.find(params[:id]) - # The #move_after called in update service required reloading the story, hence - # it is required to memoize the previous version_id. - version_id_was = story.version_id + # The update service reloads the story internally (via #move_after), + # so we memoize the previous version_id before the call. + version_id_was = @story.version_id call = Stories::UpdateService - .new(user: current_user, story:) - .call( - attributes: { version_id: move_params[:target_id] }, - position: move_params[:position].to_i - ) + .new(user: current_user, story: @story) + .call( + attributes: { version_id: move_params[:target_id] }, + position: move_params[:position].to_i + ) unless call.success? render_error_flash_message_via_turbo_stream( @@ -50,28 +51,24 @@ def move # rubocop:disable Metrics/AbcSize ) end - backlog = Backlog.for(sprint: @sprint, project: @project) - replace_via_turbo_stream(component: Backlogs::BacklogComponent.new(backlog:, project: @project)) + replace_backlog_component_via_turbo_stream(sprint: @sprint) - if story.version_id != version_id_was - new_sprint = story.version.becomes(Sprint) - new_backlog = Backlog.for(sprint: new_sprint, project: @project) + if @story.version_id != version_id_was + new_sprint = @story.version.becomes(Sprint) render_success_flash_message_via_turbo_stream( message: I18n.t(:notice_successful_move, from: @sprint.name, to: new_sprint.name) ) - replace_via_turbo_stream(component: Backlogs::BacklogComponent.new(backlog: new_backlog, project: @project)) + replace_backlog_component_via_turbo_stream(sprint: new_sprint) end respond_with_turbo_streams end def reorder - story = Story.visible.find(params[:id]) - call = Stories::UpdateService - .new(user: current_user, story:) - .call(attributes: { move_to: reorder_param }) + .new(user: current_user, story: @story) + .call(attributes: { move_to: reorder_param }) unless call.success? render_error_flash_message_via_turbo_stream( @@ -79,15 +76,22 @@ def reorder ) end - backlog = Backlog.for(sprint: @sprint, project: @project) - - replace_via_turbo_stream(component: Backlogs::BacklogComponent.new(backlog:, project: @project)) + replace_backlog_component_via_turbo_stream(sprint: @sprint) respond_with_turbo_streams end private + def replace_backlog_component_via_turbo_stream(sprint:) + @backlog = Backlog.for(sprint:, project: @project) + replace_via_turbo_stream(component: Backlogs::BacklogComponent.new(backlog: @backlog, project: @project)) + end + + def load_story + @story = Story.visible.find(params[:id]) + end + def move_params params.require(%i[position target_id]) params.permit(:position, :target_id) diff --git a/modules/backlogs/spec/controllers/rb_stories_controller_spec.rb b/modules/backlogs/spec/controllers/rb_stories_controller_spec.rb index dd18b71a314c..90ce9199be1b 100644 --- a/modules/backlogs/spec/controllers/rb_stories_controller_spec.rb +++ b/modules/backlogs/spec/controllers/rb_stories_controller_spec.rb @@ -67,6 +67,8 @@ expect(response).to have_turbo_stream action: "flash", target: "op-primer-flash-component" expect(assigns(:project)).to eq(project) expect(assigns(:sprint)).to eq(sprint) + expect(assigns(:story)).to eq(story) + expect(assigns(:backlog)).to be_a(Backlog) end context "when service call fails" do @@ -107,6 +109,8 @@ expect(response).to have_turbo_stream action: "replace", target: "backlogs-backlog-component-#{sprint.id}" expect(assigns(:project)).to eq(project) expect(assigns(:sprint)).to eq(sprint) + expect(assigns(:story)).to eq(story) + expect(assigns(:backlog)).to be_a(Backlog) end context "when service call fails" do From fc285cdbc0857e5c77274781d90a3741407dd148 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 6 Feb 2026 17:18:05 -0300 Subject: [PATCH 56/57] Fix breadcrumb and misc "unfriendly" project links We should generate friendly URLs using the project identifier, not project id. While this inconsistency is pervasive in the OpenProject codebase, this commit aims to at least make links within the backlogs module a consistent format. --- .../backlogs/backlog_menu_component.html.erb | 2 +- .../backlogs/sprint_page_header_component.rb | 2 +- .../backlogs/app/controllers/rb_wikis_controller.rb | 4 ++-- .../app/views/projects/settings/backlogs/show.html.erb | 4 ++-- .../app/views/rb_master_backlogs/index.html.erb | 2 +- .../backlogs/app/views/shared/_server_variables.js.erb | 10 +++++----- .../backlogs/app/views/shared/not_configured.html.erb | 2 +- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/modules/backlogs/app/components/backlogs/backlog_menu_component.html.erb b/modules/backlogs/app/components/backlogs/backlog_menu_component.html.erb index 520958c0ed1c..78f56b0dba4c 100644 --- a/modules/backlogs/app/components/backlogs/backlog_menu_component.html.erb +++ b/modules/backlogs/app/components/backlogs/backlog_menu_component.html.erb @@ -107,7 +107,7 @@ See COPYRIGHT and LICENSE files for more details. menu.with_item( label: t(".action_menu.properties"), tag: :a, - href: edit_version_path(sprint, back_url: backlogs_project_backlogs_path(project), project_id: project.id) + href: edit_version_path(sprint, back_url: backlogs_project_backlogs_path(project), project_id: project) ) do |item| item.with_leading_visual_icon(icon: :gear) end diff --git a/modules/backlogs/app/components/backlogs/sprint_page_header_component.rb b/modules/backlogs/app/components/backlogs/sprint_page_header_component.rb index 9923f29101c1..60158874b0c2 100644 --- a/modules/backlogs/app/components/backlogs/sprint_page_header_component.rb +++ b/modules/backlogs/app/components/backlogs/sprint_page_header_component.rb @@ -45,7 +45,7 @@ def initialize(sprint:, project:) end def breadcrumb_items - [{ href: project_overview_path(@project.id), text: @project.name }, + [{ href: project_overview_path(@project), text: @project.name }, { href: backlogs_project_backlogs_path(@project), text: t(:label_backlogs) }, @sprint.name] end diff --git a/modules/backlogs/app/controllers/rb_wikis_controller.rb b/modules/backlogs/app/controllers/rb_wikis_controller.rb index edd31eef7c15..7684363cffac 100644 --- a/modules/backlogs/app/controllers/rb_wikis_controller.rb +++ b/modules/backlogs/app/controllers/rb_wikis_controller.rb @@ -34,10 +34,10 @@ class RbWikisController < RbApplicationController # # NOTE: The methods #show and #edit create a template page when called. def show - redirect_to controller: "/wiki", action: "index", project_id: @project.id, id: @sprint.wiki_page + redirect_to controller: "/wiki", action: "index", project_id: @project, id: @sprint.wiki_page end def edit - redirect_to controller: "/wiki", action: "edit", project_id: @project.id, id: @sprint.wiki_page + redirect_to controller: "/wiki", action: "edit", project_id: @project, id: @sprint.wiki_page end end diff --git a/modules/backlogs/app/views/projects/settings/backlogs/show.html.erb b/modules/backlogs/app/views/projects/settings/backlogs/show.html.erb index 49424558d343..bb32c332dbf0 100644 --- a/modules/backlogs/app/views/projects/settings/backlogs/show.html.erb +++ b/modules/backlogs/app/views/projects/settings/backlogs/show.html.erb @@ -31,8 +31,8 @@ See COPYRIGHT and LICENSE files for more details. render Primer::OpenProject::PageHeader.new do |header| header.with_title { t("backlogs.definition_of_done") } header.with_breadcrumbs( - [{ href: project_overview_path(@project.id), text: @project.name }, - { href: project_settings_general_path(@project.id), text: I18n.t(:label_project_settings) }, + [{ href: project_overview_path(@project), text: @project.name }, + { href: project_settings_general_path(@project), text: I18n.t(:label_project_settings) }, t("backlogs.definition_of_done")] ) end diff --git a/modules/backlogs/app/views/rb_master_backlogs/index.html.erb b/modules/backlogs/app/views/rb_master_backlogs/index.html.erb index 5f87d6dfd1b7..ee5f07a80e8b 100644 --- a/modules/backlogs/app/views/rb_master_backlogs/index.html.erb +++ b/modules/backlogs/app/views/rb_master_backlogs/index.html.erb @@ -38,7 +38,7 @@ See COPYRIGHT and LICENSE files for more details. render Primer::OpenProject::PageHeader.new do |header| header.with_title { t(:label_backlogs) } header.with_breadcrumbs( - [{ href: project_overview_path(@project.id), text: @project.name }, + [{ href: project_overview_path(@project), text: @project.name }, t(:label_backlogs)] ) end diff --git a/modules/backlogs/app/views/shared/_server_variables.js.erb b/modules/backlogs/app/views/shared/_server_variables.js.erb index 06d9390d5869..2c3199bbe297 100644 --- a/modules/backlogs/app/views/shared/_server_variables.js.erb +++ b/modules/backlogs/app/views/shared/_server_variables.js.erb @@ -38,13 +38,13 @@ RB.constants = { RB.urlFor = (function () { const routes = { - update_sprint: '<%= backlogs_project_sprint_path(project_id: @project.identifier, id: ":id") %>', + update_sprint: '<%= backlogs_project_sprint_path(project_id: @project, id: ":id") %>', - create_task: '<%= backlogs_project_sprint_tasks_path(project_id: @project.identifier, sprint_id: ":sprint_id") %>', - update_task: '<%= backlogs_project_sprint_task_path(project_id: @project.identifier, sprint_id: ":sprint_id", id: ":id") %>', + create_task: '<%= backlogs_project_sprint_tasks_path(project_id: @project, sprint_id: ":sprint_id") %>', + update_task: '<%= backlogs_project_sprint_task_path(project_id: @project, sprint_id: ":sprint_id", id: ":id") %>', - create_impediment: '<%= backlogs_project_sprint_impediments_path(project_id: @project.identifier, sprint_id: ":sprint_id") %>', - update_impediment: '<%= backlogs_project_sprint_impediment_path(project_id: @project.identifier, sprint_id: ":sprint_id", id: ":id") %>' + create_impediment: '<%= backlogs_project_sprint_impediments_path(project_id: @project, sprint_id: ":sprint_id") %>', + update_impediment: '<%= backlogs_project_sprint_impediment_path(project_id: @project, sprint_id: ":sprint_id", id: ":id") %>' }; return function (routeName, options) { diff --git a/modules/backlogs/app/views/shared/not_configured.html.erb b/modules/backlogs/app/views/shared/not_configured.html.erb index c1475db39168..03689ecfd3bd 100644 --- a/modules/backlogs/app/views/shared/not_configured.html.erb +++ b/modules/backlogs/app/views/shared/not_configured.html.erb @@ -34,7 +34,7 @@ See COPYRIGHT and LICENSE files for more details. render Primer::OpenProject::PageHeader.new do |header| header.with_title { t(:label_backlogs) } header.with_breadcrumbs( - [{ href: project_overview_path(@project.identifier), text: @project.name }, + [{ href: project_overview_path(@project), text: @project.name }, t(:label_backlogs)] ) end From 71508a2aa58e888609b027403bf76c5faf4fa21d Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 6 Feb 2026 20:12:47 -0300 Subject: [PATCH 57/57] Remove skipped feature, Flesh out Stories feature --- .../features/backlogs/change_status_spec.rb | 146 ------------------ .../spec/features/stories_in_backlog_spec.rb | 27 +++- .../backlogs/spec/support/pages/backlogs.rb | 138 +++++------------ 3 files changed, 57 insertions(+), 254 deletions(-) delete mode 100644 modules/backlogs/spec/features/backlogs/change_status_spec.rb diff --git a/modules/backlogs/spec/features/backlogs/change_status_spec.rb b/modules/backlogs/spec/features/backlogs/change_status_spec.rb deleted file mode 100644 index 9077e4230ad2..000000000000 --- a/modules/backlogs/spec/features/backlogs/change_status_spec.rb +++ /dev/null @@ -1,146 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) the OpenProject GmbH -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See COPYRIGHT and LICENSE files for more details. -#++ - -require "spec_helper" -require_relative "../../support/pages/backlogs" - -RSpec.describe.skip "Backlogs change status", :js do - shared_let(:story_type) { create(:type_feature) } - shared_let(:task_type) { create(:type_task) } - shared_let(:project) { create(:project, types: [story_type, task_type]) } - shared_let(:role) do - create(:project_role, - permissions: %i[edit_work_packages - change_work_package_status - view_master_backlog - view_work_packages]) - end - - shared_let(:user) do - create(:user, - member_with_roles: { project => role }) - end - shared_let(:sprint) do - create(:version, - project:, - name: "Sprint") - end - shared_let(:new_status) { create(:default_status, name: "New") } - shared_let(:in_progress_status) { create(:status, name: "In progress") } - shared_let(:default_priority) { create(:default_priority) } - shared_let(:story) do - create(:work_package, - type: story_type, - project:, - status: new_status, - priority: default_priority, - story_points: 3, - version: sprint) - end - shared_let(:workflow) do - create(:workflow, - old_status: new_status, - new_status: in_progress_status, - role:, - type: story_type) - end - - let(:backlogs_page) { Pages::Backlogs.new(project) } - - before do - allow(Setting) - .to receive(:plugin_openproject_backlogs) - .and_return("story_types" => [story_type.id.to_s], - "task_type" => task_type.id.to_s) - login_as(user) - end - - def expect_fields(enabled: [], disabled: [], none: []) - enabled.each do |field| - expect(page).to have_field(WorkPackage.human_attribute_name(field)) - end - - disabled.each do |field| - expect(page).to have_field(WorkPackage.human_attribute_name(field), disabled: true) - end - - none.each do |field| - expect(page).to have_no_field(WorkPackage.human_attribute_name(field), visible: :all) - end - end - - # this test acts as a control for the other tests in this file as it's easy to - # expect a field to not be present, and have the test still pass when the - # field is renamed. - context "when the user has edit_work_packages permission" do - it "is possible to edit any story field" do - backlogs_page.visit! - backlogs_page.enter_edit_story_mode(story) - - expect_fields(enabled: %i[type subject status story_points]) - - backlogs_page.alter_attributes_in_edit_story_mode(story, subject: "Hello subject") - backlogs_page.save_story_from_edit_mode(story) - - expect(story.reload.subject).to eq("Hello subject") - end - end - - context "when the user has only change_work_package_status permission" do - before do - RolePermission.where(permission: "edit_work_packages").delete_all - end - - it "is only possible to edit status field of stories" do - backlogs_page.visit! - backlogs_page.enter_edit_story_mode(story, text: story.status.name) - - expect_fields(enabled: %i[status], disabled: %i[type subject story_points]) - - backlogs_page.alter_attributes_in_edit_story_mode(story, status: in_progress_status.name) - backlogs_page.save_story_from_edit_mode(story) - - expect(story.reload.status).to eq(in_progress_status) - end - end - - context "when the user has neither change_work_package_status nor edit_work_packages permission" do - before do - RolePermission.where(permission: ["change_work_package_status", "edit_work_packages"]).delete_all - end - - it "is not possible to edit any story field" do - backlogs_page.visit! - backlogs_page.enter_edit_story_mode(story) - - expect_fields(none: %i[type subject status story_points]) - end - end -end diff --git a/modules/backlogs/spec/features/stories_in_backlog_spec.rb b/modules/backlogs/spec/features/stories_in_backlog_spec.rb index 5cc482d6688d..41e433b11910 100644 --- a/modules/backlogs/spec/features/stories_in_backlog_spec.rb +++ b/modules/backlogs/spec/features/stories_in_backlog_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -37,7 +39,7 @@ enabled_module_names: %w(work_package_tracking backlogs)) end let!(:story) { create(:type_feature) } - let!(:other_story) { create(:type) } + let!(:other_story) { create(:type, name: "Story") } let!(:task) { create(:type_task) } let!(:priority) { create(:default_priority) } let!(:default_status) { create(:status, is_default: true) } @@ -69,7 +71,7 @@ status: default_status, version: sprint, position: 1, - story_points: 10) + story_points: 8) end let!(:sprint_story1_task) do create(:work_package, @@ -92,7 +94,7 @@ status: default_status, version: sprint, position: 2, - story_points: 20) + story_points: 13) end let!(:backlog_story1) do create(:work_package, @@ -104,8 +106,8 @@ let!(:sprint) do create(:version, project:, - start_date: Date.today - 10.days, - effective_date: Date.today + 10.days, + start_date: Time.zone.today - 10.days, + effective_date: Time.zone.today + 10.days, version_settings_attributes: [{ project:, display: VersionSetting::DISPLAY_LEFT }]) end let!(:backlog) do @@ -127,7 +129,7 @@ type: story, status: default_status, version: sprint, - story_points: 10) + story_points: 5) end let(:backlogs_page) { Pages::Backlogs.new(project) } @@ -139,7 +141,7 @@ "task_type" => task.id.to_s) end - it "displays stories which are editable" do + it "displays stories which are editable via details view" do backlogs_page.visit! # All stories are visible in their sprint/backlog @@ -166,7 +168,16 @@ .expect_stories_in_order(sprint, sprint_story1, sprint_story2) # Velocity is calculated by summing up all story points in a sprint + backlogs_page.expect_velocity(sprint, 21) + + backlogs_page + .edit_story_in_details_view(sprint_story1, story_points: 5) + + backlogs_page.expect_velocity(sprint, 18) + backlogs_page - .expect_velocity(sprint, 30) + .edit_story_in_details_view(sprint_story2, subject: "Updated story", story_points: 3) + + backlogs_page.expect_velocity(sprint, 8) end end diff --git a/modules/backlogs/spec/support/pages/backlogs.rb b/modules/backlogs/spec/support/pages/backlogs.rb index c41a5cbc19ea..0674fe8398d7 100644 --- a/modules/backlogs/spec/support/pages/backlogs.rb +++ b/modules/backlogs/spec/support/pages/backlogs.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -37,42 +39,25 @@ def initialize(project) @project = project end - def enter_edit_story_mode(story, text: nil) - text ||= story.subject - within_story(story) do - find(:css, ".editable", text:).click - end - end - def enter_edit_backlog_mode(backlog) within_backlog_menu(backlog) do |menu| menu.find(:menuitem, "Edit sprint").click end end - def alter_attributes_in_edit_story_mode(story, attributes) - edit_proc = ->(*) do + def alter_attributes_in_details_view(story, **attributes) + within_details_view(story) do |details_view| attributes.each do |key, value| - field_name = WorkPackage.human_attribute_name(key) - case key - when :subject, :story_points - fill_in field_name, with: value.to_s - when :status, :type - select value.to_s, from: field_name - else - raise NotImplementedError - end - end - end + details_view + .edit_field(key.to_s.camelize(:lower)) + .update(value) # rubocop:disable Rails/SaveBang - if story - within_story(story, &edit_proc) - else - edit_proc.call + details_view.expect_and_dismiss_toaster message: "Successful update." + end end end - def alter_attributes_in_edit_backlog_mode(backlog, attributes) + def alter_attributes_in_edit_backlog_mode(backlog, **attributes) within_backlog(backlog) do attributes.each do |key, value| case key @@ -89,48 +74,26 @@ def alter_attributes_in_edit_backlog_mode(backlog, attributes) end end - def save_story_from_edit_mode(story) - save_proc = ->(*) do - field = find_field(disabled: false, match: :first) - keys = [:return] - keys << :return if field.tag_name == "select" # select field needs a second return key sent for some reason - field.send_keys(*keys) - - expect(page).to have_no_field(WorkPackage.human_attribute_name(:subject)) - end - - if story - within_story(story, &save_proc) - else - save_proc.call - end - wait_for_save_completion - end - def save_backlog_from_edit_mode(backlog) within_backlog(backlog) do find_field("Name").send_keys :return end end - def wait_for_save_completion - expect(page).to have_no_css(".ajax-indicator") - end - - def edit_backlog(backlog, attributes) + def edit_backlog(backlog, **attributes) enter_edit_backlog_mode(backlog) - alter_attributes_in_edit_backlog_mode(backlog, attributes) + alter_attributes_in_edit_backlog_mode(backlog, **attributes) save_backlog_from_edit_mode(backlog) end - def edit_story(story, attributes) - enter_edit_story_mode(story) + def edit_story_in_details_view(story, **attributes) + click_in_story_menu(story, "Open details view") - alter_attributes_in_edit_story_mode(story, attributes) + expect(page).to have_current_path details_backlogs_project_backlogs_path(story.project, story) - save_story_from_edit_mode(story) + alter_attributes_in_details_view(story, **attributes) end def click_in_backlog_menu(backlog, item_name) @@ -139,12 +102,17 @@ def click_in_backlog_menu(backlog, item_name) end end + def click_in_story_menu(story, item_name) + within_story_menu(story) do |menu| + menu.find(:menuitem, text: item_name).click + end + end + def drag_in_sprint(moved, target, before: true) moved_element = find(story_selector(moved)) target_element = find(story_selector(target)) drag_n_drop_element from: moved_element, to: target_element, offset_x: 0, offset_y: before ? -5 : +10 - wait_for_save_completion end def fold_backlog(backlog) @@ -177,40 +145,6 @@ def expect_story_not_in_sprint(story, sprint) end end - def expect_for_story(story, attributes) - within_story(story) do - attributes.each do |key, value| - case key - when :subject - expect(page) - .to have_css("div.subject", text: value) - when :status - expect(page) - .to have_css("div.status_id", text: value) - when :type - expect(page) - .to have_css("div.type_id", text: value) - else - raise NotImplementedError - end - end - end - end - - def expect_story_link_to_wp_page(story) - within_story(story) do - expect(page) - .to have_link(story.to_param, href: work_package_path(story)) - end - end - - def expect_status_options(story, statuses) - within_story(story) do - expect(all(".status_id option").map { |n| n.text.strip }) - .to match_array(statuses.map(&:name)) - end - end - def expect_velocity(backlog, velocity) within("#backlog_#{backlog.id} .velocity") do expect(page) @@ -228,18 +162,6 @@ def expect_stories_in_order(backlog, *stories) end end - def expect_in_backlog_menu(backlog, item_name) - within_backlog(backlog) do - find(".header .menu-trigger").click - - expect(page) - .to have_css(".header .backlog-menu .item", text: item_name) - - # Close it again for next test - find(".header .menu-trigger").click - end - end - def expect_and_dismiss_error(message) expect(page).to have_content message @@ -258,6 +180,22 @@ def within_backlog_menu(backlog, &) end end + def within_story_menu(story, &) + within_story(story) do + find(:button, accessible_name: "Story actions").click + + within(:menu, &) + end + end + + def within_details_view(story, &) + details_view = Pages::PrimerizedSplitWorkPackage.new(story) + details_view.expect_tab :overview + details_view.expect_subject + + yield details_view + end + private def within_story(story, &)