formstrap 0.4.0 → 0.4.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c4dc719e94a4901d00b1b1cfce0d9c6a36653ec1bf706b56d689092db97b278d
4
- data.tar.gz: 902aab69bbe1517164fd888d55a4a881522a4e313cbe77b2596486711912ca1c
3
+ metadata.gz: ee49a6e7a9f068d3403b4ebfd46c19cf07b6c293e8893f1e561e8ae20ed0af77
4
+ data.tar.gz: f85517d919e6c9360ac31c7f1aa7b8f2ff3705ea31a2a6a76f0b72ae82f58c30
5
5
  SHA512:
6
- metadata.gz: 2691612aa8b8a8362495627b48cd7008e88f510a7b285242e8413111599a685fd05bcc6babd241f7d4b98456e4d31b465e1be0b4549672a2e320c1a4ac3650d7
7
- data.tar.gz: 707a301d754f4bb13280c008994b6971b4844698206b6d1a7bd3537c794c5d2e90c76d50dc9351956661be43831dc1c8d64fd31fe177f1ad47e6eb5b450b387e
6
+ metadata.gz: ab80f8fd25603f0c9ce6f5ea6c92dc025b62f7b932f0b0e7bd7867bd4432845f902a6f5f7ba6492a6455ae9f3eb93fe411497a9dc2cf346538e3a610a05fd253
7
+ data.tar.gz: a5db302d1730c5f0cb801ddf248d24a0c5111ec0f8ae6d7aaa24d5a36c0e50913f1939a71df275c47429016162b59e47024a4be0e186e23b661c3393321cb9e4
@@ -1,23 +1,22 @@
1
+ /* global crypto */
1
2
  import { Controller } from '@hotwired/stimulus'
2
3
  import Sortable from 'sortablejs'
3
4
 
