diff --git a/FieldtypeRecurringDatesRule.info.json b/FieldtypeRecurringDatesRule.info.json new file mode 100644 index 0000000..abbf3a8 --- /dev/null +++ b/FieldtypeRecurringDatesRule.info.json @@ -0,0 +1,8 @@ +{ + "title": "Recurring Dates Rule", + "version": "0.0.2", + "summary": "Field that stores recurring events from RRule input.", + "icon": "calendar-o", + "installs": "InputfieldRecurringDatesRule", + "requires": "ProcessWire>=3.0.184, AlpineJS" +} diff --git a/FieldtypeRecurringDatesRule.module b/FieldtypeRecurringDatesRule.module new file mode 100644 index 0000000..347d6b1 --- /dev/null +++ b/FieldtypeRecurringDatesRule.module @@ -0,0 +1,245 @@ +config->versionUrls([ + $this->config->urls->siteModules . 'FieldtypeRecurringDates/InputfieldRecurringDatesRule.js?v=123', + $this->config->urls->siteModules . 'FieldtypeRecurringDates/InputfieldRecurringDatesRule.css' + ]); + $this->config->scripts->add($urls[0]); + $this->config->styles->add($urls[1]); + } + + public function ready(){ + } + + + public function getRruleOcurrences($value) + { + $rrule = new RRule($value); + return $rrule; + } + + /** + * Return the database schema that defines an Occurrence + * + * @param Field $field + * @return array + * + */ + public function getDatabaseSchema(Field $field) + { + $schema = parent::getDatabaseSchema($field); + $schema['data'] = 'JSON NULL'; + return $schema; + } + + /** + * Return the Inputfield used to collect input for a field of this type + * + * @param Page $page + * @param Field $field + * @return Inputfield|InputfieldRecurringDates + * + */ + public function getInputfield(Page $page, Field $field) + { + $inputfield = $this->modules->get('InputfieldRecurringDatesRule'); + /** @var InputfieldRecurringDates $inputfield */ + return $inputfield; + } + + /** + * Return a blank ready-to-populate value + * + * @param Page $page + * @param Field $field + * @return RecurringDate + * + */ + public function getBlankValue(Page $page, Field $field) + { + return new RecurringDateRule(); + } + + /** + * Given a value, make it clean and of the correct type for storage within a Page + * + * @param Page $page + * @param Field $field + * @param EventArray $value + * @return mixed + * + */ + public function sanitizeValue(Page $page, Field $field, $value) + { + // if given an invalid value, return a valid blank value + return $value; + } + + protected function isSettingsValue($value) + { + $count = 0; + $keys = self::EXTRAS_TABLE_COLS; + foreach ($keys as $key) { + if (isset($value[$key]) || array_key_exists($key, $value)) { + $count++; + } + } + + if ($count == 3) { + return true; + } else { + return false; + } + //if(array_key_exists('')) + } + + + + + /** + * Prepare a value for storage in the database + * + * @param Page $page + * @param Field $field + * @param mixed $value + * @return string + * + */ + public function ___sleepValue(Page $page, Field $field, $value) + { + + if (!$value instanceof RecurringDateRule) { + return json_encode(null); + } + //bd((string) $value); + $rrule = $value->get('rrule'); + + $rruleData = null; + if ($rrule instanceof RRule) { + $rruleData = $rrule->getRule(); + } + + $data = array( + 'rrule' => $rruleData, + 'settings' => $value->settings + ); + //bd($data); + + return json_encode($data); + } + + public function ___wakeupValue(Page $page, Field $field, $value) + { + + + $value = json_decode($value, true); + + $recurring_date = $this->getBlankValue($page, $field); + + if (empty($value)) { + return $recurring_date; + } + + $recurring_date->settings = $value['settings']; + //bd($recurring_date); + if (!empty($value['rrule'])) { + $rrule = new RRule($value['rrule']); + $recurring_date->set('rrule', $rrule); + } + //return null; + //bd($recurring_date); + return $recurring_date; + } + + /** + * + * @param Page $page + * @param Field $field + * @param array $value + * @return array + * + */ + + + /** + * Render a markup string of the value (optional for Fieldtypes to implement) + * + * @param Page $page + * @param Field $field + * @param EventArray $value + * @param string $property Property to render or omit for all + * @return string|MarkupFieldtype + * + */ + + public function ___markupValue(Page $page, Field $field, $value = null, $property = '') + { + return $value; + } + + public function getMonths() + { + $months = []; + for ($m = 1; $m <= 12; $m++) { + $months[] = date('F', mktime(0, 0, 0, $m, 1, date('Y'))); + } + return $months; + } + + public function getLoadQuery(Field $field, DatabaseQuerySelect $query) + { + + return parent::getLoadQuery($field, $query); + } + + /* + * TODO Implement properly. + * Known issue is blank value on selector throws an error. Normal date + * fields don't do this, so predictable behaviour like existing fields is desired + */ + public function isEmptyValue(Field $field, $value) { + return true; + } + + + + /** + * Get selector info + * + * @param Field $field + * @param array $data + * @return array + * + */ + public function ___getSelectorInfo(Field $field, array $data = array()) { + $a = parent::___getSelectorInfo($field, $data); + $a['operators'] = array('=', '!=', '>', '>=', '<', '<=', '%=', '^=', '=""', '!=""'); + return $a; + } + +} diff --git a/InputfieldRecurringDates.js b/InputfieldRecurringDates.js index ad27425..ad65265 100644 --- a/InputfieldRecurringDates.js +++ b/InputfieldRecurringDates.js @@ -100,8 +100,13 @@ document.addEventListener('alpine:init', (e) => { this.saveString(); }); - var json_rrule = this.$refs['main-input'].dataset.rrule; - var widget_settings = this.$refs['main-input'].dataset.settings; + var mainInput = this.$refs['main-input']; + if (!mainInput) { + mainInput = this.$el.querySelector('[x-ref="main-input"]'); + } + + var json_rrule = mainInput ? mainInput.dataset.rrule : null; + var widget_settings = mainInput ? mainInput.dataset.settings : null; if (widget_settings) { this.settings = JSON.parse(widget_settings); @@ -200,7 +205,6 @@ document.addEventListener('alpine:init', (e) => { delete rrule_copy.UNTIL rrule_copy.COUNT = this.hard_limit; } - console.log(rrule_copy); var json_string = JSON.stringify(rrule_copy); if (this.$refs['pre-debug'] !== undefined) { this.$refs['pre-debug'].innerText = JSON.stringify(rrule_copy, null, 2); diff --git a/InputfieldRecurringDatesRule.css b/InputfieldRecurringDatesRule.css new file mode 100644 index 0000000..39ccc1f --- /dev/null +++ b/InputfieldRecurringDatesRule.css @@ -0,0 +1,59 @@ +.InputfieldRecurringDatesRule [x-cloak] { display: none !important; } + +.InputfieldRecurringDatesRule .bymonthday-filter-wrapper{ + max-width: 280px; +} + +.InputfieldRecurringDatesRule .bymonthday-grid-item{ + width: calc(100% / 7); +} + +.InputfieldRecurringDatesRule .bymonthday-grid-item:nth-child(7n + 1) .bymonthday-wrapper{ + border-left:1px solid #efefef; +} +.InputfieldRecurringDatesRule .bymonthday-grid-item:nth-child(-n + 7) .bymonthday-wrapper{ + border-top:1px solid #efefef; +} + +.InputfieldRecurringDatesRule .bymonthday-wrapper{ + border-right:1px solid #efefef; + border-bottom:1px solid #efefef; + width:40px; + height:40px; + display:block; + position:relative; + font-size:14px; +} + +.InputfieldRecurringDatesRule .bymonthday-wrapper label{ + line-height:40px; + text-align: center; + display:block; + position:relative; +} +.InputfieldRecurringDatesRule .bymonthday-wrapper .bymonth-checkbox-background{ + position:absolute; + top:0; + bottom:0; + left:0; + right:0; +} + +.InputfieldRecurringDatesRule .bymonthday-wrapper input[type=checkbox]:checked + .bymonth-checkbox-background{ + background-color:rgb(88,88,88); + color:white; +} + +.InputfieldRecurringDatesRule .bymonthday-wrapper input[type=checkbox]{ + width:30px; + height:30px; + display:block; + position:absolute; + top:0; + bottom:0; + left:0; + border:0; + opacity:0; + z-index:1; +} + diff --git a/InputfieldRecurringDatesRule.js b/InputfieldRecurringDatesRule.js new file mode 100644 index 0000000..68b1506 --- /dev/null +++ b/InputfieldRecurringDatesRule.js @@ -0,0 +1,155 @@ + +document.addEventListener('alpine:init', (e) => { + Alpine.data('recurringDatesRuleInput', function () { + return { + inputfield: '', + _rrule: null, + rrule: { + DTSTART: "", + FREQ: "DAILY", + INTERVAL: 1, + COUNT: 0, + UNTIL: "", + BYDAY: [], + BYMONTH: [], + BYMONTHDAY: [] + }, + _settings: null, + settings: null, + hard_limit: null, + catalogues: { + filters: [ + {label: "Months", value: 'BYMONTH'}, + {label: "Days of the week", value: 'BYDAY'}, + {label: "Days of the month", value: 'BYMONTHDAY'}, + ], + daysOfWeek: [ + {name: 'Sunday', value: 'SU'}, + {name: 'Monday', value: 'MO'}, + {name: 'Tuesday', value: 'TU'}, + {name: 'Wednesday', value: 'WE'}, + {name: 'Thursday', value: 'TH'}, + {name: 'Friday', value: 'FR'}, + {name: 'Saturday', value: 'SA'}, + ], + }, + + init: function () { + this.inputfield = this.$el.dataset.inputfieldName; + this.pageId = parseInt(this.$el.dataset.pageId); + this.fieldId = parseInt(this.$el.dataset.fieldId) + this.hard_limit = parseInt(this.$el.dataset.hardLimit) + + this.$watch('rrule', (prop) => { + this.saveString(); + }); + + this.$watch('settings', (prop, oldValue) => { + if (!this.settings) return; // Don't process if settings is null + this._settings = JSON.stringify(this.settings); + //console.log(this.settings); + var self = this; + if (self.settings.filters) { + self.catalogues.filters.forEach(function (filter) { + var found = self.settings.filters.find(filter_setting => filter_setting === filter.value); + if (found === undefined) { + if (self.rrule[filter.value] !== undefined) { + self.rrule[filter.value] = []; + } + } + }); + } + + this.saveString(); + }); + + var mainInput = this.$el.querySelector("[data-main-input]"); + var json_rrule = mainInput ? mainInput.dataset.rrule : null; + var widget_settings = mainInput ? mainInput.dataset.settings : null; + // Always initialize settings - use parsed value if provided, otherwise use defaults + if (widget_settings) { + this.settings = JSON.parse(widget_settings); + } else { + // Initialize with default settings when empty - needed for form input + this.settings = { + limit_mode: "", + rrule: "", + filters: [] + }; + } + + if (json_rrule) { + this.rrule = JSON.parse(json_rrule); + } + }, + + + is_filtering: function (filter) { + if (this.rrule[filter] !== null || this.rrule[filter] !== undefined) { + if (this.rrule[filter].length) { + return true; + } + } + }, + + cloneObject: function (obj) { + // basic type deep copy + if (obj === null || obj === undefined || typeof obj !== 'object') { + return obj + } + // array deep copy + if (obj instanceof Array) { + var cloneA = []; + for (var i = 0; i < obj.length; ++i) { + cloneA[i] = this.cloneObject(obj[i]); + } + return cloneA; + } + // object deep copy + var cloneO = {}; + for (var i in obj) { + cloneO[i] = this.cloneObject(obj[i]); + } + return cloneO; + }, + + saveString: function () { + // Don't save if settings is not initialized + if (!this.settings) { + this._rrule = ""; + return; + } + + // Check if we have a valid DTSTART - if not, send empty value + if (!this.rrule || !this.rrule.DTSTART || this.rrule.DTSTART === "") { + this._rrule = ""; + return; + } + + var rrule_copy = this.cloneObject(this.rrule); + if (this.settings.limit_mode === "count") { + delete rrule_copy.UNTIL + } + if (this.settings.limit_mode === "until") { + delete rrule_copy.COUNT + } + if (this.settings.limit_mode === "never") { + delete rrule_copy.UNTIL + delete rrule_copy.COUNT + //rrule_copy.COUNT = this.hard_limit; + } + //console.log(rrule_copy); + this.save_value = { + rrule: rrule_copy, + settings: this.settings + } + var json_string = JSON.stringify(this.save_value); + /* if (this.$refs['pre-debug'] !== undefined) { + this.$refs['pre-debug'].innerText = JSON.stringify(rrule_copy, null, 2); + } */ + this._rrule = json_string; + } + } + }) +}); + diff --git a/InputfieldRecurringDatesRule.module b/InputfieldRecurringDatesRule.module new file mode 100644 index 0000000..f2d4c47 --- /dev/null +++ b/InputfieldRecurringDatesRule.module @@ -0,0 +1,192 @@ + 'Recurring Dates Rule', + 'version' => 002, + 'summary' => 'Field that lets you define a recurring date rule.', + 'icon' => 'calendar-o', + 'requires' => 'AlpineJS' + ); + } + + public function __construct() + { + parent::__construct(); + } + + public function init() + { + // Load Alpine.js in
+ $this->set('startDateInput', 'date'); + $this->modules->AlpineJS; + } + + public function ___render() + { + /** @var RecurringDateRule $recurring_dates */ + $recurring_dates = $this->value; + + // Handle empty/null value + if (!$recurring_dates) { + $recurring_dates = new RecurringDateRule(); + } + + $this->setAttribute('class', $this->getAttribute('class') . ' main-input uk-width-1-1'); + //$occurrences = $recurring_dates->occurrences; + + if($recurring_dates->rrule){ + $rrule_array = $recurring_dates->rrule->getRule(); + + if ($this->startDateInput == "datetime") { + $rrule_array['DTSTART'] = (new \DateTime($rrule_array['DTSTART']))->format('Y-m-d H:i:s'); + } else { + $rrule_array['DTSTART'] = (new \DateTime($rrule_array['DTSTART']))->format('Y-m-d'); + } + if ($recurring_dates->rrule) { + $rule_json = json_encode($rrule_array); + } + }else{ + $rule_json = null; + } + //bd($recurring_dates); + + $this->setAttribute('class', 'uk-input main-input'); + if ($rule_json) { + $this->setAttribute('value', [$rule_json]); + } + //$this->setAttribute('x-ref', 'main-input'); + $this->setAttribute('data-main-input', 'true'); + $this->setAttribute('x-model', '_rrule'); + $this->setAttribute('data-rrule', $rule_json); + $this->setAttribute('type', 'hidden'); + $this->setAttribute('data-settings', json_encode($recurring_dates->settings)); + + $out = ""; + $filePath = "{$this->config->paths->siteModules}FieldtypeRecurringDates/partials/RecurringDatesRuleMarkup.php"; + $alpineComponent = wireRenderFile($filePath, [ + 'fieldtype' => $this->hasFieldtype, + 'inputfield' => $this, + //'occurrences' => $occurrences, + 'inputfieldValue' => $recurring_dates + ]); + + $out .= $alpineComponent; + return $out; + } + + + public function ___renderValue() + { + $fieldtype = $this->hasFieldtype; + return $fieldtype->markupValue($this->hasPage, $this->hasField, $this->value); + } + + public function __getEventsUrl() + { + + } + + + public function ___processInput(WireInputData $input) + { + $name = $this->attr('name'); + $recurring_date_obj = new RecurringDateRule(); + $value = $input[$name]; + + // Handle empty value + if ($value === "" || $value === null) { + $this->val($recurring_date_obj); + $this->trackChange('value'); + return $this; + } + + $value = json_decode($value, true); + $settings = json_decode($input[$name . "_settings"], true); + + // Handle empty or invalid JSON, or missing rrule + if ($value === null || !isset($value['rrule']) || $value['rrule'] === null || empty($value['rrule'])) { + $this->val($recurring_date_obj); + $this->trackChange('value'); + return $this; + } + + try{ + $recurring_date_obj->rrule = new RRule($value['rrule']); + //bd($recurring_date_obj->rrule); + $recurring_date_obj->settings = new RecurringDateRuleSettings($settings); + }catch(\Exception $e){ + throw new WireException("Invalid RRule: " . $e->getMessage()); + } + + $this->val($recurring_date_obj); + $this->trackChange('value'); + return $this; + + } + + /** + * Get setting + * + * @param string $key + * @return mixed + * + */ + + public function getDateStartInputType() + { + switch ($this->startDateInput) { + case "datetime": + return "datetime-local"; + default: + return "date"; + } + } + + public function ___getConfigInputfields() + { + // Get the defaults and $inputfields wrapper we can add to + $inputfields = parent::___getConfigInputfields(); + // Add a new Inputfield to it + $f = $this->modules->get('InputfieldInteger'); + $f->attr('name', 'pageSize'); + $f->label = $this->_('Page size'); + $f->value = $this->pageSize; + $inputfields->add($f); + + /** @var InputfieldSelect $f */ + $f = $this->modules->get('InputfieldSelect'); + $f->attr('name', 'startDateInput'); + $f->label = $this->_("Input type for start date"); + $f->addOptions([ + 'date' => $this->_('Date'), + 'datetime' => $this->_('Both date and time')]); + //bd($this->startDateInput); + $f->val($this->getSetting('startDateInput')); + + $inputfields->add($f); + + /** @var InputfieldInteger $f */ + $f = $this->modules->get('InputfieldInteger'); + $f->attr('name', 'hardLimit'); + $f->label = $this->_("Hard limit on the 'Never' option of the interface. Otherwise, the RRule calculation would run into an infinite loop"); + $f->val($this->getSetting('hardLimit')); + + $inputfields->add($f); + + return $inputfields; + } + +} diff --git a/RecurringDateRule.php b/RecurringDateRule.php new file mode 100644 index 0000000..220208a --- /dev/null +++ b/RecurringDateRule.php @@ -0,0 +1,35 @@ +set('settings', new RecurringDateRuleSettings()); + $this->set('rrule', null); + parent::__construct(); + } + + public function getRule(){ + return $this->rrule; + } + + public function __toString(){ + $value = [ + 'settings' => $this->settings, + 'rrule' => $this->getRule() + ]; + //bd(string($value)); + return json_encode($value); + } +} diff --git a/RecurringDateRuleSettings.php b/RecurringDateRuleSettings.php new file mode 100644 index 0000000..9c93daf --- /dev/null +++ b/RecurringDateRuleSettings.php @@ -0,0 +1,34 @@ +limit_mode = $data['limit_mode']; + } + if (array_key_exists('rrule', $data)) { + $this->rrule = $data['rrule']; + } + if (array_key_exists('filters', $data)) { + $this->filters = $data['filters']; + } + } + } + + public function __toString() + { + $value = [ + 'limit_mode' => $this->limit_mode, + 'filters' => $this->filters, + 'rrule' => $this->rrule + ]; + return json_encode($value); + } +} diff --git a/partials/RecurringDatesRuleMarkup.php b/partials/RecurringDatesRuleMarkup.php new file mode 100644 index 0000000..7bde4d4 --- /dev/null +++ b/partials/RecurringDatesRuleMarkup.php @@ -0,0 +1,239 @@ + + +
+ getAttributesString() ?>> + + getAttribute('data-json-rrule'), ENT_QUOTES, "UTF-8") + ?> + + +
+ +
+
+
+ +
+
+
+ + +
+
+
+ +
+ +
+
+ +
Ends:
+ +
+
+ +
+ +
+
+ +
+ +
+ + +
+
+
+ +
+ + +
+
+
+
+ +
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+
+ + Only on: +
+ +
    + +
+
+ + + + + + + + + + + +
+
+
+
+
+ rrule ? $inputfieldValue->rrule->humanReadable() : "" ?> +
+
+
+