New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

The Classes and Values APIs #202

Open
wants to merge 29 commits into
base: master
from

Conversation

Projects
None yet
9 participants
@sstephenson
Contributor

sstephenson commented Nov 2, 2018

This pull request introduces two new APIs to Stimulus: Classes and Values. These APIs are designed to improve upon, and ultimately obviate, the current Data Map API. We plan to ship them together in the upcoming Stimulus 1.2 release.

You should consider this pull request a rough draft. The names and behavior described below are subject to change, and we welcome your feedback on them. In particular, if you find any of the terminology below confusing, please leave a comment. If we make changes, we will revise the body of this pull request in tandem.


Classes

The most common use of the Data Map API in Basecamp is to store CSS class names.

For example, our copy-to-clipboard controller applies a CSS class to its element after a successful copy. To avoid inlining a long BEM string in our controller, and to keep things loosely coupled, we declare the class in a data-clipboard-success-class attribute:

<div data-controller="clipboard"
     data-clipboard-success-class="copy-to-clipboard--success">

and access it using this.data.get("successClass") in the controller:

this.element.classList.add(this.data.get("successClass"))

The Classes API formalizes and refines this pattern.

The data-class attribute

The first addition is a new data-class attribute which allows you to concisely declare multiple class names at once:

<div data-controller="clipboard"
     data-class="success:copy-to-clipboard--success
                 supported:copy-to-clipboard--supported">

In this example, we've declared two classes named success and supported. On the left side of each colon is the name, and on the right is the full CSS class it refers to.

Class properties

The second addition is a new static classes array on controllers. As with targets, Stimulus automatically adds properties for each class listed in the array:

// clipboard_controller.js
export default class extends Controller {
  static classes = [ "success", "supported" ]

  initialize() {
    if (/* ... */) {
      this.element.classList.add(this.supportedClass)
    }
  }

  copy() {
    // ...
    this.element.classList.add(this.successClass)
  }
}
Kind Property name Description
Getter this.[name]Class The full CSS class name string
Existential this.has[Name]Class A boolean indicating whether the name is declared in the controller element's data-class attribute

Declarations are assumed to be present

When you access a class property in a controller, such as this.supportedClass, you assert that the corresponding declaration is present in the controller element's data-class attribute. If the declaration is missing, Stimulus throws a descriptive error:

Error: Missing class descriptor for property "supportedClass" in attribute "data-class"

If a class is optional, you must first use the existential property (e.g. this.hasSupportedClass) to determine whether its declaration is present.

Names can be disambiguated

In the event that an element has multiple controllers with conflicting class names, you can prefix names with the controller's identifier followed by a period. For example:

<div data-controller="contrived example"
     data-class="contrived.success:a example.success:b">

Values

Most other uses of the Data Map API in Basecamp fall under the following categories:

  • Storing small strings, such as URLs, dates, or color values
  • Keeping track of a numeric index into a collection
  • Bootstrapping a controller with a JSON object or array
  • Conditioning behavior on a per-controller basis

However, the Data Map API only works with string values. That means we must manually convert to and from other types as needed. The Values API handles this type conversion work automatically.

Value properties

The Values API adds support for a static values object on controllers. The keys of this object are Data Map keys, and the values declare their data type:

export default class extends Controller {
  static values = {
    url: String,
    refreshInterval: Number,
    loadOnConnect: Boolean
  }
  
  connect() {
    if (this.loadOnConnectValue) {
      this.load()
    }
  }
  
  async load() {
    const response = await fetch(this.urlValue)
    // ...
    setTimeout(() => this.load, this.refreshIntervalValue)
  }
}

Stimulus automatically generates three properties for each entry in the object:

Kind Property name Description
Getter this.[name]Value Like this.data.get(name), but converted to the declared type
Setter this.[name]Value= Like this.data.set(name, value), where value is of the declared type
Existential this.has[Name]Value Like this.data.has(name)

Supported types and defaults

This pull request implements support for five built-in types:

