@innertia-solutions/innertia-nuxt 0.1.36 → 0.1.38

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/CHANGELOG.md CHANGED
@@ -4,6 +4,10 @@ All notable changes to `@innertia-solutions/innertia-nuxt` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ### Fixed
8
+
9
+ - `Kanban.Standard`: en modo `bordered=false`, el `ring` de highlight al arrastrar sobre una columna se recortaba por arriba (el contenedor `overflow-x-auto` sin padding clippeaba los 2px del ring). Se agrega `p-1` al scroll container en ese modo.
10
+
7
11
  ### BREAKING CHANGES
8
12
 
9
13
  #### apps → contexts rename
@@ -69,11 +69,11 @@ const props = defineProps({
69
69
  filters: { type: Array, default: () => [] },
70
70
  showFilters: { type: Boolean, default: true },
71
71
 
72
- // Realtime (solo endpoint mode): canal de broadcast a escuchar; el board se
73
- // recarga al recibir cualquiera de los eventos. Por convención DomainEventKey,
74
- // si no se pasan eventos se escucha '<canal>.updated'.
75
- realtimeChannel: { type: String, default: null },
76
- realtimeEvents: { type: Array, default: null },
72
+ // Realtime (solo endpoint mode): por defecto auto-suscribe a los canales que
73
+ // el backend declara en meta.channels. `realtimeChannel` es un override legacy
74
+ // que agrega un canal explícito a la lista.
75
+ realtime: { type: Boolean, default: true },
76
+ realtimeChannel: { type: String, default: null },
77
77
 
78
78
  // Caché en sessionStorage
79
79
  cached: { type: Boolean, default: false },
@@ -368,6 +368,7 @@ const fetchAll = async () => {
368
368
  const res = await api.post(props.endpoint, buildBody())
369
369
  rows.value = Array.isArray(res?.data) ? res.data : (Array.isArray(res) ? res : [])
370
370
  meta.value = res?.meta ?? null
371
+ syncRealtime()
371
372
  saveToCache()
372
373
  emit('loaded', res)
373
374
  } catch (e) {
@@ -409,6 +410,7 @@ const loadFromCache = () => {
409
410
  search.value = cached.search ?? ''
410
411
  activeFilters.value = cached.filters ?? {}
411
412
  isDataFromCache.value = true
413
+ syncRealtime()
412
414
  return true
413
415
  } catch { return false }
414
416
  }
@@ -535,19 +537,20 @@ const onDocMousedown = (e) => {
535
537
  }
536
538
 
537
539
  // ─── Realtime ────────────────────────────────────────────────────────────────
538
- const realtime = props.realtimeChannel ? useRealtime() : null
539
- async function setupRealtime() {
540
- if (!realtime || isItemsMode.value) return
541
- await realtime.connect()
542
- const events = props.realtimeEvents ?? [`${props.realtimeChannel}.updated`]
543
- realtime.subscribe(props.realtimeChannel, Object.fromEntries(events.map(e => [e, () => fetchAll()])))
540
+ // Auto-suscribe a los canales que el backend declara en meta.channels (más el
541
+ // override legacy `realtimeChannel`, si se pasa). Solo en endpoint mode.
542
+ const tableRealtime = (props.realtime && !isItemsMode.value) ? useTableRealtime(() => fetchAll()) : null
543
+ function syncRealtime() {
544
+ if (!tableRealtime) return
545
+ const fromMeta = Array.isArray(meta.value?.channels) ? meta.value.channels : []
546
+ const channels = props.realtimeChannel ? [...new Set([...fromMeta, props.realtimeChannel])] : fromMeta
547
+ tableRealtime.sync(channels)
544
548
  }
545
549
 
546
550
  // ─── Lifecycle ───────────────────────────────────────────────────────────────
547
551
  onMounted(() => {
548
552
  window.addEventListener('keydown', onEsc)
549
553
  document.addEventListener('mousedown', onDocMousedown)
550
- setupRealtime()
551
554
  if (isItemsMode.value) return // rows ya cargados por el watcher immediate
552
555
  if (loadFromCache()) return
553
556
  fetchAll()
@@ -556,7 +559,7 @@ onMounted(() => {
556
559
  onBeforeUnmount(() => {
557
560
  window.removeEventListener('keydown', onEsc)
558
561
  document.removeEventListener('mousedown', onDocMousedown)
559
- if (realtime && props.realtimeChannel) realtime.unsubscribe(props.realtimeChannel)
562
+ tableRealtime?.teardown()
560
563
  })
561
564
 
562
565
  defineExpose({ reload: fetchAll, rows })
@@ -692,7 +695,7 @@ defineExpose({ reload: fetchAll, rows })
692
695
  ]"
693
696
  >
694
697
  <div
695
- :class="['overflow-x-auto', bordered ? 'p-3' : '']"
698
+ :class="['overflow-x-auto', bordered ? 'p-3' : 'p-1']"
696
699
  :style="sizeStyle"
697
700
  >
698
701
  <div v-if="loading && !rows.length" class="flex items-center justify-center py-16 gap-2 text-muted-foreground">
@@ -15,10 +15,11 @@ const props = defineProps({
15
15
  searchPlaceholder: { type: String, default: 'Buscar...' },
16
16
  showSearch: { type: Boolean, default: true },
17
17
  showFilters: { type: Boolean, default: true },
18
- // Realtime: canal de broadcast a escuchar; la tabla hace reload() al recibir
19
- // cualquiera de los eventos. Sin realtimeEvents se escucha '<canal>.updated'.
20
- realtimeChannel: { type: String, default: null },
21
- realtimeEvents: { type: Array, default: null },
18
+ // Realtime: auto-suscripción a los canales que el backend expone en meta.channels
19
+ // (`entity.{tabla}.changed` → reload()). realtimeChannel queda como override manual
20
+ // (legacy, se suma a los del backend); realtime=false apaga la auto-suscripción.
21
+ realtimeChannel: { type: String, default: null }, // override manual (legacy)
22
+ realtime: { type: Boolean, default: true }, // apagar auto-suscripción
22
23
  showExport: { type: Boolean, default: true },
23
24
  showColumns: { type: Boolean, default: true },
24
25
  /** Mostrar selector "Filas por página" en el footer. Default false. */
@@ -382,10 +383,22 @@ const handleRowClick = (row) => {
382
383
  previewRow.value = row
383
384
  }
384
385
 
386
+ // ─── Realtime ────────────────────────────────────────────────────────────────
387
+ // Auto-suscripción a los canales que el backend expone en meta.channels. El
388
+ // override manual (realtimeChannel) se suma a los del backend. realtime=false
389
+ // desactiva la suscripción.
390
+ const tableRealtime = props.realtime ? useTableRealtime(() => reload()) : null
391
+
392
+ function channelsFrom(meta) {
393
+ const fromMeta = Array.isArray(meta?.channels) ? meta.channels : []
394
+ return props.realtimeChannel ? [...new Set([...fromMeta, props.realtimeChannel])] : fromMeta
395
+ }
396
+
385
397
  // Cuando recarga la data, mantener viva la fila previewada (o cerrar si fue eliminada).
386
398
  const handleLoaded = (res) => {
387
399
  emit('loaded', res)
388
400
  if (res?.meta) tableMeta.value = res.meta
401
+ tableRealtime?.sync(channelsFrom(res?.meta))
389
402
  if (previewRow.value && Array.isArray(res?.data)) {
390
403
  const fresh = res.data.find(r => r.id === previewRow.value.id)
391
404
  if (fresh) previewRow.value = fresh
@@ -415,20 +428,10 @@ onMounted(() => {
415
428
  })
416
429
  })
417
430
 
418
- // Realtime opcional: recarga (preservando UI) al recibir eventos del canal.
419
- const realtime = props.realtimeChannel ? useRealtime() : null
420
- async function setupRealtime() {
421
- if (!realtime) return
422
- await realtime.connect()
423
- const events = props.realtimeEvents ?? [`${props.realtimeChannel}.updated`]
424
- realtime.subscribe(props.realtimeChannel, Object.fromEntries(events.map(e => [e, () => reload()])))
425
- }
426
-
427
431
  onMounted(async () => {
428
432
  previewEnabled.value = !!slots.preview
429
433
  window.addEventListener('keydown', onEsc)
430
434
  document.addEventListener('mousedown', onDocMousedown)
431
- setupRealtime()
432
435
 
433
436
  // Load column preferences from backend
434
437
  if (props.persistPreferences && resolvedName.value) {
@@ -448,7 +451,7 @@ onMounted(async () => {
448
451
  onBeforeUnmount(() => {
449
452
  window.removeEventListener('keydown', onEsc)
450
453
  document.removeEventListener('mousedown', onDocMousedown)
451
- if (realtime && props.realtimeChannel) realtime.unsubscribe(props.realtimeChannel)
454
+ tableRealtime?.teardown()
452
455
  })
453
456
 
454
457
  // ─── Column panel ─────────────────────────────────────────────────────────────
@@ -78,6 +78,10 @@ const props = defineProps({
78
78
 
79
79
  // Resalta la fila seleccionada (id del nodo). Para selección externa via @row-click.
80
80
  selectedId: { type: [String, Number], default: null },
81
+
82
+ // Realtime: auto-suscripción a los canales que el backend declara en meta.channels
83
+ // (`entity.{tabla}.changed` → reload() preservando expansión). realtime=false apaga.
84
+ realtime: { type: Boolean, default: true },
81
85
  })
82
86
 
83
87
  const sizeStyle = computed(() => {
@@ -202,6 +206,7 @@ const fetchInitial = async () => {
202
206
  const res = await api.post(props.endpoint, buildBody())
203
207
  roots.value = res?.data ?? []
204
208
  meta.value = res?.meta ?? null
209
+ syncRealtime()
205
210
  childrenById.value = {}
206
211
  // Colapsar todo: sin esto, un nodo expandido antes de cambiar search/params
207
212
  // queda con el chevron abierto pero sin hijos (childrenById se vació).
@@ -252,6 +257,15 @@ const reload = async () => {
252
257
  }
253
258
  }
254
259
 
260
+ // ─── Realtime ─────────────────────────────────────────────────────────────────
261
+ // Auto-suscripción a los canales del backend (meta.channels). Cada `{channel}.changed`
262
+ // dispara reload() (debounced), refrescando datos sin perder la expansión actual.
263
+ const tableRealtime = props.realtime ? useTableRealtime(() => reload()) : null
264
+ function syncRealtime() {
265
+ if (!tableRealtime) return
266
+ tableRealtime.sync(Array.isArray(meta.value?.channels) ? meta.value.channels : [])
267
+ }
268
+
255
269
  // ─── Cache ────────────────────────────────────────────────────────────────────
256
270
  const cacheKey = computed(() => {
257
271
  if (!props.cached || !props.name) return null
@@ -296,6 +310,7 @@ const loadFromCacheOnMount = () => {
296
310
  activeFilters.value = cached.filters ?? {}
297
311
  columnVisibility.value = cached.columnVisibility ?? columnVisibility.value
298
312
  isDataFromCache.value = true
313
+ syncRealtime()
299
314
  return true
300
315
  } catch {
301
316
  return false
@@ -567,6 +582,7 @@ onMounted(() => {
567
582
  onBeforeUnmount(() => {
568
583
  window.removeEventListener('keydown', onEsc)
569
584
  document.removeEventListener('mousedown', onDocMousedown)
585
+ tableRealtime?.teardown()
570
586
  })
571
587
 
572
588
  defineExpose({
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Suscribe un componente de datos (table/kanban/tree) a uno o varios canales
3
+ * `entity.{tabla}` y dispara reloadFn() debounceado al recibir `{canal}.changed`.
4
+ *
5
+ * - sync(channels): difunde la lista de canales (de meta.channels). Suscribe los
6
+ * nuevos, des-suscribe los que ya no están. Idempotente: llamar tras cada fetch.
7
+ * - teardown(): des-suscribe todo (llamar en onBeforeUnmount).
8
+ *
9
+ * Coalesce: varios pings en la ventana de debounce → un solo reload.
10
+ * In-flight guard: si llega un ping mientras reloadFn está corriendo, re-dispara al terminar.
11
+ */
12
+ export function useTableRealtime(reloadFn, { debounceMs = 400 } = {}) {
13
+ const rt = useRealtime()
14
+ const subscribed = new Set()
15
+ let timer = null
16
+ let inFlight = false
17
+ let pending = false
18
+
19
+ const fire = async () => {
20
+ inFlight = true
21
+ try { await reloadFn() }
22
+ finally {
23
+ inFlight = false
24
+ if (pending) { pending = false; ping() }
25
+ }
26
+ }
27
+
28
+ const ping = () => {
29
+ if (inFlight) { pending = true; return }
30
+ clearTimeout(timer)
31
+ timer = setTimeout(fire, debounceMs)
32
+ }
33
+
34
+ const sync = async (channels = []) => {
35
+ const next = new Set(Array.isArray(channels) ? channels.filter(Boolean) : [])
36
+ if (!next.size && !subscribed.size) return
37
+ if (next.size) await rt.connect()
38
+
39
+ for (const ch of [...subscribed]) {
40
+ if (!next.has(ch)) { rt.unsubscribe(ch); subscribed.delete(ch) }
41
+ }
42
+ for (const ch of next) {
43
+ if (!subscribed.has(ch)) {
44
+ rt.subscribe(ch, { [`${ch}.changed`]: ping })
45
+ subscribed.add(ch)
46
+ }
47
+ }
48
+ }
49
+
50
+ const teardown = () => {
51
+ clearTimeout(timer)
52
+ for (const ch of [...subscribed]) rt.unsubscribe(ch)
53
+ subscribed.clear()
54
+ }
55
+
56
+ return { sync, teardown }
57
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@innertia-solutions/innertia-nuxt",
3
- "version": "0.1.36",
3
+ "version": "0.1.38",
4
4
  "description": "Innertia Solutions — Nuxt layer unificada: core, auth, multitenancy, theme y app contexts",
5
5
  "keywords": [
6
6
  "nuxt",