metaowl 0.4.1 → 0.6.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/CHANGELOG.md +50 -0
- package/README.md +267 -2
- package/build/runtime/bin/metaowl-build.js +10 -0
- package/{bin → build/runtime/bin}/metaowl-create.js +96 -177
- package/build/runtime/bin/metaowl-dev.js +10 -0
- package/build/runtime/bin/metaowl-generate.js +231 -0
- package/build/runtime/bin/metaowl-lint.js +58 -0
- package/build/runtime/bin/utils.js +68 -0
- package/build/runtime/index.js +144 -0
- package/build/runtime/modules/app-mounter.js +73 -0
- package/build/runtime/modules/auto-import.js +140 -0
- package/build/runtime/modules/cache.js +49 -0
- package/build/runtime/modules/composables.js +353 -0
- package/build/runtime/modules/constants.js +38 -0
- package/build/runtime/modules/error-boundary.js +116 -0
- package/build/runtime/modules/fetch.js +31 -0
- package/build/runtime/modules/file-router.js +207 -0
- package/build/runtime/modules/fonts.js +172 -0
- package/build/runtime/modules/forms.js +193 -0
- package/build/runtime/modules/i18n.js +180 -0
- package/build/runtime/modules/image.js +175 -0
- package/build/runtime/modules/layouts.js +214 -0
- package/build/runtime/modules/link.js +141 -0
- package/build/runtime/modules/meta.js +117 -0
- package/build/runtime/modules/odoo-rpc.js +265 -0
- package/build/runtime/modules/pwa.js +272 -0
- package/build/runtime/modules/router.js +384 -0
- package/build/runtime/modules/seo.js +186 -0
- package/build/runtime/modules/store.js +198 -0
- package/build/runtime/modules/templates-manager.js +52 -0
- package/build/runtime/modules/test-utils.js +238 -0
- package/build/runtime/vite/plugin.js +197 -0
- package/eslint.js +29 -0
- package/package.json +45 -27
- package/CONTRIBUTING.md +0 -49
- package/bin/metaowl-build.js +0 -12
- package/bin/metaowl-dev.js +0 -12
- package/bin/metaowl-generate.js +0 -339
- package/bin/metaowl-lint.js +0 -71
- package/bin/utils.js +0 -82
- package/eslint.config.js +0 -3
- package/index.js +0 -328
- package/modules/app-mounter.js +0 -104
- package/modules/auto-import.js +0 -225
- package/modules/cache.js +0 -59
- package/modules/composables.js +0 -600
- package/modules/error-boundary.js +0 -228
- package/modules/fetch.js +0 -51
- package/modules/file-router.js +0 -478
- package/modules/forms.js +0 -353
- package/modules/i18n.js +0 -333
- package/modules/layouts.js +0 -431
- package/modules/link.js +0 -255
- package/modules/meta.js +0 -119
- package/modules/odoo-rpc.js +0 -511
- package/modules/pwa.js +0 -515
- package/modules/router.js +0 -769
- package/modules/seo.js +0 -501
- package/modules/store.js +0 -409
- package/modules/templates-manager.js +0 -89
- package/modules/test-utils.js +0 -532
- package/test/auto-import.test.js +0 -110
- package/test/cache.test.js +0 -55
- package/test/composables.test.js +0 -103
- package/test/dynamic-routes.test.js +0 -469
- package/test/error-boundary.test.js +0 -126
- package/test/fetch.test.js +0 -100
- package/test/file-router.test.js +0 -55
- package/test/forms.test.js +0 -203
- package/test/i18n.test.js +0 -188
- package/test/layouts.test.js +0 -395
- package/test/link.test.js +0 -189
- package/test/meta.test.js +0 -146
- package/test/odoo-rpc.test.js +0 -547
- package/test/pwa.test.js +0 -154
- package/test/router-guards.test.js +0 -229
- package/test/router.test.js +0 -77
- package/test/seo.test.js +0 -353
- package/test/store.test.js +0 -476
- package/test/templates-manager.test.js +0 -83
- package/test/test-utils.test.js +0 -314
- package/vite/plugin.js +0 -290
- package/vitest.config.js +0 -8
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module FileRouter
|
|
3
|
+
*
|
|
4
|
+
* File-based routing with dynamic route parameter support.
|
|
5
|
+
*/
|
|
6
|
+
import { buildRouteRegexPattern } from './constants.js';
|
|
7
|
+
export function pathFromKey(key) {
|
|
8
|
+
const relativePath = key.replace(/^\.\/pages\//, '');
|
|
9
|
+
const parts = relativePath.split('/');
|
|
10
|
+
parts.pop();
|
|
11
|
+
if (parts.length === 0) {
|
|
12
|
+
return '/';
|
|
13
|
+
}
|
|
14
|
+
if (parts.length === 1 && parts[0] === 'index') {
|
|
15
|
+
return '/';
|
|
16
|
+
}
|
|
17
|
+
return '/' + parts.map(segmentToPattern).join('/');
|
|
18
|
+
}
|
|
19
|
+
function segmentToPattern(segment) {
|
|
20
|
+
const insideBrackets = segment.match(/^\[(.+)\]$/);
|
|
21
|
+
if (insideBrackets) {
|
|
22
|
+
const content = insideBrackets[1];
|
|
23
|
+
if (content.startsWith('...')) {
|
|
24
|
+
const paramName = content.slice(3) || 'path';
|
|
25
|
+
return `:${paramName}(.*)`;
|
|
26
|
+
}
|
|
27
|
+
if (content.endsWith('?')) {
|
|
28
|
+
const paramName = content.slice(0, -1);
|
|
29
|
+
return `:${paramName}?`;
|
|
30
|
+
}
|
|
31
|
+
return `:${content}`;
|
|
32
|
+
}
|
|
33
|
+
return segment;
|
|
34
|
+
}
|
|
35
|
+
function extractParamNames(filePath) {
|
|
36
|
+
const params = [];
|
|
37
|
+
const parts = filePath.split('/');
|
|
38
|
+
for (const part of parts) {
|
|
39
|
+
const match = part.match(/^\[([^?\]]+)\??\]$|^\[\.\.\.([^\]]+)\]$/);
|
|
40
|
+
if (match) {
|
|
41
|
+
params.push((match[1] || match[2] || 'path'));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return params;
|
|
45
|
+
}
|
|
46
|
+
function buildRegexPattern(path) {
|
|
47
|
+
return buildRouteRegexPattern(path);
|
|
48
|
+
}
|
|
49
|
+
export function matchRoute(pattern, path) {
|
|
50
|
+
const paramNames = [];
|
|
51
|
+
const paramRegex = /:([^/?(]+)/g;
|
|
52
|
+
let match;
|
|
53
|
+
while ((match = paramRegex.exec(pattern)) !== null) {
|
|
54
|
+
paramNames.push(match[1]);
|
|
55
|
+
}
|
|
56
|
+
const regex = new RegExp(buildRegexPattern(pattern));
|
|
57
|
+
const matches = path.match(regex);
|
|
58
|
+
if (!matches) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const params = {};
|
|
62
|
+
for (let index = 0; index < paramNames.length; index++) {
|
|
63
|
+
const value = matches[index + 1];
|
|
64
|
+
if (value !== undefined) {
|
|
65
|
+
params[paramNames[index]] = value;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { params, pattern };
|
|
69
|
+
}
|
|
70
|
+
export function isDynamicRoute(path) {
|
|
71
|
+
return path.includes(':');
|
|
72
|
+
}
|
|
73
|
+
function componentFromModule(mod, key) {
|
|
74
|
+
if (typeof mod.default === 'function') {
|
|
75
|
+
return mod.default;
|
|
76
|
+
}
|
|
77
|
+
const funcs = Object.values(mod).filter((v) => typeof v === 'function');
|
|
78
|
+
if (funcs.length === 0) {
|
|
79
|
+
throw new Error(`[metaowl] No component export found in "${key}"`);
|
|
80
|
+
}
|
|
81
|
+
return funcs[0];
|
|
82
|
+
}
|
|
83
|
+
export function buildRoutes(modules) {
|
|
84
|
+
const routes = [];
|
|
85
|
+
const nameCounts = {};
|
|
86
|
+
for (const [key, mod] of Object.entries(modules)) {
|
|
87
|
+
const derivedPath = pathFromKey(key);
|
|
88
|
+
const component = componentFromModule(mod, key);
|
|
89
|
+
const routeConfig = component.route || {};
|
|
90
|
+
const routePath = typeof routeConfig.path === 'string' ? routeConfig.path : derivedPath;
|
|
91
|
+
const baseName = routePath === '/'
|
|
92
|
+
? 'index'
|
|
93
|
+
: routePath.slice(1).replace(/[^a-zA-Z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
|
94
|
+
const routeName = nameCounts[baseName] !== undefined
|
|
95
|
+
? `${baseName}-${++nameCounts[baseName]}`
|
|
96
|
+
: (nameCounts[baseName] = 0, baseName);
|
|
97
|
+
const route = {
|
|
98
|
+
name: routeName,
|
|
99
|
+
path: [routePath],
|
|
100
|
+
component,
|
|
101
|
+
params: extractParamNames(key),
|
|
102
|
+
meta: component.route?.meta || {}
|
|
103
|
+
};
|
|
104
|
+
if (component.route) {
|
|
105
|
+
Object.assign(route, component.route);
|
|
106
|
+
route.path = [typeof route.path === 'string' ? route.path : routePath];
|
|
107
|
+
route.meta = route.meta || {};
|
|
108
|
+
}
|
|
109
|
+
routes.push(route);
|
|
110
|
+
}
|
|
111
|
+
routes.sort((left, right) => {
|
|
112
|
+
const leftPath = left.path[0];
|
|
113
|
+
const rightPath = right.path[0];
|
|
114
|
+
const leftIsCatchAll = leftPath.includes('(.*)');
|
|
115
|
+
const rightIsCatchAll = rightPath.includes('(.*)');
|
|
116
|
+
if (!leftIsCatchAll && rightIsCatchAll)
|
|
117
|
+
return -1;
|
|
118
|
+
if (leftIsCatchAll && !rightIsCatchAll)
|
|
119
|
+
return 1;
|
|
120
|
+
const leftIsDynamic = isDynamicRoute(leftPath);
|
|
121
|
+
const rightIsDynamic = isDynamicRoute(rightPath);
|
|
122
|
+
if (!leftIsDynamic && rightIsDynamic)
|
|
123
|
+
return -1;
|
|
124
|
+
if (leftIsDynamic && !rightIsDynamic)
|
|
125
|
+
return 1;
|
|
126
|
+
if (leftIsDynamic && rightIsDynamic) {
|
|
127
|
+
const leftSegments = leftPath.split('/').length;
|
|
128
|
+
const rightSegments = rightPath.split('/').length;
|
|
129
|
+
if (leftSegments !== rightSegments)
|
|
130
|
+
return rightSegments - leftSegments;
|
|
131
|
+
const leftParamCount = left.params?.length || 0;
|
|
132
|
+
const rightParamCount = right.params?.length || 0;
|
|
133
|
+
return leftParamCount - rightParamCount;
|
|
134
|
+
}
|
|
135
|
+
return 0;
|
|
136
|
+
});
|
|
137
|
+
return routes;
|
|
138
|
+
}
|
|
139
|
+
export function findRoute(routes, path) {
|
|
140
|
+
for (const route of routes) {
|
|
141
|
+
for (const routePath of route.path) {
|
|
142
|
+
const match = matchRoute(routePath, path);
|
|
143
|
+
if (match) {
|
|
144
|
+
return {
|
|
145
|
+
...route,
|
|
146
|
+
matchedPath: routePath,
|
|
147
|
+
params: match.params
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
export function generateUrl(routes, name, params = {}) {
|
|
155
|
+
const route = routes.find((candidate) => candidate.name === name);
|
|
156
|
+
if (!route) {
|
|
157
|
+
throw new Error(`[metaowl] Route "${name}" not found`);
|
|
158
|
+
}
|
|
159
|
+
let path = route.path[0];
|
|
160
|
+
for (const [key, value] of Object.entries(params)) {
|
|
161
|
+
path = path.replace(`:${key}`, value);
|
|
162
|
+
path = path.replace(`:${key}?`, value);
|
|
163
|
+
}
|
|
164
|
+
return path.replace(/\/:[^/]+\?/g, '').replace(/\?$/, '');
|
|
165
|
+
}
|
|
166
|
+
export function validateRouteParams(route, params) {
|
|
167
|
+
const required = route.params || [];
|
|
168
|
+
const provided = Object.keys(params);
|
|
169
|
+
const missing = required.filter((param) => !provided.includes(param));
|
|
170
|
+
const extra = provided.filter((param) => !required.includes(param));
|
|
171
|
+
return {
|
|
172
|
+
valid: missing.length === 0,
|
|
173
|
+
missing,
|
|
174
|
+
extra
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
export function parseCurrentRoute(routes) {
|
|
178
|
+
return findRoute(routes, document.location.pathname);
|
|
179
|
+
}
|
|
180
|
+
export function defineRoute(config) {
|
|
181
|
+
return config;
|
|
182
|
+
}
|
|
183
|
+
export function route(config) {
|
|
184
|
+
return function decorator(componentClass) {
|
|
185
|
+
componentClass.route = config;
|
|
186
|
+
return componentClass;
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
export function createCatchAllRoute(component, options = {}) {
|
|
190
|
+
return {
|
|
191
|
+
name: options.name || '404',
|
|
192
|
+
path: ['/:path(.*)'],
|
|
193
|
+
component,
|
|
194
|
+
params: ['path'],
|
|
195
|
+
meta: { ...options.meta, catchAll: true }
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
export function createRedirectRoute(from, to) {
|
|
199
|
+
const name = from.replace(/^\//, '').replace(/[^a-zA-Z0-9]/g, '-').replace(/-+/g, '-');
|
|
200
|
+
return {
|
|
201
|
+
name: `redirect-${name}`,
|
|
202
|
+
path: [from],
|
|
203
|
+
redirect: to,
|
|
204
|
+
component: null,
|
|
205
|
+
meta: {}
|
|
206
|
+
};
|
|
207
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Fonts
|
|
3
|
+
*
|
|
4
|
+
* Font optimization utilities for metaowl applications.
|
|
5
|
+
* Provides font loading strategies, font face declarations, and font display optimization.
|
|
6
|
+
*/
|
|
7
|
+
const loadedFonts = new Map();
|
|
8
|
+
const fontPreloadLinks = new Map();
|
|
9
|
+
export function defineFontFace(options) {
|
|
10
|
+
const { family, src, weight = 'normal', style = 'normal', display = 'swap', unicodeRange } = options;
|
|
11
|
+
const srcString = Array.isArray(src) ? src.map((s) => `url("${s}")`).join(', ') : `url("${src}")`;
|
|
12
|
+
const weightStr = Array.isArray(weight) ? weight.join(' ') : weight;
|
|
13
|
+
const descriptors = {
|
|
14
|
+
weight: weightStr,
|
|
15
|
+
style,
|
|
16
|
+
display,
|
|
17
|
+
unicodeRange: unicodeRange || 'U+0-FFFF'
|
|
18
|
+
};
|
|
19
|
+
const fontFace = new FontFace(`${family}-${weightStr}`, srcString, descriptors);
|
|
20
|
+
return fontFace;
|
|
21
|
+
}
|
|
22
|
+
export async function loadFont(options) {
|
|
23
|
+
const { family, weight = 'normal' } = options;
|
|
24
|
+
const key = `${family}-${weight}`;
|
|
25
|
+
if (!loadedFonts.has(family)) {
|
|
26
|
+
loadedFonts.set(family, new Set());
|
|
27
|
+
}
|
|
28
|
+
const fontFace = defineFontFace(options);
|
|
29
|
+
try {
|
|
30
|
+
await fontFace.load();
|
|
31
|
+
document.fonts.add(fontFace);
|
|
32
|
+
const familySet = loadedFonts.get(family);
|
|
33
|
+
familySet.add(key);
|
|
34
|
+
return fontFace;
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
console.warn(`[metaowl] Failed to load font ${family}:`, error);
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export async function loadFontFamily(family, variants) {
|
|
42
|
+
return Promise.all(variants.map((variant) => loadFont({ ...variant, family })));
|
|
43
|
+
}
|
|
44
|
+
export function isFontLoaded(family, weight) {
|
|
45
|
+
if (!loadedFonts.has(family)) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
if (weight) {
|
|
49
|
+
return loadedFonts.get(family).has(`${family}-${weight}`);
|
|
50
|
+
}
|
|
51
|
+
return loadedFonts.get(family).size > 0;
|
|
52
|
+
}
|
|
53
|
+
export function preloadFont(family, src, options = {}) {
|
|
54
|
+
const { weight, as = 'font', type = 'font/woff2' } = options;
|
|
55
|
+
const linkId = `metaowl-font-preload-${family}-${weight || 'normal'}`;
|
|
56
|
+
if (document.getElementById(linkId)) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const link = document.createElement('link');
|
|
60
|
+
link.id = linkId;
|
|
61
|
+
link.rel = 'preload';
|
|
62
|
+
link.as = as;
|
|
63
|
+
link.href = src;
|
|
64
|
+
link.crossOrigin = 'anonymous';
|
|
65
|
+
if (type) {
|
|
66
|
+
link.type = type;
|
|
67
|
+
}
|
|
68
|
+
document.head.appendChild(link);
|
|
69
|
+
fontPreloadLinks.set(linkId, link);
|
|
70
|
+
}
|
|
71
|
+
export function removeFontPreload(family, weight) {
|
|
72
|
+
const linkId = `metaowl-font-preload-${family}-${weight || 'normal'}`;
|
|
73
|
+
const link = fontPreloadLinks.get(linkId);
|
|
74
|
+
if (link) {
|
|
75
|
+
link.remove();
|
|
76
|
+
fontPreloadLinks.delete(linkId);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export function generateFontDisplayStyle(display) {
|
|
80
|
+
return `font-display: ${display};`;
|
|
81
|
+
}
|
|
82
|
+
export function createFontFaceRule(options) {
|
|
83
|
+
const { family, src, weight = 'normal', style = 'normal', display = 'swap', unicodeRange } = options;
|
|
84
|
+
const srcString = Array.isArray(src) ? src.map((s) => `url("${s}")`).join(', ') : `url("${src}")`;
|
|
85
|
+
const weightStr = Array.isArray(weight) ? weight.join(' ') : weight;
|
|
86
|
+
let rule = '@font-face {\n';
|
|
87
|
+
rule += ` font-family: '${family}';\n`;
|
|
88
|
+
rule += ` src: ${srcString};\n`;
|
|
89
|
+
rule += ` font-weight: ${weightStr};\n`;
|
|
90
|
+
rule += ` font-style: ${style};\n`;
|
|
91
|
+
rule += ` font-display: ${display};\n`;
|
|
92
|
+
if (unicodeRange) {
|
|
93
|
+
rule += ` unicode-range: ${unicodeRange};\n`;
|
|
94
|
+
}
|
|
95
|
+
rule += '}';
|
|
96
|
+
return rule;
|
|
97
|
+
}
|
|
98
|
+
export function injectFontFaceRules(options) {
|
|
99
|
+
const rules = Array.isArray(options) ? options : [options];
|
|
100
|
+
const styleId = 'metaowl-font-faces';
|
|
101
|
+
let styleEl = document.getElementById(styleId);
|
|
102
|
+
if (!styleEl) {
|
|
103
|
+
styleEl = document.createElement('style');
|
|
104
|
+
styleEl.id = styleId;
|
|
105
|
+
document.head.appendChild(styleEl);
|
|
106
|
+
}
|
|
107
|
+
const cssText = rules.map(createFontFaceRule).join('\n\n');
|
|
108
|
+
styleEl.textContent += cssText;
|
|
109
|
+
}
|
|
110
|
+
export function measureTextWidth(text, font, size = 16) {
|
|
111
|
+
const canvas = document.createElement('canvas');
|
|
112
|
+
const ctx = canvas.getContext('2d');
|
|
113
|
+
if (!ctx) {
|
|
114
|
+
const numericSize = typeof size === 'string' ? parseInt(size, 10) : size;
|
|
115
|
+
return text.length * numericSize * 0.5;
|
|
116
|
+
}
|
|
117
|
+
const fontString = typeof size === 'number' ? `${size}px ${font}` : `${size} ${font}`;
|
|
118
|
+
ctx.font = fontString;
|
|
119
|
+
return ctx.measureText(text).width;
|
|
120
|
+
}
|
|
121
|
+
export function estimateFontMetrics(el) {
|
|
122
|
+
const style = window.getComputedStyle(el);
|
|
123
|
+
const family = style.fontFamily;
|
|
124
|
+
const fontMetricsMap = {
|
|
125
|
+
'Arial': { family: 'Arial', ascent: 0.8, descent: 0.2, lineGap: 0, unitsPerEm: 2048 },
|
|
126
|
+
'Helvetica': { family: 'Helvetica', ascent: 0.8, descent: 0.2, lineGap: 0, unitsPerEm: 2048 },
|
|
127
|
+
'Times New Roman': { family: 'Times New Roman', ascent: 0.8, descent: 0.2, lineGap: 0, unitsPerEm: 2048 },
|
|
128
|
+
'Georgia': { family: 'Georgia', ascent: 0.8, descent: 0.2, lineGap: 0, unitsPerEm: 2048 },
|
|
129
|
+
'system-ui': { family: 'system-ui', ascent: 0.8, descent: 0.2, lineGap: 0, unitsPerEm: 2048 }
|
|
130
|
+
};
|
|
131
|
+
return fontMetricsMap[family] || null;
|
|
132
|
+
}
|
|
133
|
+
export function adjustFontForFout(el, _fallbackFont = 'sans-serif', timeout = 3000) {
|
|
134
|
+
return new Promise((resolve) => {
|
|
135
|
+
el.style.setProperty('font-display', 'block');
|
|
136
|
+
const timer = setTimeout(() => {
|
|
137
|
+
el.classList.add('metaowl-font-fout');
|
|
138
|
+
resolve();
|
|
139
|
+
}, timeout);
|
|
140
|
+
document.fonts.ready.then(() => {
|
|
141
|
+
clearTimeout(timer);
|
|
142
|
+
el.classList.add('metaowl-font-loaded');
|
|
143
|
+
resolve();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
export function getFontLoadStatus() {
|
|
148
|
+
const status = {};
|
|
149
|
+
for (const [family, weights] of loadedFonts.entries()) {
|
|
150
|
+
status[family] = weights.size > 0;
|
|
151
|
+
}
|
|
152
|
+
return status;
|
|
153
|
+
}
|
|
154
|
+
export function clearLoadedFonts() {
|
|
155
|
+
loadedFonts.clear();
|
|
156
|
+
}
|
|
157
|
+
export const Fonts = {
|
|
158
|
+
defineFontFace,
|
|
159
|
+
loadFont,
|
|
160
|
+
loadFontFamily,
|
|
161
|
+
isFontLoaded,
|
|
162
|
+
preloadFont,
|
|
163
|
+
removeFontPreload,
|
|
164
|
+
generateFontDisplayStyle,
|
|
165
|
+
createFontFaceRule,
|
|
166
|
+
injectFontFaceRules,
|
|
167
|
+
measureTextWidth,
|
|
168
|
+
estimateFontMetrics,
|
|
169
|
+
adjustFontForFout,
|
|
170
|
+
getFontLoadStatus,
|
|
171
|
+
clearLoadedFonts
|
|
172
|
+
};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Forms
|
|
3
|
+
*
|
|
4
|
+
* Form handling and validation for MetaOwl applications.
|
|
5
|
+
*/
|
|
6
|
+
import { reactive } from '@odoo/owl';
|
|
7
|
+
function getChangedValue(event) {
|
|
8
|
+
if (typeof event === 'object' && event !== null && 'target' in event) {
|
|
9
|
+
const target = event.target;
|
|
10
|
+
return target?.value ?? event;
|
|
11
|
+
}
|
|
12
|
+
return event;
|
|
13
|
+
}
|
|
14
|
+
export function useForm(fieldsConfig = {}) {
|
|
15
|
+
const fields = {};
|
|
16
|
+
const errors = {};
|
|
17
|
+
const touched = {};
|
|
18
|
+
const dirty = {};
|
|
19
|
+
const validating = {};
|
|
20
|
+
for (const [name, config] of Object.entries(fieldsConfig)) {
|
|
21
|
+
const initialValue = config?.default ?? '';
|
|
22
|
+
fields[name] = initialValue;
|
|
23
|
+
errors[name] = null;
|
|
24
|
+
touched[name] = false;
|
|
25
|
+
dirty[name] = false;
|
|
26
|
+
validating[name] = false;
|
|
27
|
+
}
|
|
28
|
+
const state = reactive({
|
|
29
|
+
fields,
|
|
30
|
+
errors,
|
|
31
|
+
touched,
|
|
32
|
+
dirty,
|
|
33
|
+
validating,
|
|
34
|
+
isSubmitting: false,
|
|
35
|
+
isValidating: false,
|
|
36
|
+
submitCount: 0
|
|
37
|
+
});
|
|
38
|
+
async function validateField(name) {
|
|
39
|
+
const config = fieldsConfig[name];
|
|
40
|
+
if (!config?.validation) {
|
|
41
|
+
state.errors[name] = null;
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
const value = state.fields[name];
|
|
45
|
+
const validatorList = Array.isArray(config.validation)
|
|
46
|
+
? config.validation
|
|
47
|
+
: [config.validation];
|
|
48
|
+
for (const validator of validatorList) {
|
|
49
|
+
const result = validator(value, state.fields);
|
|
50
|
+
if (result !== true) {
|
|
51
|
+
state.errors[name] = result || 'Invalid';
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (config.asyncValidation) {
|
|
56
|
+
state.validating[name] = true;
|
|
57
|
+
state.isValidating = true;
|
|
58
|
+
try {
|
|
59
|
+
const result = await config.asyncValidation(value, state.fields);
|
|
60
|
+
if (result !== true) {
|
|
61
|
+
state.errors[name] = result || 'Invalid';
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
state.validating[name] = false;
|
|
67
|
+
state.isValidating = Object.values(state.validating).some(Boolean);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
state.errors[name] = null;
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
async function validateAll() {
|
|
74
|
+
const results = await Promise.all(Object.keys(fieldsConfig).map((name) => validateField(name)));
|
|
75
|
+
return results.every(Boolean);
|
|
76
|
+
}
|
|
77
|
+
const controller = {
|
|
78
|
+
fields: state.fields,
|
|
79
|
+
errors: state.errors,
|
|
80
|
+
touched: state.touched,
|
|
81
|
+
dirty: state.dirty,
|
|
82
|
+
validating: state.validating,
|
|
83
|
+
isSubmitting: state.isSubmitting,
|
|
84
|
+
isValidating: state.isValidating,
|
|
85
|
+
submitCount: state.submitCount,
|
|
86
|
+
get isValid() {
|
|
87
|
+
return Object.values(state.errors).every((error) => error === null);
|
|
88
|
+
},
|
|
89
|
+
get isDirty() {
|
|
90
|
+
return Object.values(state.dirty).some(Boolean);
|
|
91
|
+
},
|
|
92
|
+
get isTouched() {
|
|
93
|
+
return Object.values(state.touched).some(Boolean);
|
|
94
|
+
},
|
|
95
|
+
setValue(name, value) {
|
|
96
|
+
state.fields[name] = value;
|
|
97
|
+
state.dirty[name] = value !== (fieldsConfig[name]?.default ?? '');
|
|
98
|
+
},
|
|
99
|
+
setTouched(name) {
|
|
100
|
+
state.touched[name] = true;
|
|
101
|
+
},
|
|
102
|
+
setAllTouched() {
|
|
103
|
+
for (const name of Object.keys(fieldsConfig)) {
|
|
104
|
+
state.touched[name] = true;
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
validateField,
|
|
108
|
+
validate: validateAll,
|
|
109
|
+
reset() {
|
|
110
|
+
for (const [name, config] of Object.entries(fieldsConfig)) {
|
|
111
|
+
state.fields[name] = config?.default ?? '';
|
|
112
|
+
state.errors[name] = null;
|
|
113
|
+
state.touched[name] = false;
|
|
114
|
+
state.dirty[name] = false;
|
|
115
|
+
}
|
|
116
|
+
state.isSubmitting = false;
|
|
117
|
+
state.submitCount = 0;
|
|
118
|
+
},
|
|
119
|
+
handleSubmit(onSubmit, options = {}) {
|
|
120
|
+
const { validate = true } = options;
|
|
121
|
+
return async (...args) => {
|
|
122
|
+
state.isSubmitting = true;
|
|
123
|
+
state.submitCount++;
|
|
124
|
+
try {
|
|
125
|
+
if (validate) {
|
|
126
|
+
controller.setAllTouched();
|
|
127
|
+
const isValid = await controller.validate();
|
|
128
|
+
if (!isValid) {
|
|
129
|
+
state.isSubmitting = false;
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
await onSubmit({ ...state.fields }, ...args);
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
state.isSubmitting = false;
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
register(name) {
|
|
141
|
+
return {
|
|
142
|
+
value: state.fields[name],
|
|
143
|
+
onChange: (event) => controller.setValue(name, getChangedValue(event)),
|
|
144
|
+
onBlur: () => {
|
|
145
|
+
controller.setTouched(name);
|
|
146
|
+
void controller.validateField(name);
|
|
147
|
+
},
|
|
148
|
+
error: state.touched[name] ? state.errors[name] : null
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
return controller;
|
|
153
|
+
}
|
|
154
|
+
export const validators = {
|
|
155
|
+
required: (message = 'Required') => (value) => Boolean(value) || message,
|
|
156
|
+
minLength: (min, message) => (value) => (value?.length ?? 0) >= min || message || `Min ${min} characters`,
|
|
157
|
+
maxLength: (max, message) => (value) => (value?.length ?? 0) <= max || message || `Max ${max} characters`,
|
|
158
|
+
min: (min, message) => (value) => Number(value) >= min || message || `Min ${min}`,
|
|
159
|
+
max: (max, message) => (value) => Number(value) <= max || message || `Max ${max}`,
|
|
160
|
+
email: (message = 'Invalid email') => (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value ?? '')) || message,
|
|
161
|
+
url: (message = 'Invalid URL') => (value) => /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})[/\w .-]*\/?$/.test(String(value ?? '')) || message,
|
|
162
|
+
pattern: (regex, message = 'Invalid format') => (value) => regex.test(String(value ?? '')) || message,
|
|
163
|
+
match: (field, message) => (value, values) => value === values[field] || message || 'Fields do not match'
|
|
164
|
+
};
|
|
165
|
+
export function createSchema(schema) {
|
|
166
|
+
const fieldsConfig = {};
|
|
167
|
+
for (const [name, fieldValidators] of Object.entries(schema)) {
|
|
168
|
+
const validatorArray = Array.isArray(fieldValidators) ? fieldValidators : [fieldValidators];
|
|
169
|
+
fieldsConfig[name] = {
|
|
170
|
+
default: '',
|
|
171
|
+
validation: (value, values) => {
|
|
172
|
+
for (const validator of validatorArray) {
|
|
173
|
+
const result = validator(value, values);
|
|
174
|
+
if (result !== true)
|
|
175
|
+
return result;
|
|
176
|
+
}
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
return fieldsConfig;
|
|
182
|
+
}
|
|
183
|
+
export function fieldProps(form, name) {
|
|
184
|
+
return {
|
|
185
|
+
value: form.fields[name],
|
|
186
|
+
error: form.touched[name] ? form.errors[name] : null,
|
|
187
|
+
onChange: (value) => form.setValue(name, getChangedValue(value)),
|
|
188
|
+
onBlur: () => {
|
|
189
|
+
form.setTouched(name);
|
|
190
|
+
void form.validateField(name);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
}
|