Type Serialized attribute value Default value
Array JSON.stringify(array) []
Boolean boolean.toString() false
Number number.toString() 0
Object JSON.stringify(object) {}
String Itself ""

Each type has a default value. If a value is declared in a controller but its associated data attribute is missing, the getter property will return its type's default.

Value changed callbacks

In addition to value properties, the Values API introduces value changed callbacks. A value changed callback is a specially named method called by Stimulus whenever a value's data attribute is modified.

To observe changes to a value, define a method named [name]ValueChanged(). For example, a slideshow controller with a numeric index property might define an indexValueChanged() method to display the specified slide:

export default class extends Controller {
  static values = { index: Number }
  
  indexValueChanged() {
    this.showSlide(this.indexValue)
  }
  
  // ...
}

Stimulus invokes each value changed callback once when the controller is initialized, and again any time the value's data attribute changes.

Even if a value's data attribute is missing when the controller is initialized, Stimulus will still invoke its value changed callback. Use the existential property to determine whether the data attribute is present.



Try it out in your application

Update the Stimulus entry in package.json to point to the latest development build:

"stimulus": "https://github.com/stimulusjs/dev-builds/archive/fd71f88/stimulus.tar.gz"

Still to do…

  • Consider alternate names for the data-class attribute
  • Avoid installing value observers on controllers without any value changed callbacks defined
  • Write reference documentation
  • Update the handbook

sstephenson added some commits Sep 27, 2018

Revert "Add a `date` type"
To be useful, date values need to be wrapped in a time zone-aware object.

This reverts commit 2da572d.

@sstephenson sstephenson added this to the 1.2 milestone Nov 2, 2018

@PFWhite

This comment has been minimized.

PFWhite commented Nov 2, 2018

Very excited about the values api! Both these features seem really similar in that they both: get declarations from a static array, read the DOM, then add properties to the controller instance, just like the data-target api. Maybe the more general API could be exposed? It might be nice to have the ability to put custom data-attributes and be able to write handlers.

@sstephenson

This comment has been minimized.

Contributor

sstephenson commented Nov 2, 2018

Maybe the more general API could be exposed? It might be nice to have the ability to put custom data-attributes and be able to write handlers.

Could you clarify what you mean, exactly?

@adrienpoly

This comment has been minimized.

adrienpoly commented Nov 2, 2018

That is awesome, I love the new value API. Have been doing similar things in some controllers. I am glad this can be now fully refactored with this API. Will try to test it over the WE on one particular project.

One question for the value changed callbacks: Would it be possible to have a global valueChanged() callback (or anyValueChanged()).

Let's say that I have 3 value attributes. I would like to have only one callback function that would be triggered whenever any of the 3 value change (ideally with the changed value in the params)?

Will need to test with the current API but my initial thinking was that this could be useful. If I come up with a real use case, I will post it back.

@PFWhite

This comment has been minimized.

PFWhite commented Nov 2, 2018

Maybe the more general API could be exposed? It might be nice to have the ability to put custom data-attributes and be able to write handlers.

Could you clarify what you mean, exactly?

So the values feature covers: json, strings, numbers, and arrays. What if I had something like binary data from some custom protocol. I could then have a "data-blob" property in the HTML, a static "blobs" array in the controller definition, and then some handler which would get passed those attributes on component intialization, which would decode my binary object and add the getters and settes for "this.somethingBlobs".

If this was available people could have written data-number, and declared a numbers static array and had some handler that would split the array and add the object getters and setters on their own.

It would also cover @adrienpoly because he could write his special "data-adrien-value" attribute with its own handler logic.

Let me know if I wasn't clear and I'll try again.

@sstephenson

This comment has been minimized.

Contributor

sstephenson commented Nov 2, 2018

Would it be possible to have a global valueChanged() callback (or anyValueChanged()).

Let's say that I have 3 value attributes. I would like to have only one callback function that would be triggered whenever any of the 3 value change (ideally with the changed value in the params)?

Will need to test with the current API but my initial thinking was that this could be useful. If I come up with a real use case, I will post it back.

