phoenix_live_view 1.0.2 → 1.0.4

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.
package/README.md CHANGED
@@ -193,19 +193,11 @@ $ npm run test
193
193
 
194
194
  Running the JavaScript unit tests:
195
195
 
196
- ```bash
197
- $ cd assets
198
- $ npm install
199
- $ npm run test
200
- # to automatically run tests for files that have been changed
201
- $ npm run test.watch
202
- ```
203
-
204
- or simply:
205
-
206
196
  ```bash
207
197
  $ npm run setup
208
198
  $ npm run js:test
199
+ # to automatically run tests for files that have been changed
200
+ $ npm run js:test.watch
209
201
  ```
210
202
 
211
203
  Running the JavaScript end-to-end tests:
@@ -10,6 +10,7 @@ import {
10
10
  PHX_PARENT_ID,
11
11
  PHX_PRIVATE,
12
12
  PHX_REF_SRC,
13
+ PHX_REF_LOCK,
13
14
  PHX_PENDING_ATTRS,
14
15
  PHX_ROOT_ID,
15
16
  PHX_SESSION,
@@ -148,7 +149,7 @@ let DOM = {
148
149
  cids.forEach(cid => {
149
150
  this.filterWithinSameLiveView(this.all(node, `[${PHX_COMPONENT}="${cid}"]`), node).forEach(parent => {
150
151
  parentCids.add(cid)
151
- this.all(parent, `[${PHX_COMPONENT}]`)
152
+ this.filterWithinSameLiveView(this.all(parent, `[${PHX_COMPONENT}]`), parent)
152
153
  .map(el => parseInt(el.getAttribute(PHX_COMPONENT)))
153
154
  .forEach(childCID => childrenCids.add(childCID))
154
155
  })
@@ -545,6 +546,10 @@ let DOM = {
545
546
  if(!ops){ return }
546
547
 
547
548
  ops.forEach(([name, op, _stashed]) => this.putSticky(el, name, op))
549
+ },
550
+
551
+ isLocked(el){
552
+ return el.hasAttribute && el.hasAttribute(PHX_REF_LOCK)
548
553
  }
549
554
  }
550
555
 
@@ -18,6 +18,7 @@ import {
18
18
 
19
19
  import {
20
20
  detectDuplicateIds,
21
+ detectInvalidStreamInserts,
21
22
  isCid
22
23
  } from "./utils"
23
24
 
@@ -26,40 +27,7 @@ import DOMPostMorphRestorer from "./dom_post_morph_restorer"
26
27
  import morphdom from "morphdom"
27
28
 
28
29
  export default class DOMPatch {
29
- static patchWithClonedTree(container, clonedTree, liveSocket){
30
- let focused = liveSocket.getActiveElement()
31
- let {selectionStart, selectionEnd} = focused && DOM.hasSelectionRange(focused) ? focused : {}
32
- let phxUpdate = liveSocket.binding(PHX_UPDATE)
33
- let externalFormTriggered = null
34
-
35
- morphdom(container, clonedTree, {
36
- childrenOnly: false,
37
- onBeforeElUpdated: (fromEl, toEl) => {
38
- DOM.syncPendingAttrs(fromEl, toEl)
39
- // we cannot morph locked children
40
- if(!container.isSameNode(fromEl) && fromEl.hasAttribute(PHX_REF_LOCK)){ return false }
41
- if(DOM.isIgnored(fromEl, phxUpdate)){ return false }
42
- if(focused && focused.isSameNode(fromEl) && DOM.isFormInput(fromEl)){
43
- DOM.mergeFocusedInput(fromEl, toEl)
44
- return false
45
- }
46
- if(DOM.isNowTriggerFormExternal(toEl, liveSocket.binding(PHX_TRIGGER_ACTION))){
47
- externalFormTriggered = toEl
48
- }
49
- }
50
- })
51
-
52
- if(externalFormTriggered){
53
- liveSocket.unload()
54
- // use prototype's submit in case there's a form control with name or id of "submit"
55
- // https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/submit
56
- Object.getPrototypeOf(externalFormTriggered).submit.call(externalFormTriggered)
57
- }
58
-
59
- liveSocket.silenceEvents(() => DOM.restoreFocus(focused, selectionStart, selectionEnd))
60
- }
61
-
62
- constructor(view, container, id, html, streams, targetCID){
30
+ constructor(view, container, id, html, streams, targetCID, opts={}){
63
31
  this.view = view
64
32
  this.liveSocket = view.liveSocket
65
33
  this.container = container
@@ -79,6 +47,8 @@ export default class DOMPatch {
79
47
  afteradded: [], afterupdated: [], afterdiscarded: [], afterphxChildAdded: [],
80
48
  aftertransitionsDiscarded: []
81
49
  }
50
+ this.withChildren = opts.withChildren || opts.undoRef || false
51
+ this.undoRef = opts.undoRef
82
52
  }
83
53
 
84
54
  before(kind, callback){ this.callbacks[`before${kind}`].push(callback) }
@@ -115,7 +85,7 @@ export default class DOMPatch {
115
85
 
116
86
  let externalFormTriggered = null
117
87
 
118
- function morph(targetContainer, source, withChildren=false){
88
+ function morph(targetContainer, source, withChildren=this.withChildren){
119
89
  let morphCallbacks = {
120
90
  // normally, we are running with childrenOnly, as the patch HTML for a LV
121
91
  // does not include the LV attrs (data-phx-session, etc.)
@@ -247,7 +217,8 @@ export default class DOMPatch {
247
217
  // apply any changes that happened while the element was locked.
248
218
  let isFocusedFormEl = focused && fromEl.isSameNode(focused) && DOM.isFormInput(fromEl)
249
219
  let focusedSelectChanged = isFocusedFormEl && this.isChangedSelect(fromEl, toEl)
250
- if(fromEl.hasAttribute(PHX_REF_SRC)){
220
+ // only perform the clone step if this is not a patch that unlocks
221
+ if(fromEl.hasAttribute(PHX_REF_SRC) && fromEl.getAttribute(PHX_REF_LOCK) != this.undoRef){
251
222
  if(DOM.isUploadInput(fromEl)){
252
223
  DOM.mergeAttrs(fromEl, toEl, {isIgnored: true})
253
224
  this.trackBefore("updated", fromEl, toEl)
@@ -341,6 +312,7 @@ export default class DOMPatch {
341
312
 
342
313
  if(liveSocket.isDebugEnabled()){
343
314
  detectDuplicateIds()
315
+ detectInvalidStreamInserts(this.streamInserts)
344
316
  // warn if there are any inputs named "id"
345
317
  Array.from(document.querySelectorAll("input[name=id]")).forEach(node => {
346
318
  if(node.form){
@@ -461,7 +433,7 @@ export default class DOMPatch {
461
433
  transitionPendingRemoves(){
462
434
  let {pendingRemoves, liveSocket} = this
463
435
  if(pendingRemoves.length > 0){
464
- liveSocket.transitionRemoves(pendingRemoves, false, () => {
436
+ liveSocket.transitionRemoves(pendingRemoves, () => {
465
437
  pendingRemoves.forEach(el => {
466
438
  let child = DOM.firstPhxChild(el)
467
439
  if(child){ liveSocket.destroyViewByEl(child) }
@@ -11,6 +11,15 @@ import {
11
11
  import DOM from "./dom"
12
12
 
13
13
  export default class ElementRef {
14
+ static onUnlock(el, callback){
15
+ if(!DOM.isLocked(el) && !el.closest(`[${PHX_REF_LOCK}]`)){ return callback() }
16
+ const closestLock = el.closest(`[${PHX_REF_LOCK}]`)
17
+ const ref = closestLock.closest(`[${PHX_REF_LOCK}]`).getAttribute(PHX_REF_LOCK)
18
+ closestLock.addEventListener(`phx:undo-lock:${ref}`, () => {
19
+ callback()
20
+ }, {once: true})
21
+ }
22
+
14
23
  constructor(el){
15
24
  this.el = el
16
25
  this.loadingRef = el.hasAttribute(PHX_REF_LOADING) ? parseInt(el.getAttribute(PHX_REF_LOADING), 10) : null
@@ -47,11 +47,32 @@ let Hooks = {
47
47
  mounted(){
48
48
  this.focusStart = this.el.firstElementChild
49
49
  this.focusEnd = this.el.lastElementChild
50
- this.focusStart.addEventListener("focus", () => ARIA.focusLast(this.el))
51
- this.focusEnd.addEventListener("focus", () => ARIA.focusFirst(this.el))
52
- this.el.addEventListener("phx:show-end", () => this.el.focus())
53
- if(window.getComputedStyle(this.el).display !== "none"){
54
- ARIA.focusFirst(this.el)
50
+ this.focusStart.addEventListener("focus", (e) => {
51
+ if(!e.relatedTarget || !this.el.contains(e.relatedTarget)){
52
+ // Handle focus entering from outside (e.g. Tab when body is focused)
53
+ // https://github.com/phoenixframework/phoenix_live_view/issues/3636
54
+ const nextFocus = e.target.nextElementSibling
55
+ ARIA.attemptFocus(nextFocus) || ARIA.focusFirst(nextFocus)
56
+ } else {
57
+ ARIA.focusLast(this.el)
58
+ }
59
+ })
60
+ this.focusEnd.addEventListener("focus", (e) => {
61
+ if(!e.relatedTarget || !this.el.contains(e.relatedTarget)){
62
+ // Handle focus entering from outside (e.g. Shift+Tab when body is focused)
63
+ // https://github.com/phoenixframework/phoenix_live_view/issues/3636
64
+ const nextFocus = e.target.previousElementSibling
65
+ ARIA.attemptFocus(nextFocus) || ARIA.focusLast(nextFocus)
66
+ } else {
67
+ ARIA.focusFirst(this.el)
68
+ }
69
+ })
70
+ // only try to change the focus if it is not already inside
71
+ if(!this.el.contains(document.activeElement)){
72
+ this.el.addEventListener("phx:show-end", () => this.el.focus())
73
+ if(window.getComputedStyle(this.el).display !== "none"){
74
+ ARIA.focusFirst(this.el)
75
+ }
55
76
  }
56
77
  }
57
78
  }
@@ -97,11 +97,11 @@ let JS = {
97
97
  },
98
98
 
99
99
  exec_focus(e, eventType, phxEvent, view, sourceEl, el){
100
- window.requestAnimationFrame(() => ARIA.attemptFocus(el))
100
+ ARIA.attemptFocus(el)
101
101
  },
102
102
 
103
103
  exec_focus_first(e, eventType, phxEvent, view, sourceEl, el){
104
- window.requestAnimationFrame(() => ARIA.focusFirstInteractive(el) || ARIA.focusFirst(el))
104
+ ARIA.focusFirstInteractive(el) || ARIA.focusFirst(el)
105
105
  },
106
106
 
107
107
  exec_push_focus(e, eventType, phxEvent, view, sourceEl, el){
@@ -219,18 +219,14 @@ let JS = {
219
219
  }
220
220
  } else {
221
221
  if(this.isVisible(el)){
222
- window.requestAnimationFrame(() => {
223
- el.dispatchEvent(new Event("phx:hide-start"))
224
- DOM.putSticky(el, "toggle", currentEl => currentEl.style.display = "none")
225
- el.dispatchEvent(new Event("phx:hide-end"))
226
- })
222
+ el.dispatchEvent(new Event("phx:hide-start"))
223
+ DOM.putSticky(el, "toggle", currentEl => currentEl.style.display = "none")
224
+ el.dispatchEvent(new Event("phx:hide-end"))
227
225
  } else {
228
- window.requestAnimationFrame(() => {
229
- el.dispatchEvent(new Event("phx:show-start"))
230
- let stickyDisplay = display || this.defaultDisplay(el)
231
- DOM.putSticky(el, "toggle", currentEl => currentEl.style.display = stickyDisplay)
232
- el.dispatchEvent(new Event("phx:show-end"))
233
- })
226
+ el.dispatchEvent(new Event("phx:show-start"))
227
+ let stickyDisplay = display || this.defaultDisplay(el)
228
+ DOM.putSticky(el, "toggle", currentEl => currentEl.style.display = stickyDisplay)
229
+ el.dispatchEvent(new Event("phx:show-end"))
234
230
  }
235
231
  }
236
232
  },
@@ -375,7 +375,9 @@ export default class LiveSocket {
375
375
  DOM.all(document, `${PHX_VIEW_SELECTOR}:not([${PHX_PARENT_ID}])`, rootEl => {
376
376
  if(!this.getRootById(rootEl.id)){
377
377
  let view = this.newRootView(rootEl)
378
- view.setHref(this.getHref())
378
+ // stickies cannot be mounted at the router and therefore should not
379
+ // get a href set on them
380
+ if(!DOM.isPhxSticky(rootEl)){ view.setHref(this.getHref()) }
379
381
  view.join()
380
382
  if(rootEl.hasAttribute(PHX_MAIN)){ this.main = view }
381
383
  }
@@ -391,22 +393,26 @@ export default class LiveSocket {
391
393
  }
392
394
 
393
395
  replaceMain(href, flash, callback = null, linkRef = this.setPendingLink(href)){
394
- let liveReferer = this.currentLocation.href
396
+ const liveReferer = this.currentLocation.href
395
397
  this.outgoingMainEl = this.outgoingMainEl || this.main.el
396
- let removeEls = DOM.all(this.outgoingMainEl, `[${this.binding("remove")}]`)
397
- let newMainEl = DOM.cloneNode(this.outgoingMainEl, "")
398
+
399
+ const stickies = DOM.findPhxSticky(document) || []
400
+ const removeEls = DOM.all(this.outgoingMainEl, `[${this.binding("remove")}]`)
401
+ .filter(el => !DOM.isChildOfAny(el, stickies))
402
+
403
+ const newMainEl = DOM.cloneNode(this.outgoingMainEl, "")
398
404
  this.main.showLoader(this.loaderTimeout)
399
405
  this.main.destroy()
400
406
 
401
407
  this.main = this.newRootView(newMainEl, flash, liveReferer)
402
408
  this.main.setRedirect(href)
403
- this.transitionRemoves(removeEls, true)
409
+ this.transitionRemoves(removeEls)
404
410
  this.main.join((joinCount, onDone) => {
405
411
  if(joinCount === 1 && this.commitPendingLink(linkRef)){
406
412
  this.requestDOMUpdate(() => {
407
413
  // remove phx-remove els right before we replace the main element
408
414
  removeEls.forEach(el => el.remove())
409
- DOM.findPhxSticky(document).forEach(el => newMainEl.appendChild(el))
415
+ stickies.forEach(el => newMainEl.appendChild(el))
410
416
  this.outgoingMainEl.replaceWith(newMainEl)
411
417
  this.outgoingMainEl = null
412
418
  callback && callback(linkRef)
@@ -416,12 +422,8 @@ export default class LiveSocket {
416
422
  })
417
423
  }
418
424
 
419
- transitionRemoves(elements, skipSticky, callback){
425
+ transitionRemoves(elements, callback){
420
426
  let removeAttr = this.binding("remove")
421
- if(skipSticky){
422
- const stickies = DOM.findPhxSticky(document) || []
423
- elements = elements.filter(el => !DOM.isChildOfAny(el, stickies))
424
- }
425
427
  let silenceEvents = (e) => {
426
428
  e.preventDefault()
427
429
  e.stopImmediatePropagation()
@@ -693,7 +695,7 @@ export default class LiveSocket {
693
695
  })
694
696
  window.addEventListener("popstate", event => {
695
697
  if(!this.registerNewLocation(window.location)){ return }
696
- let {type, backType, id, root, scroll, position} = event.state || {}
698
+ let {type, backType, id, scroll, position} = event.state || {}
697
699
  let href = window.location.href
698
700
 
699
701
  // Compare positions to determine direction
@@ -707,15 +709,11 @@ export default class LiveSocket {
707
709
 
708
710
  DOM.dispatchEvent(window, "phx:navigate", {detail: {href, patch: type === "patch", pop: true, direction: isForward ? "forward" : "backward"}})
709
711
  this.requestDOMUpdate(() => {
712
+ const callback = () => { this.maybeScroll(scroll) }
710
713
  if(this.main.isConnected() && (type === "patch" && id === this.main.id)){
711
- this.main.pushLinkPatch(event, href, null, () => {
712
- this.maybeScroll(scroll)
713
- })
714
+ this.main.pushLinkPatch(event, href, null, callback)
714
715
  } else {
715
- this.replaceMain(href, null, () => {
716
- if(root){ this.replaceRootHistory() }
717
- this.maybeScroll(scroll)
718
- })
716
+ this.replaceMain(href, null, callback)
719
717
  }
720
718
  })
721
719
  }, false)
@@ -802,7 +800,8 @@ export default class LiveSocket {
802
800
  }
803
801
 
804
802
  historyRedirect(e, href, linkState, flash, targetEl){
805
- if(targetEl && e.isTrusted && e.type !== "popstate"){ targetEl.classList.add("phx-click-loading") }
803
+ const clickLoading = targetEl && e.isTrusted && e.type !== "popstate"
804
+ if(clickLoading){ targetEl.classList.add("phx-click-loading") }
806
805
  if(!this.isConnected() || !this.main.isMain()){ return Browser.redirect(href, flash) }
807
806
 
808
807
  // convert to full href if only path prefix
@@ -831,20 +830,14 @@ export default class LiveSocket {
831
830
  DOM.dispatchEvent(window, "phx:navigate", {detail: {href, patch: false, pop: false, direction: "forward"}})
832
831
  this.registerNewLocation(window.location)
833
832
  }
833
+ // explicitly undo click-loading class
834
+ // (in case it originated in a sticky live view, otherwise it would be removed anyway)
835
+ if(clickLoading){ targetEl.classList.remove("phx-click-loading") }
834
836
  done()
835
837
  })
836
838
  })
837
839
  }
838
840
 
839
- replaceRootHistory(){
840
- Browser.pushState("replace", {
841
- root: true,
842
- type: "patch",
843
- id: this.main.id,
844
- position: this.currentHistoryPosition // Preserve current position
845
- })
846
- }
847
-
848
841
  registerNewLocation(newLocation){
849
842
  let {pathname, search} = this.currentLocation
850
843
  if(pathname + search === newLocation.pathname + newLocation.search){
@@ -23,6 +23,17 @@ export function detectDuplicateIds(){
23
23
  }
24
24
  }
25
25
 
26
+ export function detectInvalidStreamInserts(inserts){
27
+ const errors = new Set()
28
+ Object.keys(inserts).forEach((id) => {
29
+ const streamEl = document.getElementById(id)
30
+ if(streamEl && streamEl.parentElement && streamEl.parentElement.getAttribute("phx-update") !== "stream"){
31
+ errors.add(`The stream container with id "${streamEl.parentElement.id}" is missing the phx-update="stream" attribute. Ensure it is set for streams to work properly.`)
32
+ }
33
+ })
34
+ errors.forEach(error => console.error(error))
35
+ }
36
+
26
37
  export let debug = (view, kind, msg, obj) => {
27
38
  if(view.liveSocket.isDebugEnabled()){
28
39
  console.log(`${view.id} ${kind}: ${msg} - `, obj)
@@ -318,8 +318,12 @@ export default class View {
318
318
  this.formsForRecovery = this.getFormsForRecovery()
319
319
  }
320
320
  if(this.isMain() && window.history.state === null){
321
- // set initial history entry if this is the first page load
322
- this.liveSocket.replaceRootHistory()
321
+ // set initial history entry if this is the first page load (no history)
322
+ Browser.pushState("replace", {
323
+ type: "patch",
324
+ id: this.id,
325
+ position: this.liveSocket.currentHistoryPosition
326
+ })
323
327
  }
324
328
 
325
329
  if(liveview_version !== this.liveSocket.version()){
@@ -697,6 +701,9 @@ export default class View {
697
701
  addHook(el){
698
702
  let hookElId = ViewHook.elementID(el)
699
703
 
704
+ // only ever try to add hooks to elements owned by this view
705
+ if(el.getAttribute && !this.ownsElement(el)){ return }
706
+
700
707
  if(hookElId && !this.viewHooks[hookElId]){
701
708
  // hook created, but not attached (createHook for web component)
702
709
  let hook = DOM.getCustomElHook(el) || logError(`no hook found for custom element: ${el.id}`)
@@ -710,7 +717,6 @@ export default class View {
710
717
  } else {
711
718
  // new hook found with phx-hook attribute
712
719
  let hookName = el.getAttribute(`data-phx-${PHX_HOOK}`) || el.getAttribute(this.binding(PHX_HOOK))
713
- if(hookName && !this.ownsElement(el)){ return }
714
720
  let callbacks = this.liveSocket.getHookCallbacks(hookName)
715
721
 
716
722
  if(callbacks){
@@ -725,9 +731,12 @@ export default class View {
725
731
  }
726
732
 
727
733
  destroyHook(hook){
734
+ // __destroyed clears the elementID from the hook, therefore
735
+ // we need to get it before calling __destroyed
736
+ const hookId = ViewHook.elementID(hook.el)
728
737
  hook.__destroyed()
729
738
  hook.__cleanup__()
730
- delete this.viewHooks[ViewHook.elementID(hook.el)]
739
+ delete this.viewHooks[hookId]
731
740
  }
732
741
 
733
742
  applyPendingUpdates(){
@@ -971,11 +980,12 @@ export default class View {
971
980
  let elRef = new ElementRef(el)
972
981
 
973
982
  elRef.maybeUndo(ref, phxEvent, clonedTree => {
974
- let hook = this.triggerBeforeUpdateHook(el, clonedTree)
975
- DOMPatch.patchWithClonedTree(el, clonedTree, this.liveSocket)
983
+ // we need to perform a full patch on unlocked elements
984
+ // to perform all the necessary logic (like calling updated for hooks, etc.)
985
+ let patch = new DOMPatch(this, el, this.id, clonedTree, [], null, {undoRef: ref})
986
+ const phxChildrenAdded = this.performPatch(patch, true)
976
987
  DOM.all(el, `[${PHX_REF_SRC}="${this.refSrc()}"]`, child => this.undoElRef(child, ref, phxEvent))
977
- this.execNewMounted(el)
978
- if(hook){ hook.__updated() }
988
+ if(phxChildrenAdded){ this.joinNewChildren() }
979
989
  })
980
990
  }
981
991
 
@@ -1182,15 +1192,20 @@ export default class View {
1182
1192
  }
1183
1193
  this.pushWithReply(refGenerator, "event", event).then(({resp}) => {
1184
1194
  if(DOM.isUploadInput(inputEl) && DOM.isAutoUpload(inputEl)){
1185
- if(LiveUploader.filesAwaitingPreflight(inputEl).length > 0){
1186
- let [ref, _els] = refGenerator()
1187
- this.undoRefs(ref, phxEvent, [inputEl.form])
1188
- this.uploadFiles(inputEl.form, phxEvent, targetCtx, ref, cid, (_uploads) => {
1189
- callback && callback(resp)
1190
- this.triggerAwaitingSubmit(inputEl.form, phxEvent)
1191
- this.undoRefs(ref, phxEvent)
1192
- })
1193
- }
1195
+ // the element could be inside a locked parent for other unrelated changes;
1196
+ // we can only start uploads when the tree is unlocked and the
1197
+ // necessary data attributes are set in the real DOM
1198
+ ElementRef.onUnlock(inputEl, () => {
1199
+ if(LiveUploader.filesAwaitingPreflight(inputEl).length > 0){
1200
+ let [ref, _els] = refGenerator()
1201
+ this.undoRefs(ref, phxEvent, [inputEl.form])
1202
+ this.uploadFiles(inputEl.form, phxEvent, targetCtx, ref, cid, (_uploads) => {
1203
+ callback && callback(resp)
1204
+ this.triggerAwaitingSubmit(inputEl.form, phxEvent)
1205
+ this.undoRefs(ref, phxEvent)
1206
+ })
1207
+ }
1208
+ })
1194
1209
  } else {
1195
1210
  callback && callback(resp)
1196
1211
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phoenix_live_view",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "The Phoenix LiveView JavaScript client.",
5
5
  "license": "MIT",
6
6
  "module": "./priv/static/phoenix_live_view.esm.js",
@@ -23,17 +23,35 @@
23
23
  "priv/static/*",
24
24
  "assets/js/phoenix_live_view/*"
25
25
  ],
26
+ "dependencies": {
27
+ "morphdom": "2.7.4"
28
+ },
26
29
  "devDependencies": {
27
- "@eslint/js": "^9.10.0",
28
- "@playwright/test": "^1.47.1",
30
+ "@babel/cli": "7.26.4",
31
+ "@babel/core": "7.26.0",
32
+ "@babel/preset-env": "7.26.0",
33
+ "@eslint/js": "^9.18.0",
34
+ "@playwright/test": "^1.49.1",
35
+ "@stylistic/eslint-plugin-js": "^2.12.1",
36
+ "css.escape": "^1.5.1",
37
+ "eslint": "9.18.0",
38
+ "eslint-plugin-jest": "28.10.0",
29
39
  "eslint-plugin-playwright": "^2.1.0",
30
- "monocart-reporter": "^2.8.0"
40
+ "globals": "^15.14.0",
41
+ "jest": "^29.7.0",
42
+ "jest-environment-jsdom": "^29.7.0",
43
+ "jest-monocart-coverage": "^1.1.1",
44
+ "monocart-reporter": "^2.9.13",
45
+ "phoenix": "1.7.18"
31
46
  },
32
47
  "scripts": {
33
- "setup": "mix deps.get && npm install && cd assets && npm install",
48
+ "setup": "mix deps.get && npm install",
34
49
  "e2e:server": "MIX_ENV=e2e mix test --cover --export-coverage e2e test/e2e/test_helper.exs",
35
50
  "e2e:test": "mix assets.build && cd test/e2e && npx playwright install && npx playwright test",
36
- "js:test": "cd assets && npm install && npm run test",
51
+ "js:test": "jest",
52
+ "js:test.coverage": "jest --coverage",
53
+ "js:test.watch": "jest --watch",
54
+ "js:lint": "eslint --fix && cd assets && eslint --fix",
37
55
  "test": "npm run js:test && npm run e2e:test",
38
56
  "cover:merge": "node test/e2e/merge-coverage.mjs",
39
57
  "cover": "npm run test && npm run cover:merge",