Skip to content

Commit

Permalink
Recurring meetings (#15620)
Browse files Browse the repository at this point in the history
* Prepare basic recurring meeting setup

* Add proof of concept to create schedules

* Add feature flag

* Rework menu

* Pass request to meetings menu

This doesn't help, as the requested url is the turbo frame

* Add filter menu for recurring meetings

* Start primerized create form

* Add recurring meetings to dropdown and dialog

* Add schedule

* Add frequency/iterations form

* Add services

* Recurring meetings menu entry

* Fix setting project

* Template

* add bi-weekly

* Add template sideinfo

* Skip mails when adding participants

* exclude templated meetings

* Add header for templates

* Prevent deletion of meeting template

* Fix breadcrumb

* WIP Add show table and initialization

* Change icon for calendar

* remove unused template

* add template has_one

* meeting form

* Fix creation of recurring

* working edit

* specify end time

* Add header actions

* Add initial recurring meeting frequency label

* Add index page

* Add soft delete and restore for occurrences

* Refine implementation of scheduled meetings using skeletons

* Add template link

* Paginate

* Move init of meeting to recurring

* Add schedule in words

* Add show series action item

* Implement changed show page of occurrence

* Add interval, fewer frequencies

* Add interval to schedule

* Try to add non working days

* Update meetings index to display recurring occurrences correctly

* Update schedule in words

* Fix exception rule

* Fix occurrence for working days

* Hide interval when selecting working_days

* Document show-when-value-selected

* Set interval to 1 when changing to working_days

* Fix dialog

* Adapt to mobile table

* Update type filter

* Rename TypeFilter -> RecurringFilter

* Start date group

* Combined Filter component

* Updated menu

* Update label created by me

* Add validation on end_date > start_date

* Implement sorting through query

* Render all meeting series in the sidebar

* Select my meetings only when href passed

* WIP Add initial specs

* Fix date validation

* Use the correct date formatter in the subtitle

* Add spec for date validation

* Change label to View template

* Add edit series button

* Fix deletion of recurring meetings

* Correctly hide form for end_after options

* Update copy behaviour to never allow recurring copies

* Fix location value in form

* Fix meeting details form

* Add scheduled meetings table

* Replace destroy/restore with actual destroy

* Uniqueness

* Delete with schedule

* Switch to start_time

* Use start_time in schedule

Otherwise, we get time mismatches

* Add helper for time formatting to the current user

* fixup! Delete with schedule

* Move update_start_time to concern

* Delete cancelled meetings when changing schedule

* Allow passing dates directly

* Remove cancelled occurrences when changing schedules

* Limit count_rule until end_date

* Show changed start times for occurrences

* Indent case

* Fix destroy path to include project

* Fix past meetings

* Schedule first occurrence on creation

* Linting

* fixup! Linting

* Autoschedule job

* Add more schedule tests

* Fix project-based destroy

* Redirect to template, not show

* Don't cache user time zone in request store

* Fix forgotten invited where

* fixup! Linting

* Remove default meeting order

* Fix meetings index spec with new ordering

* Fix path

* Convert meeting tab to cuprite

* Fix expectation for meeting tab

* More robust selector on meeting tab

* Change global menu spec

* Add more CRUD specs

* Fix dates and times

* Allow cancellation of scheduled meetings

* Lint

* Reschedule init job when updating schedule

* Fix end date in the form

* Test cancellation

* Extract create spec

* Fix specs

* Add spec when lacking permissions

* Fix cancellation of scheduled meetings

* Render meetings with correct project link

* Remove unused disable

* Remove useless cop disable

* Add contract specs

* Add delete contract spec

* Revert "Add delete contract spec"

This reverts commit 27a517d.

* Add delete contract spec for meetings itself

* Fix spec

* Hide past cancelled occurrences

* Show different delete labels and messages

* Move to request spec

---------

Co-authored-by: Mir Bhatia <[email protected]>
  • Loading branch information
oliverguenther and mrmir authored Dec 4, 2024
1 parent b856ee2 commit 25fc7cd
Show file tree
Hide file tree
Showing 110 changed files with 4,734 additions and 402 deletions.
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,9 @@ gem "paper_trail", "~> 15.2.0"

gem "op-clamav-client", "~> 3.4", require: "clamav"

# Recurring meeting events definition
gem "ice_cube", "~> 0.17.0"

group :production do
# we use dalli as standard memcache client
# requires memcached 1.4+
Expand Down
1 change: 1 addition & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1270,6 +1270,7 @@ DEPENDENCIES
httpx
i18n-js (~> 4.2.3)
i18n-tasks (~> 1.0.13)
ice_cube (~> 0.17.0)
json_schemer (~> 2.3.0)
json_spec (~> 1.1.4)
ladle
Expand Down
4 changes: 2 additions & 2 deletions app/components/op_primer/border_box_table_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ class << self
#
# This results in the description columns to be hidden on mobile
def mobile_columns(*names)
return @mobile_columns || columns if names.empty?
return Array(@mobile_columns || columns) if names.empty?

@mobile_columns = names.map(&:to_sym)
end
Expand All @@ -54,7 +54,7 @@ def mobile_columns(*names)
#
# This results in the description columns to be hidden on mobile
def mobile_labels(*names)
return @mobile_labels if names.empty?
return Array(@mobile_labels) if names.empty?

@mobile_labels = names.map(&:to_sym)
end
Expand Down
11 changes: 8 additions & 3 deletions app/menus/submenu.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ class Submenu
include Rails.application.routes.url_helpers
attr_reader :view_type, :project, :params

def initialize(view_type:, project: nil, params: nil)
def initialize(view_type:, params:, project: nil)
@view_type = view_type
@project = project
@params = params
Expand Down Expand Up @@ -108,12 +108,13 @@ def query_params(id)
{ query_id: id }
end

def menu_item(title:, icon_key: nil, count: nil, show_enterprise_icon: false, query_params: {})
def menu_item(title:, icon_key: nil, count: nil, show_enterprise_icon: false,
query_params: {}, selected: selected?(query_params))
OpenProject::Menu::MenuItem.new(title:,
href: query_path(query_params),
icon: icon_map.fetch(icon_key, icon_key),
count:,
selected: selected?(query_params),
selected:,
favored: favored?(query_params),
show_enterprise_icon:)
end
Expand Down Expand Up @@ -144,4 +145,8 @@ def icon_map
def query_path(query_params)
raise NotImplementedError
end

def url_helpers
@url_helpers ||= OpenProject::StaticRouting::StaticRouter.new.url_helpers
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class IntegerListOptional < ::Queries::Filters::Strategies::Integer
def operator_map
super_value = super.dup
super_value["="] = ::Queries::Operators::EqualsOr
super_value["*"] = ::Queries::Operators::All

super_value
end
Expand Down
8 changes: 8 additions & 0 deletions app/models/setting/aliases.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,13 @@ def host_without_protocol
def optional_port_from_host_name
Setting.host_name&.split(":")&.[](1)
end

##
# Get the names of working days
# @return [Array<String>] the names of the working days
def working_day_names
weekdays = %i[monday tuesday wednesday thursday friday saturday sunday]
Setting.working_days.map { |day| weekdays[day - 1] }
end
end
end
2 changes: 2 additions & 0 deletions config/initializers/feature_decisions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@
OpenProject::FeatureDecisions.add :custom_field_of_type_hierarchy,
description: "Allows the use of the custom field type 'Hierarchy'."

OpenProject::FeatureDecisions.add :recurring_meetings,
description: "Differentiate between one-time and recurring meetings."
# TODO: Remove once the feature flag primerized_work_package_activities is removed altogether
OpenProject::FeatureDecisions.define_singleton_method(:primerized_work_package_activities_active?) do
Rails.env.production? ||
Expand Down
2 changes: 2 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1025,6 +1025,7 @@ en:
messages:
accepted: "must be accepted."
after: "must be after %{date}."
after_today: "must be in the future."
after_or_equal_to: "must be after or equal to %{date}."
before: "must be before %{date}."
before_or_equal_to: "must be before or equal to %{date}."
Expand Down Expand Up @@ -3102,6 +3103,7 @@ en:
notice_successful_connection: "Successful connection."
notice_successful_create: "Successful creation."
notice_successful_delete: "Successful deletion."
notice_successful_cancel: "Successful cancellation."
notice_successful_update: "Successful update."
notice_successful_update_custom_fields_added_to_project: |
Successful update. The custom fields of the activated types are automatically activated
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,18 @@ export default class OpShowWhenValueSelectedController extends ApplicationContro
}