Yes, that's definitely possible, and seems intuitively appealing too. Probably something we'd want to add in a subsequent release. Please do share your use case if you come up with one!

@sstephenson

This comment has been minimized.

Contributor

sstephenson commented Nov 2, 2018

So the values feature covers: json, strings, numbers, and arrays. What if I had something like binary data from some custom protocol. I could then have a "data-blob" property in the HTML, a static "blobs" array in the controller definition, and then some handler which would get passed those attributes on component intialization, which would decode my binary object and add the getters and settes for "this.somethingBlobs".

Yeah, we talked about the possibility of supporting custom types when designing the Values API. It's not too hard to imagine something like:

class extends Controller {
  static values = { person: Person }
}

which would automatically serialize and deserialize into an instance of your Person class. I think that's something that'd be interesting to explore for a future release.

That said, I don't think you'd ever want to store binary data like that in an HTML attribute. Probably best to stick with a URL instead.

If this was available people could have written data-number, and declared a numbers static array and had some handler that would split the array and add the object getters and setters on their own.

Hmm, I don't immediately see how exposing a single attribute of each type, and requiring you to pack and unpack objects manually into that attribute, would be an improvement over what's here... maybe you could show a HTML and JavaScript code example that illustrates your use case?

@tvandervossen

This comment has been minimized.

tvandervossen commented Nov 5, 2018

My first feeling upon seeing this is that data-classes might work better as an attribute name than data-class. This makes it match the naming of the static classes array on the Stimulus controller and it also avoids newcomers and other peopler reading existing template source from thinking the data-class attribute is somehow related to the class attribute on the same element.

@adrienpoly

This comment has been minimized.

adrienpoly commented Nov 5, 2018

I have tested the value API with the flatpickr wrapper I developed and it works great.

I have encountered a small issue.
one of the options of flatpickr is time_24hr (with an underscore)

In Rails I have something like that

<%= f.text_field :start_at,
    data: {
      controller: "flatpickr",
      flatpickr_time_24hr: true,
    } %>

and it generate in the HTML data-flatpickr-time-24hr

but the dasherize function in stimulus does not dasherize _.

so my static values = { time_24hr: Boolean } is not recognized. This create the following functions: hasTime_24hrValue and time_24hrValue

I am really unsure whether it is a Stimulus issue or more a convention issue at the first place when putting an underscore before numbers in a JS variable.
On my side I ended up modifying my dasherize funtion to convert underscore to dash.

my 2 cents

@PFWhite

This comment has been minimized.

PFWhite commented Nov 5, 2018

@sstephenson Read through core, now I realize I need to override initialize method. I missed it on here because it doesn't have a section like connect and disconnect do. This does everything I need it to.

@sstephenson

This comment has been minimized.

Contributor

sstephenson commented Nov 5, 2018

My first feeling upon seeing this is that data-classes might work better as an attribute name than data-class. This makes it match the naming of the static classes array on the Stimulus controller and it also avoids newcomers and other peopler reading existing template source from thinking the data-class attribute is somehow related to the class attribute on the same element.

data-classes is one alternative attribute name we've discussed.

All of our other attribute names are singular, including data-target, which maps to a plural static array property named targets. Token list attributes with singular names (data-controller, data-action, data-target, data-class) are consistent with HTML (class, rel, sandbox).

However, I agree data-class could be misperceived as having the same behavior as the HTML class attribute. I wonder if data-classes is possibly too subtle of a difference, then?

The other name we've tossed around is data-class-map (ClassMap is the name of the object that decodes these attributes).

@dhh

This comment has been minimized.

Collaborator

dhh commented Nov 5, 2018

@sstephenson

This comment has been minimized.

Contributor

sstephenson commented Nov 5, 2018

I have tested the value API with the flatpickr wrapper I developed and it works great.

Wonderful! Thanks for testing it out ✌️

I have encountered a small issue.
one of the options of flatpickr is time_24hr (with an underscore)

In Rails I have something like that

<%= f.text_field :start_at,
    data: {
      controller: "flatpickr",
      flatpickr_time_24hr: true,
    } %>

and it generate in the HTML data-flatpickr-time-24hr

but the dasherize function in stimulus does not dasherize _.

so my static values = { time_24hr: Boolean } is not recognized. This create the following functions: hasTime_24hrValue and time_24hrValue

I am really unsure whether it is a Stimulus issue or more a convention issue at the first place when putting an underscore before numbers in a JS variable.
On my side I ended up modifying my dasherize funtion to convert underscore to dash.

In general, keys should be snake_case in Ruby, kebab-case in HTML, and camelCase in JavaScript, following each language's own conventions. Rails and Stimulus attempt to convert between these as necessary. That means no underscores in your Stimulus controllers.

You've hit a case where the mapping between the three is not "round-trippable". Your snake-case data key time_24hr becomes the kebab-case attribute name time-24hr. But in camelCase, you'd write static values = { time24hr: Boolean }, which translates to the kebab-case attribute name time24hr. Blech!

To fix this, I'd suggest picking a different name that doesn't have this problem. For example, by renaming the attribute to refer to AM/PM instead, you could get the round-trippable data_flatpickr_uses_am_pm: falsedata-flatpickr-uses-am-pmstatic values = { usesAmPm: Boolean }.

@sstephenson

This comment has been minimized.

Contributor

sstephenson commented Nov 5, 2018

Actually, I think we can do better. By replacing this definition with a new definition of name:

  const name = `${camelize(key)}Value`

you'd be able to specify non-roundtrippable static values keys in kebab-case, just as they appear in HTML:

class extends Controller {
  static values = { "time-24hr": Boolean }
}

while still being able to use the camelized form for property access: this.time24hrValue.

@sstephenson

This comment has been minimized.

Contributor

sstephenson commented Nov 5, 2018

Thanks for pointing this out, @adrienpoly! fd71f88

@tvandervossen

This comment has been minimized.

tvandervossen commented Nov 6, 2018

All of our other attribute names are singular […]

Sorry for overlooking that. Using classes would indeed be inconsistent.

I don't see how anyone is going to be confused that data-class does not mean the same as class.

I’m not worried about people assuming it means the exact same thing; it just feels a bit generic and too close to the naming of the regular class attribute.

What seems really nice about the existing attribute names (data-controller, data-action, and data-target) is that even without any knowledge about Stimulus, someone reading HTML source can figure out what things are and how they’re going to behave.

The other name we've tossed around is data-class-map (ClassMap is the name of the object that decodes these attributes).

Have to say I’d prefer to see that while reading HTML source, but in the end it might indeed be more sensible to have the attribute naming match the property naming in the controller.

@javan

This comment has been minimized.

Contributor

javan commented Nov 6, 2018

What I dislike about data-class is the bare word “class”, which is overloaded with meaning. Controllers are classes, CSS has classes, elements have a class attribute, but no class property (they have element.className, element.classList properties).

Seeing (somewhat contrived) HTML like,

<div data-controller="publication" data-class="published:post">

I’m reminded of ActiveRecord’s syntax for declaring an association’s class,

has_many :published_posts, class_name: "Publication::Post"

and it’s not immediately clear to me that the data-class value…

  1. is a key/value pairing
  2. contains CSS class names
  3. isn’t specifying a JavaScript class for the controller

classy
@adrienpoly

This comment has been minimized.

adrienpoly commented Nov 6, 2018

In general, keys should be snake_case in Ruby, kebab-case in HTML, and camelCase in JavaScript, following each language's own conventions. Rails and Stimulus attempt to convert between these as necessary. That means no underscores in your Stimulus controllers.

I know! here as I am doing a wrapper between Flatpickr and Stimulus, I am taking the options that are provided by the parent library. I agree that time_24hr was probably a bad choice at the first place.

You've hit a case where the mapping between the three is not "round-trippable". Your snake-case data key time_24hr becomes the kebab-case attribute name time-24hr. But in camelCase, you'd write static values = { time24hr: Boolean }, which translates to the kebab-case attribute name time24hr. Blech!