4
5
  export default class extends Controller {
6
+ static get values () {
7
+ return {
8
+ name: String
9
+ }
10
+ }
11
+
5
12
  static get targets () {
6
13
  return ['item', 'template', 'thumbnails', 'modalButton', 'placeholder', 'count', 'editButton', 'validationInput']
7
14
  }
8
15
 
9
16
  connect () {
10
- document.addEventListener('mediaSelectionSubmitted', (event) => {
11
- if (event.detail.name === this.element.dataset.name) {
12
- this.selectItems(event.detail.items)
13
- }
14
- })
15
-
16
- // Init sorting
17
- if (this.hasSorting()) {
18
- this.initSortable()
19
- }
20
-
17
+ this.randomizeName()
18
+ this.listenForMediaSelection()
19
+ this.initializeSorting()
21
20
  this.validate()
22
21
  }
23
22
 
@@ -47,6 +46,34 @@ export default class extends Controller {
47
46
  }
48
47
 
49
48
  // Methods
49
+ randomizeName () {
50
+ this.nameValue = crypto.randomUUID().substring(0, 8)
51
+ this.updateModalButtonUrls()
52
+ }
53
+
54
+ updateModalButtonUrls () {
55
+ this.modalButtonTargets.forEach((button) => {
56
+ const url = new URL(button.getAttribute('href'))
57
+ url.searchParams.set('name', this.nameValue)
58
+ button.setAttribute('href', url.toString())
59
+ })
60
+ }
61
+
62
+ listenForMediaSelection () {
63
+ document.addEventListener('mediaSelectionSubmitted', (event) => {
64
+ if (event.detail.name === this.nameValue) {
65
+ this.selectItems(event.detail.items)
66
+ this.updateModalButtonUrls()
67
+ }
68
+ })
69
+ }
70
+
71
+ initializeSorting () {
72
+ if (this.hasSorting()) {
73
+ this.initSortable()
74
+ }
75
+ }
76
+
50
77
  initSortable () {
51
78
  Sortable.create(this.thumbnailsTarget, {
52
79
  handle: '.media-drag-sort-handle',
@@ -170,9 +197,8 @@ export default class extends Controller {
170
197
 
171
198
  createItem (item) {
172
199
  // Copy template
173
- const template = this.templateTarget
174
- const html = this.randomizeIds(template)
175
- this.thumbnailsTarget.insertAdjacentHTML('beforeend', html)
200
+ const templateHtml = this.templateTarget.innerHTML
201
+ this.thumbnailsTarget.insertAdjacentHTML('beforeend', templateHtml)
176
202
 
177
203
  // Set new values
178
204
  const newItem = this.itemTargets.pop()
@@ -191,12 +217,6 @@ export default class extends Controller {
191
217
  oldThumbnail.parentNode.replaceChild(newThumbnail, oldThumbnail)
192
218
  }
193
219
 
194
- randomizeIds (template) {
195
- const regex = new RegExp(template.dataset.templateIdRegex, 'g')
196
- const randomNumber = Math.floor(100000000 + Math.random() * 900000000)
197
- return template.innerHTML.replace(regex, randomNumber)
198
- }
199
-
200
220
  removeAllDeselectedItems (items) {
201
221
  this.removeDeselectedItems(items, this.itemTargets)
202
222
  }
@@ -126,7 +126,8 @@ export default class extends Controller {
126
126
  const formData = new FormData()
127
127
 
128
128
  // Replace all occurrences of "page[blocks_attributes][0]" with "block"
129
- const regex = /\w+\[([^\]]+)s_attributes]\[\d+]/g
129
+ // Replace all occurrences of "form[fields_attributes][random]" with "field"
130
+ const regex = /\w+\[([^\]]+)s_attributes]\[[^\]]+]/g
130
131
  const formElements = fields.querySelectorAll('input[name]:not([name$="[id]"]), select[name]:not([name$="[id]"]), textarea[name]:not([name$="[id]"]), button[name]:not([name$="[id]"])')
131
132
  formElements.forEach((element) => {
132
133
  const currentName = element.getAttribute('name')
@@ -1,3 +1,4 @@
1
+ /* global crypto */
1
2
  import { Controller } from '@hotwired/stimulus'
2
3
  import Sortable from 'sortablejs'
3
4
 
@@ -23,7 +24,6 @@ export default class extends Controller {
23
24
  this.resetPositions()
24
25
  }
25
26
  })
26
-
27
27
  this.toggleEmpty()
28
28
  }
29
29
 
@@ -38,8 +38,7 @@ export default class extends Controller {
38
38
  }
39
39
 
40
40
  updatePopupButtonIndices (index) {
41
- const popup = document.querySelector(`[data-popup-target="popup"][data-popup-id="repeater-buttons-${this.idValue}"]`)
42
- const buttons = popup.querySelectorAll('[data-popup-target="button"]')
41
+ const buttons = document.querySelectorAll(`[data-popup-target="button"][data-popup-id="repeater-buttons-${this.idValue}"]`)
43
42
  buttons.forEach((button) => {
44
43
  button.dataset.rowIndex = index
45
44
  })
@@ -53,7 +52,7 @@ export default class extends Controller {
53
52
 
54
53
  // Prepare html from template
55
54
  let template = this.getTemplate(templateName).content.cloneNode(true)
56
- template = this.replaceIdsWithTimestamps(template)
55
+ template = this.randomizeIds(template)
57
56
 
58
57
  // Fallback to last row if no index is set
59
58
  if (rowIndex) {
@@ -107,44 +106,24 @@ export default class extends Controller {
107
106
  })[0]
108
107
  }
109
108
 
110
- replaceIdsWithTimestamps (template) {
111
- const pattern = 'rrrrrrrrr'
112
- const replacement = new Date().getTime().toString()
109
+ randomizeIds (template) {
110
+ const randomNumber = crypto.randomUUID().substring(0, 8)
111
+ const pattern = `_${this.idValue}_`
113
112
  const regex = new RegExp(pattern, 'g')
114
113
 
115
- // Replace ids
116
- template.querySelectorAll(`input[id*="${pattern}"], select[id*="${pattern}"], textarea[id*="${pattern}"], button[id*="${pattern}"]`).forEach((node) => {
117
- const idValue = node.getAttribute('id')
118
- node.setAttribute('id', idValue.replace(pattern, replacement))
119
- })
120
-
121
- // Search and replace pattern in templates
122
- template.querySelectorAll('template').forEach((node) => {
123
- node.innerHTML = node.innerHTML.replace(regex, replacement)
124
- })
125
-
126
- // Replace labels
127
- template.querySelectorAll(`label[for*="${pattern}"]`).forEach((node) => {
128
- const forValue = node.getAttribute('for')
129
- node.setAttribute('for', forValue.replace(pattern, replacement))
130
- })
131
-
132
- // Replace names
133
- template.querySelectorAll(`input[name*="${pattern}"], select[name*="${pattern}"], textarea[name*="${pattern}"], button[name*="${pattern}"]`).forEach((node) => {
134
- const nameValue = node.getAttribute('name')
135
- node.setAttribute('name', nameValue.replace(pattern, replacement))
136
- })
137
-
138
- // Replace offcanvas targets
139
- template.querySelectorAll(`div[data-bs-target="#offcanvas-${pattern}"]`).forEach((node) => {
140
- const targetValue = node.getAttribute('data-bs-target')
141
- node.setAttribute('data-bs-target', targetValue.replace(pattern, replacement))
142
- })
114
+ // Loop through each node in the template
115
+ template.querySelectorAll('*').forEach(node => {
116
+ // Replace attribute values
117
+ for (const attribute of node.attributes) {
118
+ if (attribute.value.includes(pattern)) {
119
+ attribute.value = attribute.value.replace(pattern, randomNumber)
120
+ }
121
+ }
143
122
 
144
- // Replace offcanvas ids
145
- template.querySelectorAll(`.offcanvas[id="offcanvas-${pattern}"]`).forEach((node) => {
146
- const idValue = node.getAttribute('id')
147
- node.setAttribute('id', idValue.replace(pattern, replacement))
123
+ // Replace template content
124
+ if (node.nodeName === 'TEMPLATE' && node.innerHTML.includes(pattern)) {
125
+ node.innerHTML = node.innerHTML.replace(regex, randomNumber)
126
+ }
148
127
  })
149
128
 
150
129
  return template
@@ -4,7 +4,7 @@ import I18n from '../config/i18n'
4
4
 
5
5
  export default class extends Controller {
6
6
  connect () {
7
- if (this.element.hasAttribute('multiple')) {
7
+ if (this.element.hasAttribute('multiple') || this.element.dataset.tomSelect === 'true') {
8
8
  this.initTomSelect()
9
9
  }
10
10
  }
@@ -11076,18 +11076,18 @@ var sortable_esm_default = Sortable;
11076
11076
 
11077
11077
  // app/assets/javascripts/formstrap/controllers/media_controller.js
11078
11078
  var media_controller_default = class extends Controller {
11079
+ static get values() {
11080
+ return {
11081
+ name: String
11082
+ };
11083
+ }
11079
11084
  static get targets() {
11080
11085
  return ["item", "template", "thumbnails", "modalButton", "placeholder", "count", "editButton", "validationInput"];
11081
11086
  }
11082
11087
  connect() {
11083
- document.addEventListener("mediaSelectionSubmitted", (event) => {
11084
- if (event.detail.name === this.element.dataset.name) {
11085
- this.selectItems(event.detail.items);
11086
- }
11087
- });
11088
- if (this.hasSorting()) {
11089
- this.initSortable();
11090
- }
11088
+ this.randomizeName();
11089
+ this.listenForMediaSelection();
11090
+ this.initializeSorting();
11091
11091
  this.validate();
11092
11092
  }
11093
11093
  destroy(event) {
@@ -11106,6 +11106,30 @@ var media_controller_default = class extends Controller {
11106
11106
  button.setAttribute("href", url.toString());
11107
11107
  });
11108
11108
  }
11109
+ randomizeName() {
11110
+ this.nameValue = crypto.randomUUID().substring(0, 8);
11111
+ this.updateModalButtonUrls();
11112
+ }
11113
+ updateModalButtonUrls() {
11114
+ this.modalButtonTargets.forEach((button) => {
11115
+ const url = new URL(button.getAttribute("href"));
11116
+ url.searchParams.set("name", this.nameValue);
11117
+ button.setAttribute("href", url.toString());
11118
+ });
11119
+ }
11120
+ listenForMediaSelection() {
11121
+ document.addEventListener("mediaSelectionSubmitted", (event) => {
11122
+ if (event.detail.name === this.nameValue) {
11123
+ this.selectItems(event.detail.items);
11124
+ this.updateModalButtonUrls();
11125
+ }
11126
+ });
11127
+ }
11128
+ initializeSorting() {
11129
+ if (this.hasSorting()) {
11130
+ this.initSortable();
11131
+ }
11132
+ }
11109
11133
  initSortable() {
11110
11134
  sortable_esm_default.create(this.thumbnailsTarget, {
11111
11135
  handle: ".media-drag-sort-handle",
@@ -11197,9 +11221,8 @@ var media_controller_default = class extends Controller {
11197
11221
  item.classList.remove("d-none");
11198
11222
  }
11199
11223
  createItem(item) {
11200
- const template = this.templateTarget;
11201
- const html = this.randomizeIds(template);
11202
- this.thumbnailsTarget.insertAdjacentHTML("beforeend", html);
11224
+ const templateHtml = this.templateTarget.innerHTML;
11225
+ this.thumbnailsTarget.insertAdjacentHTML("beforeend", templateHtml);
11203
11226
  const newItem = this.itemTargets.pop();
11204
11227
  newItem.querySelector('input[name*="[blob_id]"]').value = item.blobId;
11205
11228
  newItem.querySelector('input[name*="[_destroy]"]').value = false;
@@ -11211,11 +11234,6 @@ var media_controller_default = class extends Controller {
11211
11234
  const newThumbnail = item.thumbnail.cloneNode(true);
11212
11235
  oldThumbnail.parentNode.replaceChild(newThumbnail, oldThumbnail);
11213
11236
  }
11214
- randomizeIds(template) {
11215
- const regex = new RegExp(template.dataset.templateIdRegex, "g");
11216
- const randomNumber = Math.floor(1e8 + Math.random() * 9e8);
11217
- return template.innerHTML.replace(regex, randomNumber);
11218
- }
11219
11237
  removeAllDeselectedItems(items) {
11220
11238
  this.removeDeselectedItems(items, this.itemTargets);
11221
11239
  }
@@ -11498,7 +11516,7 @@ var nested_preview_controller_default = class extends Controller {
11498
11516
  buildFormData() {
11499
11517
  const fields = this.fieldsTarget;
11500
11518
  const formData = new FormData();
11501
- const regex = /\w+\[([^\]]+)s_attributes]\[\d+]/g;
11519
+ const regex = /\w+\[([^\]]+)s_attributes]\[[^\]]+]/g;
11502
11520
  const formElements = fields.querySelectorAll('input[name]:not([name$="[id]"]), select[name]:not([name$="[id]"]), textarea[name]:not([name$="[id]"]), button[name]:not([name$="[id]"])');
11503
11521
  formElements.forEach((element) => {
11504
11522
  const currentName = element.getAttribute("name");
@@ -13276,8 +13294,7 @@ var repeater_controller_default = class extends Controller {
13276
13294
  return this.rowTargets.includes(row);
13277
13295
  }
13278
13296
  updatePopupButtonIndices(index2) {
13279
- const popup = document.querySelector(`[data-popup-target="popup"][data-popup-id="repeater-buttons-${this.idValue}"]`);
13280
- const buttons = popup.querySelectorAll('[data-popup-target="button"]');
13297
+ const buttons = document.querySelectorAll(`[data-popup-target="button"][data-popup-id="repeater-buttons-${this.idValue}"]`);
13281
13298
  buttons.forEach((button) => {
13282
13299
  button.dataset.rowIndex = index2;
13283
13300
  });
@@ -13288,7 +13305,7 @@ var repeater_controller_default = class extends Controller {
13288
13305
  const templateName = button.dataset.templateName;
13289
13306
  const rowIndex = button.dataset.rowIndex;
13290
13307
  let template = this.getTemplate(templateName).content.cloneNode(true);
13291
- template = this.replaceIdsWithTimestamps(template);
13308
+ template = this.randomizeIds(template);
13292
13309
  if (rowIndex) {
13293
13310
  const row = this.rowTargets[rowIndex];
13294
13311
  this.listTarget.insertBefore(template, row.nextSibling);
@@ -13324,32 +13341,19 @@ var repeater_controller_default = class extends Controller {
13324
13341
  return template.dataset.templateName === name;
13325
13342
  })[0];
13326
13343
  }
13327
- replaceIdsWithTimestamps(template) {
13328
- const pattern = "rrrrrrrrr";
13329
- const replacement = new Date().getTime().toString();
13344
+ randomizeIds(template) {
13345
+ const randomNumber = crypto.randomUUID().substring(0, 8);
13346
+ const pattern = `_${this.idValue}_`;
13330
13347
  const regex = new RegExp(pattern, "g");
13331
- template.querySelectorAll(`input[id*="${pattern}"], select[id*="${pattern}"], textarea[id*="${pattern}"], button[id*="${pattern}"]`).forEach((node) => {
13332
- const idValue = node.getAttribute("id");
13333
- node.setAttribute("id", idValue.replace(pattern, replacement));
13334
- });
13335
- template.querySelectorAll("template").forEach((node) => {
13336
- node.innerHTML = node.innerHTML.replace(regex, replacement);
13337
- });
13338
- template.querySelectorAll(`label[for*="${pattern}"]`).forEach((node) => {
13339
- const forValue = node.getAttribute("for");
13340
- node.setAttribute("for", forValue.replace(pattern, replacement));
13341
- });
13342
- template.querySelectorAll(`input[name*="${pattern}"], select[name*="${pattern}"], textarea[name*="${pattern}"], button[name*="${pattern}"]`).forEach((node) => {
13343
- const nameValue = node.getAttribute("name");
13344
- node.setAttribute("name", nameValue.replace(pattern, replacement));
13345
- });
13346
- template.querySelectorAll(`div[data-bs-target="#offcanvas-${pattern}"]`).forEach((node) => {
13347
- const targetValue = node.getAttribute("data-bs-target");
13348
- node.setAttribute("data-bs-target", targetValue.replace(pattern, replacement));
13349
- });
13350
- template.querySelectorAll(`.offcanvas[id="offcanvas-${pattern}"]`).forEach((node) => {
13351
- const idValue = node.getAttribute("id");
13352
- node.setAttribute("id", idValue.replace(pattern, replacement));
13348
+ template.querySelectorAll("*").forEach((node) => {
13349
+ for (const attribute of node.attributes) {
13350
+ if (attribute.value.includes(pattern)) {
13351
+ attribute.value = attribute.value.replace(pattern, randomNumber);
13352
+ }
13353
+ }
13354
+ if (node.nodeName === "TEMPLATE" && node.innerHTML.includes(pattern)) {
13355
+ node.innerHTML = node.innerHTML.replace(regex, randomNumber);
13356
+ }
13353
13357
  });
13354
13358
  return template;
13355
13359
  }
@@ -13388,7 +13392,7 @@ var repeater_controller_default = class extends Controller {
13388
13392
  var import_tom_select = __toESM(require_tom_select_complete());
13389
13393
  var select_controller_default = class extends Controller {
13390
13394
  connect() {
13391
- if (this.element.hasAttribute("multiple")) {
13395
+ if (this.element.hasAttribute("multiple") || this.element.dataset.tomSelect === "true") {
13392
13396
  this.initTomSelect();
13393
13397
  }
13394
13398
  }
@@ -15,7 +15,6 @@
15
15
  }
16
16
 
17
17
  .rx-container {
18
- overflow: hidden;
19
18
  padding: 0 !important;
20
19
  border: var(--bs-border-width) solid var(--bs-border-color);
21
20
  border-radius: var(--bs-border-radius);
@@ -1299,7 +1299,6 @@ span.flatpickr-weekday {
1299
1299
  --bs-input-focus-color: var(--bs-body-color);
1300
1300
  }
1301
1301
  .rx-container {
1302
- overflow: hidden;
1303
1302
  padding: 0 !important;
1304
1303
  border: var(--bs-border-width) solid var(--bs-border-color);
1305
1304
  border-radius: var(--bs-border-radius);
@@ -18,7 +18,6 @@ module Formstrap
18
18
  class: ["mb-3", ("form-floating" if float)],
19
19
  data: {
20
20
  controller: "media",
21
- name: name,
22
21
  sort: sort,
23
22
  accept: accept,
24
23
  required: required.nil? ? 0 : required
@@ -122,7 +121,7 @@ module Formstrap
122
121
  end
123
122
 
124
123
  def modal_url
125
- formstrap_media_path(
124
+ formstrap_media_url(
126
125
  name: name,
127
126
  ids: blob_ids,
128
127
  min: min,
@@ -28,8 +28,6 @@ module Formstrap
28
28
  theme: "light",
29
29
  # Popup when highlighting text
30
30
  context: false,
31
- # Top toolbar
32
- toolbar: true,
33
31
  popups: {
34
32
  # Options in addbar popup (press + button)
35
33
  addbar: %w[format bold italic deleted list table link embed],
@@ -11,13 +11,16 @@ module Formstrap
11
11
  end
12
12
 
13
13
  def default_options
14
- {
14
+ options = {
15
15
  redactor: {
16
16
  context: !toolbar,
17
17
  extrabar: toolbar,
18
- toolbar: toolbar
19
18
  }
20
19
  }
20
+
21
+ options[:redactor][:toolbar] = false if @toolbar == false
22
+
23
+ options
21
24
  end
22
25
  end
23
26
  end
@@ -106,8 +106,9 @@
106
106
  class="btn btn-sm btn-outline-secondary"
107
107
  data-repeater-target="addButton"
108
108
  data-popup-target="button"
109
- data-popup-id="<%= "repeater-buttons-#{repeater_id}" %>"
109
+ data-popup-id="repeater-buttons-<%= repeater_id %>"
110
110
  data-popup-pass-thru="<%= pass_thru %>"
111
+ data-row-index=""
111
112
  data-action="click->repeater#resetButtonIndices click->popup#open"
112
113
  >
113
114
  <%= bootstrap_icon("plus") %>
@@ -121,7 +122,7 @@
121
122
  <div
122
123
  class="btn btn-sm btn-outline-secondary"
123
124
  data-popup-target="button"
124
- data-popup-id="<%= "repeater-buttons-#{repeater_id}" %>"
125
+ data-popup-id="repeater-buttons-<%= repeater_id %>"
125
126
  data-action="click->repeater#addRow click->popup#close"
126
127
  data-row-index=""
127
128
  data-template-name="<%= name %>"
@@ -135,7 +136,7 @@
135
136
  <!-- Templates -->
136
137
  <% template_names.each do |template_name| %>
137
138
  <template data-repeater-target="template" data-template-name="<%= template_name %>">
138
- <%= form.fields_for attribute, association_object, child_index: "rrrrrrrrr" do |ff| %>
139
+ <%= form.fields_for attribute, association_object, child_index: "_#{repeater_id}_" do |ff| %>
139
140
  <%= render "formstrap/repeater/row", row_options.merge(form: ff, pass_thru: pass_thru, repeater_id: repeater_id, index: nil, template_name: template_name) do %>
140
141
  <%= yield(ff, template_name) %>
141
142
  <% end %>
@@ -41,7 +41,7 @@
41
41
  title="<%= t(".add") %>"
42
42
  data-repeater-target="addButton"
43
43
  data-popup-target="button"
44
- data-popup-id="<%= "repeater-buttons-#{repeater_id}" %>"
44
+ data-popup-id="repeater-buttons-<%= repeater_id %>"
45
45
  data-popup-pass-thru="<%= pass_thru %>"
46
46
  data-action="click->repeater#resetButtonIndices click->popup#open"
47
47
  >
@@ -1,3 +1,3 @@
1
1
  module Formstrap
2
- VERSION = "0.4.0"
2
+ VERSION = "0.4.2"
3
3
  end
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontierdotbe/formstrap",
3
- "version": "0.3.4",
3
+ "version": "0.4.1",
4
4
  "description": "Bootstrap-powered Form Helpers",
5
5
  "module": "app/assets/javascripts/formstrap.js",
6
6
  "main": "app/assets/javascripts/formstrap.js",
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: formstrap
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jef Vlamings
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-05-09 00:00:00.000000000 Z
11
+ date: 2024-06-27 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: An extensive Bootstrap form library to power your Ruby On Rails application.
14
14
  email: