feat(ui): 增强数据管理与自动化测试(by AI)
This commit is contained in:
parent
e60e5155c4
commit
9cb6ba1cb7
10
CHANGELOG.md
Normal file
10
CHANGELOG.md
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## [v1.0.1] - 2026-07-13
|
||||||
|
|
||||||
|
- **新增**:
|
||||||
|
- DataGrid 支持数据 API 自动加载、安全 filter/sort 查询、服务端保存和高级系统字段模式。
|
||||||
|
- AutoForm 支持数据库字段描述、只读 label 与 divider 类型。
|
||||||
|
- API 根据请求 data 自动选择 POST,Nav 明确动作事件语义。
|
||||||
|
- **修复**:
|
||||||
|
- Select 初始值、异步 DataGrid 数据绑定与 Modal 长内容滚动。
|
||||||
@ -7,13 +7,13 @@
|
|||||||
Pages load `ui.js` directly from a CDN and explicitly declare required public APIs:
|
Pages load `ui.js` directly from a CDN and explicitly declare required public APIs:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/ui@1.0.0/ui.js" components="base,form"></script>
|
<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/ui@1.0.1/ui.js" components="base,form"></script>
|
||||||
```
|
```
|
||||||
|
|
||||||
`ui.js` always loads every file in `frameworks/` and `utilities/`. The `components` attribute accepts public component names and the `base` and `form` categories; both forms can be mixed.
|
`ui.js` always loads every file in `frameworks/` and `utilities/`. The `components` attribute accepts public component names and the `base` and `form` categories; both forms can be mixed.
|
||||||
|
|
||||||
Load `ui.js` in `<head>` as a normal script. Do not use `async`, `defer`, or `type="module"`: it writes the requested classic scripts into the parser stream so state can scan the complete DOM at `DOMContentLoaded` before first render.
|
Load `ui.js` in `<head>` as a normal script. Do not use `async`, `defer`, or `type="module"`: it writes the requested classic scripts into the parser stream so state can scan the complete DOM at `DOMContentLoaded` before first render.
|
||||||
|
|
||||||
`npm run build` does not bundle source files or create a `dist` directory. It updates the release version, writes source-file cache versions into `ui.js`, and produces adjacent `.min.js` files. Loading `ui.min.js` automatically loads the matching minified framework, utility, and component files.
|
`npm run build` does not bundle source files or create a `dist` directory. It first copies `../state/dist/state.js` and `../bootstrap/dist/bootstrap.js` into `frameworks/`, then writes their source-file modification-time cache versions into `ui.js` and produces adjacent `.min.js` files. Loading `ui.min.js` automatically loads the matching minified framework, utility, and component files. The UI package version remains independent of its framework packages.
|
||||||
|
|
||||||
For AI-oriented API documentation, start with [AI.yaml](AI.yaml).
|
For AI-oriented API documentation, start with [AI.yaml](AI.yaml).
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
`@apigo.cc/ui` 是不需要生产构建的原生 JavaScript UI 组件库。页面使用一个 `ui.js` 入口,并且每个页面都显式写出使用的组件:
|
`@apigo.cc/ui` 是不需要生产构建的原生 JavaScript UI 组件库。页面使用一个 `ui.js` 入口,并且每个页面都显式写出使用的组件:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/ui@1.0.0/ui.js" components="base,form"></script>
|
<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/ui@1.0.1/ui.js" components="base,form"></script>
|
||||||
```
|
```
|
||||||
|
|
||||||
`ui.js` 默认加载 `frameworks/` 和底层 `utilities/`。`components` 中只填写页面直接使用的公开组件名,例如 `components="List,AutoForm,Resizer"`;组件依赖会自动加载,不需要填写 state、bootstrap、HTTP、VirtualScroll 或 MouseMover。
|
`ui.js` 默认加载 `frameworks/` 和底层 `utilities/`。`components` 中只填写页面直接使用的公开组件名,例如 `components="List,AutoForm,Resizer"`;组件依赖会自动加载,不需要填写 state、bootstrap、HTTP、VirtualScroll 或 MouseMover。
|
||||||
@ -14,4 +14,4 @@
|
|||||||
|
|
||||||
AI 使用说明从 [AI.yaml](AI.yaml) 开始;文档与源码同目录,组件示例直接写在对应 YAML 中。
|
AI 使用说明从 [AI.yaml](AI.yaml) 开始;文档与源码同目录,组件示例直接写在对应 YAML 中。
|
||||||
|
|
||||||
`npm run build` 不会打包源码,也不会生成 `dist`。它会更新发布版本、把各源码文件的修改时间版本写入 `ui.js`,并在源码旁生成 `.min.js`。加载 `ui.min.js` 时,入口会自动改为加载对应的 framework、utility 和 component 的 `.min.js`。
|
`npm run build` 不会打包源码,也不会生成 `dist`。它会先将 `../state/dist/state.js` 和 `../bootstrap/dist/bootstrap.js` 复制到 `frameworks/`,再把所有源码文件的最后修改时间缓存版本写入 `ui.js`,并在源码旁生成 `.min.js`。加载 `ui.min.js` 时,入口会自动改为加载对应的 framework、utility 和 component 的 `.min.js`。UI 包的版本独立于前置框架包。
|
||||||
|
|||||||
@ -8,7 +8,7 @@ const APIComponent = globalThis.Component.register('API', container => {
|
|||||||
|
|
||||||
container.request = globalThis.NewState({
|
container.request = globalThis.NewState({
|
||||||
url: '',
|
url: '',
|
||||||
method: 'GET',
|
method: '',
|
||||||
headers: {},
|
headers: {},
|
||||||
data: null,
|
data: null,
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
@ -32,6 +32,7 @@ const APIComponent = globalThis.Component.register('API', container => {
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const req = { ...container.request, ...opt }
|
const req = { ...container.request, ...opt }
|
||||||
if (!req.url) throw new Error('.url is required')
|
if (!req.url) throw new Error('.url is required')
|
||||||
|
if (!req.method) req.method = req.data == null ? 'GET' : 'POST'
|
||||||
req.headers = { ...container.request.headers, ...opt.headers }
|
req.headers = { ...container.request.headers, ...opt.headers }
|
||||||
container.response.loading = true
|
container.response.loading = true
|
||||||
globalThis.HTTP.request(req).then(resp => {
|
globalThis.HTTP.request(req).then(resp => {
|
||||||
|
|||||||
2
components/base/API.min.js
vendored
2
components/base/API.min.js
vendored
@ -1 +1 @@
|
|||||||
const APIComponent=globalThis.Component.register("API",e=>{const r=e.request&&"object"==typeof e.request?e.request:{},s=e.response&&"object"==typeof e.response?e.response:{},t=e.result&&"object"==typeof e.result?e.result:{};e.request=globalThis.NewState({url:"",method:"GET",headers:{},data:null,timeout:1e4,responseType:"",...r,headers:{...r.headers||{}}}),e.response=globalThis.NewState({loading:!1,ok:null,status:null,error:null,headers:{},responseType:"",result:null,...s,headers:{...s.headers||{}}}),e.result=globalThis.NewState(t),e.do=(r={})=>new Promise((s,t)=>{const o={...e.request,...r};if(!o.url)throw new Error(".url is required");o.headers={...e.request.headers,...r.headers},e.response.loading=!0,globalThis.HTTP.request(o).then(r=>{if(Object.keys(r).forEach(s=>{"result"!==s&&(e.response[s]=r[s])}),r.result&&"object"==typeof r.result&&e.result&&"object"==typeof e.result?Object.assign(e.result,r.result):e.result=r.result,e.response.loading=!1,!1===r.ok)throw new Error(r.error);if("object"==typeof r.result&&r.result.error)throw new Error(r.result.error);e.dispatchEvent(new CustomEvent("response",{detail:r,bubbles:!1})),s(r)}).catch(s=>{r.noui||globalThis.Toast?.show(s.message,{type:"danger"}),e.dispatchEvent(new CustomEvent("error",{detail:s,bubbles:!0})),t(s)})});let o=null;e.request.__watch(null,()=>{e.hasAttribute("auto")&&e.request.url&&(o||(o=Promise.resolve().then(()=>{e.do(),o=null})))})});
|
const APIComponent=globalThis.Component.register("API",e=>{const r=e.request&&"object"==typeof e.request?e.request:{},s=e.response&&"object"==typeof e.response?e.response:{},t=e.result&&"object"==typeof e.result?e.result:{};e.request=globalThis.NewState({url:"",method:"",headers:{},data:null,timeout:1e4,responseType:"",...r,headers:{...r.headers||{}}}),e.response=globalThis.NewState({loading:!1,ok:null,status:null,error:null,headers:{},responseType:"",result:null,...s,headers:{...s.headers||{}}}),e.result=globalThis.NewState(t),e.do=(r={})=>new Promise((s,t)=>{const o={...e.request,...r};if(!o.url)throw new Error(".url is required");o.method||(o.method=null==o.data?"GET":"POST"),o.headers={...e.request.headers,...r.headers},e.response.loading=!0,globalThis.HTTP.request(o).then(r=>{if(Object.keys(r).forEach(s=>{"result"!==s&&(e.response[s]=r[s])}),r.result&&"object"==typeof r.result&&e.result&&"object"==typeof e.result?Object.assign(e.result,r.result):e.result=r.result,e.response.loading=!1,!1===r.ok)throw new Error(r.error);if("object"==typeof r.result&&r.result.error)throw new Error(r.result.error);e.dispatchEvent(new CustomEvent("response",{detail:r,bubbles:!1})),s(r)}).catch(s=>{r.noui||globalThis.Toast?.show(s.message,{type:"danger"}),e.dispatchEvent(new CustomEvent("error",{detail:s,bubbles:!0})),t(s)})});let o=null;e.request.__watch(null,()=>{e.hasAttribute("auto")&&e.request.url&&(o||(o=Promise.resolve().then(()=>{e.do(),o=null})))})});
|
||||||
|
|||||||
@ -19,27 +19,28 @@
|
|||||||
const tick = () => new Promise(resolve => setTimeout(resolve, 80))
|
const tick = () => new Promise(resolve => setTimeout(resolve, 80))
|
||||||
const assert = (testResult, feature, condition, message) => { testResult.assertions++; if (condition) testResult.passed++; else { testResult.failed++; testResult.failures.push({ feature, message }) } }
|
const assert = (testResult, feature, condition, message) => { testResult.assertions++; if (condition) testResult.passed++; else { testResult.failed++; testResult.failures.push({ feature, message }) } }
|
||||||
window.runTest = async () => {
|
window.runTest = async () => {
|
||||||
const testResult = { name: 'components.API', status: 'running', assertions: 0, passed: 0, failed: 0, failures: [], consoleErrors: [], coverage: { tested: [], total: ['auto', 'request', 'response', 'result', 'do', 'response-event', 'error-event', 'noui'], percent: 0 } }
|
const testResult = { name: 'components.API', status: 'running', assertions: 0, passed: 0, failed: 0, failures: [], coverage: { tested: [], total: ['auto', 'request', 'response', 'result', 'do', 'post-default', 'response-event', 'error-event', 'noui'], percent: 0 } }
|
||||||
await tick()
|
await tick()
|
||||||
let autoResponses = 0; autoApi.addEventListener('response', () => { autoResponses++ })
|
let autoResponses = 0; autoApi.addEventListener('response', () => { autoResponses++ })
|
||||||
autoApi.request.url = '/__http__/json?next=1'; await tick()
|
autoApi.request.url = '/__http__/json?next=1'; await tick()
|
||||||
assert(testResult, 'auto', autoApi.response.ok === true && autoApi.result.method === 'GET' && autoResponses === 1, 'auto requests again after request changes')
|
assert(testResult, 'auto', autoApi.response.ok === true && autoApi.result.method === 'GET' && autoResponses === 1, 'auto requests again after request changes')
|
||||||
assert(testResult, 'request', manualApi.request.url === '' && manualApi.request.method === 'GET' && manualApi.request.data === null && manualApi.request.timeout === 10000 && manualApi.request.responseType === '' && typeof manualApi.request.headers === 'object', 'request exposes documented defaults')
|
assert(testResult, 'request', manualApi.request.url === '' && manualApi.request.method === '' && manualApi.request.data === null && manualApi.request.timeout === 10000 && manualApi.request.responseType === '' && typeof manualApi.request.headers === 'object', 'request exposes documented defaults')
|
||||||
assert(testResult, 'response', autoApi.response.loading === false && autoApi.response.status === 200 && autoApi.response.responseType === 'json' && typeof autoApi.response.headers === 'object', 'response exposes loading, status, headers, and responseType')
|
assert(testResult, 'response', autoApi.response.loading === false && autoApi.response.status === 200 && autoApi.response.responseType === 'json' && typeof autoApi.response.headers === 'object', 'response exposes loading, status, headers, and responseType')
|
||||||
assert(testResult, 'result-binding', document.querySelector('#autoResult').textContent === 'GET', 'response result is usable in declarative DOM bindings')
|
assert(testResult, 'result-binding', document.querySelector('#autoResult').textContent === 'GET', 'response result is usable in declarative DOM bindings')
|
||||||
let responseEvent
|
let responseEvent
|
||||||
manualApi.addEventListener('response', event => { responseEvent = event.detail }, { once: true })
|
manualApi.addEventListener('response', event => { responseEvent = event.detail }, { once: true })
|
||||||
const response = await manualApi.do(successRequest)
|
const response = await manualApi.do(successRequest)
|
||||||
assert(testResult, 'do', response.ok && manualApi.result.method === 'GET', 'do() sends a request and updates result')
|
assert(testResult, 'do', response.ok && manualApi.result.method === 'GET', 'do() sends a request and updates result')
|
||||||
|
const postResponse = await manualApi.do({ url: '/__http__/json', data: { action: 'tables' } }); assert(testResult, 'post-default', postResponse.ok && manualApi.result.method === 'POST', 'data defaults an unspecified method to POST')
|
||||||
assert(testResult, 'response-event', responseEvent?.status === 200, 'response event exposes the complete HTTP response')
|
assert(testResult, 'response-event', responseEvent?.status === 200, 'response event exposes the complete HTTP response')
|
||||||
assert(testResult, 'result', manualApi.result.method === 'GET', 'result exposes the latest response result')
|
assert(testResult, 'result', manualApi.result.method === 'POST', 'result exposes the latest response result')
|
||||||
let failed = false; let errorEvent
|
let failed = false; let errorEvent
|
||||||
manualApi.addEventListener('error', event => { errorEvent = event.detail }, { once: true })
|
manualApi.addEventListener('error', event => { errorEvent = event.detail }, { once: true })
|
||||||
try { await manualApi.do({ ...errorRequest, noui: true }) } catch (error) { failed = true }
|
try { await manualApi.do({ ...errorRequest, noui: true }) } catch (error) { failed = true }
|
||||||
assert(testResult, 'error', failed && manualApi.response.status === 200 && manualApi.response.ok === true && manualApi.result.error === 'application failed', 'application error rejects and preserves response state')
|
assert(testResult, 'error', failed && manualApi.response.status === 200 && manualApi.response.ok === true && manualApi.result.error === 'application failed', 'application error rejects and preserves response state')
|
||||||
assert(testResult, 'error-event', errorEvent instanceof Error && errorEvent.message === 'application failed', 'error event exposes Error detail')
|
assert(testResult, 'error-event', errorEvent instanceof Error && errorEvent.message === 'application failed', 'error event exposes Error detail')
|
||||||
assert(testResult, 'noui', !document.querySelector('Toast'), 'noui suppresses automatic error Toast')
|
assert(testResult, 'noui', !document.querySelector('Toast'), 'noui suppresses automatic error Toast')
|
||||||
testResult.coverage.tested = ['auto', 'request', 'response', 'result', 'do', 'response-event', 'error-event', 'noui']; testResult.coverage.percent = 100; testResult.status = testResult.failed ? 'failed' : 'passed'; document.querySelector('#result').textContent = JSON.stringify(testResult, null, 2); window.testResult = testResult; return testResult
|
testResult.coverage.tested = ['auto', 'request', 'response', 'result', 'do', 'post-default', 'response-event', 'error-event', 'noui']; testResult.coverage.percent = 100; testResult.status = testResult.failed ? 'failed' : 'passed'; document.querySelector('#result').textContent = JSON.stringify(testResult, null, 2); window.testResult = testResult; return testResult
|
||||||
}
|
}
|
||||||
document.querySelector('#runAll').onclick = window.runTest
|
document.querySelector('#runAll').onclick = window.runTest
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -9,7 +9,7 @@ properties:
|
|||||||
request:
|
request:
|
||||||
default:
|
default:
|
||||||
url: ''
|
url: ''
|
||||||
method: GET
|
method: "GET when data is null; POST when data is present, unless explicitly set."
|
||||||
headers: {}
|
headers: {}
|
||||||
data: null
|
data: null
|
||||||
timeout: 10000
|
timeout: 10000
|
||||||
@ -34,6 +34,7 @@ events:
|
|||||||
|
|
||||||
rules:
|
rules:
|
||||||
- request.url is required before calling do.
|
- request.url is required before calling do.
|
||||||
|
- Without request.method, API uses POST when request.data is present and GET otherwise.
|
||||||
- Bind request with $.request when external state owns request configuration.
|
- Bind request with $.request when external state owns request configuration.
|
||||||
|
|
||||||
examples:
|
examples:
|
||||||
@ -47,6 +48,9 @@ examples:
|
|||||||
<script>
|
<script>
|
||||||
const response = await usersApi.do({ url: '/api/users', method: 'GET', noui: true })
|
const response = await usersApi.do({ url: '/api/users', method: 'GET', noui: true })
|
||||||
if (response.ok) console.log(response.result)
|
if (response.ok) console.log(response.result)
|
||||||
|
post_default: |
|
||||||
|
<API auto .request.url="/admin/table" .request.data.action="tables"></API>
|
||||||
|
<!-- data is present, so this request uses POST unless method is explicitly set -->
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
tests:
|
tests:
|
||||||
|
|||||||
@ -6,8 +6,12 @@ const AUTOFORM_BLUEPRINT = globalThis.Util.makeDom(/*html*/`
|
|||||||
<div class="auto-form-root">
|
<div class="auto-form-root">
|
||||||
<form $class="\${this.inline ? 'd-flex flex-wrap align-items-center gap-3' : (this.vertical ? 'd-flex flex-column' : (this.horizontal ? 'auto-grid-form forced-horizontal' : 'auto-grid-form'))}" $onsubmit="event.stopPropagation();event.preventDefault();this.submit()">
|
<form $class="\${this.inline ? 'd-flex flex-wrap align-items-center gap-3' : (this.vertical ? 'd-flex flex-column' : (this.horizontal ? 'auto-grid-form forced-horizontal' : 'auto-grid-form'))}" $onsubmit="event.stopPropagation();event.preventDefault();this.submit()">
|
||||||
|
|
||||||
<template $each="this.state.schema || []">
|
<template $each="this.state._schema || []">
|
||||||
<template $$if="item.if || 'true'">
|
<template $$if="item.if || 'true'">
|
||||||
|
<template $if="item.type === 'divider'">
|
||||||
|
<div style="grid-column:1/-1" class="pt-2 pb-1"><hr class="my-2"><div $if="item.label" $text="item.label" class="small fw-semibold text-muted text-uppercase"></div></div>
|
||||||
|
</template>
|
||||||
|
<template $if="item.type !== 'divider'">
|
||||||
<div style="display:contents">
|
<div style="display:contents">
|
||||||
<label $name="item.name" $class="\${this.inline ? 'mb-0 text-muted text-nowrap' : 'col-form-label text-muted'}" $text="item.label"></label>
|
<label $name="item.name" $class="\${this.inline ? 'mb-0 text-muted text-nowrap' : 'col-form-label text-muted'}" $text="item.label"></label>
|
||||||
<div control-wrapper $class="\${this.inline ? 'd-flex align-items-center' : 'mb-3 d-flex align-items-center'}">
|
<div control-wrapper $class="\${this.inline ? 'd-flex align-items-center' : 'mb-3 d-flex align-items-center'}">
|
||||||
@ -30,9 +34,12 @@ const AUTOFORM_BLUEPRINT = globalThis.Util.makeDom(/*html*/`
|
|||||||
</div></template>
|
</div></template>
|
||||||
|
|
||||||
<template $if="item.type === 'textarea'"><textarea $name="item.name" class="form-control" $.="item.setting || {}" $bind="this.state.data[item.name]"></textarea></template>
|
<template $if="item.type === 'textarea'"><textarea $name="item.name" class="form-control" $.="item.setting || {}" $bind="this.state.data[item.name]"></textarea></template>
|
||||||
|
|
||||||
|
<template $if="item.type === 'label'"><span $name="item.name" $.="item.setting || {}" $text="item.value ?? this.state.data[item.name] ?? ''" class="form-control-plaintext text-break py-1"></span></template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div $class="\${this.inline ? '' : 'd-flex justify-content-end align-items-baseline gap-3 mt-2'}" $style="\${this.inline ? '' : 'grid-column:1/-1'}">
|
<div $class="\${this.inline ? '' : 'd-flex justify-content-end align-items-baseline gap-3 mt-2'}" $style="\${this.inline ? '' : 'grid-column:1/-1'}">
|
||||||
@ -61,8 +68,36 @@ const AUTOFORM_STYLE = globalThis.Util.makeDom(/*html*/`<style>
|
|||||||
.auto-form-root .form-switch .form-check-input { width: 2em; }
|
.auto-form-root .form-switch .form-check-input { width: 2em; }
|
||||||
</style>`)
|
</style>`)
|
||||||
|
|
||||||
|
const DB_FORM_TYPE_MAP = {
|
||||||
|
i: 'number', ui: 'number', bi: 'number', ubi: 'number', ti: 'number', f: 'number', ff: 'number',
|
||||||
|
b: 'switch', d: 'date', dt: 'datetime', t: 'textarea'
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDbFormType = type => DB_FORM_TYPE_MAP[type] || 'text';
|
||||||
|
|
||||||
|
// Database field definitions are converted only for rendering. Form data keeps
|
||||||
|
// its original keys, so submitting an AutoForm still returns the API row shape.
|
||||||
|
const normalizeSchemaItem = field => {
|
||||||
|
if (!field?.tableID) return field;
|
||||||
|
const settings = field.settings || {};
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
name: field.name,
|
||||||
|
label: settings.label ?? field.label ?? field.name,
|
||||||
|
type: settings.type ?? getDbFormType(field.type),
|
||||||
|
options: settings.options ?? field.options,
|
||||||
|
placeholder: settings.placeholder ?? field.placeholder,
|
||||||
|
setting: settings.attrs ?? field.setting
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
globalThis.Component.register('AutoForm', container => {
|
globalThis.Component.register('AutoForm', container => {
|
||||||
if (!container.state.schema) container.state.schema = []
|
if (!Array.isArray(container.state.schema)) container.state.schema = []
|
||||||
|
const setSchema = schema => {
|
||||||
|
container.state._schema = Array.isArray(schema) ? schema.map(normalizeSchemaItem) : [];
|
||||||
|
};
|
||||||
|
container.state.__watch('schema', setSchema)
|
||||||
|
setSchema(container.state.schema)
|
||||||
|
|
||||||
const ensureProxy = v => (v && typeof v === 'object' && !v.__isProxy) ? globalThis.NewState(v) : v;
|
const ensureProxy = v => (v && typeof v === 'object' && !v.__isProxy) ? globalThis.NewState(v) : v;
|
||||||
const setData = value => {
|
const setData = value => {
|
||||||
|
|||||||
2
components/base/AutoForm.min.js
vendored
2
components/base/AutoForm.min.js
vendored
File diff suppressed because one or more lines are too long
@ -31,6 +31,10 @@
|
|||||||
{ name: 'hidden', label: 'Hidden', type: 'text', if: "this.data.role === 'user'" }
|
{ name: 'hidden', label: 'Hidden', type: 'text', if: "this.data.role === 'user'" }
|
||||||
]
|
]
|
||||||
const compactSchema = [{ name: 'name', label: 'Name', type: 'text' }]
|
const compactSchema = [{ name: 'name', label: 'Name', type: 'text' }]
|
||||||
|
const databaseFields = [
|
||||||
|
{ id: 'f-name', tableID: 'users', name: 'name', type: 'v100', settings: { type: 'text', attrs: { required: true } } },
|
||||||
|
{ id: 'f-role', tableID: 'users', name: 'role', type: 'v30', settings: { type: 'select', options: ['admin', 'user'] } }
|
||||||
|
]
|
||||||
const saveRequest = { url: '/__http__/json', method: 'POST' }
|
const saveRequest = { url: '/__http__/json', method: 'POST' }
|
||||||
const errorRequest = { url: '/__http__/api-error', method: 'POST' }
|
const errorRequest = { url: '/__http__/api-error', method: 'POST' }
|
||||||
</script>
|
</script>
|
||||||
@ -45,11 +49,12 @@
|
|||||||
<AutoForm id="saveForm" nobutton $.api="saveApi" $.state.data="profile" $.state.schema="compactSchema"></AutoForm>
|
<AutoForm id="saveForm" nobutton $.api="saveApi" $.state.data="profile" $.state.schema="compactSchema"></AutoForm>
|
||||||
<AutoForm id="errorForm" nobutton $.api="errorApi" $.state.data="profile" $.state.schema="compactSchema"></AutoForm>
|
<AutoForm id="errorForm" nobutton $.api="errorApi" $.state.data="profile" $.state.schema="compactSchema"></AutoForm>
|
||||||
<AutoForm id="cancelForm" nobutton $.api="saveApi" $.state.data="profile" $.state.schema="compactSchema"></AutoForm>
|
<AutoForm id="cancelForm" nobutton $.api="saveApi" $.state.data="profile" $.state.schema="compactSchema"></AutoForm>
|
||||||
|
<AutoForm id="databaseForm" nobutton $.state.data="profile" $.state.schema="databaseFields"></AutoForm>
|
||||||
<button id="runAll" type="button">测试全部</button>
|
<button id="runAll" type="button">测试全部</button>
|
||||||
<pre id="result"></pre>
|
<pre id="result"></pre>
|
||||||
<script>
|
<script>
|
||||||
const tick = () => new Promise(resolve => setTimeout(resolve, 80))
|
const tick = () => new Promise(resolve => setTimeout(resolve, 80))
|
||||||
const cases = ['data', 'all-controls', 'setting', 'options', 'if', 'custom-type', 'vertical', 'horizontal', 'inline', 'nobutton', 'submitlabel', 'slot', 'submit', 'submit-event', 'response-event', 'error-event']
|
const cases = ['data', 'all-controls', 'setting', 'options', 'database-schema', 'if', 'custom-type', 'vertical', 'horizontal', 'inline', 'nobutton', 'submitlabel', 'slot', 'submit', 'submit-event', 'response-event', 'error-event']
|
||||||
const assert = (testResult, feature, condition, message) => { testResult.assertions++; if (condition) testResult.passed++; else { testResult.failed++; testResult.failures.push({ feature, message }) } }
|
const assert = (testResult, feature, condition, message) => { testResult.assertions++; if (condition) testResult.passed++; else { testResult.failed++; testResult.failures.push({ feature, message }) } }
|
||||||
window.runTest = async () => {
|
window.runTest = async () => {
|
||||||
const testResult = { name: 'components.form.AutoForm', status: 'running', assertions: 0, passed: 0, failed: 0, failures: [], consoleErrors: [], coverage: { tested: [], total: cases, percent: 0 } }
|
const testResult = { name: 'components.form.AutoForm', status: 'running', assertions: 0, passed: 0, failed: 0, failures: [], consoleErrors: [], coverage: { tested: [], total: cases, percent: 0 } }
|
||||||
@ -61,6 +66,7 @@
|
|||||||
assert(testResult, 'all-controls', form.querySelectorAll('[control-wrapper]').length === 16 && standardTypes.every(type => form.querySelector(`[name="${profileSchema.find(item => item.type === type).name}"]`)) && customTypes.every(type => form.querySelector(type)), 'one schema renders every documented standard and custom control type')
|
assert(testResult, 'all-controls', form.querySelectorAll('[control-wrapper]').length === 16 && standardTypes.every(type => form.querySelector(`[name="${profileSchema.find(item => item.type === type).name}"]`)) && customTypes.every(type => form.querySelector(type)), 'one schema renders every documented standard and custom control type')
|
||||||
assert(testResult, 'setting', form.querySelector('input[name="name"]').required, 'setting attributes are copied to controls')
|
assert(testResult, 'setting', form.querySelector('input[name="name"]').required, 'setting attributes are copied to controls')
|
||||||
assert(testResult, 'options', form.querySelectorAll('[name="role"] option').length === 2, 'select options render schema options')
|
assert(testResult, 'options', form.querySelectorAll('[name="role"] option').length === 2, 'select options render schema options')
|
||||||
|
const databaseForm = document.querySelector('#databaseForm'); assert(testResult, 'database-schema', databaseForm.querySelector('input[name="name"]')?.required && databaseForm.querySelectorAll('select[name="role"] option').length === 2, 'database field definitions are converted to a plain form schema without changing form data keys')
|
||||||
assert(testResult, 'if', !form.querySelector('input[name="hidden"]'), '$$if hides fields whose expression is false')
|
assert(testResult, 'if', !form.querySelector('input[name="hidden"]'), '$$if hides fields whose expression is false')
|
||||||
form.data.role = 'user'; await tick(); assert(testResult, 'if', !!form.querySelector('input[name="hidden"]'), '$$if evaluates item.if again after form data changes'); form.data.role = 'admin'
|
form.data.role = 'user'; await tick(); assert(testResult, 'if', !!form.querySelector('input[name="hidden"]'), '$$if evaluates item.if again after form data changes'); form.data.role = 'admin'
|
||||||
assert(testResult, 'custom-type', !!form.querySelector('TagsInput'), 'registered AutoForm component type renders its component')
|
assert(testResult, 'custom-type', !!form.querySelector('TagsInput'), 'registered AutoForm component type renders its component')
|
||||||
|
|||||||
@ -20,10 +20,13 @@ properties:
|
|||||||
|
|
||||||
schema:
|
schema:
|
||||||
item: "{name, type, label?, setting?, options?, placeholder?, if?}"
|
item: "{name, type, label?, setting?, options?, placeholder?, if?}"
|
||||||
types: "text|password|email|number|date|datetime|file|select|checkbox|radio|switch|textarea|DatePicker|ColorPicker|IconPicker|TagsInput"
|
types: "text|password|email|number|date|datetime|file|select|checkbox|radio|switch|textarea|label|divider|DatePicker|ColorPicker|IconPicker|TagsInput"
|
||||||
options: "select/checkbox/radio options: ['A', {value:'b', label:'B'}]"
|
options: "select/checkbox/radio options: ['A', {value:'b', label:'B'}]"
|
||||||
setting: "attributes copied to the control; DatePicker rangeEnd is an example"
|
setting: "attributes copied to the control; DatePicker rangeEnd is an example"
|
||||||
if: 'JavaScript expression evaluated by `$$if`; use component data through `this.data`, for example `this.data.role === "admin"`.'
|
if: 'JavaScript expression evaluated by `$$if`; use component data through `this.data`, for example `this.data.role === "admin"`.'
|
||||||
|
label: Read-only text displaying item.value or data[item.name].
|
||||||
|
divider: Full-width section divider; label is its optional section title.
|
||||||
|
database_fields: 'Data-API field definitions with tableID are accepted and converted for rendering only: name stays the data key; settings.type is the control type; settings.options are control options; settings.attrs become control attributes. The original fields and data are not changed.'
|
||||||
|
|
||||||
submit:
|
submit:
|
||||||
source: Set api to an API element, or set request.url.
|
source: Set api to an API element, or set request.url.
|
||||||
@ -42,6 +45,10 @@ examples:
|
|||||||
]
|
]
|
||||||
</script>
|
</script>
|
||||||
<AutoForm $.state.data="profile" $.state.schema="profileSchema"></AutoForm>
|
<AutoForm $.state.data="profile" $.state.schema="profileSchema"></AutoForm>
|
||||||
|
database_fields: |
|
||||||
|
const fields = [{ id: 'f-role', tableID: 'users', name: 'Role', type: 'v30', settings: { type: 'select', options: ['Admin', 'User'] } }]
|
||||||
|
const user = { Role: 'Admin' }
|
||||||
|
<AutoForm $.state.data="user" $.state.schema="fields"></AutoForm>
|
||||||
submit_api: |
|
submit_api: |
|
||||||
<script>
|
<script>
|
||||||
const profile = { name: 'Ada' }
|
const profile = { name: 'Ada' }
|
||||||
|
|||||||
@ -15,7 +15,7 @@ globalThis.Component.register('Modal', container => {
|
|||||||
container.hide = (...args) => container.modal.hide(...args)
|
container.hide = (...args) => container.modal.hide(...args)
|
||||||
}, globalThis.Util.makeDom(/*html*/`
|
}, globalThis.Util.makeDom(/*html*/`
|
||||||
<div class="modal fade" data-bs-backdrop="static">
|
<div class="modal fade" data-bs-backdrop="static">
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
|
||||||
<div $class="modal-content border-\${this.state?.type || 'primary'} border-2 shadow-lg">
|
<div $class="modal-content border-\${this.state?.type || 'primary'} border-2 shadow-lg">
|
||||||
<div slot-id="header" class="modal-header py-2 px-3 bg-light">
|
<div slot-id="header" class="modal-header py-2 px-3 bg-light">
|
||||||
<h6 $class="modal-title fw-bold text-\${this.state?.type || 'primary'}" $text="this.state?.title"></h6>
|
<h6 $class="modal-title fw-bold text-\${this.state?.type || 'primary'}" $text="this.state?.title"></h6>
|
||||||
|
|||||||
2
components/base/Modal.min.js
vendored
2
components/base/Modal.min.js
vendored
@ -1 +1 @@
|
|||||||
globalThis.Component.register("Modal",t=>{t.modal=new bootstrap.Modal(t),t.addEventListener("bind",d=>{d.detail?t.modal.show():t.modal.hide()}),t.addEventListener("hide.bs.modal",()=>{document.activeElement?.blur(),t.dispatchEvent(new CustomEvent("change",{bubbles:!1,detail:!1}))}),t.show=(...d)=>t.modal.show(...d),t.hide=(...d)=>t.modal.hide(...d)},globalThis.Util.makeDom('\n<div class="modal fade" data-bs-backdrop="static">\n <div class="modal-dialog modal-dialog-centered">\n <div $class="modal-content border-${this.state?.type || \'primary\'} border-2 shadow-lg">\n <div slot-id="header" class="modal-header py-2 px-3 bg-light">\n <h6 $class="modal-title fw-bold text-${this.state?.type || \'primary\'}" $text="this.state?.title"></h6>\n <button type="button" class="btn btn-link ms-2 bi bi-x-lg link-reset p-0" style="color:inherit; text-decoration:none" data-bs-dismiss="modal"></button>\n </div>\n <div slot-id="body" class="modal-body p-3"></div>\n <div slot-id="footer" class="modal-footer py-2 px-3 bg-light"></div>\n </div>\n </div>\n</div>\n'));
|
globalThis.Component.register("Modal",t=>{t.modal=new bootstrap.Modal(t),t.addEventListener("bind",d=>{d.detail?t.modal.show():t.modal.hide()}),t.addEventListener("hide.bs.modal",()=>{document.activeElement?.blur(),t.dispatchEvent(new CustomEvent("change",{bubbles:!1,detail:!1}))}),t.show=(...d)=>t.modal.show(...d),t.hide=(...d)=>t.modal.hide(...d)},globalThis.Util.makeDom('\n<div class="modal fade" data-bs-backdrop="static">\n <div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">\n <div $class="modal-content border-${this.state?.type || \'primary\'} border-2 shadow-lg">\n <div slot-id="header" class="modal-header py-2 px-3 bg-light">\n <h6 $class="modal-title fw-bold text-${this.state?.type || \'primary\'}" $text="this.state?.title"></h6>\n <button type="button" class="btn btn-link ms-2 bi bi-x-lg link-reset p-0" style="color:inherit; text-decoration:none" data-bs-dismiss="modal"></button>\n </div>\n <div slot-id="body" class="modal-body p-3"></div>\n <div slot-id="footer" class="modal-footer py-2 px-3 bg-light"></div>\n </div>\n </div>\n</div>\n'));
|
||||||
|
|||||||
@ -17,7 +17,7 @@
|
|||||||
<pre id="result" class="h-25 overflow-auto border rounded bg-body p-2 mt-3 mb-0 flex-shrink-0 small"></pre>
|
<pre id="result" class="h-25 overflow-auto border rounded bg-body p-2 mt-3 mb-0 flex-shrink-0 small"></pre>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
const cases = ['binding', 'title', 'type', 'show', 'hide', 'change-event', 'header-slot', 'body-slot', 'footer-slot']
|
const cases = ['binding', 'title', 'type', 'show', 'hide', 'change-event', 'header-slot', 'body-slot', 'footer-slot', 'body-scroll']
|
||||||
const errors = []; const originalError = console.error; console.error = (...args) => { errors.push(args.map(String).join(' ')); originalError(...args) }; addEventListener('error', event => errors.push(String(event.message || event.error || event))); addEventListener('unhandledrejection', event => errors.push(String(event.reason || event)))
|
const errors = []; const originalError = console.error; console.error = (...args) => { errors.push(args.map(String).join(' ')); originalError(...args) }; addEventListener('error', event => errors.push(String(event.message || event.error || event))); addEventListener('unhandledrejection', event => errors.push(String(event.reason || event)))
|
||||||
const tick = () => new Promise(resolve => setTimeout(resolve, 500)); const assert = (r, feature, ok, message) => { r.assertions++; if (ok) r.passed++; else { r.failed++; r.failures.push({ feature, message }) } }
|
const tick = () => new Promise(resolve => setTimeout(resolve, 500)); const assert = (r, feature, ok, message) => { r.assertions++; if (ok) r.passed++; else { r.failed++; r.failures.push({ feature, message }) } }
|
||||||
window.runTest = async () => { errors.length = 0; const r = { name: 'components.Modal', status: 'running', assertions: 0, passed: 0, failed: 0, failures: [], consoleErrors: [], coverage: { tested: [], total: cases, percent: 0 } }; const modal = document.querySelector('#editModal'); let change
|
window.runTest = async () => { errors.length = 0; const r = { name: 'components.Modal', status: 'running', assertions: 0, passed: 0, failed: 0, failures: [], consoleErrors: [], coverage: { tested: [], total: cases, percent: 0 } }; const modal = document.querySelector('#editModal'); let change
|
||||||
@ -29,6 +29,7 @@
|
|||||||
assert(r, 'header-slot', modal.querySelector('#customHeader')?.textContent === 'Custom header', 'header slot replaces the header')
|
assert(r, 'header-slot', modal.querySelector('#customHeader')?.textContent === 'Custom header', 'header slot replaces the header')
|
||||||
assert(r, 'body-slot', modal.querySelector('#customBody')?.textContent === 'Custom body', 'body slot replaces the body')
|
assert(r, 'body-slot', modal.querySelector('#customBody')?.textContent === 'Custom body', 'body slot replaces the body')
|
||||||
assert(r, 'footer-slot', modal.querySelector('#customFooter')?.textContent === 'Custom footer', 'footer slot replaces the footer')
|
assert(r, 'footer-slot', modal.querySelector('#customFooter')?.textContent === 'Custom footer', 'footer slot replaces the footer')
|
||||||
|
assert(r, 'body-scroll', modal.querySelector('.modal-dialog')?.classList.contains('modal-dialog-scrollable'), 'long body content scrolls without moving the header or footer')
|
||||||
modal.hide(); await tick(); assert(r, 'hide', !modal.classList.contains('show'), 'hide() hides the Bootstrap modal'); assert(r, 'change-event', change === false, 'change detail is false when the modal is hidden')
|
modal.hide(); await tick(); assert(r, 'hide', !modal.classList.contains('show'), 'hide() hides the Bootstrap modal'); assert(r, 'change-event', change === false, 'change detail is false when the modal is hidden')
|
||||||
r.coverage.tested = [...cases]; r.coverage.percent = 100; r.consoleErrors = [...errors]; r.status = r.failed || r.consoleErrors.length ? 'failed' : 'passed'; document.querySelector('#status').className = 'badge text-bg-' + (r.status === 'passed' ? 'success' : 'danger'); document.querySelector('#status').textContent = r.status === 'passed' ? '通过' : '失败'; document.querySelector('#coverage').textContent = '覆盖 100%'; document.querySelector('#result').textContent = JSON.stringify(r, null, 2); return r }
|
r.coverage.tested = [...cases]; r.coverage.percent = 100; r.consoleErrors = [...errors]; r.status = r.failed || r.consoleErrors.length ? 'failed' : 'passed'; document.querySelector('#status').className = 'badge text-bg-' + (r.status === 'passed' ? 'success' : 'danger'); document.querySelector('#status').textContent = r.status === 'passed' ? '通过' : '失败'; document.querySelector('#coverage').textContent = '覆盖 100%'; document.querySelector('#result').textContent = JSON.stringify(r, null, 2); return r }
|
||||||
document.querySelector('#runAll').onclick = window.runTest
|
document.querySelector('#runAll').onclick = window.runTest
|
||||||
|
|||||||
@ -3,6 +3,9 @@ name: Modal
|
|||||||
binding:
|
binding:
|
||||||
behavior: Bind a boolean with $bind to show or hide the modal.
|
behavior: Bind a boolean with $bind to show or hide the modal.
|
||||||
|
|
||||||
|
layout:
|
||||||
|
behavior: Long body content scrolls inside the modal; the header and footer remain visible.
|
||||||
|
|
||||||
state:
|
state:
|
||||||
title: Default header title.
|
title: Default header title.
|
||||||
type: Bootstrap contextual type. Default is primary.
|
type: Bootstrap contextual type. Default is primary.
|
||||||
|
|||||||
@ -11,20 +11,21 @@ globalThis.Component.register('Nav', container => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
container.click = (item, noselect) => {
|
container.click = (item, noselect) => {
|
||||||
if (!item.noselect && !noselect) {
|
const selected = !item.noselect && !noselect
|
||||||
|
if (selected) {
|
||||||
container.state.value = item.name
|
container.state.value = item.name
|
||||||
container.dispatchEvent(new CustomEvent('change', { detail: item.name, bubbles: false }))
|
container.dispatchEvent(new CustomEvent('change', { detail: item.name, bubbles: false }))
|
||||||
}
|
}
|
||||||
container.dispatchEvent(new CustomEvent('nav', { detail: { item }, bubbles: false }))
|
container.dispatchEvent(new CustomEvent('nav', { detail: { item, name: item.name, selected }, bubbles: false }))
|
||||||
}
|
}
|
||||||
container.clickSubitem = item => {
|
container.clickSubitem = item => {
|
||||||
container.state.value = item.name
|
container.state.value = item.name
|
||||||
container.dispatchEvent(new CustomEvent('change', { detail: item.name, bubbles: false }))
|
container.dispatchEvent(new CustomEvent('change', { detail: item.name, bubbles: false }))
|
||||||
container.dispatchEvent(new CustomEvent('nav', { detail: { item }, bubbles: false }))
|
container.dispatchEvent(new CustomEvent('nav', { detail: { item, name: item.name, selected: true }, bubbles: false }))
|
||||||
}
|
}
|
||||||
container.toggleGroup = item => {
|
container.toggleGroup = item => {
|
||||||
container.state.openName = container.state.openName === item.name ? null : item.name
|
container.state.openName = container.state.openName === item.name ? null : item.name
|
||||||
container.dispatchEvent(new CustomEvent('nav', { detail: { item, open: container.state.openName === item.name }, bubbles: false }))
|
container.dispatchEvent(new CustomEvent('nav', { detail: { item, name: item.name, open: container.state.openName === item.name, selected: false }, bubbles: false }))
|
||||||
}
|
}
|
||||||
}, globalThis.Util.makeDom(/*html*/`
|
}, globalThis.Util.makeDom(/*html*/`
|
||||||
<div $class="\${this.vertical ? 'd-flex flex-column border-end h-100 align-self-stretch overflow-visible' : 'navbar navbar-expand border-bottom'} bg-body-secondary px-2 \${this.vertical ? 'py-3' : 'py-1'} align-items-center \${this.vertical ? 'align-items-start' : ''}" $style="this.vertical ? 'min-height:0;' : ''">
|
<div $class="\${this.vertical ? 'd-flex flex-column border-end h-100 align-self-stretch overflow-visible' : 'navbar navbar-expand border-bottom'} bg-body-secondary px-2 \${this.vertical ? 'py-3' : 'py-1'} align-items-center \${this.vertical ? 'align-items-start' : ''}" $style="this.vertical ? 'min-height:0;' : ''">
|
||||||
@ -41,7 +42,7 @@ globalThis.Component.register('Nav', container => {
|
|||||||
</div>
|
</div>
|
||||||
<div $class="d-flex \${this.vertical ? 'w-100 flex-fill mt-2 flex-column' : 'ms-2 align-items-center flex-fill'}" $style="this.vertical ? 'min-height:0; overflow-y:auto; overflow-x:visible;' : ''">
|
<div $class="d-flex \${this.vertical ? 'w-100 flex-fill mt-2 flex-column' : 'ms-2 align-items-center flex-fill'}" $style="this.vertical ? 'min-height:0; overflow-y:auto; overflow-x:visible;' : ''">
|
||||||
<template $each="this.state?.list || []">
|
<template $each="this.state?.list || []">
|
||||||
<div $class="\${this.vertical ? 'nav nav-pills flex-column w-100 position-relative' : 'navbar-nav flex-row align-items-center'} \${!this.vertical && item.type==='fill' ? 'flex-fill' : ''}" style="transform: translateY(3px);">
|
<div $class="\${this.vertical ? 'nav nav-pills flex-column w-100 position-relative' : 'navbar-nav flex-row align-items-center'} \${!this.vertical && item.type==='fill' ? 'flex-fill' : ''}">
|
||||||
<template $if="item.type==='label'">
|
<template $if="item.type==='label'">
|
||||||
<div $class="\${this.vertical ? 'small text-uppercase text-body-secondary fw-semibold px-2 py-1 mt-2' : 'navbar-text small text-uppercase text-body-secondary fw-semibold px-2'} \${item.class || ''}" $text="item.label"></div>
|
<div $class="\${this.vertical ? 'small text-uppercase text-body-secondary fw-semibold px-2 py-1 mt-2' : 'navbar-text small text-uppercase text-body-secondary fw-semibold px-2'} \${item.class || ''}" $text="item.label"></div>
|
||||||
</template>
|
</template>
|
||||||
@ -86,7 +87,7 @@ globalThis.Component.register('Nav', container => {
|
|||||||
<button type="button" $class="nav-link dropdown-toggle \${item.class || ''}" data-bs-toggle="dropdown">
|
<button type="button" $class="nav-link dropdown-toggle \${item.class || ''}" data-bs-toggle="dropdown">
|
||||||
<i $class="bi bi-\${item.icon} me-2"></i><span $class="'d-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'" $text="item.label"></span>
|
<i $class="bi bi-\${item.icon} me-2"></i><span $class="'d-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'" $text="item.label"></span>
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-menu dropdown-menu-end p-2 bg-body-secondary shadow" $style="'width: ' + (item.width || 250) + 'px;'">
|
<div class="dropdown-menu dropdown-menu-end p-2 bg-body-secondary shadow" $style="'width: ' + (item.width || 250) + 'px; z-index: 1080;'">
|
||||||
<template $each="item.items || item.list" as="subitem">
|
<template $each="item.items || item.list" as="subitem">
|
||||||
<template $if="subitem.type==='label'">
|
<template $if="subitem.type==='label'">
|
||||||
<div $class="dropdown-header \${subitem.class || ''}" $text="subitem.label"></div>
|
<div $class="dropdown-header \${subitem.class || ''}" $text="subitem.label"></div>
|
||||||
|
|||||||
2
components/base/Nav.min.js
vendored
2
components/base/Nav.min.js
vendored
File diff suppressed because one or more lines are too long
@ -5,14 +5,14 @@
|
|||||||
<title>components.Nav tests</title><script src="../../ui.js" components="Nav"></script>
|
<title>components.Nav tests</title><script src="../../ui.js" components="Nav"></script>
|
||||||
<script>
|
<script>
|
||||||
const navBrand = { icon: 'box', label: 'Apigo' }
|
const navBrand = { icon: 'box', label: 'Apigo' }
|
||||||
const navItems = [{ type: 'label', label: 'Main' }, { type: 'button', name: 'home', label: 'Home', icon: 'house' }, { type: 'dropdown', name: 'admin', label: 'Admin', icon: 'shield', items: [{ type: 'label', label: 'Manage' }, { type: 'button', name: 'roles', label: 'Roles', icon: 'person' }, { type: 'switch', name: 'alerts', label: 'Alerts', bind: { alerts: false } }] }, { type: 'fill' }, { type: 'button', name: 'settings', label: 'Settings', icon: 'gear' }]
|
const navItems = [{ type: 'label', label: 'Main' }, { type: 'button', name: 'home', label: 'Home', icon: 'house' }, { type: 'dropdown', name: 'admin', label: 'Admin', icon: 'shield', items: [{ type: 'label', label: 'Manage' }, { type: 'button', name: 'roles', label: 'Roles', icon: 'person' }, { type: 'switch', name: 'alerts', label: 'Alerts', bind: { alerts: false } }] }, { type: 'fill' }, { type: 'button', name: 'settings', label: 'Settings', icon: 'gear' }, { type: 'button', name: 'logout', label: 'Logout', icon: 'door-open', noselect: true }]
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body class="vh-100 overflow-hidden bg-body-tertiary"><div class="container-fluid h-100 py-3 d-flex flex-column">
|
<body class="vh-100 overflow-hidden bg-body-tertiary"><div class="container-fluid h-100 py-3 d-flex flex-column">
|
||||||
<header class="d-flex align-items-center gap-2 mb-3 flex-shrink-0"><h1 class="h4 mb-0">components.Nav</h1><span id="status" class="badge text-bg-secondary">未测试</span><button id="runAll" class="btn btn-primary btn-sm ms-auto" type="button">测试全部</button><span id="coverage" class="small text-body-secondary"></span></header>
|
<header class="d-flex align-items-center gap-2 mb-3 flex-shrink-0"><h1 class="h4 mb-0">components.Nav</h1><span id="status" class="badge text-bg-secondary">未测试</span><button id="runAll" class="btn btn-primary btn-sm ms-auto" type="button">测试全部</button><span id="coverage" class="small text-body-secondary"></span></header>
|
||||||
<main class="flex-fill overflow-auto"><Nav id="horizontalNav" $.state.brand="navBrand" $.state.list="navItems" $bind="Hash.nav"></Nav><div class="mt-3 border" style="height:220px"><Nav id="verticalNav" vertical $.state.brand="navBrand" $.state.list="navItems" $bind="Hash.nav"></Nav></div></main><pre id="result" class="h-25 overflow-auto border rounded bg-body p-2 mt-3 mb-0 flex-shrink-0 small"></pre>
|
<main class="flex-fill overflow-auto"><Nav id="horizontalNav" $.state.brand="navBrand" $.state.list="navItems" $bind="Hash.nav"></Nav><div class="mt-3 border" style="height:220px"><Nav id="verticalNav" vertical $.state.brand="navBrand" $.state.list="navItems" $bind="Hash.nav"></Nav></div></main><pre id="result" class="h-25 overflow-auto border rounded bg-body p-2 mt-3 mb-0 flex-shrink-0 small"></pre>
|
||||||
</div><script>
|
</div><script>
|
||||||
const cases = ['vertical', 'brand', 'list', 'value', 'Hash.nav', 'openName', 'label', 'button', 'dropdown', 'fill', 'change-event', 'nav-event', 'item-shape']; const errors = []; const originalError = console.error; console.error = (...args) => { errors.push(args.map(String).join(' ')); originalError(...args) }; addEventListener('error', event => errors.push(String(event.message || event.error || event))); addEventListener('unhandledrejection', event => errors.push(String(event.reason || event))); const tick = () => new Promise(resolve => setTimeout(resolve, 60)); const assert = (r, feature, ok, message) => { r.assertions++; if (ok) r.passed++; else { r.failed++; r.failures.push({ feature, message }) } }
|
const cases = ['vertical', 'brand', 'list', 'value', 'Hash.nav', 'openName', 'label', 'button', 'dropdown', 'fill', 'change-event', 'nav-event', 'noselect-nav', 'item-shape']; const errors = []; const originalError = console.error; console.error = (...args) => { errors.push(args.map(String).join(' ')); originalError(...args) }; addEventListener('error', event => errors.push(String(event.message || event.error || event))); addEventListener('unhandledrejection', event => errors.push(String(event.reason || event))); const tick = () => new Promise(resolve => setTimeout(resolve, 60)); const assert = (r, feature, ok, message) => { r.assertions++; if (ok) r.passed++; else { r.failed++; r.failures.push({ feature, message }) } }
|
||||||
window.runTest = async () => { errors.length = 0; const r = { name: 'components.Nav', status: 'running', assertions: 0, passed: 0, failed: 0, failures: [], consoleErrors: [], coverage: { tested: [], total: cases, percent: 0 } }; await tick(); const horizontal = document.querySelector('#horizontalNav'); const vertical = document.querySelector('#verticalNav'); const navLink = (nav, label) => [...nav.querySelectorAll('.nav-link')].find(node => node.textContent.includes(label)); let change; const navEvents = []; horizontal.addEventListener('change', event => { change = event.detail }); horizontal.addEventListener('nav', event => navEvents.push(event.detail)); vertical.addEventListener('nav', event => navEvents.push(event.detail))
|
window.runTest = async () => { errors.length = 0; const r = { name: 'components.Nav', status: 'running', assertions: 0, passed: 0, failed: 0, failures: [], consoleErrors: [], coverage: { tested: [], total: cases, percent: 0 } }; await tick(); const horizontal = document.querySelector('#horizontalNav'); const vertical = document.querySelector('#verticalNav'); const navLink = (nav, label) => [...nav.querySelectorAll('.nav-link')].find(node => node.textContent.includes(label)); let change; const navEvents = []; horizontal.addEventListener('change', event => { change = event.detail }); horizontal.addEventListener('nav', event => navEvents.push(event.detail)); vertical.addEventListener('nav', event => navEvents.push(event.detail))
|
||||||
assert(r, 'vertical', vertical.vertical === true && vertical.querySelector('.flex-column'), 'vertical attribute enables vertical navigation')
|
assert(r, 'vertical', vertical.vertical === true && vertical.querySelector('.flex-column'), 'vertical attribute enables vertical navigation')
|
||||||
assert(r, 'brand', horizontal.textContent.includes('Apigo') && horizontal.querySelector('.bi-box') && vertical.textContent.includes('Apigo'), 'brand renders the same icon and label in both layouts')
|
assert(r, 'brand', horizontal.textContent.includes('Apigo') && horizontal.querySelector('.bi-box') && vertical.textContent.includes('Apigo'), 'brand renders the same icon and label in both layouts')
|
||||||
@ -23,5 +23,6 @@
|
|||||||
navLink(vertical, 'Admin').click(); await tick(); assert(r, 'dropdown', vertical.textContent.includes('Manage') && vertical.textContent.includes('Roles'), 'dropdown renders documented items field'); assert(r, 'openName', vertical.state.openName === 'admin', 'vertical dropdown stores its open name')
|
navLink(vertical, 'Admin').click(); await tick(); assert(r, 'dropdown', vertical.textContent.includes('Manage') && vertical.textContent.includes('Roles'), 'dropdown renders documented items field'); assert(r, 'openName', vertical.state.openName === 'admin', 'vertical dropdown stores its open name')
|
||||||
navLink(vertical, 'Roles').click(); await tick(); assert(r, 'item-shape', vertical.state.value === 'roles' && navLink(vertical, 'Roles').classList.contains('active'), 'item shape name and items select an active subitem')
|
navLink(vertical, 'Roles').click(); await tick(); assert(r, 'item-shape', vertical.state.value === 'roles' && navLink(vertical, 'Roles').classList.contains('active'), 'item shape name and items select an active subitem')
|
||||||
assert(r, 'nav-event', navEvents.some(detail => detail.item?.name === 'home') && navEvents.some(detail => detail.item?.name === 'admin' && detail.open === true), 'nav event exposes item and dropdown open detail')
|
assert(r, 'nav-event', navEvents.some(detail => detail.item?.name === 'home') && navEvents.some(detail => detail.item?.name === 'admin' && detail.open === true), 'nav event exposes item and dropdown open detail')
|
||||||
|
const selectedBeforeLogout = horizontal.state.value; navLink(horizontal, 'Logout').click(); await tick(); assert(r, 'noselect-nav', horizontal.state.value === selectedBeforeLogout && navEvents.some(detail => detail.name === 'logout' && detail.selected === false), 'noselect action keeps selection and still dispatches nav')
|
||||||
r.coverage.tested = [...cases]; r.coverage.percent = 100; r.consoleErrors = [...errors]; r.status = r.failed || r.consoleErrors.length ? 'failed' : 'passed'; document.querySelector('#status').className = 'badge text-bg-' + (r.status === 'passed' ? 'success' : 'danger'); document.querySelector('#status').textContent = r.status === 'passed' ? '通过' : '失败'; document.querySelector('#coverage').textContent = '覆盖 100%'; document.querySelector('#result').textContent = JSON.stringify(r, null, 2); return r }; document.querySelector('#runAll').onclick = window.runTest
|
r.coverage.tested = [...cases]; r.coverage.percent = 100; r.consoleErrors = [...errors]; r.status = r.failed || r.consoleErrors.length ? 'failed' : 'passed'; document.querySelector('#status').className = 'badge text-bg-' + (r.status === 'passed' ? 'success' : 'danger'); document.querySelector('#status').textContent = r.status === 'passed' ? '通过' : '失败'; document.querySelector('#coverage').textContent = '覆盖 100%'; document.querySelector('#result').textContent = JSON.stringify(r, null, 2); return r }; document.querySelector('#runAll').onclick = window.runTest
|
||||||
</script></body></html>
|
</script></body></html>
|
||||||
|
|||||||
@ -18,9 +18,9 @@ item_types:
|
|||||||
|
|
||||||
events:
|
events:
|
||||||
change:
|
change:
|
||||||
detail: Selected item name.
|
detail: Selected item name. Fires only when the selected value changes.
|
||||||
nav:
|
nav:
|
||||||
detail: '{ item } or { item, open } for vertical dropdown changes.'
|
detail: '{ item, name, selected } or { item, name, selected, open } for vertical dropdown changes. Fires for every activated button, including noselect action items.'
|
||||||
|
|
||||||
item_shape: "{ type, name?, label?, icon?, items?, bind?, class?, width? }"
|
item_shape: "{ type, name?, label?, icon?, items?, bind?, class?, width? }"
|
||||||
|
|
||||||
@ -42,4 +42,5 @@ example: |
|
|||||||
|
|
||||||
rules:
|
rules:
|
||||||
- Bind `$bind="Hash.nav"` when the selected navigation item must survive a page refresh.
|
- Bind `$bind="Hash.nav"` when the selected navigation item must survive a page refresh.
|
||||||
|
- Use nav for actions such as Logout: `$onnav="if(event.detail.name==='logout') logout()"`. Use change only for selected-page state.
|
||||||
- Use the same `navItems` data for horizontal and vertical navigation when comparing layouts.
|
- Use the same `navItems` data for horizontal and vertical navigation when comparing layouts.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
2
components/data/DataGrid.min.js
vendored
2
components/data/DataGrid.min.js
vendored
File diff suppressed because one or more lines are too long
@ -5,21 +5,23 @@
|
|||||||
<title>components.data.DataGrid tests</title>
|
<title>components.data.DataGrid tests</title>
|
||||||
<script src="../../ui.js" components="data.DataGrid"></script>
|
<script src="../../ui.js" components="data.DataGrid"></script>
|
||||||
<script>
|
<script>
|
||||||
|
const tableID = 'testTable'
|
||||||
|
const field = (id, name, type, settings) => ({ id, tableID, name, type, isIndex: false, memo: '', settings })
|
||||||
const gridFields = [
|
const gridFields = [
|
||||||
{ id: 'id', name: 'ID', type: 'number', settings: { width: 70, formType: 'number', pinned: 'left' } },
|
field('f-id', 'id', 'bi', { width: 70, type: 'number', pinned: 'left' }),
|
||||||
{ id: 'name', name: 'Name', type: 'text', settings: { width: 160, formType: 'text' } },
|
field('f-name', 'name', 'v100', { width: 160, type: 'text' }),
|
||||||
{ id: 'role', name: 'Role', type: 'text', settings: { width: 120, formType: 'select', options: [{ label: 'Administrator', value: 'admin' }, { label: 'Editor', value: 'editor' }] } },
|
field('f-role', 'role', 'v30', { width: 120, type: 'select', options: [{ label: 'Administrator', value: 'admin' }, { label: 'Editor', value: 'editor' }] }),
|
||||||
{ id: 'score', name: 'Score', type: 'number', settings: { width: 100, formType: 'number', prefix: '$', decimals: 2 } },
|
field('f-score', 'score', 'ff', { width: 100, type: 'number', prefix: '$', decimals: 2 }),
|
||||||
{ id: 'active', name: 'Active', type: 'boolean', settings: { width: 90, formType: 'switch' } },
|
field('f-active', 'active', 'b', { width: 90, type: 'switch' }),
|
||||||
{ id: 'choices', name: 'Choices', type: 'object', settings: { width: 130, formType: 'checkbox', options: ['a', 'b'] } },
|
field('f-choices', 'choices', 'v4096', { width: 130, type: 'checkbox', options: ['a', 'b'] }),
|
||||||
{ id: 'priority', name: 'Priority', type: 'text', settings: { width: 120, formType: 'radio', options: ['low', 'high'] } },
|
field('f-priority', 'priority', 'v100', { width: 120, type: 'radio', options: ['low', 'high'] }),
|
||||||
{ id: 'note', name: 'Note', type: 'text', settings: { width: 160, formType: 'textarea' } },
|
field('f-note', 'note', 't', { width: 160, type: 'textarea' }),
|
||||||
{ id: 'date', name: 'Date', type: 'date', settings: { width: 120, formType: 'date' } },
|
field('f-date', 'date', 'd', { width: 120, type: 'date' }),
|
||||||
{ id: 'datetime', name: 'DateTime', type: 'datetime', settings: { width: 180, formType: 'datetime' } },
|
field('f-datetime', 'datetime', 'dt', { width: 180, type: 'datetime' }),
|
||||||
{ id: 'tags', name: 'Tags', type: 'object', settings: { width: 140, formType: 'TagsInput' } },
|
field('f-tags', 'tags', 'v4096', { width: 140, type: 'TagsInput' }),
|
||||||
{ id: 'range', name: 'Range', type: 'date', settings: { width: 140, formType: 'DatePicker' } },
|
field('f-range', 'range', 'd', { width: 140, type: 'DatePicker' }),
|
||||||
{ id: 'color', name: 'Color', type: 'text', settings: { width: 110, formType: 'ColorPicker' } },
|
field('f-color', 'color', 'v100', { width: 110, type: 'ColorPicker' }),
|
||||||
{ id: 'icon', name: 'Icon', type: 'text', settings: { width: 120, formType: 'IconPicker' } }
|
field('f-icon', 'icon', 'v100', { width: 120, type: 'IconPicker' })
|
||||||
]
|
]
|
||||||
const gridRows = Array.from({ length: 200 }, (_, index) => ({ id: index + 1, name: 'User ' + (index + 1), role: index % 2 ? 'editor' : 'admin', score: index + 0.5, active: index % 2 === 0, choices: ['a'], priority: 'high', note: 'Note ' + (index + 1), date: '2026-07-12', datetime: '2026-07-12T09:30', tags: ['grid'], range: '2026-07-12', color: '#0d6efd', icon: 'star' }))
|
const gridRows = Array.from({ length: 200 }, (_, index) => ({ id: index + 1, name: 'User ' + (index + 1), role: index % 2 ? 'editor' : 'admin', score: index + 0.5, active: index % 2 === 0, choices: ['a'], priority: 'high', note: 'Note ' + (index + 1), date: '2026-07-12', datetime: '2026-07-12T09:30', tags: ['grid'], range: '2026-07-12', color: '#0d6efd', icon: 'star' }))
|
||||||
</script>
|
</script>
|
||||||
@ -28,22 +30,25 @@
|
|||||||
<header class="d-flex align-items-center gap-2 mb-3 flex-shrink-0"><h1 class="h4 mb-0">components.data.DataGrid</h1><span id="status" class="badge text-bg-secondary">未测试</span><button id="runAll" class="btn btn-primary btn-sm ms-auto" type="button">测试全部</button><span id="coverage" class="small text-body-secondary"></span></header>
|
<header class="d-flex align-items-center gap-2 mb-3 flex-shrink-0"><h1 class="h4 mb-0">components.data.DataGrid</h1><span id="status" class="badge text-bg-secondary">未测试</span><button id="runAll" class="btn btn-primary btn-sm ms-auto" type="button">测试全部</button><span id="coverage" class="small text-body-secondary"></span></header>
|
||||||
<main class="flex-fill overflow-hidden"><DataGrid id="usersGrid" class="h-100" editable $.state.fields="gridFields" $.state.list="gridRows"></DataGrid></main><pre id="result" class="h-25 overflow-auto border rounded bg-body p-2 mt-3 mb-0 flex-shrink-0 small"></pre>
|
<main class="flex-fill overflow-hidden"><DataGrid id="usersGrid" class="h-100" editable $.state.fields="gridFields" $.state.list="gridRows"></DataGrid></main><pre id="result" class="h-25 overflow-auto border rounded bg-body p-2 mt-3 mb-0 flex-shrink-0 small"></pre>
|
||||||
</div><script>
|
</div><script>
|
||||||
const cases = ['editable', 'fields', 'list', 'field', 'formType', 'format', 'addRow', 'saveChanges', 'save-event', 'applySortFilter', 'sortConfig', 'filterConfig', 'virtual-scroll', 'selection-scroll', 'deleteField', 'DataGrid.registerFieldType', 'DataGrid.getFieldTypes']; const errors = []; const originalError = console.error; console.error = (...args) => { errors.push(args.map(String).join(' ')); originalError(...args) }; addEventListener('error', event => errors.push(String(event.message || event.error || event))); addEventListener('unhandledrejection', event => errors.push(String(event.reason || event))); const tick = (ms = 260) => new Promise(resolve => setTimeout(resolve, ms)); const assert = (r, feature, ok, message) => { r.assertions++; if (ok) r.passed++; else { r.failed++; r.failures.push({ feature, message }) } }
|
const cases = ['editable', 'fields', 'list', 'async-binding', 'field', 'formType', 'format', 'select-editor', 'addRow', 'saveChanges', 'save-event', 'applySortFilter', 'sortConfig', 'filterConfig', 'virtual-scroll', 'selection-scroll', 'deleteField', 'api-mode', 'advanced-mode', 'DataGrid.registerFieldType', 'DataGrid.getFieldTypes']; const errors = []; const originalError = console.error; console.error = (...args) => { errors.push(args.map(String).join(' ')); originalError(...args) }; addEventListener('error', event => errors.push(String(event.message || event.error || event))); addEventListener('unhandledrejection', event => errors.push(String(event.reason || event))); const tick = (ms = 260) => new Promise(resolve => setTimeout(resolve, ms)); const assert = (r, feature, ok, message) => { r.assertions++; if (ok) r.passed++; else { r.failed++; r.failures.push({ feature, message }) } }
|
||||||
window.runTest = async () => { errors.length = 0; const r = { name: 'components.data.DataGrid', status: 'running', assertions: 0, passed: 0, failed: 0, failures: [], consoleErrors: [], coverage: { tested: [], total: cases, percent: 0 } }; await tick(); const grid = document.querySelector('#usersGrid');
|
window.runTest = async () => { errors.length = 0; const r = { name: 'components.data.DataGrid', status: 'running', assertions: 0, passed: 0, failed: 0, failures: [], consoleErrors: [], coverage: { tested: [], total: cases, percent: 0 } }; await tick(); const grid = document.querySelector('#usersGrid');
|
||||||
assert(r, 'editable', grid.editable === true && grid.querySelector('.dt-footer'), 'editable enables grid controls')
|
assert(r, 'editable', grid.editable === true && grid.querySelector('.dt-footer'), 'editable enables grid controls')
|
||||||
assert(r, 'fields', grid.state.fields.length === gridFields.length && grid.querySelectorAll('.dt-header-row .dt-cell').length === gridFields.length, 'fields render grid columns')
|
assert(r, 'fields', grid.state.fields.length === gridFields.length && grid.querySelectorAll('.dt-header-row .dt-cell').length === gridFields.length, 'fields render grid columns')
|
||||||
assert(r, 'list', grid.state.list.length === 200 && grid.querySelectorAll('.dt-body-row').length < 200, 'list renders a virtual row window')
|
assert(r, 'list', grid.state.list.length === 200 && grid.querySelectorAll('.dt-body-row').length < 200, 'list renders a virtual row window')
|
||||||
|
grid.state.list = undefined; await tick(); grid.state.list = gridRows; await tick(); assert(r, 'async-binding', grid.state.list.length === gridRows.length && !errors.length, 'undefined async binding values do not break a later list update')
|
||||||
assert(r, 'field', grid.state.fields.every(field => field.id && field.name), 'field definitions expose id and name')
|
assert(r, 'field', grid.state.fields.every(field => field.id && field.name), 'field definitions expose id and name')
|
||||||
assert(r, 'formType', ['text', 'number', 'select', 'switch', 'checkbox', 'radio', 'textarea', 'date', 'datetime', 'TagsInput', 'DatePicker', 'ColorPicker', 'IconPicker'].every(type => grid.state.fields.some(field => field.settings.formType === type)), 'one grid visibly declares every supported editor type')
|
assert(r, 'formType', ['text', 'number', 'select', 'switch', 'checkbox', 'radio', 'textarea', 'date', 'datetime', 'TagsInput', 'DatePicker', 'ColorPicker', 'IconPicker'].every(type => grid.state.fields.some(field => field.settings.type === type)), 'one grid visibly declares every supported editor type')
|
||||||
assert(r, 'format', grid.format('admin', grid.state.fields[2]) === 'Administrator' && grid.format(1.5, grid.state.fields[3]) === '$1.50', 'field settings and formatters render values')
|
assert(r, 'format', grid.format('admin', grid.state.fields[2]) === 'Administrator' && grid.format(1.5, grid.state.fields[3]) === '$1.50', 'field settings and formatters render values')
|
||||||
|
const roleCell = grid.querySelector('.dt-body-row .dt-cell[data-fidx="2"]'); grid.onMainDblClick({ target: roleCell }); await tick(); const roleSelect = grid.querySelector('.dt-editor-overlay select'); assert(r, 'select-editor', roleSelect?.value === 'admin' && grid.state.fields[2].settings.type === 'select', 'settings.type selects the editor and preserves its initial value'); grid.hideEditor(true)
|
||||||
const beforeRows = grid.state.list.length; grid.addRow(); await tick(); assert(r, 'addRow', grid.state.list.length === beforeRows + 1 && grid.state.isDirty, 'addRow adds a field-shaped row and marks changes dirty')
|
const beforeRows = grid.state.list.length; grid.addRow(); await tick(); assert(r, 'addRow', grid.state.list.length === beforeRows + 1 && grid.state.isDirty, 'addRow adds a field-shaped row and marks changes dirty')
|
||||||
let save; grid.addEventListener('save', event => { save = event.detail }, { once: true }); grid.saveChanges(); assert(r, 'saveChanges', grid.state.isDirty === false, 'saveChanges clears pending changes'); assert(r, 'save-event', save?.list.length === beforeRows + 1 && save?.fields.length === gridFields.length, 'save event exposes list and fields')
|
let save; grid.addEventListener('save', event => { save = event.detail }, { once: true }); grid.saveChanges(); assert(r, 'saveChanges', grid.state.isDirty === false, 'saveChanges clears pending changes'); assert(r, 'save-event', save?.list.length === beforeRows + 1 && save?.fields.length === gridFields.length, 'save event exposes list and fields')
|
||||||
grid.state.activeFieldId = 'name'; grid.applySortFilter({ sort: 'desc' }); await tick(); assert(r, 'applySortFilter', grid.state.list[0].name === 'User 99', 'applySortFilter sorts the current rows')
|
grid.state.activeFieldId = 'f-name'; grid.applySortFilter({ sort: 'desc' }); await tick(); assert(r, 'applySortFilter', grid.state.list[0].name === 'User 99', 'applySortFilter sorts the current rows')
|
||||||
assert(r, 'sortConfig', grid.state.sortConfig.fieldId === 'name' && grid.state.sortConfig.direction === 'desc', 'sortConfig records field and direction')
|
assert(r, 'sortConfig', grid.state.sortConfig.fieldId === 'f-name' && grid.state.sortConfig.direction === 'desc', 'sortConfig records field and direction')
|
||||||
grid.state.activeFieldId = 'role'; grid.state.filterConfig = { role: { mode: 'equals', value: 'admin', selectedValues: [] } }; grid.applySortFilter(); await tick(); assert(r, 'filterConfig', grid.state.list.every(row => row.role === 'admin'), 'filterConfig filters by field value')
|
grid.state.activeFieldId = 'f-role'; grid.state.filterConfig = { 'f-role': { mode: 'equals', value: 'admin', selectedValues: [] } }; grid.applySortFilter(); await tick(); assert(r, 'filterConfig', grid.state.list.every(row => row.role === 'admin'), 'filterConfig filters by field value')
|
||||||
const main = grid.querySelector('.dt-main'); main.scrollTop = main.scrollHeight; main.dispatchEvent(new Event('scroll')); await tick(); assert(r, 'virtual-scroll', grid.state._renderedList.length < grid.state.list.length && grid.state._listStartIndex > 0, 'virtual scroll updates the rendered row window')
|
const main = grid.querySelector('.dt-main'); main.scrollTop = main.scrollHeight; main.dispatchEvent(new Event('scroll')); await tick(); assert(r, 'virtual-scroll', grid.state._renderedList.length < grid.state.list.length && grid.state._listStartIndex > 0, 'virtual scroll updates the rendered row window')
|
||||||
const selected = grid.querySelector('.dt-body-row .dt-cell'); grid.onMainMouseDown({ target: selected, ctrlKey: false, metaKey: false }); main.scrollTop = 0; main.dispatchEvent(new Event('scroll')); await tick(400); main.scrollTop = main.scrollHeight; main.dispatchEvent(new Event('scroll')); await tick(400); assert(r, 'selection-scroll', grid.state.selectedRowCount === 1 && grid.querySelectorAll('.dt-cell-selected').length > 0, 'selected cells restore their visible highlight after virtual scrolling away and back')
|
const selected = grid.querySelector('.dt-body-row .dt-cell'); grid.onMainMouseDown({ target: selected, ctrlKey: false, metaKey: false }); main.scrollTop = 0; main.dispatchEvent(new Event('scroll')); await tick(400); main.scrollTop = main.scrollHeight; main.dispatchEvent(new Event('scroll')); await tick(400); assert(r, 'selection-scroll', grid.state.selectedRowCount === 1 && grid.querySelectorAll('.dt-cell-selected').length > 0, 'selected cells restore their visible highlight after virtual scrolling away and back')
|
||||||
const fieldCount = grid.state.fields.length; grid.state.activeField = grid.state.fields.at(-1); const removing = grid.deleteField(); await tick(550); document.querySelector('Dialog')?.querySelectorAll('button')[1]?.click(); await removing; await tick(); assert(r, 'deleteField', grid.state.fields.length === fieldCount - 1 && !grid.querySelector('.null') && !grid.textContent.includes('null'), 'deleteField removes its cells without orphan null rows')
|
const fieldCount = grid.state.fields.length; grid.state.activeField = grid.state.fields.at(-1); const removing = grid.deleteField(); await tick(550); document.querySelector('Dialog')?.querySelectorAll('button')[1]?.click(); await removing; await tick(); assert(r, 'deleteField', grid.state.fields.length === fieldCount - 1 && !grid.querySelector('.null') && !grid.textContent.includes('null'), 'deleteField removes its cells without orphan null rows')
|
||||||
|
const request = HTTP.request; const requests = []; HTTP.request = async opt => { requests.push(opt.data); if (opt.data.action === 'fields') return { ok: true, result: { ok: true, fields: [field('remote-role', 'Role', 'v30', { type: 'select', options: ['Admin', 'User'] })] } }; if (opt.data.action === 'query') return { ok: true, result: { ok: true, data: [{ id: 'remote-1', Role: 'Admin', creator: '_system' }] } }; return { ok: true, result: { ok: true } } }; grid.apiUrl = '/admin/table'; grid.state.table = 'remote-users'; await tick(); assert(r, 'api-mode', grid.state.fields.length === 1 && grid.state.list[0]?.Role === 'Admin' && requests.some(req => req.action === 'fields') && requests.some(req => req.action === 'query' && Array.isArray(req.filter) && Array.isArray(req.sort)), 'api mode loads fields and rows through safe filter and sort POST actions'); grid.state.advancedMode = true; await tick(); assert(r, 'advanced-mode', grid.state._displayFields.length === 6 && grid.state._displayFields.at(-1).name === 'updateTime', 'advanced mode adds internal read-only system columns without changing fields'); HTTP.request = request
|
||||||
DataGrid.registerFieldType({ value: 'test', label: 'Test', schema: [] }); assert(r, 'DataGrid.registerFieldType', DataGrid.getFieldTypes().some(type => type.value === 'test'), 'registerFieldType registers a public field type'); assert(r, 'DataGrid.getFieldTypes', DataGrid.getFieldTypes().length >= 8, 'getFieldTypes returns built-in and registered types')
|
DataGrid.registerFieldType({ value: 'test', label: 'Test', schema: [] }); assert(r, 'DataGrid.registerFieldType', DataGrid.getFieldTypes().some(type => type.value === 'test'), 'registerFieldType registers a public field type'); assert(r, 'DataGrid.getFieldTypes', DataGrid.getFieldTypes().length >= 8, 'getFieldTypes returns built-in and registered types')
|
||||||
r.coverage.tested = [...cases]; r.coverage.percent = 100; r.consoleErrors = [...errors]; r.status = r.failed || r.consoleErrors.length ? 'failed' : 'passed'; document.querySelector('#status').className = 'badge text-bg-' + (r.status === 'passed' ? 'success' : 'danger'); document.querySelector('#status').textContent = r.status === 'passed' ? '通过' : '失败'; document.querySelector('#coverage').textContent = '覆盖 100%'; document.querySelector('#result').textContent = JSON.stringify(r, null, 2); return r }
|
r.coverage.tested = [...cases]; r.coverage.percent = 100; r.consoleErrors = [...errors]; r.status = r.failed || r.consoleErrors.length ? 'failed' : 'passed'; document.querySelector('#status').className = 'badge text-bg-' + (r.status === 'passed' ? 'success' : 'danger'); document.querySelector('#status').textContent = r.status === 'passed' ? '通过' : '失败'; document.querySelector('#coverage').textContent = '覆盖 100%'; document.querySelector('#result').textContent = JSON.stringify(r, null, 2); return r }
|
||||||
document.querySelector('#runAll').onclick = window.runTest
|
document.querySelector('#runAll').onclick = window.runTest
|
||||||
|
|||||||
@ -4,10 +4,16 @@ attributes:
|
|||||||
editable:
|
editable:
|
||||||
type: boolean
|
type: boolean
|
||||||
behavior: Enable row, field, cell, and save controls.
|
behavior: Enable row, field, cell, and save controls.
|
||||||
|
api:
|
||||||
|
type: string
|
||||||
|
behavior: POST endpoint for the data-table action protocol. Requires state.table.
|
||||||
|
|
||||||
|
purpose: Editable data-API table manager. It consumes the field definitions returned by the data API and keeps them unchanged for save events.
|
||||||
|
|
||||||
state:
|
state:
|
||||||
fields: Array of column definitions; assign before list.
|
table: Table name sent as request.name when api is configured.
|
||||||
list: Array of row objects; each key matches a field id.
|
fields: Array of data-API field definitions; assign before list.
|
||||||
|
list: Array of row objects; each key matches a field name.
|
||||||
_originalList: Internal unfiltered row snapshot.
|
_originalList: Internal unfiltered row snapshot.
|
||||||
sortConfig: "{ fieldId, direction }"
|
sortConfig: "{ fieldId, direction }"
|
||||||
filterConfig: Per-field filter configuration.
|
filterConfig: Per-field filter configuration.
|
||||||
@ -17,18 +23,20 @@ state:
|
|||||||
editing:
|
editing:
|
||||||
formTypes: [text, number, select, checkbox, radio, switch, textarea, date, datetime, TagsInput, DatePicker, ColorPicker, IconPicker]
|
formTypes: [text, number, select, checkbox, radio, switch, textarea, date, datetime, TagsInput, DatePicker, ColorPicker, IconPicker]
|
||||||
rule: Double-click an editable cell, then click outside the editor or press Enter to save its bound value.
|
rule: Double-click an editable cell, then click outside the editor or press Enter to save its bound value.
|
||||||
|
field_settings: Field Name and Field Type are common settings. Advanced Settings shows read-only Field ID and Table ID, plus editable Database Type, Index, and Memo. Changing Field Type assigns its default database type; Database Type may be manually overridden.
|
||||||
|
|
||||||
filtering:
|
filtering:
|
||||||
rule: Click a column header, enter a value in its menu, and the grid filters immediately. Use the menu controls for sort and reset.
|
rule: Click a column header, enter a value in its menu, and the grid filters immediately. Use the menu controls for sort and reset.
|
||||||
|
|
||||||
field:
|
field:
|
||||||
required: [id, name]
|
required: [id, tableID, name, type, settings]
|
||||||
fields:
|
fields:
|
||||||
id: Unique column id and row value key.
|
id: _Fields record ID; used for field metadata operations.
|
||||||
name: Header label.
|
tableID: Owning table ID.
|
||||||
type: Source value type.
|
name: Database column name and row value key.
|
||||||
|
type: Database storage type, for example v100, v30, ff, b, or dt. It is preserved when editing an unchanged UI type; changing settings.type assigns that UI type's default storage type.
|
||||||
memo: Field description.
|
memo: Field description.
|
||||||
settings: "{ width, pinned, formType, options, decimals, prefix, suffix, thousandSep, labelOn, labelOff }"
|
settings: "{ type, label, options, attrs, width, pinned, decimals, prefix, suffix, thousandSep, labelOn, labelOff }; type is the application/editor type."
|
||||||
formatter: Function receiving value and field.
|
formatter: Function receiving value and field.
|
||||||
|
|
||||||
methods:
|
methods:
|
||||||
@ -50,6 +58,20 @@ events:
|
|||||||
remove:
|
remove:
|
||||||
detail: "{ items }"
|
detail: "{ items }"
|
||||||
|
|
||||||
|
api:
|
||||||
|
request: "POST { action, name: state.table, ...payload }"
|
||||||
|
actions:
|
||||||
|
fields: "response { ok, fields }; loaded when table changes."
|
||||||
|
query: "request { filter, sort, offset: 0, limit: 1000 }; response { ok, data, count } or { ok, list, count }. Filter items are { field, operator, value }; sort is ['field DESC']."
|
||||||
|
setField: "request { fields: [field] }; called after adding or editing a field."
|
||||||
|
removeField: "request { fields: [field] }; called after deleting a field."
|
||||||
|
save: "request { data: list }; called by Save."
|
||||||
|
remove: "request { data: items }; called by Delete."
|
||||||
|
behavior: Sorting and filters build validated filter plus sort structures and reload from the server. Pending edits require confirmation before a reload.
|
||||||
|
|
||||||
|
advanced_mode:
|
||||||
|
behavior: The footer toggle appends read-only internal columns id, creator, createTime, updater, and updateTime. They are not added to fields or included in field save requests.
|
||||||
|
|
||||||
global:
|
global:
|
||||||
DataGrid:
|
DataGrid:
|
||||||
methods:
|
methods:
|
||||||
@ -69,6 +91,9 @@ related:
|
|||||||
|
|
||||||
rules:
|
rules:
|
||||||
- Bind fields and list with $.state.fields and $.state.list.
|
- Bind fields and list with $.state.fields and $.state.list.
|
||||||
|
- DataGrid uses field.name (not field.id) to read and update each row value.
|
||||||
|
- DataGrid only supports data-API field definitions; use datatable for generic tabular display.
|
||||||
|
- Async fields and list bindings may be undefined before an API response; DataGrid waits for arrays.
|
||||||
- Give the DataGrid or its parent an explicit height so virtual scrolling has a viewport.
|
- Give the DataGrid or its parent an explicit height so virtual scrolling has a viewport.
|
||||||
- Treat underscore-prefixed state fields as internal read-only diagnostics.
|
- Treat underscore-prefixed state fields as internal read-only diagnostics.
|
||||||
- Custom field editors must be registered in AutoForm before use.
|
- Custom field editors must be registered in AutoForm before use.
|
||||||
@ -76,12 +101,9 @@ rules:
|
|||||||
example: |
|
example: |
|
||||||
<script>
|
<script>
|
||||||
const orderFields = [
|
const orderFields = [
|
||||||
{ id: 'name', name: 'Name', type: 'text', settings: { formType: 'text' } },
|
{ id: 'f-name', tableID: 'orders', name: 'name', type: 'v100', isIndex: true, memo: '', settings: { type: 'text' } },
|
||||||
{ id: 'total', name: 'Total', type: 'number', settings: { formType: 'number', prefix: '$', decimals: 2 } },
|
{ id: 'f-total', tableID: 'orders', name: 'total', type: 'ff', isIndex: false, memo: '', settings: { type: 'number', prefix: '$', decimals: 2 } },
|
||||||
{ id: 'status', name: 'Status', type: 'text', settings: { formType: 'select', options: ['draft', 'paid'] } },
|
{ id: 'f-status', tableID: 'orders', name: 'status', type: 'v30', isIndex: false, memo: '', settings: { type: 'select', options: ['draft', 'paid'] } }
|
||||||
{ id: 'paid', name: 'Paid', type: 'boolean', settings: { formType: 'switch' } },
|
|
||||||
{ id: 'tags', name: 'Tags', type: 'object', settings: { formType: 'TagsInput' } },
|
|
||||||
{ id: 'due', name: 'Due', type: 'date', settings: { formType: 'DatePicker' } }
|
|
||||||
]
|
]
|
||||||
const orders = [{ name: 'Ada', total: 42, status: 'paid', paid: true, tags: ['priority'], due: '2026-07-12' }]
|
const orders = [{ name: 'Ada', total: 42, status: 'paid', paid: true, tags: ['priority'], due: '2026-07-12' }]
|
||||||
const saveRows = rows => console.log(rows)
|
const saveRows = rows => console.log(rows)
|
||||||
@ -89,6 +111,7 @@ example: |
|
|||||||
<div style="height: 480px">
|
<div style="height: 480px">
|
||||||
<DataGrid id="orders" editable $.state.fields="orderFields" $.state.list="orders" $onsave="saveRows(event.detail.list)"></DataGrid>
|
<DataGrid id="orders" editable $.state.fields="orderFields" $.state.list="orders" $onsave="saveRows(event.detail.list)"></DataGrid>
|
||||||
</div>
|
</div>
|
||||||
|
<DataGrid editable api="/admin/table" $.state.table="Hash.table"></DataGrid>
|
||||||
|
|
||||||
tests:
|
tests:
|
||||||
- DataGrid.test.html
|
- DataGrid.test.html
|
||||||
|
|||||||
@ -2113,7 +2113,71 @@ url("data:font/woff;base64,d09GRgABAAAAAsBAAAsAAAAHavgAAQAAAAAAAAAAAAAAAAAAAAAAA
|
|||||||
.bi-globe-americas-fill::before { content: "\\f91b"; }
|
.bi-globe-americas-fill::before { content: "\\f91b"; }
|
||||||
.bi-globe-asia-australia-fill::before { content: "\\f91c"; }
|
.bi-globe-asia-australia-fill::before { content: "\\f91c"; }
|
||||||
.bi-globe-central-south-asia-fill::before { content: "\\f91d"; }
|
.bi-globe-central-south-asia-fill::before { content: "\\f91d"; }
|
||||||
.bi-globe-europe-africa-fill::before { content: "\\f91e"; }`));
|
.bi-globe-europe-africa-fill::before { content: "\\f91e"; }
|
||||||
|
/* Bootstrap 原生仅提供 .btn-link;为链接按钮补充语义色变体。 */
|
||||||
|
.btn-link-primary,
|
||||||
|
.btn-link.btn-link-primary {
|
||||||
|
--bs-btn-color: var(--bs-primary);
|
||||||
|
--bs-btn-hover-color: color-mix(in srgb, var(--bs-primary) 85%, #000);
|
||||||
|
--bs-btn-active-color: color-mix(in srgb, var(--bs-primary) 80%, #000);
|
||||||
|
--bs-btn-disabled-color: var(--bs-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link-secondary,
|
||||||
|
.btn-link.btn-link-secondary {
|
||||||
|
--bs-btn-color: var(--bs-secondary);
|
||||||
|
--bs-btn-hover-color: color-mix(in srgb, var(--bs-secondary) 85%, #000);
|
||||||
|
--bs-btn-active-color: color-mix(in srgb, var(--bs-secondary) 80%, #000);
|
||||||
|
--bs-btn-disabled-color: var(--bs-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link-success,
|
||||||
|
.btn-link.btn-link-success {
|
||||||
|
--bs-btn-color: var(--bs-success);
|
||||||
|
--bs-btn-hover-color: color-mix(in srgb, var(--bs-success) 85%, #000);
|
||||||
|
--bs-btn-active-color: color-mix(in srgb, var(--bs-success) 80%, #000);
|
||||||
|
--bs-btn-disabled-color: var(--bs-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link-info,
|
||||||
|
.btn-link.btn-link-info {
|
||||||
|
--bs-btn-color: var(--bs-info);
|
||||||
|
--bs-btn-hover-color: color-mix(in srgb, var(--bs-info) 85%, #000);
|
||||||
|
--bs-btn-active-color: color-mix(in srgb, var(--bs-info) 80%, #000);
|
||||||
|
--bs-btn-disabled-color: var(--bs-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link-warning,
|
||||||
|
.btn-link.btn-link-warning {
|
||||||
|
--bs-btn-color: var(--bs-warning);
|
||||||
|
--bs-btn-hover-color: color-mix(in srgb, var(--bs-warning) 85%, #000);
|
||||||
|
--bs-btn-active-color: color-mix(in srgb, var(--bs-warning) 80%, #000);
|
||||||
|
--bs-btn-disabled-color: var(--bs-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link-danger,
|
||||||
|
.btn-link.btn-link-danger {
|
||||||
|
--bs-btn-color: var(--bs-danger);
|
||||||
|
--bs-btn-hover-color: color-mix(in srgb, var(--bs-danger) 85%, #000);
|
||||||
|
--bs-btn-active-color: color-mix(in srgb, var(--bs-danger) 80%, #000);
|
||||||
|
--bs-btn-disabled-color: var(--bs-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link-light,
|
||||||
|
.btn-link.btn-link-light {
|
||||||
|
--bs-btn-color: var(--bs-light);
|
||||||
|
--bs-btn-hover-color: color-mix(in srgb, var(--bs-light) 85%, #000);
|
||||||
|
--bs-btn-active-color: color-mix(in srgb, var(--bs-light) 80%, #000);
|
||||||
|
--bs-btn-disabled-color: var(--bs-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link-dark,
|
||||||
|
.btn-link.btn-link-dark {
|
||||||
|
--bs-btn-color: var(--bs-dark);
|
||||||
|
--bs-btn-hover-color: color-mix(in srgb, var(--bs-dark) 85%, #000);
|
||||||
|
--bs-btn-active-color: color-mix(in srgb, var(--bs-dark) 80%, #000);
|
||||||
|
--bs-btn-disabled-color: var(--bs-dark);
|
||||||
|
}`));
|
||||||
document.head.appendChild(elementStyle);
|
document.head.appendChild(elementStyle);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -7192,15 +7256,17 @@ url("data:font/woff;base64,d09GRgABAAAAAsBAAAsAAAAHavgAAQAAAAAAAAAAAAAAAAAAAAAAA
|
|||||||
const rgb = Bootstrap._hexToRgb(hex);
|
const rgb = Bootstrap._hexToRgb(hex);
|
||||||
if (rgb) {
|
if (rgb) {
|
||||||
const rgbStr = `${rgb.r}, ${rgb.g}, ${rgb.b}`;
|
const rgbStr = `${rgb.r}, ${rgb.g}, ${rgb.b}`;
|
||||||
|
const hoverColor = Bootstrap._shadeColor(hex, 0.15);
|
||||||
|
const activeColor = Bootstrap._shadeColor(hex, 0.2);
|
||||||
root.style.setProperty(`--bs-${name}-rgb`, rgbStr);
|
root.style.setProperty(`--bs-${name}-rgb`, rgbStr);
|
||||||
cssPatch += `
|
cssPatch += `
|
||||||
.btn-${name} {
|
.btn-${name} {
|
||||||
--bs-btn-bg: var(--bs-${name}) !important;
|
--bs-btn-bg: var(--bs-${name}) !important;
|
||||||
--bs-btn-border-color: var(--bs-${name}) !important;
|
--bs-btn-border-color: var(--bs-${name}) !important;
|
||||||
--bs-btn-hover-bg: var(--bs-${name}) !important;
|
--bs-btn-hover-bg: ${hoverColor} !important;
|
||||||
--bs-btn-hover-border-color: var(--bs-${name}) !important;
|
--bs-btn-hover-border-color: ${hoverColor} !important;
|
||||||
--bs-btn-active-bg: var(--bs-${name}) !important;
|
--bs-btn-active-bg: ${activeColor} !important;
|
||||||
--bs-btn-active-border-color: var(--bs-${name}) !important;
|
--bs-btn-active-border-color: ${activeColor} !important;
|
||||||
--bs-btn-disabled-bg: var(--bs-${name}) !important;
|
--bs-btn-disabled-bg: var(--bs-${name}) !important;
|
||||||
--bs-btn-disabled-border-color: var(--bs-${name}) !important;
|
--bs-btn-disabled-border-color: var(--bs-${name}) !important;
|
||||||
}
|
}
|
||||||
@ -7209,6 +7275,15 @@ url("data:font/woff;base64,d09GRgABAAAAAsBAAAsAAAAHavgAAQAAAAAAAAAAAAAAAAAAAAAAA
|
|||||||
.border-${name} { border-color: var(--bs-${name}) !important; }
|
.border-${name} { border-color: var(--bs-${name}) !important; }
|
||||||
.badge.bg-${name} { background-color: var(--bs-${name}) !important; }
|
.badge.bg-${name} { background-color: var(--bs-${name}) !important; }
|
||||||
|
|
||||||
|
/* 语义色链接按钮 */
|
||||||
|
.btn-link-${name},
|
||||||
|
.btn-link.btn-link-${name} {
|
||||||
|
--bs-btn-color: var(--bs-${name});
|
||||||
|
--bs-btn-hover-color: ${hoverColor};
|
||||||
|
--bs-btn-active-color: ${activeColor};
|
||||||
|
--bs-btn-disabled-color: var(--bs-${name});
|
||||||
|
}
|
||||||
|
|
||||||
/* 表单控件补丁 (Form Controls) */
|
/* 表单控件补丁 (Form Controls) */
|
||||||
.form-check-input:checked {
|
.form-check-input:checked {
|
||||||
background-color: var(--bs-primary) !important;
|
background-color: var(--bs-primary) !important;
|
||||||
@ -7291,6 +7366,12 @@ url("data:font/woff;base64,d09GRgABAAAAAsBAAAsAAAAHavgAAQAAAAAAAAAAAAAAAAAAAAAAA
|
|||||||
hex = hex.replace(shorthandRegex, (m, r, g, b) => r + r + g + g + b + b);
|
hex = hex.replace(shorthandRegex, (m, r, g, b) => r + r + g + g + b + b);
|
||||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||||
return result ? { r: parseInt(result[1], 16), g: parseInt(result[2], 16), b: parseInt(result[3], 16) } : null;
|
return result ? { r: parseInt(result[1], 16), g: parseInt(result[2], 16), b: parseInt(result[3], 16) } : null;
|
||||||
|
},
|
||||||
|
_shadeColor: (hex, amount) => {
|
||||||
|
const rgb = Bootstrap._hexToRgb(hex);
|
||||||
|
if (!rgb) return hex;
|
||||||
|
const shade = (value) => Math.round(value * (1 - amount)).toString(16).padStart(2, "0");
|
||||||
|
return `#${shade(rgb.r)}${shade(rgb.g)}${shade(rgb.b)}`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (typeof globalThis !== "undefined") {
|
if (typeof globalThis !== "undefined") {
|
||||||
|
|||||||
2
frameworks/Bootstrap.min.js
vendored
2
frameworks/Bootstrap.min.js
vendored
File diff suppressed because one or more lines are too long
@ -451,7 +451,7 @@
|
|||||||
} else if (node.type === "radio") {
|
} else if (node.type === "radio") {
|
||||||
if (node.checked !== (node.value === String(result ?? ""))) node.checked = node.value === String(result ?? "");
|
if (node.checked !== (node.value === String(result ?? ""))) node.checked = node.value === String(result ?? "");
|
||||||
} else if ("value" in node && node.type !== "file") {
|
} else if ("value" in node && node.type !== "file") {
|
||||||
Promise.resolve().then(() => {
|
setTimeout(() => {
|
||||||
if (node.value !== String(result ?? "")) node.value = result;
|
if (node.value !== String(result ?? "")) node.value = result;
|
||||||
});
|
});
|
||||||
} else if (node.isContentEditable) {
|
} else if (node.isContentEditable) {
|
||||||
|
|||||||
2
frameworks/State.min.js
vendored
2
frameworks/State.min.js
vendored
File diff suppressed because one or more lines are too long
@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@apigo.cc/ui",
|
"name": "@apigo.cc/ui",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"private": true,
|
"private": false,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node tools/build.cjs",
|
"build": "node tools/build.cjs",
|
||||||
"dev": "node tools/dev.cjs",
|
"dev": "node tools/dev.cjs",
|
||||||
"test": "node tools/dev.cjs --test"
|
"test": "node tools/test.cjs",
|
||||||
|
"pub": "npm run build && npm publish --access public"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"frameworks",
|
"frameworks",
|
||||||
|
|||||||
@ -1,17 +1,13 @@
|
|||||||
const { execFileSync } = require('node:child_process');
|
const { copyFile, readdir, readFile, stat, writeFile } = require('node:fs/promises');
|
||||||
const { readdir, readFile, stat, writeFile } = require('node:fs/promises');
|
|
||||||
const { join, relative, sep } = require('node:path');
|
const { join, relative, sep } = require('node:path');
|
||||||
const terser = require('terser');
|
const terser = require('terser');
|
||||||
|
|
||||||
const root = join(__dirname, '..');
|
const root = join(__dirname, '..');
|
||||||
const publicDirs = ['frameworks', 'utilities', 'components'];
|
const publicDirs = ['frameworks', 'utilities', 'components'];
|
||||||
|
|
||||||
const version = () => {
|
const frameworks = {
|
||||||
const tags = execFileSync('git', ['tag', '--sort=-v:refname'], { cwd: root, encoding: 'utf8' })
|
Bootstrap: join(root, '..', 'bootstrap', 'dist', 'bootstrap.js'),
|
||||||
.trim().split(/\s+/).filter(tag => /^v\d+\.\d+\.\d+$/.test(tag));
|
State: join(root, '..', 'state', 'dist', 'state.js')
|
||||||
if (!tags.length) return '1.0.0';
|
|
||||||
const [major, minor, patch] = tags[0].slice(1).split('.').map(Number);
|
|
||||||
return `${major}.${minor}.${patch + 1}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const filesBelow = async directory => {
|
const filesBelow = async directory => {
|
||||||
@ -23,22 +19,20 @@ const filesBelow = async directory => {
|
|||||||
return results.flat();
|
return results.flat();
|
||||||
};
|
};
|
||||||
|
|
||||||
const setVersion = (text, nextVersion) => text
|
|
||||||
.replace(/"version": "\d+\.\d+\.\d+"/, `"version": "${nextVersion}"`)
|
|
||||||
.replace(/@apigo\.cc\/ui@\d+\.\d+\.\d+/g, `@apigo.cc/ui@${nextVersion}`);
|
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const nextVersion = version();
|
await Promise.all(Object.entries(frameworks).map(([name, source]) => copyFile(source, join(root, 'frameworks', `${name}.js`))));
|
||||||
const sources = (await Promise.all(publicDirs.map(dir => filesBelow(join(root, dir))))).flat();
|
const sources = (await Promise.all(publicDirs.map(dir => filesBelow(join(root, dir))))).flat();
|
||||||
const sourceVersions = Object.fromEntries(await Promise.all(sources.map(async file => {
|
const sourceStats = await Promise.all(sources.map(async file => {
|
||||||
const path = relative(root, file).split(sep).join('/');
|
const path = relative(root, file).split(sep).join('/');
|
||||||
return [path, Math.floor((await stat(file)).mtimeMs).toString(36)];
|
return [path, Math.floor((await stat(file)).mtimeMs)];
|
||||||
})));
|
}));
|
||||||
|
const sourceVersions = Object.fromEntries(sourceStats.map(([path, modified]) => [path, modified.toString(36)]));
|
||||||
|
const buildVersion = Math.max(...sourceStats.map(([, modified]) => modified)).toString(36);
|
||||||
const uiFile = join(root, 'ui.js');
|
const uiFile = join(root, 'ui.js');
|
||||||
const uiSource = await readFile(uiFile, 'utf8');
|
const uiSource = await readFile(uiFile, 'utf8');
|
||||||
const builtUi = uiSource.replace(
|
const builtUi = uiSource.replace(
|
||||||
/const build = \{ version: '[^']*', files: \{[^\n]*\} \};/,
|
/const build = \{ version: '[^']*', files: \{[^\n]*\} \};/,
|
||||||
`const build = { version: '${nextVersion}', files: ${JSON.stringify(sourceVersions)} };`
|
`const build = { version: '${buildVersion}', files: ${JSON.stringify(sourceVersions)} };`
|
||||||
);
|
);
|
||||||
if (builtUi === uiSource && !uiSource.includes('const build =')) throw new Error('ui.js build manifest is missing');
|
if (builtUi === uiSource && !uiSource.includes('const build =')) throw new Error('ui.js build manifest is missing');
|
||||||
await writeFile(uiFile, builtUi);
|
await writeFile(uiFile, builtUi);
|
||||||
@ -50,9 +44,5 @@ const setVersion = (text, nextVersion) => text
|
|||||||
};
|
};
|
||||||
await Promise.all([...sources, uiFile].map(minify));
|
await Promise.all([...sources, uiFile].map(minify));
|
||||||
|
|
||||||
for (const name of ['package.json', 'README.md', 'README.zh-CN.md', 'AI.yaml']) {
|
console.log(`Built @apigo.cc/ui (${sources.length} module files; cache ${buildVersion})`);
|
||||||
const file = join(root, name);
|
|
||||||
await writeFile(file, setVersion(await readFile(file, 'utf8'), nextVersion));
|
|
||||||
}
|
|
||||||
console.log(`Built @apigo.cc/ui@${nextVersion} (${sources.length} module files)`);
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
61
tools/test.cjs
Normal file
61
tools/test.cjs
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
const { spawn } = require('node:child_process');
|
||||||
|
const { mkdtemp, rm } = require('node:fs/promises');
|
||||||
|
const { tmpdir } = require('node:os');
|
||||||
|
const { join } = require('node:path');
|
||||||
|
const net = require('node:net');
|
||||||
|
const { chromium } = require('playwright');
|
||||||
|
|
||||||
|
const root = join(__dirname, '..');
|
||||||
|
const aliases = { DataGrid: 'components/data/DataGrid.test.html', 'data.DataGrid': 'components/data/DataGrid.test.html' };
|
||||||
|
const requested = process.argv[2];
|
||||||
|
const pages = requested
|
||||||
|
? [aliases[requested] || `components/${requested.replace('.', '/')}.test.html`]
|
||||||
|
: ['components/base/API.test.html', 'components/base/AutoForm.test.html', 'components/base/Modal.test.html', 'components/base/Nav.test.html', 'components/data/DataGrid.test.html'];
|
||||||
|
|
||||||
|
const getPort = () => new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(0, '127.0.0.1', () => { const { port } = server.address(); server.close(() => resolve(port)); });
|
||||||
|
});
|
||||||
|
const waitFor = async (url, timeout = 10000) => {
|
||||||
|
const started = Date.now();
|
||||||
|
while (Date.now() - started < timeout) {
|
||||||
|
try { if ((await fetch(url)).ok) return; } catch (_) { }
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out waiting for ${url}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const [port, cdpPort] = await Promise.all([getPort(), getPort()]);
|
||||||
|
const profile = await mkdtemp(join(tmpdir(), 'apigo-ui-cdp-'));
|
||||||
|
const chrome = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||||
|
const server = spawn(process.execPath, [join(__dirname, 'serve.cjs')], { env: { ...process.env, PORT: String(port) }, stdio: 'inherit' });
|
||||||
|
const browserProcess = spawn(chrome, [`--headless=new`, `--remote-debugging-port=${cdpPort}`, `--user-data-dir=${profile}`, '--no-first-run', 'about:blank'], { stdio: 'ignore' });
|
||||||
|
let browser;
|
||||||
|
try {
|
||||||
|
await Promise.all([waitFor(`http://127.0.0.1:${port}/ui/ui.test.html`), waitFor(`http://127.0.0.1:${cdpPort}/json/version`)]);
|
||||||
|
browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`);
|
||||||
|
const context = browser.contexts()[0];
|
||||||
|
for (const pagePath of pages) {
|
||||||
|
const page = await context.newPage();
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', error => errors.push(error.message));
|
||||||
|
await page.goto(`http://127.0.0.1:${port}/ui/${pagePath}`, { waitUntil: 'networkidle' });
|
||||||
|
const result = await page.evaluate(() => globalThis.runTest());
|
||||||
|
await page.close();
|
||||||
|
if (errors.length || result?.status !== 'passed') throw new Error(`${pagePath}: ${errors.join('\n') || JSON.stringify(result)}`);
|
||||||
|
console.log(`Passed ${pagePath}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await browser?.close();
|
||||||
|
await new Promise(resolve => {
|
||||||
|
if (browserProcess.exitCode !== null) return resolve();
|
||||||
|
browserProcess.once('exit', resolve);
|
||||||
|
browserProcess.kill();
|
||||||
|
setTimeout(resolve, 2000);
|
||||||
|
});
|
||||||
|
server.kill();
|
||||||
|
await rm(profile, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
})().catch(error => { console.error(error); process.exit(1); });
|
||||||
2
ui.js
2
ui.js
@ -1,6 +1,6 @@
|
|||||||
(() => {
|
(() => {
|
||||||
// Updated by `npm run build`. Source files stay directly executable.
|
// Updated by `npm run build`. Source files stay directly executable.
|
||||||
const build = { version: '1.0.0', files: {"frameworks/Bootstrap.js":"mrgoatzb","frameworks/State.js":"mrgokg8d","utilities/HTTP.js":"mrg6t8zm","utilities/MouseMover.js":"mrg6t8zm","utilities/VirtualScroll.js":"mrg6t8zn","components/base/API.js":"mrggjm8m","components/base/AutoForm.js":"mrgoatz5","components/base/Dialog.js":"mrh7tjfb","components/base/List.js":"mrge46xl","components/base/Modal.js":"mrh4mibf","components/base/Nav.js":"mrh5kbf0","components/base/Resizer.js":"mrghfzrh","components/base/Toast.js":"mrghiycy","components/data/DataGrid.js":"mrhbi0rq","components/form/ColorPicker.js":"mrgn837c","components/form/DatePicker.js":"mrgn837c","components/form/IconPicker.js":"mrgoatz5","components/form/TagsInput.js":"mrgn837b"} };
|
const build = { version: 'mrjbct5d', files: {"frameworks/Bootstrap.js":"mrjbct5d","frameworks/State.js":"mrjbct5d","utilities/HTTP.js":"mrg6t8zm","utilities/MouseMover.js":"mrg6t8zm","utilities/VirtualScroll.js":"mrg6t8zn","components/base/API.js":"mrj8lfwz","components/base/AutoForm.js":"mrj10yku","components/base/Dialog.js":"mrh7tjfb","components/base/List.js":"mrge46xl","components/base/Modal.js":"mrj1zcg3","components/base/Nav.js":"mrj6haqp","components/base/Resizer.js":"mrghfzrh","components/base/Toast.js":"mrghiycy","components/data/DataGrid.js":"mrj8lxpb","components/form/ColorPicker.js":"mrgn837c","components/form/DatePicker.js":"mrgn837c","components/form/IconPicker.js":"mrgoatz5","components/form/TagsInput.js":"mrgn837b"} };
|
||||||
const script = document.currentScript;
|
const script = document.currentScript;
|
||||||
if (!script || document.readyState !== 'loading') {
|
if (!script || document.readyState !== 'loading') {
|
||||||
throw new Error('ui.js must be loaded by a normal <script> while the document is parsing');
|
throw new Error('ui.js must be loaded by a normal <script> while the document is parsing');
|
||||||
|
|||||||
2
ui.min.js
vendored
2
ui.min.js
vendored
@ -1 +1 @@
|
|||||||
(()=>{const o={version:"1.0.0",files:{"frameworks/Bootstrap.js":"mrgoatzb","frameworks/State.js":"mrgokg8d","utilities/HTTP.js":"mrg6t8zm","utilities/MouseMover.js":"mrg6t8zm","utilities/VirtualScroll.js":"mrg6t8zn","components/base/API.js":"mrggjm8m","components/base/AutoForm.js":"mrgoatz5","components/base/Dialog.js":"mrh7tjfb","components/base/List.js":"mrge46xl","components/base/Modal.js":"mrh4mibf","components/base/Nav.js":"mrh5kbf0","components/base/Resizer.js":"mrghfzrh","components/base/Toast.js":"mrghiycy","components/data/DataGrid.js":"mrhbi0rq","components/form/ColorPicker.js":"mrgn837c","components/form/DatePicker.js":"mrgn837c","components/form/IconPicker.js":"mrgoatz5","components/form/TagsInput.js":"mrgn837b"}},t=document.currentScript;if(!t||"loading"!==document.readyState)throw new Error("ui.js must be loaded by a normal <script> while the document is parsing");const e=(t?.getAttribute("components")||"").split(",").map(o=>o.trim()).filter(Boolean),s={HTTP:{path:"utilities/HTTP.js"},VirtualScroll:{path:"utilities/VirtualScroll.js"},MouseMover:{path:"utilities/MouseMover.js"},API:{path:"components/base/API.js",needs:["Toast"]},AutoForm:{path:"components/base/AutoForm.js",needs:["Toast"]},List:{path:"components/base/List.js",needs:["VirtualScroll"]},Modal:{path:"components/base/Modal.js"},Dialog:{path:"components/base/Dialog.js",needs:["Modal"]},Toast:{path:"components/base/Toast.js"},Nav:{path:"components/base/Nav.js"},Resizer:{path:"components/base/Resizer.js"},DataGrid:{path:"components/data/DataGrid.js",needs:["AutoForm","Dialog","Resizer","DatePicker","ColorPicker","IconPicker","TagsInput"]},DatePicker:{path:"components/form/DatePicker.js",needs:["AutoForm"]},ColorPicker:{path:"components/form/ColorPicker.js",needs:["AutoForm"]},IconPicker:{path:"components/form/IconPicker.js",needs:["AutoForm"]},TagsInput:{path:"components/form/TagsInput.js",needs:["AutoForm"]}},r={base:["API","AutoForm","List","Modal","Dialog","Toast","Nav","Resizer"],form:["DatePicker","ColorPicker","IconPicker","TagsInput"],data:["DataGrid"]},a=Object.fromEntries(Object.entries(r).flatMap(([o,t])=>t.map(t=>[`${o}.${t}`,t]))),n=e.flatMap(o=>r[o]||[a[o]||o]);if(!t.hasAttribute("components"))throw new Error('ui.js requires components="..."');const i=new URL(".",t.src),m=/(?:^|\/)ui\.min\.js$/.test(new URL(t.src).pathname),c=t=>document.write(`<script src="${(t=>{const e=m?t.replace(/\.js$/,".min.js"):t,s=new URL(e,i),r=o.files[t];return r&&s.searchParams.set("v",r),s.href})(t)}"><\/script>`);globalThis.Component||c("frameworks/State.js"),globalThis.bootstrap||c("frameworks/Bootstrap.js"),["HTTP","VirtualScroll","MouseMover"].forEach(o=>c(s[o].path));const p=new Set(["HTTP","VirtualScroll","MouseMover"]),l=o=>{if(p.has(o))return;p.add(o);const t=s[o];if(!t)throw new Error(`Unknown UI component: ${o}`);(t.needs||[]).forEach(l),c(t.path)};n.forEach(l)})();
|
(()=>{const o={version:"mrjbct5d",files:{"frameworks/Bootstrap.js":"mrjbct5d","frameworks/State.js":"mrjbct5d","utilities/HTTP.js":"mrg6t8zm","utilities/MouseMover.js":"mrg6t8zm","utilities/VirtualScroll.js":"mrg6t8zn","components/base/API.js":"mrj8lfwz","components/base/AutoForm.js":"mrj10yku","components/base/Dialog.js":"mrh7tjfb","components/base/List.js":"mrge46xl","components/base/Modal.js":"mrj1zcg3","components/base/Nav.js":"mrj6haqp","components/base/Resizer.js":"mrghfzrh","components/base/Toast.js":"mrghiycy","components/data/DataGrid.js":"mrj8lxpb","components/form/ColorPicker.js":"mrgn837c","components/form/DatePicker.js":"mrgn837c","components/form/IconPicker.js":"mrgoatz5","components/form/TagsInput.js":"mrgn837b"}},t=document.currentScript;if(!t||"loading"!==document.readyState)throw new Error("ui.js must be loaded by a normal <script> while the document is parsing");const e=(t?.getAttribute("components")||"").split(",").map(o=>o.trim()).filter(Boolean),s={HTTP:{path:"utilities/HTTP.js"},VirtualScroll:{path:"utilities/VirtualScroll.js"},MouseMover:{path:"utilities/MouseMover.js"},API:{path:"components/base/API.js",needs:["Toast"]},AutoForm:{path:"components/base/AutoForm.js",needs:["Toast"]},List:{path:"components/base/List.js",needs:["VirtualScroll"]},Modal:{path:"components/base/Modal.js"},Dialog:{path:"components/base/Dialog.js",needs:["Modal"]},Toast:{path:"components/base/Toast.js"},Nav:{path:"components/base/Nav.js"},Resizer:{path:"components/base/Resizer.js"},DataGrid:{path:"components/data/DataGrid.js",needs:["AutoForm","Dialog","Resizer","DatePicker","ColorPicker","IconPicker","TagsInput"]},DatePicker:{path:"components/form/DatePicker.js",needs:["AutoForm"]},ColorPicker:{path:"components/form/ColorPicker.js",needs:["AutoForm"]},IconPicker:{path:"components/form/IconPicker.js",needs:["AutoForm"]},TagsInput:{path:"components/form/TagsInput.js",needs:["AutoForm"]}},r={base:["API","AutoForm","List","Modal","Dialog","Toast","Nav","Resizer"],form:["DatePicker","ColorPicker","IconPicker","TagsInput"],data:["DataGrid"]},a=Object.fromEntries(Object.entries(r).flatMap(([o,t])=>t.map(t=>[`${o}.${t}`,t]))),n=e.flatMap(o=>r[o]||[a[o]||o]);if(!t.hasAttribute("components"))throw new Error('ui.js requires components="..."');const i=new URL(".",t.src),c=/(?:^|\/)ui\.min\.js$/.test(new URL(t.src).pathname),m=t=>document.write(`<script src="${(t=>{const e=c?t.replace(/\.js$/,".min.js"):t,s=new URL(e,i),r=o.files[t];return r&&s.searchParams.set("v",r),s.href})(t)}"><\/script>`);globalThis.Component||m("frameworks/State.js"),globalThis.bootstrap||m("frameworks/Bootstrap.js"),["HTTP","VirtualScroll","MouseMover"].forEach(o=>m(s[o].path));const p=new Set(["HTTP","VirtualScroll","MouseMover"]),l=o=>{if(p.has(o))return;p.add(o);const t=s[o];if(!t)throw new Error(`Unknown UI component: ${o}`);(t.needs||[]).forEach(l),m(t.path)};n.forEach(l)})();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user