How to add custom JS file to new rails 7 project
Asked Answered
S

4

46

I created new rails 7 project rails new my_project and have a problem to include my custom JS file to be processed by rails.

my "javascript/application.js"

import "@hotwired/turbo-rails"
import "controllers"

import "chartkick"
import "Chart.bundle"
import "custom/uni_toggle"

my custom JS file: "javascript/custom/uni_toggle.js"

function uniToggleShow() {
    document.querySelectorAll(".uni-toggle").forEach(e => e.classList.remove("hidden"))
}

function uniToggleHide() {
    console.log("uni toggle hide")
    document.querySelectorAll(".uni-toggle").forEach(e => e.classList.add("hidden"))
}

window.uniToggleShow = uniToggleShow
window.uniToggleHide = uniToggleHide

I'm using in my layout <%= javascript_importmap_tags %>

and my "config/importmap.rb"

pin "application", preload: true
pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true
pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true
pin_all_from "app/javascript/controllers", under: "controllers"
Satisfied answered 1/1, 2022 at 12:18 Comment(0)
I
111
1. Quickstart            - quick things you should know
2. `pin_all_from`        - a few details
3. `pin`                   ...
4. Run in a console      - when you need to figure stuff out
5. Relative imports      - don't do it, unless you want to
6. Examples              - to make it extra clear

If you're not using importmap-rails, really, you should not have any issues. Add a file then import "./path/to/file". Make sure to run bin/dev to compile your javascript if you're using jsbundling-rails.

If you're using importmap-rails, there is no compilation, every single file has to be served individually in development and production, and every import has to be mapped to a url for browser to fetch.

pin and pin_all_from is a rails way of constructing an importmap. Imports are mapped to local files through an asset url. So just keep in mind, import "something" could map to url /assets/file-123.js which could map to file app/some_asset_path/file.js or in production public/assets/file-123.js:

<script type="importmap" data-turbo-track="reload">{
  "imports": {
    "application":                  "/assets/application-da9b182f12cdd2de0b86de67fc7fde8d1887a7d9ffbde46937f8cd8553cb748d.js",
    "@hotwired/turbo-rails":        "/assets/turbo.min-49f8a244b039107fa6d058adce740847d31bdf3832c043b860ebcda099c0688c.js",
    "@hotwired/stimulus":           "/assets/stimulus-a1299f07b3a1d1083084767c6e16a178a910487c81874b80623f7f2e48f99a86.js",
    "@hotwired/stimulus-loading":   "/assets/stimulus-loading-6024ee603e0509bba59098881b54a52936debca30ff797835b5ec6a4ef77ba37.js",
    "controllers/application":      "/assets/controllers/application-44e5edd38372876617b8ba873a82d48737d4c089e5180f706bdea0bb7b6370be.js",
    "controllers/hello_controller": "/assets/controllers/hello_controller-29468750494634340c5c12678fe2cdc3bee371e74ac4e9de625cdb7a89faf11b.js",
    "controllers":                  "/assets/controllers/index-e70bed6fafbd4e4aae72f8c6fce4381d19507272ff2ff0febb3f775447accb4b.js",
  }#    ^                             ^
   #    |                             |
   #  names you use to import        urls browser uses to get it
   #    |                             ^ 
   #    |                             |
   #    `------>  mapped to  ---------'
}</script>

Once you have an importmap you have to import the things you need. Importmap doesn't load anything, it is just a configuration.


Quickstart

Let's say we've added a plugin directory:

app/
└── javascript/
   ├── application.js   # <= imports go here and other js files
   └── plugin/
      ├── app.js
      └── index.js
config/
└── importmap.rb        # <= pins go here

Pin a single file:

# config/importmap.rb
pin "plugin/app"
pin "plugin/index"

# app/javascript/application.js
import "plugin/app"     # which maps to a url which maps to a file
import "plugin/index"

or pin all the files in plugin directory and subdirectories:

# config/importmap.rb
pin_all_from "app/javascript/plugin", under: "plugin"

# app/javascript/application.js
import "plugin/app"
import "plugin"         # will import plugin/index.js

