metaowl 0.1.2 → 0.2.0
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 +853 -10
- package/bin/metaowl-create.js +431 -9
- package/index.js +155 -1
- package/modules/app-mounter.js +7 -0
- package/modules/auto-import.js +225 -0
- package/modules/cache.js +2 -0
- package/modules/composables.js +600 -0
- package/modules/error-boundary.js +228 -0
- package/modules/fetch.js +7 -0
- package/modules/file-router.js +425 -19
- package/modules/forms.js +353 -0
- package/modules/i18n.js +333 -0
- package/modules/layouts.js +433 -0
- package/modules/odoo-rpc.js +511 -0
- package/modules/pwa.js +515 -0
- package/modules/router.js +593 -29
- package/modules/seo.js +501 -0
- package/modules/store.js +409 -0
- package/modules/templates-manager.js +5 -0
- package/modules/test-utils.js +532 -0
- package/package.json +1 -1
- package/test/auto-import.test.js +110 -0
- package/test/composables.test.js +103 -0
- package/test/dynamic-routes.test.js +520 -0
- package/test/error-boundary.test.js +126 -0
- package/test/forms.test.js +203 -0
- package/test/i18n.test.js +188 -0
- package/test/layouts.test.js +395 -0
- package/test/odoo-rpc.test.js +547 -0
- package/test/pwa.test.js +154 -0
- package/test/router-guards.test.js +617 -0
- package/test/seo.test.js +353 -0
- package/test/store.test.js +476 -0
- package/test/test-utils.test.js +314 -0
- package/vite/plugin.js +43 -5
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Layouts
|
|
3
|
+
*
|
|
4
|
+
* Layout system for OWL applications, enabling shared page structures.
|
|
5
|
+
*
|
|
6
|
+
* Features:
|
|
7
|
+
* - Named layouts with automatic resolution
|
|
8
|
+
* - Fallback to default layout
|
|
9
|
+
* - Layout-specific state and lifecycle
|
|
10
|
+
* - Nested layouts support
|
|
11
|
+
* - Layout transitions
|
|
12
|
+
*
|
|
13
|
+
* Directory Convention:
|
|
14
|
+
* src/
|
|
15
|
+
* layouts/
|
|
16
|
+
* default/
|
|
17
|
+
* DefaultLayout.js # Default layout for all pages
|
|
18
|
+
* DefaultLayout.xml
|
|
19
|
+
* DefaultLayout.css
|
|
20
|
+
* auth/
|
|
21
|
+
* AuthLayout.js # Layout for auth pages (login, register)
|
|
22
|
+
* admin/
|
|
23
|
+
* AdminLayout.js # Layout with sidebar for admin pages
|
|
24
|
+
*
|
|
25
|
+
* Usage in Pages:
|
|
26
|
+
* export class MyPage extends Component {
|
|
27
|
+
* static layout = 'admin' // Use admin layout
|
|
28
|
+
* }
|
|
29
|
+
*
|
|
30
|
+
* Layout Template Convention:
|
|
31
|
+
* <templates>
|
|
32
|
+
* <t t-name="DefaultLayout">
|
|
33
|
+
* <div class="layout-default">
|
|
34
|
+
* <header t-name="header"/>
|
|
35
|
+
* <main>
|
|
36
|
+
* <t t-slot="default"/> <!-- Page content renders here -->
|
|
37
|
+
* </main>
|
|
38
|
+
* <footer t-name="footer"/>
|
|
39
|
+
* </div>
|
|
40
|
+
* </t>
|
|
41
|
+
* </templates>
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* // layouts/default/DefaultLayout.js
|
|
45
|
+
* import { Component } from '@odoo/owl'
|
|
46
|
+
*
|
|
47
|
+
* export class DefaultLayout extends Component {
|
|
48
|
+
* static template = 'DefaultLayout'
|
|
49
|
+
* }
|
|
50
|
+
*
|
|
51
|
+
* // pages/index/Index.js
|
|
52
|
+
* import { Component } from '@odoo/owl'
|
|
53
|
+
*
|
|
54
|
+
* export class IndexPage extends Component {
|
|
55
|
+
* static template = 'IndexPage'
|
|
56
|
+
* static layout = 'default' // Optional, 'default' is implicit
|
|
57
|
+
* }
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
import { Component, xml } from '@odoo/owl'
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Registry of layout components.
|
|
64
|
+
* @type {Map<string, typeof Component>}
|
|
65
|
+
*/
|
|
66
|
+
const _layouts = new Map()
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Default layout name used when none specified.
|
|
70
|
+
* @type {string}
|
|
71
|
+
*/
|
|
72
|
+
let _defaultLayout = 'default'
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Current active layout instance.
|
|
76
|
+
* @type {Component|null}
|
|
77
|
+
*/
|
|
78
|
+
let _currentLayout = null
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Layout change listeners.
|
|
82
|
+
* @type {Function[]}
|
|
83
|
+
*/
|
|
84
|
+
const _listeners = []
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Layout configuration per route.
|
|
88
|
+
* @type {Map<string, string>}
|
|
89
|
+
*/
|
|
90
|
+
const _routeLayouts = new Map()
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Register a layout component.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} name - Layout identifier
|
|
96
|
+
* @param {typeof Component} layoutComponent - OWL component class
|
|
97
|
+
* @param {object} options - Layout options
|
|
98
|
+
* @param {boolean} [options.default=false] - Set as default layout
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* import { DefaultLayout } from './layouts/default/DefaultLayout.js'
|
|
102
|
+
* import { AdminLayout } from './layouts/admin/AdminLayout.js'
|
|
103
|
+
*
|
|
104
|
+
* registerLayout('default', DefaultLayout, { default: true })
|
|
105
|
+
* registerLayout('admin', AdminLayout)
|
|
106
|
+
*/
|
|
107
|
+
export function registerLayout(name, layoutComponent, options = {}) {
|
|
108
|
+
_layouts.set(name, layoutComponent)
|
|
109
|
+
|
|
110
|
+
if (options.default) {
|
|
111
|
+
_defaultLayout = name
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Notify listeners
|
|
115
|
+
for (const listener of _listeners) {
|
|
116
|
+
listener({ type: 'register', name, layout: layoutComponent })
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Unregister a layout.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} name - Layout identifier
|
|
124
|
+
* @returns {boolean} True if layout was removed
|
|
125
|
+
*/
|
|
126
|
+
export function unregisterLayout(name) {
|
|
127
|
+
const removed = _layouts.delete(name)
|
|
128
|
+
|
|
129
|
+
if (removed) {
|
|
130
|
+
for (const listener of _listeners) {
|
|
131
|
+
listener({ type: 'unregister', name })
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return removed
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Get a registered layout by name.
|
|
140
|
+
*
|
|
141
|
+
* @param {string} name - Layout identifier
|
|
142
|
+
* @returns {typeof Component|undefined}
|
|
143
|
+
*/
|
|
144
|
+
export function getLayout(name) {
|
|
145
|
+
return _layouts.get(name)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Check if a layout is registered.
|
|
150
|
+
*
|
|
151
|
+
* @param {string} name - Layout identifier
|
|
152
|
+
* @returns {boolean}
|
|
153
|
+
*/
|
|
154
|
+
export function hasLayout(name) {
|
|
155
|
+
return _layouts.has(name)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Get all registered layout names.
|
|
160
|
+
*
|
|
161
|
+
* @returns {string[]}
|
|
162
|
+
*/
|
|
163
|
+
export function getLayoutNames() {
|
|
164
|
+
return Array.from(_layouts.keys())
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Set the default layout name.
|
|
169
|
+
*
|
|
170
|
+
* @param {string} name - Layout identifier
|
|
171
|
+
*/
|
|
172
|
+
export function setDefaultLayout(name) {
|
|
173
|
+
if (!_layouts.has(name)) {
|
|
174
|
+
console.warn(`[metaowl] Layout "${name}" is not registered yet`)
|
|
175
|
+
}
|
|
176
|
+
_defaultLayout = name
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Get the current default layout name.
|
|
181
|
+
*
|
|
182
|
+
* @returns {string}
|
|
183
|
+
*/
|
|
184
|
+
export function getDefaultLayout() {
|
|
185
|
+
return _defaultLayout
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Resolve layout name for a given component or route.
|
|
190
|
+
*
|
|
191
|
+
* @param {typeof Component} component - Page component
|
|
192
|
+
* @param {string} [routePath] - Optional route path for route-specific layout
|
|
193
|
+
* @returns {string} Layout name
|
|
194
|
+
*/
|
|
195
|
+
export function resolveLayout(component, routePath) {
|
|
196
|
+
// Check route-specific layout first
|
|
197
|
+
if (routePath && _routeLayouts.has(routePath)) {
|
|
198
|
+
return _routeLayouts.get(routePath)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Check component static property
|
|
202
|
+
if (component.layout) {
|
|
203
|
+
return component.layout
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Check for layout defined via decorator/metadata
|
|
207
|
+
if (component._layout) {
|
|
208
|
+
return component._layout
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Fall back to default
|
|
212
|
+
return _defaultLayout
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Assign a layout to a specific route.
|
|
217
|
+
*
|
|
218
|
+
* @param {string} routePath - Route path pattern
|
|
219
|
+
* @param {string} layoutName - Layout identifier
|
|
220
|
+
*/
|
|
221
|
+
export function setRouteLayout(routePath, layoutName) {
|
|
222
|
+
_routeLayouts.set(routePath, layoutName)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Get layout assigned to a route.
|
|
227
|
+
*
|
|
228
|
+
* @param {string} routePath - Route path
|
|
229
|
+
* @returns {string|undefined}
|
|
230
|
+
*/
|
|
231
|
+
export function getRouteLayout(routePath) {
|
|
232
|
+
return _routeLayouts.get(routePath)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Create a layout wrapper component that renders the page inside the layout.
|
|
237
|
+
*
|
|
238
|
+
* @param {typeof Component} layoutComponent - Layout component class
|
|
239
|
+
* @param {typeof Component} pageComponent - Page component class
|
|
240
|
+
* @param {object} [props] - Props to pass to page component
|
|
241
|
+
* @returns {typeof Component} Wrapper component
|
|
242
|
+
*/
|
|
243
|
+
export function createLayoutWrapper(layoutComponent, pageComponent, props = {}) {
|
|
244
|
+
const LayoutClass = layoutComponent
|
|
245
|
+
const PageClass = pageComponent
|
|
246
|
+
|
|
247
|
+
return class LayoutWrapper extends Component {
|
|
248
|
+
static template = xml`
|
|
249
|
+
<t t-component="layout" t-props="layoutProps">
|
|
250
|
+
<t t-component="page" t-props="pageProps"/>
|
|
251
|
+
</t>
|
|
252
|
+
`
|
|
253
|
+
|
|
254
|
+
setup() {
|
|
255
|
+
this.layout = LayoutClass
|
|
256
|
+
this.page = PageClass
|
|
257
|
+
this.layoutProps = {}
|
|
258
|
+
this.pageProps = props
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Mount a page component within its resolved layout.
|
|
265
|
+
*
|
|
266
|
+
* @param {typeof Component} pageComponent - Page component to mount
|
|
267
|
+
* @param {HTMLElement} target - Mount target element
|
|
268
|
+
* @param {object} [options] - Mount options
|
|
269
|
+
* @param {string} [options.routePath] - Current route path
|
|
270
|
+
* @param {object} [options.props] - Props for page component
|
|
271
|
+
* @param {object} [config] - OWL mount configuration
|
|
272
|
+
* @returns {Promise<Component>} Mounted component instance
|
|
273
|
+
*/
|
|
274
|
+
export async function mountWithLayout(pageComponent, target, options = {}, config = {}) {
|
|
275
|
+
const { routePath, props = {} } = options
|
|
276
|
+
|
|
277
|
+
const layoutName = resolveLayout(pageComponent, routePath)
|
|
278
|
+
const LayoutClass = getLayout(layoutName)
|
|
279
|
+
|
|
280
|
+
if (!LayoutClass) {
|
|
281
|
+
console.warn(`[metaowl] Layout "${layoutName}" not found, mounting page without layout`)
|
|
282
|
+
const { mount } = await import('@odoo/owl')
|
|
283
|
+
return mount(pageComponent, target, { ...config, props })
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Create wrapper that combines layout and page
|
|
287
|
+
const WrapperClass = createLayoutWrapper(LayoutClass, pageComponent, props)
|
|
288
|
+
|
|
289
|
+
const { mount } = await import('@odoo/owl')
|
|
290
|
+
const instance = await mount(WrapperClass, target, config)
|
|
291
|
+
|
|
292
|
+
_currentLayout = instance
|
|
293
|
+
|
|
294
|
+
// Notify listeners
|
|
295
|
+
for (const listener of _listeners) {
|
|
296
|
+
listener({ type: 'mount', layout: layoutName, page: pageComponent.name })
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return instance
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Get the currently active layout instance.
|
|
304
|
+
*
|
|
305
|
+
* @returns {Component|null}
|
|
306
|
+
*/
|
|
307
|
+
export function getCurrentLayout() {
|
|
308
|
+
return _currentLayout
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Subscribe to layout events.
|
|
313
|
+
*
|
|
314
|
+
* @param {Function} callback - Event listener
|
|
315
|
+
* @returns {Function} Unsubscribe function
|
|
316
|
+
*/
|
|
317
|
+
export function subscribeToLayouts(callback) {
|
|
318
|
+
_listeners.push(callback)
|
|
319
|
+
return () => {
|
|
320
|
+
const index = _listeners.indexOf(callback)
|
|
321
|
+
if (index > -1) _listeners.splice(index, 1)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Clear all layouts and reset state (useful for testing).
|
|
327
|
+
*/
|
|
328
|
+
export function clearLayouts() {
|
|
329
|
+
_layouts.clear()
|
|
330
|
+
_routeLayouts.clear()
|
|
331
|
+
_listeners.length = 0
|
|
332
|
+
_defaultLayout = 'default'
|
|
333
|
+
_currentLayout = null
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Layout decorator for setting layout on component class.
|
|
338
|
+
*
|
|
339
|
+
* @param {string} name - Layout identifier
|
|
340
|
+
* @returns {Function} Decorator function
|
|
341
|
+
*
|
|
342
|
+
* @example
|
|
343
|
+
* @layout('admin')
|
|
344
|
+
* export class AdminPage extends Component {
|
|
345
|
+
* // ...
|
|
346
|
+
* }
|
|
347
|
+
*/
|
|
348
|
+
export function layout(name) {
|
|
349
|
+
return function decorator(ComponentClass) {
|
|
350
|
+
ComponentClass.layout = name
|
|
351
|
+
return ComponentClass
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* DefineLayout decorator with additional options.
|
|
357
|
+
*
|
|
358
|
+
* @param {string} name - Layout identifier
|
|
359
|
+
* @param {object} options - Layout options
|
|
360
|
+
* @returns {Function} Decorator function
|
|
361
|
+
*
|
|
362
|
+
* @example
|
|
363
|
+
* @defineLayout('admin', { persistent: true })
|
|
364
|
+
* export class AdminPage extends Component {
|
|
365
|
+
* // ...
|
|
366
|
+
* }
|
|
367
|
+
*/
|
|
368
|
+
export function defineLayout(name, options = {}) {
|
|
369
|
+
return function decorator(ComponentClass) {
|
|
370
|
+
ComponentClass.layout = name
|
|
371
|
+
ComponentClass.layoutOptions = options
|
|
372
|
+
return ComponentClass
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Build layouts from import.meta.glob result (file-based layouts).
|
|
378
|
+
*
|
|
379
|
+
* Convention:
|
|
380
|
+
* './layouts/default/DefaultLayout.js' → layout 'default'
|
|
381
|
+
* './layouts/admin/AdminLayout.js' → layout 'admin'
|
|
382
|
+
*
|
|
383
|
+
* @param {Record<string, object>} modules - import.meta.glob result
|
|
384
|
+
* @returns {object} Map of layout names to component classes
|
|
385
|
+
*/
|
|
386
|
+
export function buildLayouts(modules) {
|
|
387
|
+
const layouts = {}
|
|
388
|
+
|
|
389
|
+
for (const [key, mod] of Object.entries(modules)) {
|
|
390
|
+
// Extract layout name from path: './layouts/default/DefaultLayout.js'
|
|
391
|
+
const match = key.match(/\.\/layouts\/([^/]+)/)
|
|
392
|
+
if (!match) continue
|
|
393
|
+
|
|
394
|
+
const layoutName = match[1]
|
|
395
|
+
const ComponentClass = mod.default || Object.values(mod).find(v => typeof v === 'function')
|
|
396
|
+
|
|
397
|
+
if (ComponentClass) {
|
|
398
|
+
layouts[layoutName] = ComponentClass
|
|
399
|
+
registerLayout(layoutName, ComponentClass)
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return layouts
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Auto-discover and register layouts from the layouts directory.
|
|
408
|
+
*
|
|
409
|
+
* @param {object} options
|
|
410
|
+
* @param {string} [options.defaultLayout='default'] - Default layout name
|
|
411
|
+
* @returns {Promise<object>} Registered layouts
|
|
412
|
+
*
|
|
413
|
+
* @example
|
|
414
|
+
* // metaowl.js
|
|
415
|
+
* import { discoverLayouts } from 'metaowl'
|
|
416
|
+
*
|
|
417
|
+
* await discoverLayouts({ defaultLayout: 'default' })
|
|
418
|
+
* boot()
|
|
419
|
+
*/
|
|
420
|
+
export async function discoverLayouts(options = {}) {
|
|
421
|
+
const { defaultLayout = 'default' } = options
|
|
422
|
+
|
|
423
|
+
// This will be transformed by Vite plugin at build time
|
|
424
|
+
const modules = import.meta.glob('./layouts/**/*.js', { eager: true })
|
|
425
|
+
const layouts = buildLayouts(modules)
|
|
426
|
+
|
|
427
|
+
// Set default if specified layout exists
|
|
428
|
+
if (layouts[defaultLayout]) {
|
|
429
|
+
setDefaultLayout(defaultLayout)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return layouts
|
|
433
|
+
}
|