diff --git a/Gemfile b/Gemfile index 651ee83..3bfa381 100644 --- a/Gemfile +++ b/Gemfile @@ -11,7 +11,7 @@ group :development, :test do gem 'puma' gem 'rails' gem 'shakapacker', '7.2.2' - gem 'sqlite3' + gem 'sqlite3', '~> 1.4' gem 'formtastic', '~> 5.0' gem 'nokogiri' diff --git a/Gemfile.lock b/Gemfile.lock index a7ba200..323fb0c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -300,7 +300,7 @@ DEPENDENCIES shakapacker (= 7.2.2) simple_form (~> 5.1) simplecov - sqlite3 + sqlite3 (~> 1.4) BUNDLED WITH 2.5.3 diff --git a/dev/gemfiles/rails-6.1.x.gemfile b/dev/gemfiles/rails-6.1.x.gemfile index 4f87c50..0e3c50f 100644 --- a/dev/gemfiles/rails-6.1.x.gemfile +++ b/dev/gemfiles/rails-6.1.x.gemfile @@ -9,7 +9,7 @@ group :development, :test do gem 'puma' gem 'rails', '~> 6.1.0' gem 'shakapacker', '~> 7.1.0' - gem 'sqlite3' + gem 'sqlite3', '~> 1.4' gem 'formtastic', '~> 5.0' gem 'nokogiri' diff --git a/dev/gemfiles/rails-7.0.x.gemfile b/dev/gemfiles/rails-7.0.x.gemfile index 159a6d2..8e565ee 100644 --- a/dev/gemfiles/rails-7.0.x.gemfile +++ b/dev/gemfiles/rails-7.0.x.gemfile @@ -9,7 +9,7 @@ group :development, :test do gem 'puma' gem 'rails', '~> 7.0.0' gem 'shakapacker', '~> 7.1.0' - gem 'sqlite3' + gem 'sqlite3', '~> 1.4' gem 'formtastic', '~> 5.0' gem 'nokogiri' diff --git a/npm/README.md b/npm/README.md index 3e1df82..980cd82 100644 --- a/npm/README.md +++ b/npm/README.md @@ -204,6 +204,24 @@ Event handlers receive a `CustomEvent` with following detail: You can cancel an action within the `cocooned:before-` callback using `event.preventDefault()`. +## Attribute substitutions on create + +New items need to have a bunch of attributes updated before insert to make their `name`, `id` and `for` attributes consistent and unique. If you need additional substitutions to be made, you can configure them with: + +```javascript +import Cocooned from '@notus.sh/cocooned' + +/** + * These 4 replacements are already set by default. + */ +Cocooned.registerReplacement({ attribute: 'name', delimiters: ['[', ']'] }) +// Same start and end? Don't repeat yourself. +Cocooned.registerReplacement({ attribute: 'id', delimiters: ['_'] }) +// You can target specific tags (else '*' is implied). +Cocooned.registerReplacement({ tag: 'label', attribute: 'for', delimiters: ['_'] }) +Cocooned.registerReplacement({ tag: 'trix-editor', attribute: 'input', delimiters: ['_'] }) +``` + ## Migration from a previous version These migrations steps only highlight major changes. When upgrading from a previous version, always refer to [the CHANGELOG](https://github.com/notus-sh/cocooned/blob/main/CHANGELOG.md) for new features and breaking changes. diff --git a/npm/__tests__/unit/cocooned/plugins/core.js b/npm/__tests__/unit/cocooned/plugins/core.js index fd09dfd..9dbc50d 100644 --- a/npm/__tests__/unit/cocooned/plugins/core.js +++ b/npm/__tests__/unit/cocooned/plugins/core.js @@ -23,6 +23,37 @@ describe('coreMixin', () => { }) }) + describe('replacements', () => { + it('returns default replacements', () => { + expect(given.extended.replacements).toEqual(expect.arrayContaining([ + { tag: 'label', attribute: 'for', delimiters: ['_'] }, + { tag: '*', attribute: 'id', delimiters: ['_'] }, + { tag: '*', attribute: 'name', delimiters: ['[', ']'] } + ])) + }) + + it('returns replacements for Trix compatibility', () => { + expect(given.extended.replacements).toEqual(expect.arrayContaining([ + { tag: 'trix-editor', attribute: 'input', delimiters: ['_'] } + ])) + }) + + describe('when extended', () => { + beforeEach(() => { + given.extended.registerReplacement({ attribute: given.attribute, delimiters: [given.delimiter] }) + }) + + given('attribute', () => faker.lorem.word()) + given('delimiter', () => faker.string.fromCharacters('_.-/')) + + it('returns registered additional replacement', () => { + expect(given.extended.replacements).toEqual(expect.arrayContaining([ + { attribute: given.attribute, delimiters: [given.delimiter], tag: '*' } + ])) + }) + }) + }) + describe('when instanciated', () => { beforeEach(() => { document.body.innerHTML = given.html diff --git a/npm/__tests__/unit/cocooned/plugins/core/triggers/add.js b/npm/__tests__/unit/cocooned/plugins/core/triggers/add.js index ae85316..ed0f915 100644 --- a/npm/__tests__/unit/cocooned/plugins/core/triggers/add.js +++ b/npm/__tests__/unit/cocooned/plugins/core/triggers/add.js @@ -1,6 +1,7 @@ /* global given */ -import { Base as Cocooned } from '@notus.sh/cocooned/src/cocooned/base' +import { coreMixin } from '@notus.sh/cocooned/src/cocooned/plugins/core' +import { Base } from '@notus.sh/cocooned/src/cocooned/base' import { Builder } from '@notus.sh/cocooned/src/cocooned/plugins/core/triggers/add/builder' import { Add } from '@notus.sh/cocooned/src/cocooned/plugins/core/triggers/add' import { jest } from '@jest/globals' @@ -13,12 +14,16 @@ import itBehavesLikeACancellableEvent from '@cocooned/tests/shared/events/cancel describe('Add', () => { beforeEach(() => { document.body.innerHTML = given.html }) - given('add', () => new Add(given.addTrigger, new Cocooned(given.container), given.options)) + given('extended', () => coreMixin(Base)) + given('add', () => new Add(given.addTrigger, new given.extended(given.container), given.options)) given('addTrigger', () => getAddLink(given.container)) given('container', () => document.querySelector('[data-cocooned-container]')) given('builder', () => { const template = document.querySelector('template[data-name="template"]') - return new Builder(template.content, 'new_item') + return new Builder( + template.content, + given.extended.replacementsFor('new_item') + ) }) given('options', () => ({ builder: given.builder, node: given.addTrigger.parentElement, method: 'before' })) diff --git a/npm/__tests__/unit/cocooned/plugins/core/triggers/add/builder.js b/npm/__tests__/unit/cocooned/plugins/core/triggers/add/builder.js index 12a2a40..4ce9aad 100644 --- a/npm/__tests__/unit/cocooned/plugins/core/triggers/add/builder.js +++ b/npm/__tests__/unit/cocooned/plugins/core/triggers/add/builder.js @@ -1,11 +1,18 @@ /* global given */ +import { coreMixin } from '@notus.sh/cocooned/src/cocooned/plugins/core' +import { Base } from '@notus.sh/cocooned/src/cocooned/base' import { Builder } from '@notus.sh/cocooned/src/cocooned/plugins/core/triggers/add/builder' import { faker } from '@cocooned/tests/support/faker' describe('Builder', () => { - given('builder', () => new Builder(given.template.content, given.association)) - given('association', () => 'new_person') + given('extended', () => coreMixin(Base)) + given('builder', () => { + return new Builder( + given.template.content, + given.extended.replacementsFor('new_person') + ) + }) given('id', () => faker.string.numeric(5)) const replacements = [ @@ -38,6 +45,17 @@ describe('Builder', () => { ` + }, + { + desc: 'Trix editor', + template: ` + + + `, + expected: (id) => ` + + + ` } ] diff --git a/npm/__tests__/unit/cocooned/plugins/core/triggers/add/extractor.js b/npm/__tests__/unit/cocooned/plugins/core/triggers/add/extractor.js index 43bbad2..9b2f158 100644 --- a/npm/__tests__/unit/cocooned/plugins/core/triggers/add/extractor.js +++ b/npm/__tests__/unit/cocooned/plugins/core/triggers/add/extractor.js @@ -1,6 +1,7 @@ /* global given */ -import { Base as Cocooned } from '@notus.sh/cocooned/src/cocooned/base' +import { coreMixin } from '@notus.sh/cocooned/src/cocooned/plugins/core' +import { Base } from '@notus.sh/cocooned/src/cocooned/base' import { Extractor } from '@notus.sh/cocooned/src/cocooned/plugins/core/triggers/add/extractor' import { Builder } from '@notus.sh/cocooned/src/cocooned/plugins/core/triggers/add/builder' import { deprecator } from '@notus.sh/cocooned/src/cocooned/deprecation' @@ -11,7 +12,8 @@ import { getAddLink } from '@cocooned/tests/support/helpers' describe('Extractor', () => { beforeEach(() => { document.body.innerHTML = given.html }) - given('extractor', () => new Extractor(given.addTrigger, new Cocooned(given.container))) + given('extended', () => coreMixin(Base)) + given('extractor', () => new Extractor(given.addTrigger, new given.extended(given.container))) given('container', () => document.querySelector('[data-cocooned-container]')) given('addTrigger', () => getAddLink(document)) given('html', () => ` diff --git a/npm/src/cocooned/plugins/core.js b/npm/src/cocooned/plugins/core.js index 61439ba..145b2b4 100644 --- a/npm/src/cocooned/plugins/core.js +++ b/npm/src/cocooned/plugins/core.js @@ -1,8 +1,21 @@ import { Add } from './core/triggers/add.js' import { Remove } from './core/triggers/remove.js' +import { Replacement } from './core/triggers/add/replacement.js' import { clickHandler, itemDelegatedClickHandler } from '../events/handlers.js' const coreMixin = (Base) => class extends Base { + static registerReplacement ({ tag = '*', attribute, delimiters }) { + this.__replacements.push({ tag, attribute, delimiters }) + } + + static get replacements () { + return this.__replacements; + } + + static replacementsFor (association) { + return this.replacements.map(r => new Replacement({ association, ...r })) + } + static get selectors () { return { ...super.selectors, @@ -31,6 +44,21 @@ const coreMixin = (Base) => class extends Base { }) ) } + + replacementsFor (association) { + return this.constructor.replacementsFor(association); + } + + /* Protected and private attributes and methods */ + static __replacements = [ + // Default attributes + { tag: 'label', attribute: 'for', delimiters: ['_'] }, + { tag: '*', attribute: 'id', delimiters: ['_'] }, + { tag: '*', attribute: 'name', delimiters: ['[', ']'] }, + + // Compatibility with Trix. See #65 on Github. + { tag: 'trix-editor', attribute: 'input', delimiters: ['_'] }, + ]; } export { diff --git a/npm/src/cocooned/plugins/core/triggers/add/builder.js b/npm/src/cocooned/plugins/core/triggers/add/builder.js index e850c14..a7b58d0 100644 --- a/npm/src/cocooned/plugins/core/triggers/add/builder.js +++ b/npm/src/cocooned/plugins/core/triggers/add/builder.js @@ -1,60 +1,7 @@ -/** - * Borrowed from Lodash - * See https://lodash.com/docs/#escapeRegExp - */ -const reRegExpChar = /[\\^$.*+?()[\]{}|]/g -const reHasRegExpChar = RegExp(reRegExpChar.source) - -class Replacement { - attribute - - constructor (attribute, name, startDelimiter, endDelimiter = null) { - this.attribute = attribute - - this.#name = name - this.#startDelimiter = startDelimiter - this.#endDelimiter = endDelimiter || startDelimiter - } - - apply (node, id) { - const value = node.getAttribute(this.attribute) - if (!this.#regexp.test(value)) { - return - } - - node.setAttribute(this.attribute, value.replace(this.#regexp, this.#replacement(id))) - } - - /* Protected and private attributes and methods */ - #name - #startDelimiter - #endDelimiter - - #replacement (id) { - return `${this.#startDelimiter}${id}${this.#endDelimiter}$1` - } - - get #regexp () { - const escaped = this.#escape(`${this.#startDelimiter}${this.#name}${this.#endDelimiter}`) - return new RegExp(`${escaped}(.*?)`, 'g') - } - - #escape (string) { - return (string && reHasRegExpChar.test(string)) - ? string.replace(reRegExpChar, '\\$&') - : (string || '') - } -} - class Builder { - constructor (documentFragment, association) { + constructor (documentFragment, replacements) { this.#documentFragment = documentFragment - this.#association = association - this.#replacements = [ - new Replacement('for', association, '_'), - new Replacement('id', association, '_'), - new Replacement('name', association, '[', ']') - ] + this.#replacements = replacements } build (id) { @@ -64,13 +11,14 @@ class Builder { } /* Protected and private attributes and methods */ - #association #documentFragment #replacements #applyReplacements (node, id) { this.#replacements.forEach(replacement => { - node.querySelectorAll(`*[${replacement.attribute}]`).forEach(node => replacement.apply(node, id)) + node.querySelectorAll(`${replacement.tag}[${replacement.attribute}]`).forEach(node => { + return replacement.apply(node, id) + }) }) node.querySelectorAll('template').forEach(template => { diff --git a/npm/src/cocooned/plugins/core/triggers/add/extractor.js b/npm/src/cocooned/plugins/core/triggers/add/extractor.js index 0735d69..704f73c 100644 --- a/npm/src/cocooned/plugins/core/triggers/add/extractor.js +++ b/npm/src/cocooned/plugins/core/triggers/add/extractor.js @@ -39,7 +39,10 @@ class Extractor { return null } - return new Builder(template.content, `new_${this.#dataset.association}`) + return new Builder( + template.content, + this.#cocooned.replacementsFor(`new_${this.#dataset.association}`) + ); } _extractCount () { diff --git a/npm/src/cocooned/plugins/core/triggers/add/replacement.js b/npm/src/cocooned/plugins/core/triggers/add/replacement.js new file mode 100644 index 0000000..0a57585 --- /dev/null +++ b/npm/src/cocooned/plugins/core/triggers/add/replacement.js @@ -0,0 +1,53 @@ +/** + * Borrowed from Lodash + * See https://lodash.com/docs/#escapeRegExp + */ +const reRegExpChar = /[\\^$.*+?()[\]{}|]/g +const reHasRegExpChar = RegExp(reRegExpChar.source) + +class Replacement { + attribute + tag + + constructor ({ tag = '*', attribute, association, delimiters }) { + this.attribute = attribute + this.tag = tag + + this.#association = association + this.#startDelimiter = delimiters[0] + this.#endDelimiter = delimiters[delimiters.length - 1] + } + + apply (node, id) { + const value = node.getAttribute(this.attribute) + if (!this.#regexp.test(value)) { + return + } + + node.setAttribute(this.attribute, value.replace(this.#regexp, this.#replacement(id))) + } + + /* Protected and private attributes and methods */ + #association + #startDelimiter + #endDelimiter + + #replacement (id) { + return `${this.#startDelimiter}${id}${this.#endDelimiter}$1` + } + + get #regexp () { + const escaped = this.#escape(`${this.#startDelimiter}${this.#association}${this.#endDelimiter}`) + return new RegExp(`${escaped}(.*?)`, 'g') + } + + #escape (string) { + return (string && reHasRegExpChar.test(string)) + ? string.replace(reRegExpChar, '\\$&') + : (string || '') + } +} + +export { + Replacement +}