Do not use relative imports, such as import "./plugin/app", it may work in development, but it will break in production.
See the output of bin/importmap json to know what you can import and verify importmap.rb config.

Do not precompile in development, it will serve precompiled assets from public/assets which do not update when you make changes.
Run bin/rails assets:clobber to remove precompiled assets.

In case something doesn't work, app/javascript directory has to be in:
Rails.application.config.assets.paths
and app/assets/config/manifest.js as //= link_tree ../../javascript .js


Pinning your files doesn't make them load. They have to be imported in application.js:

// app/javascript/application.js
import "plugin"

Alternatively, if you want to split up your bundle, you can use a separate module tag in your layout:

<%= javascript_import_module_tag "plugin" %>

or templates:

<% content_for :head do %>
  <%= javascript_import_module_tag "plugin" %>
<% end %>

# add this to the end of the <head> tag:
# <%= yield :head %>

You can also add another entrypoint in addition to application.js, say you've added app/javascript/admin.js. You can import it with all the pins:

# this doesn't `import` application.js anymore
<%= javascript_importmap_tags "admin" %>

Because application pin has preload: true option set by default it will issue a request to load application.js file, even when you override application entrypoint with admin. Preloading and importing are two separate things, one does not cause the other. Remove preload option to avoid unnecessary request.


pin_all_from(dir, under: nil, to: nil, preload: false)

Pins all the files in a directory and subdirectories.

https://github.com/rails/importmap-rails/blob/v1.1.2/lib/importmap/map.rb#L33

def pin_all_from(dir, under: nil, to: nil, preload: false)
  clear_cache
  @directories[dir] = MappedDir.new(dir: dir, under: under, path: to, preload: preload)
end

dir - Path relative to Rails.root or an absolute path.

Options:

:under - Optional[1] pin prefix. Required if you have index.js file.

:to - Optional[1] path to asset. Falls back to :under option. Required if :under is omitted. This path is relative to Rails.application.config.assets.paths.

:preload - Adds a modulepreload link if set to true:

<link rel="modulepreload" href="/assets/turbo-5605bff731621f9ca32b71f5270be7faa9ccb0c7c810187880b97e74175d85e2.js">
  1. note: either :under or :to is required

To pin all the files in the plugin directory:

pin_all_from "app/javascript/plugin", under: "plugin"

# NOTE: `index.js` file gets a special treatment, instead
#       of pinning `plugin/index` it is just `plugin`.
{
  "imports": {
    "plugin/app": "/assets/plugin/app-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js",
    "plugin": "/assets/plugin/index-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js"
  }
}

