recurrence_ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +208 -0
- data/Rakefile +8 -0
- data/app/assets/config/recurrence_ui_manifest.js +1 -0
- data/app/assets/stylesheets/recurrence_ui/application.css +15 -0
- data/app/assets/stylesheets/recurrence_ui/components.css +295 -0
- data/app/assets/stylesheets/recurrence_ui/tokens.css +102 -0
- data/app/components/recurrence_ui/recurrence_picker_component.html.erb +234 -0
- data/app/components/recurrence_ui/recurrence_picker_component.rb +204 -0
- data/app/controllers/recurrence_ui/application_controller.rb +32 -0
- data/app/helpers/recurrence_ui/application_helper.rb +23 -0
- data/app/javascript/recurrence_ui/index.js +22 -0
- data/app/javascript/recurrence_ui/recurrence_ui_controller.js +954 -0
- data/app/jobs/recurrence_ui/application_job.rb +4 -0
- data/app/mailers/recurrence_ui/application_mailer.rb +6 -0
- data/app/models/concerns/recurrence_ui/has_recurrence.rb +351 -0
- data/app/models/recurrence_ui/application_record.rb +5 -0
- data/app/services/recurrence_ui/recurrence_formatter_service.rb +536 -0
- data/app/views/layouts/recurrence_ui/application.html.erb +15 -0
- data/app/views/recurrence_ui/_daily.html.erb +22 -0
- data/app/views/recurrence_ui/_exceptions.html.erb +50 -0
- data/app/views/recurrence_ui/_footer_actions.html.erb +17 -0
- data/app/views/recurrence_ui/_monthly_by_day_of_month.html.erb +47 -0
- data/app/views/recurrence_ui/_monthly_by_weekday.html.erb +55 -0
- data/app/views/recurrence_ui/_rdates.html.erb +50 -0
- data/app/views/recurrence_ui/_rule_card.html.erb +61 -0
- data/app/views/recurrence_ui/_summary_link.html.erb +25 -0
- data/app/views/recurrence_ui/_termination.html.erb +44 -0
- data/app/views/recurrence_ui/_weekly.html.erb +44 -0
- data/app/views/recurrence_ui/_yearly_by_day_of_month.html.erb +63 -0
- data/app/views/recurrence_ui/_yearly_by_weekday.html.erb +72 -0
- data/config/i18n-tasks.yml +212 -0
- data/config/importmap.rb +3 -0
- data/config/locales/recurrence_ui.be.yml +245 -0
- data/config/locales/recurrence_ui.en.yml +210 -0
- data/config/locales/recurrence_ui.ja.yml +201 -0
- data/config/locales/recurrence_ui.ru.yml +245 -0
- data/config/locales/recurrence_ui.uk.yml +245 -0
- data/config/routes.rb +5 -0
- data/lib/generators/recurrence_ui/views_generator.rb +26 -0
- data/lib/recurrence_ui/configuration.rb +116 -0
- data/lib/recurrence_ui/engine.rb +57 -0
- data/lib/recurrence_ui/version.rb +3 -0
- data/lib/recurrence_ui.rb +25 -0
- data/lib/tasks/recurrence_ui_tasks.rake +4 -0
- metadata +195 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
<%# app/components/recurrence_ui/recurrence_picker_component.html.erb %>
|
|
2
|
+
<% I18n.with_locale(locale) do %>
|
|
3
|
+
<div data-controller="<%= identifier %>"
|
|
4
|
+
data-<%= identifier %>-time-zone-value="<%= time_zone %>"
|
|
5
|
+
data-<%= identifier %>-translations-value="<%= t('recurrence_ui', locale: locale).to_json %>"
|
|
6
|
+
data-<%= identifier %>-locale-value="<%= locale %>"
|
|
7
|
+
class="rui-container"
|
|
8
|
+
data-rui-theme="<%= RecurrenceUi.configuration.theme_mode %>">
|
|
9
|
+
|
|
10
|
+
<%# 1. Секция полей времени, окончания и часового пояса %>
|
|
11
|
+
<div class="space-y-4 pb-4 border-b border-[var(--rui-border-color)]">
|
|
12
|
+
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
13
|
+
|
|
14
|
+
<!-- 1. Дата и время начала (всегда на одной строке, не тянутся) -->
|
|
15
|
+
<div>
|
|
16
|
+
<%= form.label :start_at, t('recurrence_ui.labels.start_at'), class: "rui-label block" %>
|
|
17
|
+
<div class="flex items-center space-x-2 mt-1">
|
|
18
|
+
<%= form.date_field :form_start_date,
|
|
19
|
+
value: initial_start_at&.to_date || Date.current,
|
|
20
|
+
class: "rui-input w-36 shrink-0",
|
|
21
|
+
data: { "#{identifier}_target": "startDate", action: "change->#{identifier}#calculateEndDateFromDuration" } %>
|
|
22
|
+
<%= form.time_field :form_start_time,
|
|
23
|
+
value: initial_start_at&.strftime('%H:%M') || '09:00',
|
|
24
|
+
class: "rui-input w-24 shrink-0",
|
|
25
|
+
data: { "#{identifier}_target": "startTime", action: "change->#{identifier}#calculateEndDateFromDuration" } %>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<!-- Часовой пояс -->
|
|
30
|
+
<div>
|
|
31
|
+
<%= form.label :time_zone, t('recurrence_ui.labels.time_zone'), class: "rui-label block" %>
|
|
32
|
+
<%= form.time_zone_select :time_zone,
|
|
33
|
+
ActiveSupport::TimeZone.all,
|
|
34
|
+
{ default: record.public_send(time_zone_field).presence || Time.zone.name },
|
|
35
|
+
{ class: "rui-input rui-select cursor-pointer w-full mt-1" } %>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<!-- Блок выбора: Длительность или Окончание -->
|
|
42
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 my-4">
|
|
43
|
+
|
|
44
|
+
<!-- Вариант 4.1: Длительность -->
|
|
45
|
+
<label class="flex items-start space-x-3 p-2 rounded-lg transition-colors cursor-pointer hover:bg-[var(--rui-hover-bg-soft)]">
|
|
46
|
+
<%= form.radio_button :form_duration_mode, :duration,
|
|
47
|
+
checked: (initial_form_duration_mode == :duration),
|
|
48
|
+
class: "mt-1 shrink-0",
|
|
49
|
+
data: { "#{identifier}_target": "modeDuration", action: "change->#{identifier}#toggleDurationEndMode" } %>
|
|
50
|
+
|
|
51
|
+
<div class="flex-1 min-w-0">
|
|
52
|
+
<span class="rui-label font-medium select-none">
|
|
53
|
+
<%= t('recurrence_ui.labels.duration') %>
|
|
54
|
+
</span>
|
|
55
|
+
|
|
56
|
+
<div class="flex flex-nowrap items-start gap-3 mt-2"
|
|
57
|
+
data-<%= identifier %>-target="durationContainer">
|
|
58
|
+
|
|
59
|
+
<%# Дни %>
|
|
60
|
+
<div class="flex flex-col items-center gap-1 shrink-0"
|
|
61
|
+
onclick="event.stopPropagation()">
|
|
62
|
+
<span class="rui-duration-unit">
|
|
63
|
+
<%= t('recurrence_ui.duration.form_days') %>
|
|
64
|
+
</span>
|
|
65
|
+
|
|
66
|
+
<%= form.number_field :form_duration_days,
|
|
67
|
+
value: initial_form_duration_days,
|
|
68
|
+
min: 0,
|
|
69
|
+
class: "rui-input w-16 text-center",
|
|
70
|
+
data: { "#{identifier}_target": "daysField", action: "change->#{identifier}#calculateEndDateFromDuration" } %>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<%# Часы %>
|
|
74
|
+
<div class="flex flex-col items-center gap-1 shrink-0"
|
|
75
|
+
onclick="event.stopPropagation()">
|
|
76
|
+
<span class="rui-duration-unit">
|
|
77
|
+
<%= t('recurrence_ui.duration.form_hours') %>
|
|
78
|
+
</span>
|
|
79
|
+
|
|
80
|
+
<%= form.number_field :form_duration_hours,
|
|
81
|
+
value: initial_form_duration_hours,
|
|
82
|
+
min: 0,
|
|
83
|
+
max: 23,
|
|
84
|
+
class: "rui-input w-16 text-center",
|
|
85
|
+
data: { "#{identifier}_target": "hoursField", action: "change->#{identifier}#calculateEndDateFromDuration" } %>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<%# Минуты %>
|
|
89
|
+
<div class="flex flex-col items-center gap-1 shrink-0"
|
|
90
|
+
onclick="event.stopPropagation()">
|
|
91
|
+
<span class="rui-duration-unit">
|
|
92
|
+
<%= t('recurrence_ui.duration.form_minutes') %>
|
|
93
|
+
</span>
|
|
94
|
+
|
|
95
|
+
<%= form.number_field :form_duration_minutes,
|
|
96
|
+
value: initial_form_duration_minutes,
|
|
97
|
+
min: 0,
|
|
98
|
+
max: 59,
|
|
99
|
+
class: "rui-input w-16 text-center",
|
|
100
|
+
data: { "#{identifier}_target": "minutesField", action: "change->#{identifier}#calculateEndDateFromDuration" } %>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
</label>
|
|
106
|
+
|
|
107
|
+
<!-- Вариант 4.2: Дата и время окончания -->
|
|
108
|
+
<label class="flex items-start space-x-3 p-2 rounded-lg transition-colors cursor-pointer hover:bg-[var(--rui-hover-bg-soft)]">
|
|
109
|
+
<%= form.radio_button :form_duration_mode, :end_at,
|
|
110
|
+
checked: (initial_form_duration_mode == :end_at),
|
|
111
|
+
class: "mt-1 shrink-0",
|
|
112
|
+
data: { "#{identifier}_target": "modeEnd", action: "change->#{identifier}#toggleDurationEndMode" } %>
|
|
113
|
+
|
|
114
|
+
<div class="flex-1 min-w-0">
|
|
115
|
+
<span class="rui-label"><%= t('recurrence_ui.labels.end_at') %></span>
|
|
116
|
+
|
|
117
|
+
<!-- Поля даты и времени окончания -->
|
|
118
|
+
<div class="flex items-center space-x-2 mt-1" onclick="event.stopPropagation()">
|
|
119
|
+
<%= form.date_field :form_end_date,
|
|
120
|
+
value: initial_end_at&.to_date || Date.current,
|
|
121
|
+
class: "rui-input w-36 shrink-0 disabled:opacity-50 disabled:--rui-input-disabled-bg disabled:cursor-not-allowed",
|
|
122
|
+
disabled: (initial_form_duration_mode != :end_at),
|
|
123
|
+
data: { "#{identifier}_target": "endDateField"} %>
|
|
124
|
+
<%= form.time_field :form_end_time,
|
|
125
|
+
value: initial_end_at&.strftime('%H:%M') || '09:00',
|
|
126
|
+
class: ["rui-input w-24 shrink-0 disabled:opacity-50 disabled:--rui-input-disabled-bg disabled:cursor-not-allowed", ("rui-input-error" if record.errors[:form_end_time].any?)],
|
|
127
|
+
disabled: (initial_form_duration_mode != :end_at),
|
|
128
|
+
data: { "#{identifier}_target": "endTimeField" } %>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
</label>
|
|
132
|
+
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<!-- 4. При показе саммари: только заголовок Повторение и саммари под ним -->
|
|
136
|
+
<div data-<%= identifier %>-target="displayMode" class="space-y-1">
|
|
137
|
+
<div class="rui-panel-header">
|
|
138
|
+
<span class="rui-label">
|
|
139
|
+
<%= t('recurrence_ui.labels.recurrence') %>
|
|
140
|
+
</span>
|
|
141
|
+
</div>
|
|
142
|
+
<div>
|
|
143
|
+
<%= render "recurrence_ui/summary_link", form: form, identifier: identifier, rec_ui: self %>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<!-- 5. Режим редактирования: заголовок Повторение + кнопка кода напротив, все поля под ними -->
|
|
148
|
+
<div data-<%= identifier %>-target="editMode" class="hidden space-y-3 pt-2">
|
|
149
|
+
|
|
150
|
+
<!-- Шапка с кнопкой кода справа напротив заголовка Повторение -->
|
|
151
|
+
<div class="rui-panel-header flex items-center justify-between">
|
|
152
|
+
<span class="rui-header-title">
|
|
153
|
+
<%= t('recurrence_ui.labels.recurrence') %>
|
|
154
|
+
</span>
|
|
155
|
+
<button type="button"
|
|
156
|
+
data-action="click-><%= identifier %>#toggleCodeEditor"
|
|
157
|
+
title="<%= t('recurrence_ui.actions.code_mode') %>"
|
|
158
|
+
class="rui-btn-secondary font-mono text-xs px-2 py-1">
|
|
159
|
+
</>
|
|
160
|
+
</button>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<%# Visual Rule Editor Section %>
|
|
164
|
+
<div data-<%= identifier %>-target="visualEditor" class="space-y-3">
|
|
165
|
+
<div class="space-y-1">
|
|
166
|
+
<%# Dynamic Rule Cards Container %>
|
|
167
|
+
<div data-<%= identifier %>-target="rulesContainer" class="space-y-2"></div>
|
|
168
|
+
|
|
169
|
+
<%# Add Rule Action Button %>
|
|
170
|
+
<div class="pt-1">
|
|
171
|
+
<button type="button"
|
|
172
|
+
data-action="click-><%= identifier %>#addRule"
|
|
173
|
+
class="rui-link-action">
|
|
174
|
+
<span class="font-bold">+</span> <%= t('recurrence_ui.actions.add_rule', default: 'Add rule') %>
|
|
175
|
+
</button>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
<%# Exceptions & Additional Dates Grid %>
|
|
180
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 pt-2 rui-divider">
|
|
181
|
+
<div data-<%= identifier %>-target="rdatesBlock" class="hidden">
|
|
182
|
+
<%= render "recurrence_ui/rdates", form: form, identifier: identifier, rec_ui: self, rdates: rules[:rdates] || [] %>
|
|
183
|
+
</div>
|
|
184
|
+
<div data-<%= identifier %>-target="exceptionsBlock" class="hidden">
|
|
185
|
+
<%= render "recurrence_ui/exceptions", form: form, identifier: identifier, rec_ui: self, exdates: rules[:exdates] || [] %>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
<%# Bottom Action Bar %>
|
|
190
|
+
<div class="pt-2">
|
|
191
|
+
<%= render "recurrence_ui/footer_actions", form: form, identifier: identifier, rec_ui: self %>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<%# Raw JSON / Code Editor Panel %>
|
|
196
|
+
<div data-<%= identifier %>-target="codeEditor" class="hidden space-y-3">
|
|
197
|
+
<div>
|
|
198
|
+
<label class="rui-label">
|
|
199
|
+
<%= t('recurrence_ui.labels.raw_json', default: 'Raw JSON') %>
|
|
200
|
+
</label>
|
|
201
|
+
<textarea data-<%= identifier %>-target="iceCubeField"
|
|
202
|
+
rows="6"
|
|
203
|
+
class="rui-textarea font-mono text-xs w-full"></textarea>
|
|
204
|
+
</div>
|
|
205
|
+
<div class="flex items-center justify-end space-x-2">
|
|
206
|
+
<button type="button"
|
|
207
|
+
data-action="click-><%= identifier %>#toggleCodeEditor"
|
|
208
|
+
class="rui-btn-secondary">
|
|
209
|
+
<%= t('recurrence_ui.actions.cancel') %>
|
|
210
|
+
</button>
|
|
211
|
+
<button type="button"
|
|
212
|
+
data-action="click-><%= identifier %>#applyCodeChanges"
|
|
213
|
+
class="rui-btn-primary">
|
|
214
|
+
<%= t('recurrence_ui.actions.apply_code') %>
|
|
215
|
+
</button>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
<%# Rule Card Template %>
|
|
222
|
+
<template data-<%= identifier %>-target="ruleCardTemplate">
|
|
223
|
+
<%= render "recurrence_ui/rule_card",
|
|
224
|
+
form: form,
|
|
225
|
+
identifier: identifier,
|
|
226
|
+
days_for_ui: days_for_ui,
|
|
227
|
+
rec_ui: self,
|
|
228
|
+
rule_options: rule_options %>
|
|
229
|
+
</template>
|
|
230
|
+
|
|
231
|
+
<%# Hidden Form Field %>
|
|
232
|
+
<%= form.hidden_field field_name, value: rules_json, data: { "#{identifier}_target": "hiddenField" } %>
|
|
233
|
+
</div>
|
|
234
|
+
<% end %>
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# app/components/recurrence_ui/recurrence_picker_component.rb
|
|
2
|
+
module RecurrenceUi
|
|
3
|
+
class RecurrencePickerComponent < ViewComponent::Base
|
|
4
|
+
include ActionView::Helpers::TranslationHelper
|
|
5
|
+
attr_reader :record, :i18n_prefix, :identifier, :locale, :time_zone, :week_start, :enabled_rule_types
|
|
6
|
+
|
|
7
|
+
def initialize(
|
|
8
|
+
record:,
|
|
9
|
+
form: nil,
|
|
10
|
+
i18n_prefix: 'recurrence_ui',
|
|
11
|
+
locale: nil,
|
|
12
|
+
time_zone: nil,
|
|
13
|
+
week_start: nil,
|
|
14
|
+
enabled_rule_types: nil
|
|
15
|
+
)
|
|
16
|
+
@raw_form = form
|
|
17
|
+
@record = record
|
|
18
|
+
@i18n_prefix = i18n_prefix
|
|
19
|
+
@identifier = "recurrence-ui"
|
|
20
|
+
|
|
21
|
+
# Resolve enabled_rule_types considering allowed values and order preservation
|
|
22
|
+
@enabled_rule_types = RecurrenceUi.config.resolved_enabled_rule_types(enabled_rule_types)
|
|
23
|
+
|
|
24
|
+
# Resolve environment configuration fallbacks
|
|
25
|
+
@locale = RecurrenceUi.config.resolved_locale(locale)
|
|
26
|
+
@time_zone = RecurrenceUi.config.resolved_time_zone(time_zone)
|
|
27
|
+
@week_start = RecurrenceUi.config.resolved_week_start(week_start)
|
|
28
|
+
|
|
29
|
+
log_configuration_details if Rails.env.development?
|
|
30
|
+
end
|
|
31
|
+
def field_name
|
|
32
|
+
@field_name ||= if record.class.respond_to?(:recurrence_rules_field)
|
|
33
|
+
record.class.recurrence_rules_field
|
|
34
|
+
else
|
|
35
|
+
RecurrenceUi.config.default_recurring_rules_field
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
def form
|
|
39
|
+
@form ||= @raw_form || ActionView::Helpers::FormBuilder.new(
|
|
40
|
+
record_name,
|
|
41
|
+
record,
|
|
42
|
+
helpers,
|
|
43
|
+
{}
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
# --- Динамические хелперы для вычисления начальных значений длительности ---
|
|
47
|
+
# Вычисляет количество дней длительности из записи
|
|
48
|
+
def initial_form_duration_days
|
|
49
|
+
dur = record.public_send(duration_field).to_i
|
|
50
|
+
return 0 if dur <= 0
|
|
51
|
+
(dur / 1.day).to_i
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Вычисляет оставшиеся часы длительности
|
|
55
|
+
def initial_form_duration_hours
|
|
56
|
+
dur = record.public_send(duration_field).to_i
|
|
57
|
+
return 1 if dur <= 0 # дефолтное значение, если пусто
|
|
58
|
+
((dur % 1.day) / 1.hour).to_i
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Вычисляет оставшиеся минуты длительности
|
|
62
|
+
def initial_form_duration_minutes
|
|
63
|
+
dur = record.public_send(duration_field).to_i
|
|
64
|
+
return 0 if dur <= 0
|
|
65
|
+
((dur % 1.hour) / 1.minute).to_i
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Определяет, какая радиокнопка должна быть выбрана при открытии формы (:duration или :end_at)
|
|
69
|
+
def initial_form_duration_mode
|
|
70
|
+
saved_mode = rules[:form_duration_mode]
|
|
71
|
+
return saved_mode.to_sym if saved_mode.present?
|
|
72
|
+
|
|
73
|
+
:duration # дефолт
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# --- Динамические хелперы для имён полей из модели ---
|
|
78
|
+
|
|
79
|
+
def start_time_field
|
|
80
|
+
@start_time_field ||= record.class.respond_to?(:recurrence_start_time_field) ? record.class.recurrence_start_time_field : :start_at
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def end_time_field
|
|
84
|
+
@end_time_field ||= record.class.respond_to?(:recurrence_end_time_field) ? record.class.recurrence_end_time_field : :end_at
|
|
85
|
+
end
|
|
86
|
+
# Возвращает начало события в часовом поясе самого события.
|
|
87
|
+
def initial_start_at
|
|
88
|
+
value = record.public_send(start_time_field)
|
|
89
|
+
return if value.blank?
|
|
90
|
+
|
|
91
|
+
value.in_time_zone(event_time_zone)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Возвращает окончание события в часовом поясе самого события.
|
|
95
|
+
def initial_end_at
|
|
96
|
+
value = record.public_send(end_time_field)
|
|
97
|
+
return if value.blank?
|
|
98
|
+
|
|
99
|
+
value.in_time_zone(event_time_zone)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Определяет часовой пояс события.
|
|
103
|
+
def event_time_zone
|
|
104
|
+
if record.respond_to?(:event_time_zone) && record.event_time_zone.present?
|
|
105
|
+
record.event_time_zone
|
|
106
|
+
else
|
|
107
|
+
zone_name = record.respond_to?(time_zone_field) ? record.public_send(time_zone_field) : nil
|
|
108
|
+
|
|
109
|
+
ActiveSupport::TimeZone[zone_name.presence] ||
|
|
110
|
+
Time.zone ||
|
|
111
|
+
ActiveSupport::TimeZone[Rails.application.config.time_zone]
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
def duration_field
|
|
115
|
+
@duration_field ||= record.class.respond_to?(:recurrence_duration_field) ? record.class.recurrence_duration_field : :duration
|
|
116
|
+
end
|
|
117
|
+
def time_zone_field
|
|
118
|
+
@time_zone_field ||= record.class.respond_to?(:recurrence_time_zone_field) ? record.class.recurrence_time_zone_field : :time_zone
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def rules
|
|
122
|
+
@rules ||= begin
|
|
123
|
+
raw = record.public_send(field_name)
|
|
124
|
+
parsed = raw.is_a?(String) ? (JSON.parse(raw) rescue {}) : raw
|
|
125
|
+
(parsed || {}).with_indifferent_access
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def rules_json
|
|
130
|
+
rules.to_json
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def current_rule_type
|
|
134
|
+
raw_type = rules[:rule_type].to_s.demodulize.underscore.gsub('_rule', '')
|
|
135
|
+
return 'never' if raw_type.blank? || raw_type == 'never'
|
|
136
|
+
|
|
137
|
+
if %w[monthly yearly].include?(raw_type)
|
|
138
|
+
sub_type = validations[:day_of_week].present? ? 'by_weekday' : 'by_day_of_month'
|
|
139
|
+
"#{raw_type}_#{sub_type}"
|
|
140
|
+
else
|
|
141
|
+
raw_type
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def validations = rules[:validations] || {}
|
|
146
|
+
def initial_interval = rules[:interval] || 1
|
|
147
|
+
def selected_weekly_days = (validations[:day] || []).map(&:to_i)
|
|
148
|
+
def selected_monthly_days = (validations[:day_of_month] || []).map(&:to_i)
|
|
149
|
+
def selected_nth_days = validations[:day_of_week] || {}
|
|
150
|
+
|
|
151
|
+
# Uses the pre-resolved @enabled_rule_types directly without re-resolving
|
|
152
|
+
def rule_options
|
|
153
|
+
enabled_rule_types.reject { |type| type == 'never' }.map do |type|
|
|
154
|
+
[I18n.t("#{i18n_prefix}.types.#{type}", locale: locale), type]
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def days_for_ui
|
|
159
|
+
shift = (week_start == 1) ? 1 : 0
|
|
160
|
+
(0..6).to_a.rotate(shift)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def months_for_ui
|
|
164
|
+
I18n.with_locale(locale) do
|
|
165
|
+
(1..12).map { |m| [I18n.t("#{i18n_prefix}.date.month_names", default: I18n.t("recurrence_ui.date.month_names"))[m], m] }
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def nth_weeks
|
|
170
|
+
I18n.with_locale(locale) do
|
|
171
|
+
[1, 2, 3, 4, 5, -1].map do |n|
|
|
172
|
+
label = if n == -1
|
|
173
|
+
I18n.t("#{i18n_prefix}.last", default: "last")
|
|
174
|
+
else
|
|
175
|
+
I18n.t("#{i18n_prefix}.nth_weeks.#{n}", default: "#{n}th")
|
|
176
|
+
end
|
|
177
|
+
[n, label]
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def recurrence_summary
|
|
183
|
+
rule_hash = rules
|
|
184
|
+
return "" if rule_hash.blank?
|
|
185
|
+
|
|
186
|
+
RecurrenceUi::RecurrenceFormatterService.new(
|
|
187
|
+
rule_hash,
|
|
188
|
+
locale: locale,
|
|
189
|
+
time_zone: event_time_zone,
|
|
190
|
+
week_start: week_start
|
|
191
|
+
).to_s
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
private
|
|
195
|
+
|
|
196
|
+
def record_name
|
|
197
|
+
record.respond_to?(:model_name) ? record.model_name.param_key : :record
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def log_configuration_details
|
|
201
|
+
Rails.logger.debug("[RecurrenceUi] Locale: #{locale} | TimeZone: #{time_zone} | WeekStart: #{week_start}")
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# app/controllers/recurrence_ui/application_controller.rb
|
|
2
|
+
module RecurrenceUi
|
|
3
|
+
class ApplicationController < ActionController::Base
|
|
4
|
+
protect_from_forgery with: :exception
|
|
5
|
+
|
|
6
|
+
around_action :with_recurrence_context
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def with_recurrence_context(&action)
|
|
11
|
+
# Resolve the locale, time zone, and week start based on the provided params or defaults
|
|
12
|
+
resolved_locale = RecurrenceUi.config.resolved_locale(params[:locale])
|
|
13
|
+
resolved_time_zone = RecurrenceUi.config.resolved_time_zone(params[:time_zone])
|
|
14
|
+
|
|
15
|
+
raw_week_start = RecurrenceUi.config.resolved_week_start(params[:week_start])
|
|
16
|
+
resolved_week_start = (raw_week_start == 1) ? :monday : :sunday
|
|
17
|
+
|
|
18
|
+
# Force the environment to the time of HTTP request execution
|
|
19
|
+
I18n.with_locale(resolved_locale) do
|
|
20
|
+
Time.use_zone(resolved_time_zone) do
|
|
21
|
+
old_week_start = Date.beginning_of_week
|
|
22
|
+
Date.beginning_of_week = resolved_week_start
|
|
23
|
+
begin
|
|
24
|
+
action.call
|
|
25
|
+
ensure
|
|
26
|
+
Date.beginning_of_week = old_week_start
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# app/helpers/recurrence_ui/application_helper.rb
|
|
2
|
+
module RecurrenceUi
|
|
3
|
+
module ApplicationHelper
|
|
4
|
+
def recurrence_ui_tag(options = {})
|
|
5
|
+
# Ensure that the locale is set to the current locale if not provided in options
|
|
6
|
+
# This is important for the translations to work correctly, especially when the locale is changed dynamically in the application.
|
|
7
|
+
# Priority: local options -> gem config -> defaults
|
|
8
|
+
locale = options[:locale] || RecurrenceUi.config.resolved_locale
|
|
9
|
+
time_zone = options[:time_zone] || RecurrenceUi.config.resolved_time_zone
|
|
10
|
+
week_start = options[:week_start] || RecurrenceUi.config.resolved_week_start
|
|
11
|
+
|
|
12
|
+
content_tag :div,
|
|
13
|
+
"",
|
|
14
|
+
data: {
|
|
15
|
+
controller: "recurrence-ui",
|
|
16
|
+
"recurrence-ui-locale-value": locale,
|
|
17
|
+
"recurrence-ui-time-zone-value": time_zone,
|
|
18
|
+
"recurrence-ui-week-start-value": week_start,
|
|
19
|
+
"recurrence-ui-translations-value": I18n.t("recurrence_ui", locale: locale).to_json
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import RecurrenceUiController from "./recurrence_ui_controller"
|
|
2
|
+
|
|
3
|
+
export function registerRecurrenceUiController(application) {
|
|
4
|
+
if (application) {
|
|
5
|
+
application.register("recurrence-ui", RecurrenceUiController)
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Automatically register the controller if Stimulus is already initialized
|
|
10
|
+
if (typeof window !== "undefined") {
|
|
11
|
+
if (window.Stimulus) {
|
|
12
|
+
window.Stimulus.register("recurrence-ui", RecurrenceUiController)
|
|
13
|
+
} else {
|
|
14
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
15
|
+
if (window.Stimulus) {
|
|
16
|
+
window.Stimulus.register("recurrence-ui", RecurrenceUiController)
|
|
17
|
+
}
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export { RecurrenceUiController }
|