private toggleDisabled(evt:InputEvent):void {
const value = (evt.target as HTMLInputElement).value;
this.effectTargets.forEach((el) => {
el.hidden = !(el.dataset.value === value);
const input = evt.target as HTMLInputElement;
const targetName = input.dataset.targetName;

this
.effectTargets
.filter((el) => targetName === el.dataset.targetName)
.forEach((el) => {
if (el.dataset.notValue) {
el.hidden = el.dataset.notValue === input.value;
} else {
el.hidden = !(el.dataset.value === input.value);
}
});
}
}
19 changes: 13 additions & 6 deletions lib_static/redmine/i18n.rb
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,7 @@ def link_regex
def format_time_as_date(time, format: nil)
return nil unless time

zone = User.current.time_zone
local_date = time.in_time_zone(zone).to_date
local_date = in_user_zone(time).to_date

if format
local_date.strftime(format)
Expand All @@ -148,21 +147,29 @@ def format_time_as_date(time, format: nil)
def format_time(time, include_date: true, format: Setting.time_format)
return nil unless time

zone = User.current.time_zone
local = time.in_time_zone(zone)
local = in_user_zone(time)

(include_date ? "#{format_date(local)} " : "") +
(format.blank? ? ::I18n.l(local, format: :time) : local.strftime(format))
end

##
# Formats the given time as a time string according to the +user+'s time zone
# @param time [Time] The time to format.
# @param user [User] The user to use for the time zone. Defaults to +User.current+.
# @return [Time] The time with the user's time zone applied.
def in_user_zone(time, user: User.current)
time.in_time_zone(user.time_zone)
end

# Returns the offset to UTC (with utc prepended) currently active
# in the current users time zone. DST is factored in so the offset can
# shift over the course of the year
def formatted_time_zone_offset
def formatted_time_zone_offset(user: User.current)
# Doing User.current.time_zone and format that will not take heed of DST as it has no notion
# of a current time.
# https://github.com/rails/rails/issues/7297
"UTC#{User.current.time_zone.now.formatted_offset}"
"UTC#{user.time_zone.now.formatted_offset}"
end

def day_name(day)
Expand Down
97 changes: 86 additions & 11 deletions lookbook/docs/patterns/02-forms.md.erb
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,81 @@ allowing to put some content in between:

This is the regular way of using Primer forms.



### Basic Interactivity

In many cases, you want to show or hide a certain field based on the value of another field. For this purpose, there is the Stimulus controller `show-when-value-selected`.

To use it, pass the controller to the form, and then use `cause` and `effect` targets together with a `data-value`.

Basic example:

```ruby
primer_form_with(
...
data: { controller: "show-when-value-selected" }
) do |f|
render_inline_form(f) do |my_form|
my_form.select_list(
name: "frequency",
label: "Choose frequency",
data: {
target_name: "frequency",
"show-when-value-selected-target": "cause"
}
) do |list|
list.option(label: "Foo", value: "foo")
list.option(label: "Bar", value: "bar")
list.option(label: "Third option", value: "other")
end

# Will be shown when "Foo" is selected
my_form.text_field(
name: :interval,
type: :number,
data: {
target_name: "frequency",
value: "foo"
"show-when-value-selected-target": "cause"
}
end

# Will be shown when "Bar" is selected
my_form.text_field(
name: :random,
type: :text,
data: {
target_name: "frequency",
value: "bar"
"show-when-value-selected-target": "effect"
}
end

# Will be shown when not "other" is selected
my_form.text_field(
name: :random,
type: :text,
hidden: @my_object.state == 'other'
data: {
target_name: "frequency",
not_value: "other"
"show-when-value-selected-target": "effect"
}
end
end
```

Important data inputs:

- `"show-when-value-selected-target": "cause"` marks a field as the emitter of a change. Whenever this input changes, the other fields visibility will be toggled.
- `"show-when-value-selected-target": "effect"` is the input that will get hidden or shown depending on the selected value
- `data: { value: 'XYZ'}` or `data-value="XYZ"` the value to be checked against the cause
- `data: { not_value: 'XYZ'}` or `data-not-value="XYZ"` the value to be checked against the cause, resulting in it being hidden if the selected value is NOT the given one.
- data: `{ target_name: "abc"}` or `data-target-name="abc"` allows you to define multiple sets of cause/effect handlers



### Accessing the form model

When defining a form, the model sometimes needs to be accessed, for instance to remove or add some fields depending on the state of the model.
Expand Down Expand Up @@ -308,22 +383,22 @@ administration pages.
So far, the following helpers are available:

* `text_field(name:, **options)`: renders a text field for the setting called
`name`, automatically setting the label, value, and disabled state from the
setting's attributes.
`name`, automatically setting the label, value, and disabled state from the
setting's attributes.

* `check_box(name:, **options)`: renders a checkbox for the setting called
`name`, automatically setting the label, checked state, and disabled state
from the setting's attributes.
`name`, automatically setting the label, checked state, and disabled state
from the setting's attributes.

* `radio_button_group(name:, values:, button_options: {}, **options)`: renders
a radio button group for the setting called `name` and radio button for each
element of `values`, automatically setting the label, checked state, html
caption, and disabled state from the setting's attributes.
a radio button group for the setting called `name` and radio button for each
element of `values`, automatically setting the label, checked state, html
caption, and disabled state from the setting's attributes.

* `submit`: renders a submit button with the label "Save" and the primary
scheme.
scheme.

* `form`: the form builder instance if you need to render some form elements
normally handled by the settings form decorator in another way than intended.
Any call to a method that is not defined on the settings form decorator will
be forwarded to this form builder instance so its usage is transparent.
normally handled by the settings form decorator in another way than intended.
Any call to a method that is not defined on the settings form decorator will
be forwarded to this form builder instance so its usage is transparent.
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
<%=
component_wrapper(data: wrapper_data_attributes) do
flex_layout(mb: 3) do |flex|
if @meeting.template?
flex.with_row(mb: 3) do
render Primer::Alpha::Banner.new(scheme: :default,
icon: :info,
dismiss_scheme: :none) do
t("recurring_meeting.template.banner_html",
link: link_to(@meeting.recurring_meeting.title,
recurring_meeting_path(@meeting.recurring_meeting)))
end
end
end
flex.with_row(classes: 'dragula-container', id: insert_target_modifier_id, data: { 'allowed-drop-type': 'section' }.merge(drop_target_config) ) do
first_and_last = [@meeting.sections.first, @meeting.sections.last]
render(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<%=
flex_layout do |flex|
flex.with_column do
render(Meetings::MeetingFilterButtonComponent.new(query: @query, project: @project))
end

flex.with_column(ml: 1) do
render(Primer::Alpha::SegmentedControl.new("aria-label": I18n.t(:label_meeting_date_time))) do |control|
control.with_item(tag: :a,
icon: :"arrow-right",
href: dynamic_path,
label: t(:label_upcoming_meetings_short),
title: t(:label_upcoming_meetings),
selected: upcoming_query?)
control.with_item(tag: :a,
icon: :history,
href: dynamic_path(upcoming: false),
label: t(:label_past_meetings_short),
title: t(:label_past_meetings),
selected: !upcoming_query?)
end
end
end
%>
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#-- 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 Meetings
class CombinedFilterComponent < ApplicationComponent
include ApplicationHelper
include OpTurbo::Streamable
include OpPrimer::ComponentHelpers
include Redmine::I18n

def initialize(query:, params:, project: nil)
super()

@query = query
@project = project
@params = params
end

def dynamic_path(upcoming: true)
polymorphic_path([@project, :meetings], current_params.merge(upcoming:))
end

def upcoming_query?
filter = @query.filters.find { |f| f.name == :time }
filter ? !filter.past? : true
end

def current_params
@current_params ||= params.slice(:filters, :page, :per_page).permit!
end
end
end
Loading

0 comments on commit 25fc7cd

Please sign in to comment.