Here is how it all fits together:
(if something doesn't work, take your options and follow the arrows, especially the path_to_asset part, you can try it in the console, see below)

   "plugin/app": "/assets/plugin/app-04024382391bb...4145d8113cf788597.js"
#   ^      ^      ^
#   |      |      |  
# :under   |      `-path_to_asset("plugin/app.js")
#          |                       ^      ^
#          |                       |      |
#          |..       (:to||:under)-'      |
#  "#{dir}/app.js"                        |
#          '''''`-------------------------'             

:to option might not be obvious here. It is useful if :under option is changed, which will make path_to_asset fail to find app.js.

For example, :under option can be anything you want, but :to option has to be a path that asset pipeline, Sprockets, can find (see Rails.application.config.assets.paths) and also precompile (see app/assets/config/manifest.js).

pin_all_from "app/javascript/plugin", under: "@plug", to: "plugin"

# Outputs these pins
#
#   "@plug/app": "/assets/plugin/app-04024382391b1...16beb14ce788597.js"
#   "@plug": "/assets/plugin/index-04024382391bb91...4ebeb14ce788597.js"
#
# and can be used like this
#
#   import "@plug";
#   import "@plug/app";

Specifying absolute path will bypass asset pipeline:

pin_all_from("app/javascript/plugin", under: "plugin", to: "/plugin")

#   "plugin/app": "/plugin/app.js"
#   "plugin": "/plugin/index.js"
#
# NOTE: It is up to you to set up `/plugin/*` route and serve these files.

pin(name, to: nil, preload: false)

Pins a single file.

https://github.com/rails/importmap-rails/blob/v1.1.2/lib/importmap/map.rb#L28

def pin(name, to: nil, preload: false)
  clear_cache
  @packages[name] = MappedFile.new(name: name, path: to || "#{name}.js", preload: preload)
end

name - Name of the pin.

Options:

:to - Optional path to asset. Falls back to {name}.js. This path is relative to Rails.application.config.assets.paths.

:preload - Adds a modulepreload link if set to true


When pinning a local file, specify name relative to app/javascript directory (or vendor or any other asset directory).

pin "plugin/app"
pin "plugin/index"

{
  "imports": {
    "plugin/app": "/assets/plugin/app-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js",
    "plugin/index": "/assets/plugin/index-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js"
  }
}

Here is how it fits together:

   "plugin/app": "/assets/plugin/app-04024382391bb...16cebeb14ce788597.js"
#   ^             ^
#   |             |  
#  name           `-path_to_asset("plugin/app.js")
#                                  ^
#                                  |
#              (:to||"#{name}.js")-'

If you want to change the name of the pin, :to option is required to give path_to_asset a valid file location.

For example, to get the same pin for index.js file as the one we get from pin_all_from:

pin "plugin", to: "plugin/index"

{
  "imports": {
    "plugin": "/assets/plugin/index-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js"
  }
} 

Run in a console

You can mess around with Importmap in the console, it's faster to debug and learn what works and what doesn't:

>> helper.path_to_asset("plugin/app.js")
=> "/assets/plugin/app-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js"

>> map = Importmap::Map.new
>> map.pin_all_from("app/javascript/plugin", under: "plugin")
>> puts map.to_json(resolver: helper)
{
  "imports": {
    "plugin/app": "/assets/plugin/app-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js",
    "plugin": "/assets/plugin/index-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js"
  }
}

>> map.pin("application")
>> puts map.to_json(resolver: helper)
{
  "imports": {
    "application": "/assets/application-8cab2d9024ef6f21fd55792af40001fd4ee1b72b8b7e14743452fab1348b4f5a.js"
  }
}

# Importmap from config/importmap.rb
>> Rails.application.importmap

Relative/absolute imports

Relative/absolute imports could work, if you make the correct mapping:

# config/importmap.rb
pin "/assets/plugin/app", to: "plugin/app.js"
// app/javascript/application.js
import "./plugin/app"

application.js is mapped to digested /assets/application-123.js, because ./plugin/app is relative to /assets/application-123.js, it should be correctly resolved to /assets/plugin/app which has an importmap that we made with our pin:

"/assets/plugin/app": "/assets/plugin/app-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js",

This should also just work:

// app/javascript/plugin/index.js
import "./app"

However, while import-maps support all the relative and absolute imports, this doesn't seem to be the intended use case in importmap-rails.


Examples

This should cover just about everything:

.
├── app/
│   └── javascript/
│       ├── admin.js
│       ├── application.js
│       ├── extra/
│       │   └── nested/
│       │       └── directory/
│       │           └── special.js
│       └── plugin/
│           ├── app.js
│           └── index.js
└── vendor/
    └── javascript/
        ├── downloaded.js
        └── package/
            └── vendored.js

Output is from running bin/importmap json:

# this is the only time when both `to` and `under` options can be omitted
# you don't really want to do this, at least not for `app/javascript`
pin_all_from "app/javascript"
pin_all_from "vendor/javascript"

"admin":                          "/assets/admin-761ee3050e9046942e5918c64dbfee795eeade86bf3fec34ec126c0d43c931b0.js",
"application":                    "/assets/application-d0d262731ff4f756b418662f3149e17b608d2aab7898bb983abeb669cc73bf2e.js",
"extra/nested/directory/special": "/assets/extra/nested/directory/special-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js",
"plugin/app":                     "/assets/plugin/app-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js",
"plugin":                         "/assets/plugin/index-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js",
"downloaded":                     "/assets/downloaded-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js",
"package/vendored":               "/assets/package/vendored-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js"

Note the difference:

pin_all_from "app/javascript/extra", under: "extra"    # `to: "extra"` is implied
"extra/nested/directory/special": "/assets/extra/nested/directory/special-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js"
 ^

pin_all_from "app/javascript/extra", to: "extra"
"nested/directory/special": "/assets/extra/nested/directory/special-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js"
 ^

pin_all_from "app/javascript/extra", under: "@name", to: "extra"
"@name/nested/directory/special": "/assets/extra/nested/directory/special-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js"
 ^

Note the pattern:

pin_all_from "app/javascript"
pin_all_from "app/javascript/extra",                  under: "extra"
pin_all_from "app/javascript/extra/nested",           under: "extra/nested"
pin_all_from "app/javascript/extra/nested/directory", under: "extra/nested/directory"
pin_all_from "app/javascript/extra",                  to: "extra"
pin_all_from "app/javascript/extra/nested",           to: "extra/nested"
pin_all_from "app/javascript/extra/nested/directory", to: "extra/nested/directory"
pin_all_from "app/javascript/extra",                  under: "@name", to: "extra"
pin_all_from "app/javascript/extra/nested",           under: "@name", to: "extra/nested"
pin_all_from "app/javascript/extra/nested/directory", under: "@name", to: "extra/nested/directory"

Same exact thing works for vendor:

pin_all_from "vendor/javascript/package", under: "package"
# etc

Single files are easy:

pin "admin"
pin "application"
pin "extra/nested/directory/special"
pin "@extra/special", to: "extra/nested/directory/special"

pin "downloaded"
pin "renamed", to: "downloaded"

When pin_all_from fails:

# if you ever tried this, it doesn't work:
# pin_all_from "app/javascript", under: "@app", to: ""
# but it can be done:
app_js = Rails.root.join("app/javascript")
app_js.glob("**/*.js").each do |path|
  name = path.relative_path_from(app_js).to_s.chomp(".js")
  pin "@app/#{name}", to: name
end
# useful for things like `app/components`
Intellectualize answered 4/7, 2022 at 11:3 Comment(16)
This post deserves all the upvotes. Thank you.Sarcenet
@cesoid if you want to make relative imports work, see the very last code snippet to get an idea of what to do outside of the defaults. but nothing stopping you from making import "pallete" or import "@fractal/pallete" work: pin_all_from "app/javascript/fractal_viewer", to: "fractal_viewer" or pin_all_from "app/javascript/fractal_viewer", under: "@fractal", to: "fractal_viewer". but consider that changing your imports for an hour is easier than debugging some edge case issues with relative imports. you'd be better off sticking with esbuild in that case.Intellectualize
Has anything changed since this answer that makes it easier to use relative imports? Or is there some effective equivalent that allows me to not hardcode the directory name of large isolated features of my site in all the imports (e.g., avoid import 'fractal_viewer/palette.js')? [Edit: Removed my pointless rambling.]Antipodes
@Intellectualize Thanks. I ended up doing this: def pin_all_relative(dir_name) ; pin_all_from "app/javascript/#{dir_name}", under: "#{Rails.application.config.assets.prefix}/#{dir_name}", to: dir_name ; end and then calling it once per "component". To explain: My site has over 200 of its own modules organized into directories several layers deep, and even the deepest ones will sometimes import without even referencing their parent directory. In total there are about 600 import statements which I would have to change without a simple find/replace, and I would lose a bit of flexibility and reusability.Antipodes
To explain further: The relative imports work without importmap, but in development it takes up to 10 seconds for a page to load because it isn't retrieving from static assets. This may partly be because puma doesn't support http2, but using caddy to serve them with http2 didn't speed things up. My last resort was to "precompile" the javascript in dev because this puts them in static fingerprinted files in assets, so the 10 second load only happens once, and subsequent loads only grab files that have changed. I have no idea how much of this applies to production, but I'll soon find out.Antipodes
@Antipodes maybe this is the solution for you: sprockets: Completed 200 OK in 110378ms (Allocations: 244013006) vs propshaft: Completed 200 OK in 5355ms (Allocations: 6575854). (10k files, 1k lines each).Intellectualize
This answer is like a missing manual for importmaps.Signesignet
This has to be in the official Rails docs. Especially the part about relative imports from Rails.application.config.assets.paths. It's not obvious IMHO. Thank you for all the effort you put to this commentPoltergeist
That's the most complete answer I have ever seen, congrats @Alex. However, I'm still unable to make it work. I have a app/javascript/custom/custom.js file and regardless the way I try to pin it, it doesn't work. I tried checking the console and trying many different ways but none of them puts my file in bin/import map json or puts map.to_json(resolver: helper) response. The only thing I see that might be different in my side is that my app/javascript/and vendor/javascript are Pathnames in Rails.application.config.assets.paths's responses. Any idea what I'm missing? Thx!Stocktaking
@Stocktaking pin "custom/custom" or pin_all_from "app/javascript/custom", under: "custom" if that doesn't work, the only thing i can think of is a typo somewhere, or manifest.js is misconfigured in which case you should be getting an error or importmap complaining about missing path.Intellectualize
@Alex, pin "custom/custom" worked, thank you!! The main issue was that the server was being initialized set to production, and I didn't realize it. It's all working now, thank you so much!!Stocktaking
If I do the same thing creating a subfolder "plugin", I get the error Uncaught TypeError: Failed to resolve module specifier "plugin". Relative references must start with either "/", "./", or "../". in consoleField
@Field did you create plugin/index.js file? just check bin/importmap json to see what you can importIntellectualize
Hi @Ales, yes, I found that the problem was in the manifest.js file, that did not point to the correct folderField
@Intellectualize I've two inherent questions: 1) What special treatment the index.js file gets? 2) How does importmap work with export statements like export * from "./useControllerMixinName" in an index.js file?Merino
@Merino 1) not much, other than it gets mapped to a directory name: import "plugin" will import "plugin/index.js" 2) relative name is a problemIntellectualize
S
27

Was also having trouble adding custom JS files in my Rails 7 app. I even followed DHH video --> https://www.youtube.com/watch?v=PtxZvFnL2i0 but still was facing difficulties. The following steps worked for me:

  1. Go to config/importmap.rb and add the following:

    pin_all_from "app/javascript/custom", under: "custom"

  2. Go to app/javascript/application.js file and add the following:

    import "custom/main"

  3. In 'app/javascript' directory, add 'custom' folder.

  4. In 'app/javascript/custom' directory add your custom js file 'main.js'.

  5. Run In your terminal:

    rails assets:precompile

  6. Start your rails server. Voilà 👍

Splay answered 1/3, 2022 at 3:33 Comment(6)
this kind of works but running rails assets:precompile after any edits to a custom JS file cannot be the correct behaviourBrit
@Brit I think that video is a little old now. You don't have to precompile anymore.Thermometry
I found this question and followed everything here, the javascript file but what worked for me was putting the custom javasript at the bottom of my view file. So what is the difference or the "right" way??Houstonhoustonia
The 2nd point shold be import "./custom/main"Hydroxide
no it should not. an import like "./custom/main" end up pointing directly to the assets folder. they will break in production.Campuzano
In development it's better to clobber the assets and never precompile them again. Then start the server with "bin/dev" instead of "rails server". That way assets are automatically updated when they change.Signesignet
S
22

After watching DHH video I found the last piece of the puzzle.

To make my custom JS code work, I just added this line to the "confing/importmap.rb"

pin_all_from "app/javascript/custom", under: "custom"
Satisfied answered 3/1, 2022 at 21:16 Comment(1)
so in this way, we add our js file in the "importmap", but what if I have multiple layouts and both have different js files?Navigation
N
2

If you want to use importmap, do what people have answer before.

But if you add the file in the importmap it means that in every layout, the file will be loaded, but if you only want to add single JS file, my suggestion is to use a simple , with javascript_include_tag like this:

<%= javascript_include_tag 'filename' %>
Navigation answered 22/12, 2022 at 12:22 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.