Thanks! just learned a new word and concept round trippable excellent

Thanks for pointing this out

great : Will test the new build pack

@adrienpoly

This comment has been minimized.

adrienpoly commented Nov 6, 2018

and it’s not immediately clear to me that the data-class value…

  1. is a key/value pairing

I have to admit that it also took some time on my side to get it.

Brainstorming here

values have the following pattern

data-controllerName-key="value"

how about for the class

data-class-key="value"
@javan

This comment has been minimized.

Contributor

javan commented Nov 6, 2018

how about for the class data-class-key="value"

Like this?

<div data-controller="clipboard"
     data-class-success="copy-to-clipboard--success"
     data-class-supported="copy-to-clipboard--supported">
@ngan

This comment has been minimized.

ngan commented Nov 6, 2018

Can't the new Value API do what the Class API is doing?

<div data-controller="clipboard" data-clipboard-success-class="copy-to-clipboard--success">

Then access it with:

this.successClassValue

Just my 2 cents, but I'm not sure what the gain is for specifically pulling out css classes as another concept.

@alinnert

This comment has been minimized.

alinnert commented Nov 15, 2018

I'm using Stimulus 1.1 in a project that we're building right now. I'm in a team with a back-end developer (while I'm responsible for the client side stuff: HTML, JS, Sass) and he told me that the HTML "I generate" is very talkative i. e. it's too much code - although he agrees that it's a good thing that you have full control over every aspect of the HTML.

Based on this I do think that combing all classes into one property is a very good idea, because it reduces the source code significantly (and we're also using BEM class names, like in the examples above). I also think that data-class-success="copy-to-clipboard--success" doesn't add much value over the current Data Map feature. So I'm totally in for a data-class(-map) attribute.

@alinnert

This comment has been minimized.

alinnert commented Nov 15, 2018

May I throw in an idea about the values API - based on the idea about custom value types? I'd prefer an API like this:

export default class extends Controller {
  static values = {
    url: String, // Shortcut for `url: { type: String }`?
    loadItems: { type: Number, default: 5 }
  }
}

This is similar to Vue's API. That way you can define your own defaults. The above could be an example for a "load more" button below a list of articles or search results of some kind that loads 5 items by default when the user clicks on the button. This can later also be extended to support custom serialize/deserialize functions (which I prefer over passing classes) and other stuff (mark a value as required maybe? ← not necessarily something I need, just an idea).

Example for a Controller that displays a FontAwesome icon:

<div data-controller="icon" data-icon-name="bookmark regular"></div>
export class IconController extends Controller {
  static values = {
    name: {
      type: String,
      required: true,
      deserialize (stringValue) {
        const [name, style = 'solid'] = stringValue.split(' ')
        return { name, style }
      },
      serialize ({ name, style = '' } = {}) { return (`${name} ${style}`).trim() }
    }
  }

  someMethod () {
    const { name, style } = this.nameValue
    name === 'bookmark'
    style === 'regular'
  }
}

(I do think that parse and stringify are very valid alternative names for serialize and deserialize though. Similar to the methods in JSON.)

What do you think about this?

@mrhead

This comment has been minimized.

mrhead commented Nov 27, 2018

I really like both new APIs 👏.

However, I'm not sure how to set values via JSON. Is something like the following possible? I tried different variations, but it wasn't working for me:

export default class extends Controller {
  static values = {
    price: Number,
    discountedPrice: Number,
    // a few other values
  }
}
<div data-controller="checkout" data-checkout-values="<%= @checkout.to_json %>">
  ...
</div>

If there are a lot of values, then I find it cumbersome to manually create an HTML attribute for each one of them.

I know that I can do the following, but then I'm not taking the advantage of the new values API:

export default class extends Controller {
  static values = {
    checkout: Object
  }
}
<div data-controller="checkout" data-checkout-checkout="<%= @checkout.to_json %>">
  ...
</div>

Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment