formstrap 0.4.0 → 0.4.2

Sign up to get free protection for your applications and to get access to all the features.
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: