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,954 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static classes = [ "selected" ]
|
|
5
|
+
static values = {
|
|
6
|
+
locale: String,
|
|
7
|
+
timeZone: String,
|
|
8
|
+
weekStart: Number,
|
|
9
|
+
translations: Object
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
static targets = [
|
|
13
|
+
"hiddenField", "displayMode", "editMode", "summaryText",
|
|
14
|
+
"rulesContainer", "ruleCardTemplate", "ruleCard",
|
|
15
|
+
"exceptionsBlock", "exdatesContainer", "exdateInput", "addExdateBtn", "exdateTemplate",
|
|
16
|
+
"rdatesBlock", "rdatesContainer", "rdateInput", "addRdateBtn", "rdateTemplate",
|
|
17
|
+
|
|
18
|
+
"iceCubeField",
|
|
19
|
+
"visualEditor", // Блок с карточками и кнопками (Level 2)
|
|
20
|
+
"codeEditor", // Блок с textarea для JSON (Level 2 alt)
|
|
21
|
+
"cardHeader", // Текстовая заголовок-шапка карточки (для аккордеона Level 3)
|
|
22
|
+
"cardBody", // Раскрывающийся блок с полями карточки (Level 3)
|
|
23
|
+
"startDate",
|
|
24
|
+
"startTime",
|
|
25
|
+
"endDateField",
|
|
26
|
+
"endTimeField",
|
|
27
|
+
"modeDuration",
|
|
28
|
+
"modeEnd",
|
|
29
|
+
"daysField",
|
|
30
|
+
"hoursField",
|
|
31
|
+
"minutesField"
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
ruleSnapshot = null
|
|
35
|
+
|
|
36
|
+
connect() {
|
|
37
|
+
this.isInitializing = true
|
|
38
|
+
this.initializeState()
|
|
39
|
+
this.updateDebugField()
|
|
40
|
+
this.initializeDurationMode()
|
|
41
|
+
this.isInitializing = false
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ==========================================
|
|
45
|
+
// INITIALIZATION & STATE MANAGEMENT
|
|
46
|
+
// ==========================================
|
|
47
|
+
|
|
48
|
+
initializeState() {
|
|
49
|
+
const jsonString = this.hasHiddenFieldTarget ? this.hiddenFieldTarget.value : null
|
|
50
|
+
|
|
51
|
+
// Clear rules container if target exists
|
|
52
|
+
if (this.hasRulesContainerTarget) {
|
|
53
|
+
this.rulesContainerTarget.innerHTML = ""
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Process rules, exdates, and rdates if JSON string is present
|
|
57
|
+
if (jsonString && jsonString.length > 2) {
|
|
58
|
+
try {
|
|
59
|
+
const schedule = JSON.parse(jsonString)
|
|
60
|
+
const rules = schedule.rrules || (schedule.rule_type ? [schedule] : [])
|
|
61
|
+
|
|
62
|
+
if (rules.length > 0) {
|
|
63
|
+
rules.forEach(ruleHash => this._createRuleCardFromHash(ruleHash))
|
|
64
|
+
} else if (this.hasRuleCardTemplateTarget) {
|
|
65
|
+
this.addRule()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (this.hasExdatesContainerTarget && this.hasExdateTemplateTarget) {
|
|
69
|
+
this._fillDatesSection(this.exdatesContainerTarget, this.exdateTemplateTarget, schedule.exdates)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (this.hasRdatesContainerTarget && this.hasRdateTemplateTarget) {
|
|
73
|
+
this._fillDatesSection(this.rdatesContainerTarget, this.rdateTemplateTarget, schedule.rdates)
|
|
74
|
+
}
|
|
75
|
+
} catch (e) {
|
|
76
|
+
console.error("[RecurrenceUi] JSON Parsing error:", e)
|
|
77
|
+
if (this.hasRuleCardTemplateTarget) this.addRule()
|
|
78
|
+
}
|
|
79
|
+
} else if (this.hasRuleCardTemplateTarget) {
|
|
80
|
+
this.addRule()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
this.toggleExceptionsBlock()
|
|
84
|
+
this.toggleRdatesBlock()
|
|
85
|
+
this.syncButtonsUI()
|
|
86
|
+
|
|
87
|
+
// Update the summary text only if there is a target to display it in
|
|
88
|
+
if (this.hasSummaryTextTarget) {
|
|
89
|
+
this.updateSummary()
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Установка начального состояния полей на основе выбранной радиокнопки
|
|
93
|
+
initializeDurationMode() {
|
|
94
|
+
if (this.hasModeEndTarget) {
|
|
95
|
+
const isEndMode = this.modeEndTarget.checked
|
|
96
|
+
|
|
97
|
+
if (this.hasEndDateFieldTarget) this.endDateFieldTarget.disabled = !isEndMode
|
|
98
|
+
if (this.hasEndTimeFieldTarget) this.endTimeFieldTarget.disabled = !isEndMode
|
|
99
|
+
if (this.hasDaysFieldTarget) this.daysFieldTarget.disabled = isEndMode
|
|
100
|
+
if (this.hasHoursFieldTarget) this.hoursFieldTarget.disabled = isEndMode
|
|
101
|
+
if (this.hasMinutesFieldTarget) this.minutesFieldTarget.disabled = isEndMode
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// =========================================
|
|
105
|
+
//Переключение длительности и окончания одного события
|
|
106
|
+
// =========================================
|
|
107
|
+
|
|
108
|
+
// Переключение между режимом "Длительность" и "Точное окончание"
|
|
109
|
+
toggleDurationEndMode(event) {
|
|
110
|
+
const isEndMode = this.hasModeEndTarget && this.modeEndTarget.checked
|
|
111
|
+
|
|
112
|
+
// 1. Блокируем/разблокируем поля длительности
|
|
113
|
+
if (this.hasDaysFieldTarget) this.daysFieldTarget.disabled = isEndMode
|
|
114
|
+
if (this.hasHoursFieldTarget) this.hoursFieldTarget.disabled = isEndMode
|
|
115
|
+
if (this.hasMinutesFieldTarget) this.minutesFieldTarget.disabled = isEndMode
|
|
116
|
+
|
|
117
|
+
// 2. Блокируем/разблокируем поля точного окончания (дата и время)
|
|
118
|
+
if (this.hasEndDateFieldTarget) this.endDateFieldTarget.disabled = !isEndMode
|
|
119
|
+
if (this.hasEndTimeFieldTarget) this.endTimeFieldTarget.disabled = !isEndMode
|
|
120
|
+
|
|
121
|
+
// 3. Если выбрали режим окончания — сразу пересчитываем дату/время окончания на базе Старта и Длительности
|
|
122
|
+
if (isEndMode) {
|
|
123
|
+
this.calculateEndDateFromDuration()
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Метод пересчета даты и времени окончания на основе Старта + Длительность
|
|
128
|
+
calculateEndDateFromDuration() {
|
|
129
|
+
if (!this.hasStartDateTarget || !this.hasStartTimeTarget) return
|
|
130
|
+
if (!this.hasEndDateFieldTarget || !this.hasEndTimeFieldTarget) return
|
|
131
|
+
|
|
132
|
+
const startDateVal = this.startDateTarget.value
|
|
133
|
+
const startTimeVal = this.startTimeTarget.value || "09:00"
|
|
134
|
+
|
|
135
|
+
if (!startDateVal) return
|
|
136
|
+
|
|
137
|
+
// Собираем объект Date из даты и времени начала
|
|
138
|
+
const startDateTime = new Date(`${startDateVal}T${startTimeVal}`)
|
|
139
|
+
if (isNaN(startDateTime.getTime())) return
|
|
140
|
+
|
|
141
|
+
// Получаем значения длительности с защитой от пустых полей
|
|
142
|
+
const days = parseInt(this.hasDaysFieldTarget ? this.daysFieldTarget.value : 0) || 0
|
|
143
|
+
const hours = parseInt(this.hasHoursFieldTarget ? this.hoursFieldTarget.value : 0) || 0
|
|
144
|
+
const minutes = parseInt(this.hasMinutesFieldTarget ? this.minutesFieldTarget.value : 0) || 0
|
|
145
|
+
|
|
146
|
+
// Считаем общую длительность в миллисекундах
|
|
147
|
+
const totalMs = (days * 24 * 60 * 60 + hours * 60 * 60 + minutes * 60) * 1000
|
|
148
|
+
|
|
149
|
+
// Прибавляем к старту
|
|
150
|
+
const endDateTime = new Date(startDateTime.getTime() + totalMs)
|
|
151
|
+
|
|
152
|
+
// Форматируем обратно в YYYY-MM-DD для date_field
|
|
153
|
+
const year = endDateTime.getFullYear()
|
|
154
|
+
const month = String(endDateTime.getMonth() + 1).padStart(2, '0')
|
|
155
|
+
const day = String(endDateTime.getDate()).padStart(2, '0')
|
|
156
|
+
this.endDateFieldTarget.value = `${year}-${month}-${day}`
|
|
157
|
+
|
|
158
|
+
// Форматируем в HH:MM для time_field
|
|
159
|
+
const endHours = String(endDateTime.getHours()).padStart(2, '0')
|
|
160
|
+
const endMinutes = String(endDateTime.getMinutes()).padStart(2, '0')
|
|
161
|
+
this.endTimeFieldTarget.value = `${endHours}:${endMinutes}`
|
|
162
|
+
}
|
|
163
|
+
// Переключение U1 -> U2 (Раскрытие общего модуля)
|
|
164
|
+
// Toggle Main Panel Accordion Mode
|
|
165
|
+
toggleMainAccordion() {
|
|
166
|
+
if (!this.hasEditModeTarget) return
|
|
167
|
+
const isHidden = this.editModeTarget.classList.contains('hidden')
|
|
168
|
+
|
|
169
|
+
if (isHidden) {
|
|
170
|
+
this.editModeTarget.classList.remove('hidden')
|
|
171
|
+
} else {
|
|
172
|
+
this.editModeTarget.classList.add('hidden')
|
|
173
|
+
this.updateSummary() // Recalculate summary text on close
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Toggle Rule Item Accordion Mode
|
|
178
|
+
toggleCardAccordion(event) {
|
|
179
|
+
if (event) event.preventDefault()
|
|
180
|
+
|
|
181
|
+
// Ignore accordion toggle if delete button was clicked directly
|
|
182
|
+
if (event.target.closest('.rui-btn-remove-card')) return
|
|
183
|
+
|
|
184
|
+
const card = event.currentTarget.closest(`[data-${this.identifier}-target="ruleCard"]`)
|
|
185
|
+
if (!card) return
|
|
186
|
+
|
|
187
|
+
const body = card.querySelector(`[data-${this.identifier}-target="cardBody"]`)
|
|
188
|
+
if (body) {
|
|
189
|
+
const isHidden = body.classList.contains('hidden')
|
|
190
|
+
|
|
191
|
+
if (isHidden) {
|
|
192
|
+
body.classList.remove('hidden')
|
|
193
|
+
card.classList.add('is-open') // Add active state class
|
|
194
|
+
} else {
|
|
195
|
+
body.classList.add('hidden')
|
|
196
|
+
card.classList.remove('is-open') // Remove active state class
|
|
197
|
+
this.updateCardSummary(card) // Update rule text on collapse
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// 1. Открытие/закрытие панели кода
|
|
202
|
+
toggleCodeEditor(event) {
|
|
203
|
+
if (event) event.preventDefault()
|
|
204
|
+
|
|
205
|
+
const isCodeHidden = this.codeEditorTarget.classList.contains('hidden')
|
|
206
|
+
|
|
207
|
+
if (isCodeHidden) {
|
|
208
|
+
// Считываем текущий JSON из скрытого поля формы
|
|
209
|
+
const currentJson = this.hiddenFieldTarget.value || "{}"
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
// Форматируем JSON с отступами в 2 пробела для красивого отображения в textarea
|
|
213
|
+
const parsed = JSON.parse(currentJson)
|
|
214
|
+
this.iceCubeFieldTarget.value = JSON.stringify(parsed, null, 2)
|
|
215
|
+
} catch (e) {
|
|
216
|
+
// Если там был невалидный JSON, выводим как есть
|
|
217
|
+
this.iceCubeFieldTarget.value = currentJson
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Переключаем видимость блоков
|
|
221
|
+
this.visualEditorTarget.classList.add('hidden')
|
|
222
|
+
this.codeEditorTarget.classList.remove('hidden')
|
|
223
|
+
} else {
|
|
224
|
+
// Возврат назад без сохранения
|
|
225
|
+
this.codeEditorTarget.classList.add('hidden')
|
|
226
|
+
this.visualEditorTarget.classList.remove('hidden')
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// 2. Применение кода по кнопке "ОК"
|
|
231
|
+
applyCodeChanges(event) {
|
|
232
|
+
if (event) event.preventDefault()
|
|
233
|
+
|
|
234
|
+
const rawJson = this.iceCubeFieldTarget.value.trim()
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
// Валидируем JSON перед сохранением
|
|
238
|
+
if (rawJson.length > 0) {
|
|
239
|
+
JSON.parse(rawJson)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Сохраняем в основное скрытое поле
|
|
243
|
+
this.hiddenFieldTarget.value = rawJson.length > 0 ? rawJson : "{}"
|
|
244
|
+
|
|
245
|
+
// Переинициализируем visual UI (карточки правил, теги дат) под новый JSON
|
|
246
|
+
this.initializeState()
|
|
247
|
+
|
|
248
|
+
// Переключаем обратно в визуальный режим
|
|
249
|
+
this.codeEditorTarget.classList.add('hidden')
|
|
250
|
+
this.visualEditorTarget.classList.remove('hidden')
|
|
251
|
+
|
|
252
|
+
} catch (e) {
|
|
253
|
+
alert(this.t('errors.invalid_json', { default: 'Ошибка в синтаксисе JSON' }))
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
// ==========================================
|
|
261
|
+
// CARD ACTIONS (ADD / REMOVE / TOGGLE)
|
|
262
|
+
// ==========================================
|
|
263
|
+
|
|
264
|
+
addRule(event) {
|
|
265
|
+
if (event) event.preventDefault()
|
|
266
|
+
|
|
267
|
+
const content = this.ruleCardTemplateTarget.innerHTML
|
|
268
|
+
this.rulesContainerTarget.insertAdjacentHTML('beforeend', content)
|
|
269
|
+
|
|
270
|
+
const newCard = this.rulesContainerTarget.lastElementChild
|
|
271
|
+
const body = newCard.querySelector(`[data-${this.identifier}-target="cardBody"]`)
|
|
272
|
+
|
|
273
|
+
// Раскрываем тело новой карточки
|
|
274
|
+
if (body) body.classList.remove('hidden')
|
|
275
|
+
|
|
276
|
+
// Запускаем первичную синхронизацию полей новой карточки
|
|
277
|
+
const select = newCard.querySelector('[data-item="ruleType"]')
|
|
278
|
+
if (select) {
|
|
279
|
+
this.toggleCardFields({ currentTarget: select })
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
this.toggleExceptionsBlock()
|
|
283
|
+
this.toggleRdatesBlock()
|
|
284
|
+
|
|
285
|
+
this.updateCardSummary(newCard)
|
|
286
|
+
this.updateHiddenHash()
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
removeRule(e) {
|
|
293
|
+
e.preventDefault()
|
|
294
|
+
const card = e.target.closest(`[data-${this.identifier}-target="ruleCard"]`)
|
|
295
|
+
if (card) {
|
|
296
|
+
card.remove()
|
|
297
|
+
this.toggleExceptionsBlock()
|
|
298
|
+
this.toggleRdatesBlock()
|
|
299
|
+
this.updateHiddenHash()
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
toggleCardFields(e) {
|
|
304
|
+
const select = e.currentTarget
|
|
305
|
+
const card = select.closest(`[data-${this.identifier}-target="ruleCard"]`) || select.closest('[data-item="ruleCard"]')
|
|
306
|
+
if (!card) return
|
|
307
|
+
|
|
308
|
+
const selectedValue = select.value
|
|
309
|
+
const specificFields = card.querySelectorAll('[data-item="specificFields"]')
|
|
310
|
+
|
|
311
|
+
specificFields.forEach(el => {
|
|
312
|
+
const isCurrent = el.dataset.rule === selectedValue
|
|
313
|
+
el.classList.toggle('hidden', !isCurrent)
|
|
314
|
+
|
|
315
|
+
// Если выбрали тип с полем (например, daily) и интервал не задан, выставляем 1 по умолчанию
|
|
316
|
+
if (isCurrent) {
|
|
317
|
+
const intervalInput = el.querySelector('[data-item="interval"]')
|
|
318
|
+
if (intervalInput && (!intervalInput.value || parseInt(intervalInput.value) < 1)) {
|
|
319
|
+
intervalInput.value = 1
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
this.updateHiddenHash()
|
|
325
|
+
this.updateCardSummary(card)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
toggleDay(event) {
|
|
329
|
+
event.preventDefault()
|
|
330
|
+
const btn = event.currentTarget
|
|
331
|
+
btn.dataset.selected = (btn.dataset.selected !== 'true').toString()
|
|
332
|
+
this.syncButtonsUI()
|
|
333
|
+
this.updateHiddenHash()
|
|
334
|
+
|
|
335
|
+
const card = btn.closest(`[data-${this.identifier}-target="ruleCard"]`)
|
|
336
|
+
if (card) this.updateCardSummary(card)
|
|
337
|
+
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
toggleEndFields(event) {
|
|
341
|
+
const select = event.currentTarget
|
|
342
|
+
const card = select.closest(`[data-${this.identifier}-target="ruleCard"]`)
|
|
343
|
+
if (!card) return
|
|
344
|
+
|
|
345
|
+
const mode = select.value
|
|
346
|
+
const endDate = card.querySelector('[data-item="endDateInput"]')
|
|
347
|
+
const endCount = card.querySelector('[data-item="endCount"]')
|
|
348
|
+
|
|
349
|
+
if (endDate) endDate.classList.toggle('hidden', mode !== 'until')
|
|
350
|
+
if (endCount) {
|
|
351
|
+
endCount.classList.toggle('hidden', mode !== 'count')
|
|
352
|
+
if (mode === 'count') endCount.classList.add('flex')
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
this.updateHiddenHash()
|
|
356
|
+
this.updateCardSummary(card)
|
|
357
|
+
}
|
|
358
|
+
onCardInputChanged(event) {
|
|
359
|
+
const card = event.currentTarget.closest(`[data-${this.identifier}-target="ruleCard"]`)
|
|
360
|
+
this.updateHiddenHash()
|
|
361
|
+
if (card) this.updateCardSummary(card)
|
|
362
|
+
}
|
|
363
|
+
// ==========================================
|
|
364
|
+
// MODE SWITCHING & ACTIONS
|
|
365
|
+
// ==========================================
|
|
366
|
+
|
|
367
|
+
saveRule(event) {
|
|
368
|
+
if (event) event.preventDefault()
|
|
369
|
+
this.updateHiddenHash()
|
|
370
|
+
this.showDisplayMode()
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
editRule(event) {
|
|
374
|
+
if (event) event.preventDefault()
|
|
375
|
+
this.ruleSnapshot = this.hiddenFieldTarget.value
|
|
376
|
+
this.showEditMode()
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
cancelEdit(event) {
|
|
380
|
+
if (event) event.preventDefault()
|
|
381
|
+
this.hiddenFieldTarget.value = this.ruleSnapshot || ""
|
|
382
|
+
this.initializeState()
|
|
383
|
+
this.showDisplayMode()
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
showDisplayMode() {
|
|
387
|
+
if (this.hasDisplayModeTarget) this.displayModeTarget.classList.remove('hidden')
|
|
388
|
+
if (this.hasEditModeTarget) this.editModeTarget.classList.add('hidden')
|
|
389
|
+
this.updateSummary()
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
showEditMode() {
|
|
393
|
+
if (this.hasDisplayModeTarget) this.displayModeTarget.classList.add('hidden')
|
|
394
|
+
if (this.hasEditModeTarget) this.editModeTarget.classList.remove('hidden')
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ==========================================
|
|
398
|
+
// EXCEPTIONS (EXDATES) & ADDITIONS (RDATES)
|
|
399
|
+
// ==========================================
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
// 1. Активация/деактивация кнопки "+" при вводе даты
|
|
403
|
+
onDateInputChange(event) {
|
|
404
|
+
const input = event.target
|
|
405
|
+
const value = input.value.trim()
|
|
406
|
+
|
|
407
|
+
if (input === this.exdateInputTarget && this.hasAddExdateBtnTarget) {
|
|
408
|
+
this.addExdateBtnTarget.disabled = !value
|
|
409
|
+
} else if (input === this.rdateInputTarget && this.hasAddRdateBtnTarget) {
|
|
410
|
+
this.addRdateBtnTarget.disabled = !value
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// 2. Универсальный метод вставки из <template>
|
|
415
|
+
_addDateFromTemplate(inputTarget, containerTarget, templateTarget) {
|
|
416
|
+
if (!inputTarget || !containerTarget || !templateTarget) return
|
|
417
|
+
|
|
418
|
+
const dateValue = inputTarget.value.trim()
|
|
419
|
+
if (!dateValue) return
|
|
420
|
+
|
|
421
|
+
// Проверка на дубликаты
|
|
422
|
+
const existingDates = Array.from(containerTarget.children).map(
|
|
423
|
+
el => el.dataset.exdateValue || el.dataset.rdateValue || el.dataset.date
|
|
424
|
+
)
|
|
425
|
+
if (existingDates.includes(dateValue)) return
|
|
426
|
+
|
|
427
|
+
// Подставляем данные в template
|
|
428
|
+
const html = templateTarget.innerHTML
|
|
429
|
+
.replace(/VALUE/g, dateValue)
|
|
430
|
+
.replace(/LABEL/g, this._formatDate(dateValue))
|
|
431
|
+
|
|
432
|
+
containerTarget.insertAdjacentHTML('beforeend', html)
|
|
433
|
+
inputTarget.value = ''
|
|
434
|
+
this.updateHiddenHash()
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// 3. Добавление исключения (Exdate)
|
|
438
|
+
addExdate(event) {
|
|
439
|
+
if (event) event.preventDefault()
|
|
440
|
+
this._addDateFromTemplate(this.exdateInputTarget, this.exdatesContainerTarget, this.exdateTemplateTarget)
|
|
441
|
+
if (this.hasAddExdateBtnTarget) this.addExdateBtnTarget.disabled = true
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// 4. Добавление дополнительной даты (Rdate)
|
|
445
|
+
addRdate(event) {
|
|
446
|
+
if (event) event.preventDefault()
|
|
447
|
+
this._addDateFromTemplate(this.rdateInputTarget, this.rdatesContainerTarget, this.rdateTemplateTarget)
|
|
448
|
+
if (this.hasAddRdateBtnTarget) this.addRdateBtnTarget.disabled = true
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// 5. Заполнение секции при первичной загрузке из JSON
|
|
452
|
+
_fillDatesSection(container, template, datesArray) {
|
|
453
|
+
if (!container || !template || !datesArray) return
|
|
454
|
+
container.innerHTML = ""
|
|
455
|
+
|
|
456
|
+
datesArray.forEach(dateValue => {
|
|
457
|
+
const html = template.innerHTML
|
|
458
|
+
.replace(/VALUE/g, dateValue)
|
|
459
|
+
.replace(/LABEL/g, this._formatDate(dateValue))
|
|
460
|
+
container.insertAdjacentHTML('beforeend', html)
|
|
461
|
+
})
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
removeExdate(e) {
|
|
466
|
+
e.preventDefault()
|
|
467
|
+
const badge = e.currentTarget.closest('[data-exdate-value]')
|
|
468
|
+
console.log("Removing exdate badge:", badge)
|
|
469
|
+
if (badge) {
|
|
470
|
+
badge.remove()
|
|
471
|
+
this.updateHiddenHash()
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
removeRdate(e) {
|
|
476
|
+
e.preventDefault()
|
|
477
|
+
const badge = e.currentTarget.closest('[data-rdate-value]')
|
|
478
|
+
console.log("Removing rdate badge:", badge)
|
|
479
|
+
if (badge) {
|
|
480
|
+
badge.remove()
|
|
481
|
+
this.updateHiddenHash()
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
toggleExceptionsBlock() {
|
|
486
|
+
if (!this.hasExceptionsBlockTarget) return
|
|
487
|
+
// Всегда показываем блок исключений в режиме редактирования U2
|
|
488
|
+
this.exceptionsBlockTarget.classList.remove('hidden')
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
toggleRdatesBlock() {
|
|
492
|
+
if (!this.hasRdatesBlockTarget) return
|
|
493
|
+
// Всегда показываем блок доп. дат в режиме редактирования U2
|
|
494
|
+
this.rdatesBlockTarget.classList.remove('hidden')
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ==========================================
|
|
498
|
+
// HASH BUILDER & SUMMARY GENERATION
|
|
499
|
+
// ==========================================
|
|
500
|
+
|
|
501
|
+
updateHiddenHash() {
|
|
502
|
+
if (this.isInitializing) return
|
|
503
|
+
|
|
504
|
+
// 1. Фильтруем и собираем только валидные rrules (игнорируем тип 'never' или сбойные правила)
|
|
505
|
+
const rrules = this.ruleCardTargets
|
|
506
|
+
.map(card => this._buildSingleRuleHash(card))
|
|
507
|
+
.filter(Boolean)
|
|
508
|
+
|
|
509
|
+
// 2. Извлекаем списки дат
|
|
510
|
+
const exdates = this._getDatesArray(this.exdatesContainerTarget, 'exdateValue')
|
|
511
|
+
const rdates = this._getDatesArray(this.rdatesContainerTarget, 'rdateValue')
|
|
512
|
+
|
|
513
|
+
// 3. Формируем итоговый объект расписания
|
|
514
|
+
const schedule = {}
|
|
515
|
+
|
|
516
|
+
// Записываем rrules только если они есть
|
|
517
|
+
if (rrules.length > 0) {
|
|
518
|
+
schedule.rrules = rrules
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Записываем exdates только если они есть
|
|
522
|
+
if (exdates.length > 0) {
|
|
523
|
+
schedule.exdates = exdates
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Записываем rdates только если они есть
|
|
527
|
+
if (rdates.length > 0) {
|
|
528
|
+
schedule.rdates = rdates
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// 4. Записываем в hidden_field чистый JSON (если всё пусто — сохранится "{}" вместо пустых структур)
|
|
532
|
+
this.hiddenFieldTarget.value = JSON.stringify(schedule)
|
|
533
|
+
|
|
534
|
+
// 5. Обновляем текстовую сводку и отладчик
|
|
535
|
+
this.updateSummary()
|
|
536
|
+
this.updateDebugField()
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
updateCardSummary(card) {
|
|
541
|
+
if (!card) return
|
|
542
|
+
const summaryEl = card.querySelector('[data-item="cardSummary"]')
|
|
543
|
+
if (!summaryEl) return
|
|
544
|
+
// Create a summary text for the card based on its current state
|
|
545
|
+
const ruleHash = this._buildSingleRuleHash(card)
|
|
546
|
+
|
|
547
|
+
if (!ruleHash) {
|
|
548
|
+
summaryEl.textContent = this.t('labels.new_rule', { default: 'Новое правило' })
|
|
549
|
+
return
|
|
550
|
+
}
|
|
551
|
+
// Generate a human-readable summary for the rule with _buildRuleSummaryText
|
|
552
|
+
const text = this._buildRuleSummaryText(ruleHash)
|
|
553
|
+
summaryEl.textContent = text || this.t('labels.new_rule', { default: 'Новое правило' })
|
|
554
|
+
}
|
|
555
|
+
updateSummary() {
|
|
556
|
+
if (!this.hasSummaryTextTarget || !this.hasHiddenFieldTarget) return
|
|
557
|
+
|
|
558
|
+
let schedule = {}
|
|
559
|
+
try {
|
|
560
|
+
schedule = JSON.parse(this.hiddenFieldTarget.value || "{}")
|
|
561
|
+
} catch (e) {
|
|
562
|
+
schedule = {}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const rrules = schedule.rrules || []
|
|
566
|
+
let mainText = this.t('one_time')
|
|
567
|
+
|
|
568
|
+
if (rrules.length > 0) {
|
|
569
|
+
// Build human-readable summary for each rule
|
|
570
|
+
|
|
571
|
+
const ruleTexts = rrules.map(ruleHash => this._buildRuleSummaryText(ruleHash))
|
|
572
|
+
mainText = ruleTexts.join('; ')
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
let exceptionsHtml = ""
|
|
577
|
+
if (schedule.exdates && schedule.exdates.length > 0) {
|
|
578
|
+
const exceptLabel = this.t('labels.exceptions')
|
|
579
|
+
const formattedExdates = schedule.exdates
|
|
580
|
+
.sort((a, b) => new Date(a) - new Date(b))
|
|
581
|
+
.map(d => this._formatDate(d))
|
|
582
|
+
.join(', ')
|
|
583
|
+
|
|
584
|
+
exceptionsHtml = `<span class="rui-meta-info rui-meta-info--exceptions">${exceptLabel}: ${formattedExdates}</span>`
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
let rdatesHtml = ""
|
|
588
|
+
if (schedule.rdates && schedule.rdates.length > 0) {
|
|
589
|
+
const rdateLabel = this.t('labels.rdates')
|
|
590
|
+
const formattedRdates = schedule.rdates
|
|
591
|
+
.sort((a, b) => new Date(a) - new Date(b))
|
|
592
|
+
.map(d => this._formatDate(d))
|
|
593
|
+
.join(', ')
|
|
594
|
+
|
|
595
|
+
rdatesHtml = `<span class="rui-meta-info rui-meta-info--rdates">${rdateLabel}: ${formattedRdates}</span>`
|
|
596
|
+
}
|
|
597
|
+
let mainHtml = `<span class="rui-meta-info rui-meta-info--main">${mainText}</span>`
|
|
598
|
+
|
|
599
|
+
this.summaryTextTarget.innerHTML = mainHtml + exceptionsHtml + rdatesHtml
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Updates the debug textarea with the current JSON from the hidden field
|
|
603
|
+
updateDebugField() {
|
|
604
|
+
if (this.hasIceCubeFieldTarget) {
|
|
605
|
+
this.iceCubeFieldTarget.value = this.hiddenFieldTarget.value
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Allows editing the JSON directly in the debug textarea
|
|
610
|
+
updateHiddenFromDebug() {
|
|
611
|
+
if (!this.hasIceCubeFieldTarget) return
|
|
612
|
+
try {
|
|
613
|
+
JSON.parse(this.iceCubeFieldTarget.value)
|
|
614
|
+
this.hiddenFieldTarget.value = this.iceCubeFieldTarget.value
|
|
615
|
+
this.initializeState()
|
|
616
|
+
} catch (e) {
|
|
617
|
+
// If the JSON is invalid, we can choose to ignore or show an error. For now, we'll just ignore it.
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ==========================================
|
|
622
|
+
// HELPERS & PRIVATE METHODS
|
|
623
|
+
// ==========================================
|
|
624
|
+
|
|
625
|
+
_buildSingleRuleHash(card) {
|
|
626
|
+
const typeSelect = card.querySelector('[data-item="ruleType"]')
|
|
627
|
+
if (!typeSelect) return null
|
|
628
|
+
|
|
629
|
+
const ruleType = typeSelect.value
|
|
630
|
+
// Если выбрано "Без повторения", это правило не идет в массив rrules
|
|
631
|
+
if (ruleType === 'never' || !ruleType) return null
|
|
632
|
+
|
|
633
|
+
const container = card.querySelector(`[data-item="specificFields"][data-rule="${ruleType}"]`)
|
|
634
|
+
if (!container) return null
|
|
635
|
+
|
|
636
|
+
const baseType = ruleType.split('_')[0]
|
|
637
|
+
const iceCubeType = `IceCube::${baseType.charAt(0).toUpperCase()}${baseType.slice(1)}Rule`
|
|
638
|
+
|
|
639
|
+
let ruleHash = {
|
|
640
|
+
rule_type: iceCubeType,
|
|
641
|
+
interval: parseInt(container.querySelector('[data-item="interval"]')?.value) || 1,
|
|
642
|
+
validations: {}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (ruleType === 'weekly') {
|
|
646
|
+
const days = Array.from(container.querySelectorAll('[data-selected="true"]')).map(b => parseInt(b.dataset.value))
|
|
647
|
+
if (days.length > 0) ruleHash.validations.day = days
|
|
648
|
+
} else if (ruleType.endsWith('_day_of_month')) {
|
|
649
|
+
const days = Array.from(container.querySelectorAll('[data-selected="true"]')).map(b => parseInt(b.dataset.day))
|
|
650
|
+
if (days.length > 0) ruleHash.validations.day_of_month = days
|
|
651
|
+
} else if (ruleType.endsWith('_by_weekday')) {
|
|
652
|
+
const nthDays = {}
|
|
653
|
+
container.querySelectorAll('[data-selected="true"]').forEach(btn => {
|
|
654
|
+
const day = btn.dataset.day
|
|
655
|
+
const nth = parseInt(btn.dataset.nth)
|
|
656
|
+
if (!nthDays[day]) nthDays[day] = []
|
|
657
|
+
nthDays[day].push(nth)
|
|
658
|
+
})
|
|
659
|
+
if (Object.keys(nthDays).length > 0) ruleHash.validations.day_of_week = nthDays
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (ruleType.startsWith('yearly')) {
|
|
663
|
+
const month = parseInt(container.querySelector('[data-item="month"]')?.value)
|
|
664
|
+
if (month) ruleHash.validations.month_of_year = [month]
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Termination for THIS card
|
|
668
|
+
const endTypeSelect = card.querySelector('[data-item="endType"]')
|
|
669
|
+
if (endTypeSelect) {
|
|
670
|
+
const endType = endTypeSelect.value
|
|
671
|
+
if (endType === 'until') {
|
|
672
|
+
const endDateInput = card.querySelector('[data-item="endDateInput"]')
|
|
673
|
+
if (endDateInput && endDateInput.value) ruleHash.until = endDateInput.value + "T23:59:59Z"
|
|
674
|
+
} else if (endType === 'count') {
|
|
675
|
+
const endCountInput = card.querySelector('[data-item="endCountInput"]')
|
|
676
|
+
if (endCountInput && endCountInput.value) ruleHash.count = parseInt(endCountInput.value)
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (Object.keys(ruleHash.validations).length === 0) delete ruleHash.validations
|
|
681
|
+
return ruleHash
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
_createRuleCardFromHash(ruleHash) {
|
|
685
|
+
|
|
686
|
+
if (!this.hasRuleCardTemplateTarget) return
|
|
687
|
+
const content = this.ruleCardTemplateTarget.innerHTML
|
|
688
|
+
this.rulesContainerTarget.insertAdjacentHTML('beforeend', content)
|
|
689
|
+
|
|
690
|
+
const card = this.rulesContainerTarget.lastElementChild
|
|
691
|
+
const ruleType = this._determineRuleType(ruleHash)
|
|
692
|
+
|
|
693
|
+
const typeSelect = card.querySelector('[data-item="ruleType"]')
|
|
694
|
+
if (typeSelect) typeSelect.value = ruleType
|
|
695
|
+
|
|
696
|
+
this.toggleCardFields({ currentTarget: typeSelect })
|
|
697
|
+
|
|
698
|
+
const container = card.querySelector(`[data-item="specificFields"][data-rule="${ruleType}"]`)
|
|
699
|
+
if (container) {
|
|
700
|
+
const intervalInput = container.querySelector('[data-item="interval"]')
|
|
701
|
+
if (intervalInput && ruleHash.interval) intervalInput.value = ruleHash.interval
|
|
702
|
+
|
|
703
|
+
if (ruleType.startsWith('yearly')) {
|
|
704
|
+
const monthInput = container.querySelector('[data-item="month"]')
|
|
705
|
+
if (monthInput && ruleHash.validations?.month_of_year) {
|
|
706
|
+
monthInput.value = ruleHash.validations.month_of_year[0]
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
this._syncButtonsFromHash(container, ruleHash)
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
this._fillTerminationFieldsForCard(card, ruleHash)
|
|
714
|
+
// Card should be collapsed by default, so we don't show the body unless the user expands it
|
|
715
|
+
const body = card.querySelector(`[data-${this.identifier}-target="cardBody"]`)
|
|
716
|
+
if (body) {
|
|
717
|
+
body.classList.add('hidden')
|
|
718
|
+
}
|
|
719
|
+
// Update header summary text for the card based on JSON data
|
|
720
|
+
this.updateCardSummary(card)
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
_buildRuleSummaryText(hash) {
|
|
725
|
+
const type = this._determineRuleType(hash)
|
|
726
|
+
const interval = hash.interval || 1
|
|
727
|
+
const vals = hash.validations || {}
|
|
728
|
+
|
|
729
|
+
const intervalGender = (type === 'weekly') ? 'weekly_interval' : 'male'
|
|
730
|
+
const count_f = this.formatOrdinal(interval, intervalGender)
|
|
731
|
+
|
|
732
|
+
let termination = ""
|
|
733
|
+
if (hash.until) {
|
|
734
|
+
termination = this.t('until_label', { date: this._formatDate(hash.until.split('T')[0]) })
|
|
735
|
+
} else if (hash.count) {
|
|
736
|
+
termination = this.t('count_label', { count: hash.count })
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
let weekdays = ""
|
|
740
|
+
if (vals.day || vals.day_of_week) {
|
|
741
|
+
const listFormatter = new Intl.ListFormat(this.localeValue, { style: 'long', type: 'conjunction' })
|
|
742
|
+
|
|
743
|
+
if (type === 'weekly' && vals.day) {
|
|
744
|
+
const names = vals.day.map(i => this.translationsValue.weekdays?.day_names_plural?.[i])
|
|
745
|
+
weekdays = listFormatter.format(names.filter(Boolean))
|
|
746
|
+
} else if (vals.day_of_week) {
|
|
747
|
+
const parts = Object.keys(vals.day_of_week).map(dayKey => {
|
|
748
|
+
const dayIdx = parseInt(dayKey)
|
|
749
|
+
const gender = this.translationsValue.weekdays?.weekday_genders?.[dayIdx] || 'male'
|
|
750
|
+
|
|
751
|
+
const nths = vals.day_of_week[dayKey].map(n => {
|
|
752
|
+
const nStr = n.toString()
|
|
753
|
+
const genderSpecific = this.translationsValue.nth_names?.[gender]?.[nStr]
|
|
754
|
+
if (genderSpecific) return genderSpecific
|
|
755
|
+
|
|
756
|
+
const generalSpecific = this.translationsValue.nth_names?.[nStr]
|
|
757
|
+
if (generalSpecific) return generalSpecific
|
|
758
|
+
|
|
759
|
+
return this.formatOrdinal(n, gender)
|
|
760
|
+
})
|
|
761
|
+
|
|
762
|
+
return `${listFormatter.format(nths)} ${this.translationsValue.weekdays?.day_names?.[dayIdx]}`
|
|
763
|
+
})
|
|
764
|
+
weekdays = parts.join('; ')
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const month = (vals.month_of_year) ?
|
|
769
|
+
this.translationsValue.in_month_names_yearly?.[vals.month_of_year[0]] : ""
|
|
770
|
+
|
|
771
|
+
let days_of_month = ""
|
|
772
|
+
if (vals.day_of_month) {
|
|
773
|
+
const formattedDates = vals.day_of_month.map(d => this.formatOrdinal(d, 'day_of_month'))
|
|
774
|
+
const listFormatter = new Intl.ListFormat(this.localeValue, { style: 'long', type: 'conjunction' })
|
|
775
|
+
days_of_month = this.t('days_of_month_fmt', { list: listFormatter.format(formattedDates) })
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
return this.t(`summary.${type}`, {
|
|
779
|
+
count: interval,
|
|
780
|
+
count_f: count_f,
|
|
781
|
+
until: termination,
|
|
782
|
+
weekdays: weekdays,
|
|
783
|
+
month: month,
|
|
784
|
+
days_of_month: days_of_month
|
|
785
|
+
}).replace(/\s+/g, ' ').trim()
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
_determineRuleType(hash) {
|
|
789
|
+
if (!hash || !hash.rule_type) return 'never'
|
|
790
|
+
const base = hash.rule_type.replace('IceCube::', '').replace('Rule', '').toLowerCase()
|
|
791
|
+
|
|
792
|
+
if (base === 'monthly') return hash.validations?.day_of_week ? 'monthly_by_weekday' : 'monthly_by_day_of_month'
|
|
793
|
+
if (base === 'yearly') return hash.validations?.day_of_week ? 'yearly_by_weekday' : 'yearly_by_day_of_month'
|
|
794
|
+
return base
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
_syncButtonsFromHash(container, hash) {
|
|
798
|
+
container.querySelectorAll('[data-selected]').forEach(btn => btn.dataset.selected = "false")
|
|
799
|
+
const vals = hash.validations || {}
|
|
800
|
+
|
|
801
|
+
if (vals.day) {
|
|
802
|
+
vals.day.forEach(d => {
|
|
803
|
+
const btn = container.querySelector(`[data-value="${d}"]`)
|
|
804
|
+
if (btn) btn.dataset.selected = "true"
|
|
805
|
+
})
|
|
806
|
+
}
|
|
807
|
+
if (vals.day_of_month) {
|
|
808
|
+
vals.day_of_month.forEach(d => {
|
|
809
|
+
const btn = container.querySelector(`[data-day="${d}"]`)
|
|
810
|
+
if (btn) btn.dataset.selected = "true"
|
|
811
|
+
})
|
|
812
|
+
}
|
|
813
|
+
if (vals.day_of_week) {
|
|
814
|
+
Object.keys(vals.day_of_week).forEach(dayNum => {
|
|
815
|
+
vals.day_of_week[dayNum].forEach(nth => {
|
|
816
|
+
const btn = container.querySelector(`[data-day="${dayNum}"][data-nth="${nth}"]`)
|
|
817
|
+
if (btn) btn.dataset.selected = "true"
|
|
818
|
+
})
|
|
819
|
+
})
|
|
820
|
+
}
|
|
821
|
+
this.syncButtonsUI()
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
_fillTerminationFieldsForCard(card, hash) {
|
|
825
|
+
const endTypeSelect = card.querySelector('[data-item="endType"]')
|
|
826
|
+
if (!endTypeSelect) return
|
|
827
|
+
|
|
828
|
+
if (hash.until) {
|
|
829
|
+
endTypeSelect.value = 'until'
|
|
830
|
+
const input = card.querySelector('[data-item="endDateInput"]')
|
|
831
|
+
if (input) input.value = hash.until.split('T')[0]
|
|
832
|
+
} else if (hash.count) {
|
|
833
|
+
endTypeSelect.value = 'count'
|
|
834
|
+
const input = card.querySelector('[data-item="endCountInput"]')
|
|
835
|
+
if (input) input.value = hash.count
|
|
836
|
+
} else {
|
|
837
|
+
endTypeSelect.value = 'never'
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
this.toggleEndFields({ currentTarget: endTypeSelect })
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
_addDateToList(inputTarget, containerTarget, templateTarget) {
|
|
844
|
+
const dateValue = inputTarget.value
|
|
845
|
+
if (!dateValue) return
|
|
846
|
+
|
|
847
|
+
const existingDates = Array.from(containerTarget.querySelectorAll('[data-date-value]')).map(el => el.dataset.dateValue)
|
|
848
|
+
if (existingDates.includes(dateValue)) return
|
|
849
|
+
|
|
850
|
+
const content = templateTarget.innerHTML
|
|
851
|
+
.replace(/VALUE/g, dateValue)
|
|
852
|
+
.replace(/LABEL/g, this._formatDate(dateValue))
|
|
853
|
+
|
|
854
|
+
containerTarget.insertAdjacentHTML('beforeend', content)
|
|
855
|
+
inputTarget.value = ""
|
|
856
|
+
this.updateHiddenHash()
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
_fillDatesSection(container, template, datesArray) {
|
|
860
|
+
if (!container || !template || !datesArray) return
|
|
861
|
+
container.innerHTML = ""
|
|
862
|
+
datesArray.forEach(dateValue => {
|
|
863
|
+
const content = template.innerHTML
|
|
864
|
+
.replace(/VALUE/g, dateValue)
|
|
865
|
+
.replace(/LABEL/g, this._formatDate(dateValue))
|
|
866
|
+
container.insertAdjacentHTML('beforeend', content)
|
|
867
|
+
})
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
_getDatesArray(container, dataKey) {
|
|
871
|
+
if (!container) return []
|
|
872
|
+
return Array.from(container.children)
|
|
873
|
+
.map(el => el.dataset[dataKey] || el.getAttribute(`data-${dataKey.replace(/([A-Z])/g, '-$1').toLowerCase()}`))
|
|
874
|
+
.filter(Boolean)
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
syncButtonsUI() {
|
|
878
|
+
if (!this.hasSelectedClasses) return
|
|
879
|
+
this.element.querySelectorAll('[data-selected]').forEach(btn => btn.classList.remove(...this.selectedClasses))
|
|
880
|
+
this.element.querySelectorAll('[data-selected="true"]').forEach(btn => btn.classList.add(...this.selectedClasses))
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
t(path, params = {}) {
|
|
884
|
+
let result = path.split('.').reduce((o, i) => o ? o[i] : null, this.translationsValue)
|
|
885
|
+
if (!result) return path
|
|
886
|
+
|
|
887
|
+
const count = params.count ?? params.interval
|
|
888
|
+
|
|
889
|
+
if (typeof count === 'number' && typeof result === 'object') {
|
|
890
|
+
if (count === 1 && result['one']) {
|
|
891
|
+
result = result['one']
|
|
892
|
+
} else {
|
|
893
|
+
const locale = this.localeValue || 'en'
|
|
894
|
+
const pr = new Intl.PluralRules(locale, { type: 'cardinal' })
|
|
895
|
+
const rule = pr.select(count)
|
|
896
|
+
result = result[rule] || result['other'] || result['one'] || path
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
if (typeof result !== 'string') return path
|
|
901
|
+
|
|
902
|
+
Object.keys(params).forEach(key => {
|
|
903
|
+
result = result.replace(`%{${key}}`, params[key])
|
|
904
|
+
})
|
|
905
|
+
|
|
906
|
+
return result
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
formatOrdinal(number, gender = 'male') {
|
|
910
|
+
const locale = this.localeValue || 'en'
|
|
911
|
+
const ruleType = locale.startsWith('en') ? 'ordinal' : 'cardinal'
|
|
912
|
+
const pr = new Intl.PluralRules(locale, { type: ruleType })
|
|
913
|
+
const rule = pr.select(number)
|
|
914
|
+
|
|
915
|
+
const ordinals = this.translationsValue.ordinals || {}
|
|
916
|
+
let template = null
|
|
917
|
+
|
|
918
|
+
if (ordinals[gender] && typeof ordinals[gender] === 'object') {
|
|
919
|
+
template = ordinals[gender][rule] || ordinals[gender]['other']
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
if (!template) {
|
|
923
|
+
template = ordinals[rule] || ordinals['other']
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
if (typeof template !== 'string') return number.toString()
|
|
927
|
+
|
|
928
|
+
return template.replace('%{count}', number)
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
_formatDate(dateString) {
|
|
932
|
+
if (!dateString) return ''
|
|
933
|
+
|
|
934
|
+
const [year, month, day] = dateString.split('T')[0].split('-').map(Number)
|
|
935
|
+
const date = new Date(Date.UTC(year, month - 1, day))
|
|
936
|
+
|
|
937
|
+
const y = date.getUTCFullYear()
|
|
938
|
+
const m = date.getUTCMonth() + 1
|
|
939
|
+
const d = date.getUTCDate()
|
|
940
|
+
|
|
941
|
+
const dateConfig = this.translationsValue.date || {}
|
|
942
|
+
const formatPattern = dateConfig.formats?.recurrence_date || "%Y-%m-%d"
|
|
943
|
+
const monthNames = dateConfig.month_names || []
|
|
944
|
+
const monthName = monthNames[m] || ""
|
|
945
|
+
|
|
946
|
+
return formatPattern
|
|
947
|
+
.replace(/%Y/g, y)
|
|
948
|
+
.replace(/%-m/g, m)
|
|
949
|
+
.replace(/%m/g, String(m).padStart(2, '0'))
|
|
950
|
+
.replace(/%-d/g, d)
|
|
951
|
+
.replace(/%d/g, String(d).padStart(2, '0'))
|
|
952
|
+
.replace(/%B/g, monthName)
|
|
953
|
+
}
|
|
954
|
+
}
|