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:
|
||||
|
||||
```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.
|
||||
|
||||
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).
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
`@apigo.cc/ui` 是不需要生产构建的原生 JavaScript UI 组件库。页面使用一个 `ui.js` 入口,并且每个页面都显式写出使用的组件:
|
||||
|
||||
```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。
|
||||
@ -14,4 +14,4 @@
|
||||
|
||||
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({
|
||||
url: '',
|
||||
method: 'GET',
|
||||
method: '',
|
||||
headers: {},
|
||||
data: null,
|
||||
timeout: 10000,
|
||||
@ -32,6 +32,7 @@ const APIComponent = globalThis.Component.register('API', container => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = { ...container.request, ...opt }
|
||||
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 }
|
||||
container.response.loading = true
|
||||
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 assert = (testResult, feature, condition, message) => { testResult.assertions++; if (condition) testResult.passed++; else { testResult.failed++; testResult.failures.push({ feature, message }) } }
|
||||
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()
|
||||
let autoResponses = 0; autoApi.addEventListener('response', () => { autoResponses++ })
|
||||
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, '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, 'result-binding', document.querySelector('#autoResult').textContent === 'GET', 'response result is usable in declarative DOM bindings')
|
||||
let responseEvent
|
||||
manualApi.addEventListener('response', event => { responseEvent = event.detail }, { once: true })
|
||||
const response = await manualApi.do(successRequest)
|
||||
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, '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
|
||||
manualApi.addEventListener('error', event => { errorEvent = event.detail }, { once: 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-event', errorEvent instanceof Error && errorEvent.message === 'application failed', 'error event exposes Error detail')
|
||||
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
|
||||
</script>
|
||||
|
||||
@ -9,7 +9,7 @@ properties:
|
||||
request:
|
||||
default:
|
||||
url: ''
|
||||
method: GET
|
||||
method: "GET when data is null; POST when data is present, unless explicitly set."
|
||||
headers: {}
|
||||
data: null
|
||||
timeout: 10000
|
||||
@ -34,6 +34,7 @@ events:
|
||||
|
||||
rules:
|
||||
- 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.
|
||||
|
||||
examples:
|
||||
@ -47,6 +48,9 @@ examples:
|
||||
<script>
|
||||
const response = await usersApi.do({ url: '/api/users', method: 'GET', noui: true })
|
||||
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>
|
||||
|
||||
tests:
|
||||
|
||||
@ -6,8 +6,12 @@ const AUTOFORM_BLUEPRINT = globalThis.Util.makeDom(/*html*/`
|
||||
<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()">
|
||||
|
||||
<template $each="this.state.schema || []">
|
||||
<template $each="this.state._schema || []">
|
||||
<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">
|
||||
<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'}">
|
||||
@ -30,10 +34,13 @@ const AUTOFORM_BLUEPRINT = globalThis.Util.makeDom(/*html*/`
|
||||
</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 === '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>
|
||||
</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 slot-id="actions"></div>
|
||||
@ -61,8 +68,36 @@ const AUTOFORM_STYLE = globalThis.Util.makeDom(/*html*/`<style>
|
||||
.auto-form-root .form-switch .form-check-input { width: 2em; }
|
||||
</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 => {
|
||||
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 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'" }
|
||||
]
|
||||
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 errorRequest = { url: '/__http__/api-error', method: 'POST' }
|
||||
</script>
|
||||
@ -45,11 +49,12 @@
|
||||
<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="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>
|
||||
<pre id="result"></pre>
|
||||
<script>
|
||||
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 }) } }
|
||||
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 } }
|
||||
@ -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, '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')
|
||||
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')
|
||||
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')
|
||||
|
||||
@ -20,10 +20,13 @@ properties:
|
||||
|
||||
schema:
|
||||
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'}]"
|
||||
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"`.'
|
||||
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:
|
||||
source: Set api to an API element, or set request.url.
|
||||
@ -42,6 +45,10 @@ examples:
|
||||
]
|
||||
</script>
|
||||
<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: |
|
||||
<script>
|
||||
const profile = { name: 'Ada' }
|
||||
|
||||
@ -15,7 +15,7 @@ globalThis.Component.register('Modal', container => {
|
||||
container.hide = (...args) => container.modal.hide(...args)
|
||||
}, globalThis.Util.makeDom(/*html*/`
|
||||
<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 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>
|
||||
|
||||
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>
|
||||
</div>
|
||||
<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 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
|
||||
@ -29,6 +29,7 @@
|
||||
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, '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')
|
||||
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
|
||||
|
||||
@ -3,6 +3,9 @@ name: Modal
|
||||
binding:
|
||||
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:
|
||||
title: Default header title.
|
||||
type: Bootstrap contextual type. Default is primary.
|
||||
|
||||
@ -11,20 +11,21 @@ globalThis.Component.register('Nav', container => {
|
||||
})
|
||||
|
||||
container.click = (item, noselect) => {
|
||||
if (!item.noselect && !noselect) {
|
||||
const selected = !item.noselect && !noselect
|
||||
if (selected) {
|
||||
container.state.value = item.name
|
||||
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.state.value = item.name
|
||||
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.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*/`
|
||||
<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 $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 || []">
|
||||
<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'">
|
||||
<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>
|
||||
@ -86,7 +87,7 @@ globalThis.Component.register('Nav', container => {
|
||||
<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>
|
||||
</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 $if="subitem.type==='label'">
|
||||
<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>
|
||||
<script>
|
||||
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>
|
||||
</head>
|
||||
<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>
|
||||
<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>
|
||||
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))
|
||||
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')
|
||||
@ -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, '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')
|
||||
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
|
||||
</script></body></html>
|
||||
|
||||
@ -18,9 +18,9 @@ item_types:
|
||||
|
||||
events:
|
||||
change:
|
||||
detail: Selected item name.
|
||||
detail: Selected item name. Fires only when the selected value changes.
|
||||
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? }"
|
||||
|
||||
@ -42,4 +42,5 @@ example: |
|
||||
|
||||
rules:
|
||||
- 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.
|
||||
|
||||
@ -26,9 +26,25 @@ const DataGridConfig = {
|
||||
getFieldTypes: () => Array.from(DataGridConfig._fieldTypes.values())
|
||||
};
|
||||
|
||||
const DB_EDITOR_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 getDataKey = field => field?.name;
|
||||
// settings.type is the authoritative application/editor type. field.type is a
|
||||
// database storage type and is only a fallback for legacy field definitions.
|
||||
const getEditorType = field => field?.settings?.type || DB_EDITOR_TYPE_MAP[field?.type] || 'text';
|
||||
const SYSTEM_FIELDS = [
|
||||
{ id: '__system_id', name: 'id', type: 'v100', system: true, settings: { label: 'ID', width: 130 } },
|
||||
{ id: '__system_creator', name: 'creator', type: 'v100', system: true, settings: { label: 'Creator', width: 120 } },
|
||||
{ id: '__system_createTime', name: 'createTime', type: 'bi', system: true, settings: { label: 'Create Time', width: 150 } },
|
||||
{ id: '__system_updater', name: 'updater', type: 'v100', system: true, settings: { label: 'Updater', width: 120 } },
|
||||
{ id: '__system_updateTime', name: 'updateTime', type: 'bi', system: true, settings: { label: 'Update Time', width: 150 } }
|
||||
];
|
||||
|
||||
// Register Built-in Types
|
||||
DataGridConfig.registerFieldType({
|
||||
value: 'text', label: '{#Text#}', typeForDB: 'v4096',
|
||||
value: 'text', label: '{#Text#}', typeForDB: 'v100',
|
||||
schema: [{ name: 'placeholder', label: 'Placeholder', type: 'text', if: 'this.data.user_type=="text"' }]
|
||||
});
|
||||
DataGridConfig.registerFieldType({
|
||||
@ -55,7 +71,7 @@ DataGridConfig.registerFieldType({
|
||||
}
|
||||
});
|
||||
DataGridConfig.registerFieldType({
|
||||
value: 'select', label: '{#Single Select#}', typeForDB: 'v1024',
|
||||
value: 'select', label: '{#Single Select#}', typeForDB: 'v100',
|
||||
schema: [{ name: 'options_str', label: 'Options', type: 'textarea', setting: { rows: 3, placeholder: 'Label:Value per line' }, if: 'this.data.user_type=="select"' }],
|
||||
formatter: (val, field) => {
|
||||
if (val == null || val === '') return '';
|
||||
@ -96,6 +112,11 @@ DataGridConfig.registerFieldType({
|
||||
schema: [{ name: 'placeholder', label: 'Placeholder', type: 'text', if: 'this.data.user_type=="textarea"' }]
|
||||
});
|
||||
|
||||
const getDefaultDbType = (editorType, decimals = 0) => {
|
||||
const typeInfo = DataGridConfig._fieldTypes.get(editorType);
|
||||
return editorType === 'number' ? (Number(decimals) > 0 ? 'ff' : 'bi') : (typeInfo?.typeForDB || 'v100');
|
||||
};
|
||||
|
||||
// Managers Factory Functions (Internalized)
|
||||
const createPerfMonitor = () => {
|
||||
let enabled = !!globalThis.__DT_PERF_MODE__;
|
||||
@ -149,7 +170,13 @@ const createScrollManager = (container, state, onRenderedListChange) => {
|
||||
};
|
||||
return {
|
||||
init: () => { scrollEl = container.querySelector('.dt-main'); },
|
||||
reset: (list) => { state._listStartIndex = 0; vs.reset(list, scrollEl || container); if (state.list === list) vs.init(list, () => refresh(true)); },
|
||||
reset: (list) => {
|
||||
state._listStartIndex = 0;
|
||||
const validList = Array.isArray(list) ? list : [];
|
||||
vs.reset(validList, scrollEl || container);
|
||||
// Async bindings evaluate to undefined before their API response arrives.
|
||||
if (Array.isArray(list) && state.list === list) vs.init(list, () => refresh(true));
|
||||
},
|
||||
refresh, onScroll: () => refresh(false)
|
||||
};
|
||||
};
|
||||
@ -207,8 +234,8 @@ const createSelectionManager = (container, state) => {
|
||||
const copy = async () => {
|
||||
const bounds = getSelectionBounds(); if (!bounds) return;
|
||||
const text = state.list.slice(bounds.minRow, bounds.maxRow + 1).map(row => {
|
||||
return state.fields.slice(bounds.minCol, bounds.maxCol + 1).map(f => {
|
||||
let val = String(row[f.id] ?? '');
|
||||
return state._displayFields.slice(bounds.minCol, bounds.maxCol + 1).map(f => {
|
||||
let val = String(row[getDataKey(f)] ?? '');
|
||||
if (val.includes('\t') || val.includes('\n') || val.includes('"')) val = '"' + val.replace(/"/g, '""') + '"';
|
||||
return val;
|
||||
}).join('\t');
|
||||
@ -240,7 +267,7 @@ const createSelectionManager = (container, state) => {
|
||||
rows.forEach((rowData, rOffset) => {
|
||||
const rIdx = startRow + rOffset; if (rIdx > maxRow || rIdx >= state.list.length) return;
|
||||
const rowItem = state.list[rIdx]; let rowChanged = false;
|
||||
rowData.forEach((cellData, cOffset) => { const cIdx = startCol + cOffset; if (cIdx > maxCol || cIdx >= state.fields.length) return; const field = state.fields[cIdx]; rowItem[field.id] = cellData; rowChanged = true; });
|
||||
rowData.forEach((cellData, cOffset) => { const cIdx = startCol + cOffset; if (cIdx > maxCol || cIdx >= state._displayFields.length) return; const field = state._displayFields[cIdx]; if (field.system) return; rowItem[getDataKey(field)] = cellData; rowChanged = true; });
|
||||
if (rowChanged) anyRowChanged = true;
|
||||
});
|
||||
if (anyRowChanged) state.list = [...state.list];
|
||||
@ -254,9 +281,11 @@ globalThis.Component.register('DataGrid', container => {
|
||||
if (!container.state) container.state = globalThis.NewState({})
|
||||
const state = container.state
|
||||
container.editable = container.hasAttribute('editable')
|
||||
container.getDataKey = getDataKey
|
||||
container.apiUrl = container.getAttribute('api') || ''
|
||||
|
||||
Object.assign(state, {
|
||||
list: [], fields: [], _renderedList: [],
|
||||
list: [], fields: [], _displayFields: [], _renderedList: [],
|
||||
prevHeight: 0, postHeight: 0, _listStartIndex: 0,
|
||||
selectedRowCount: 0,
|
||||
_originalList: [],
|
||||
@ -264,10 +293,51 @@ globalThis.Component.register('DataGrid', container => {
|
||||
filterConfig: {},
|
||||
activeFieldId: null, activeField: null, activeModes: [],
|
||||
_columnStats: {}, _internalUpdate: false, _appliedHash: '', _fieldsDirty: false, _masterCellNodes: null,
|
||||
isDirty: false, isBulkEdit: null,
|
||||
isDirty: false, isBulkEdit: null, advancedMode: false, table: state.table || '', apiLoading: false, total: 0,
|
||||
editable: container.editable
|
||||
})
|
||||
|
||||
const getDisplayFields = () => state.advancedMode ? [...state.fields, ...SYSTEM_FIELDS] : state.fields;
|
||||
const requestApi = async (action, payload = {}) => {
|
||||
if (!container.apiUrl || !state.table) return null;
|
||||
state.apiLoading = true;
|
||||
try {
|
||||
const response = await globalThis.HTTP.request({ url: container.apiUrl, method: 'POST', data: { action, name: state.table, ...payload } });
|
||||
const result = response.result;
|
||||
if (!response.ok || !result?.ok) throw new Error(result?.error || response.error || `${action} failed`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
globalThis.Toast?.show(error.message, { type: 'danger' });
|
||||
throw error;
|
||||
} finally {
|
||||
state.apiLoading = false;
|
||||
}
|
||||
};
|
||||
const buildQuery = () => {
|
||||
const filter = [];
|
||||
Object.entries(state.filterConfig).forEach(([fieldId, config]) => {
|
||||
const fieldDef = getDisplayFields().find(item => item.id === fieldId);
|
||||
if (!fieldDef || (!config.value && !config.selectedValues?.length)) return;
|
||||
if (config.selectedValues?.length) { filter.push({ field: fieldDef.name, operator: 'in', value: config.selectedValues }); return; }
|
||||
const operator = { equals: 'eq', '=': 'eq', '>': 'gt', '<': 'lt', contains: 'contains', starts: 'startsWith', ends: 'endsWith', between: 'between' }[config.mode] || 'contains';
|
||||
filter.push({ field: fieldDef.name, operator, value: operator === 'between' ? [config.value, config.value2] : config.value });
|
||||
});
|
||||
const sortField = state.sortConfig.fieldId && getDisplayFields().find(item => item.id === state.sortConfig.fieldId);
|
||||
return { filter, sort: sortField ? [`${sortField.name} ${state.sortConfig.direction === 'desc' ? 'DESC' : 'ASC'}`] : [] };
|
||||
};
|
||||
container.load = async ({ fields = false } = {}) => {
|
||||
if (!container.apiUrl || !state.table) return;
|
||||
try {
|
||||
if (fields) {
|
||||
const response = await requestApi('fields');
|
||||
state.fields = Array.isArray(response.fields) ? response.fields : [];
|
||||
}
|
||||
const response = await requestApi('query', { ...buildQuery(), offset: 0, limit: 1000 });
|
||||
const list = Array.isArray(response.data) ? response.data : (Array.isArray(response.list) ? response.list : []);
|
||||
state._internalUpdate = true; state.list = list; state._originalList = [...list]; state.total = Number(response.count ?? list.length); state._internalUpdate = false; state.isDirty = false;
|
||||
} catch (_) { /* requestApi already reports the error */ }
|
||||
};
|
||||
|
||||
const perf = createPerfMonitor();
|
||||
state.perf = perf.stats;
|
||||
const selection = createSelectionManager(container, state);
|
||||
@ -286,7 +356,7 @@ globalThis.Component.register('DataGrid', container => {
|
||||
|
||||
container.format = (val, field) => {
|
||||
if (field.formatter) return field.formatter(val, field);
|
||||
const typeInfo = DataGridConfig._fieldTypes.get(field.settings?.formType || field.type || 'text');
|
||||
const typeInfo = DataGridConfig._fieldTypes.get(getEditorType(field));
|
||||
if (typeInfo && typeInfo.formatter) return typeInfo.formatter(val, field);
|
||||
return val == null ? '' : (typeof val === 'object' ? JSON.stringify(val) : String(val));
|
||||
};
|
||||
@ -305,11 +375,23 @@ globalThis.Component.register('DataGrid', container => {
|
||||
const targetFilters = { ...state.filterConfig, ...(options.filters || {}) };
|
||||
const targetSort = options.sort !== undefined ? (options.sort ? { fieldId: state.activeFieldId, direction: options.sort } : { fieldId: null, direction: null }) : state.sortConfig;
|
||||
|
||||
if (container.apiUrl) {
|
||||
const previousSort = state.sortConfig;
|
||||
const previousFilters = state.filterConfig;
|
||||
state.filterConfig = targetFilters; state.sortConfig = targetSort;
|
||||
const reload = async () => {
|
||||
if (state.isDirty && !await globalThis.Dialog.confirm('Discard unsaved changes and reload data?')) { state.filterConfig = previousFilters; state.sortConfig = previousSort; return; }
|
||||
await container.load();
|
||||
};
|
||||
reload();
|
||||
return;
|
||||
}
|
||||
let filtered = [...state._originalList];
|
||||
Object.entries(targetFilters).forEach(([fId, cfg]) => {
|
||||
if (!cfg.value && (!cfg.selectedValues || cfg.selectedValues.length === 0)) return;
|
||||
filtered = filtered.filter(item => {
|
||||
const val = item[fId];
|
||||
const field = state._displayFields.find(item => item.id === fId);
|
||||
const val = item[getDataKey(field)];
|
||||
if (cfg.selectedValues?.length > 0) return cfg.selectedValues.includes(String(val));
|
||||
const search = String(cfg.value).toLowerCase();
|
||||
const target = String(val ?? '').toLowerCase();
|
||||
@ -331,8 +413,10 @@ globalThis.Component.register('DataGrid', container => {
|
||||
const fId = targetSort.fieldId;
|
||||
const dir = targetSort.direction === 'asc' ? 1 : -1;
|
||||
filtered.sort((a, b) => {
|
||||
if (a[fId] == b[fId]) return 0;
|
||||
return a[fId] > b[fId] ? dir : -dir;
|
||||
const field = state._displayFields.find(item => item.id === fId);
|
||||
const dataKey = getDataKey(field);
|
||||
if (a[dataKey] == b[dataKey]) return 0;
|
||||
return a[dataKey] > b[dataKey] ? dir : -dir;
|
||||
});
|
||||
}
|
||||
|
||||
@ -350,7 +434,7 @@ globalThis.Component.register('DataGrid', container => {
|
||||
container.applySortFilter();
|
||||
return;
|
||||
}
|
||||
const type = field.settings?.formType || field.type || 'text';
|
||||
const type = getEditorType(field);
|
||||
state.activeModes = MODE_MAP[type] || (['boolean', 'switch', 'checkbox', 'radio'].includes(type) ? [] : MODE_MAP.text);
|
||||
if (!state.filterConfig[field.id]) state.filterConfig[field.id] = { mode: state.activeModes[0] || 'contains', value: '', selectedValues: [] };
|
||||
state.activeField = field; state.activeFieldId = field.id;
|
||||
@ -380,11 +464,12 @@ globalThis.Component.register('DataGrid', container => {
|
||||
container._initRow = (rowNode) => {
|
||||
const row = rowNode._ref?.item;
|
||||
if (row && row._editingF === undefined) { Object.defineProperty(row, '_editingF', { set: (v) => { if (v === null) container.hideEditor(true); }, configurable: true }); }
|
||||
Array.from(rowNode.children).forEach(cell => { const fIdx = parseInt(cell.dataset.fidx); if (!isNaN(fIdx)) cell._ref = { ...(cell._ref || rowNode._ref), f: state.fields[fIdx], fIdx: fIdx }; });
|
||||
Array.from(rowNode.children).forEach(cell => { const fIdx = parseInt(cell.dataset.fidx); if (!isNaN(fIdx)) cell._ref = { ...(cell._ref || rowNode._ref), f: state._displayFields[fIdx], fIdx: fIdx }; });
|
||||
};
|
||||
|
||||
state.__watch('fields', fields => {
|
||||
if (!fields) return;
|
||||
if (!Array.isArray(fields)) return;
|
||||
fields = getDisplayFields(); state._displayFields = fields;
|
||||
state._fieldsDirty = true; state._masterCellNodes = null;
|
||||
container.style.setProperty('--dt-grid-template', fields.map(f => `var(--w-${f.id}, ${(f.settings?.width || f.width) || 150}px)`).join(' '));
|
||||
container.style.setProperty('--dt-row-width', fields.reduce((sum, f) => sum + ((f.settings?.width || f.width) || 150), 0) + 'px');
|
||||
@ -407,9 +492,9 @@ globalThis.Component.register('DataGrid', container => {
|
||||
state._originalList = [...(list || [])];
|
||||
setTimeout(() => {
|
||||
const stats = {};
|
||||
state.fields.forEach(f => {
|
||||
state._displayFields.forEach(f => {
|
||||
const counts = {};
|
||||
state._originalList.forEach(item => { const val = item[f.id], key = (val == null || val === '') ? '' : String(val); counts[key] = (counts[key] || 0) + 1; });
|
||||
state._originalList.forEach(item => { const val = item[getDataKey(f)], key = (val == null || val === '') ? '' : String(val); counts[key] = (counts[key] || 0) + 1; });
|
||||
stats[f.id] = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([val, count]) => ({ val, count }));
|
||||
});
|
||||
state._columnStats = stats;
|
||||
@ -417,16 +502,21 @@ globalThis.Component.register('DataGrid', container => {
|
||||
}
|
||||
scroll.init(); scroll.reset(list);
|
||||
});
|
||||
state.__watch('advancedMode', () => { state.fields = [...state.fields]; state.list = [...state.list]; });
|
||||
state.__watch('table', table => { if (container.apiUrl && table) container.load({ fields: true }); });
|
||||
if (container.apiUrl && state.table) setTimeout(() => container.load({ fields: true }));
|
||||
|
||||
container.editCell = (row, field, cellNode) => {
|
||||
const main = container.querySelector('.dt-main');
|
||||
const overlay = container.querySelector('.dt-editor-overlay'), rect = cellNode.getBoundingClientRect(), mainRect = main.getBoundingClientRect();
|
||||
currentEditingNode = cellNode;
|
||||
const formType = field.settings?.formType || field.type || 'text';
|
||||
const formType = getEditorType(field);
|
||||
const form = overlay.querySelector('AutoForm');
|
||||
if (form) {
|
||||
// Assign the schema first, then the cloned row data. This keeps a
|
||||
// native select from exposing its first option before its model value.
|
||||
form.state.schema = [{ name: getDataKey(field), label: '', type: formType, options: field.settings?.options || field.options, setting: field.settings?.attrs }];
|
||||
form.state.data = globalThis.NewState(globalThis.Util.clone(row));
|
||||
form.state.schema = [{ ...field, type: formType, options: field.settings?.options || field.options, name: field.id, label: '' }];
|
||||
}
|
||||
const isComplex = ['textarea', 'TagsInput', 'checkbox', 'radio'].includes(formType);
|
||||
let topPos = rect.top - mainRect.top + main.scrollTop - 1;
|
||||
@ -470,10 +560,10 @@ globalThis.Component.register('DataGrid', container => {
|
||||
|
||||
if (state.isBulkEdit) {
|
||||
const { minRow, maxRow, fIdx } = state.isBulkEdit;
|
||||
const field = state.fields[fIdx]; const newValue = form.data[field.id];
|
||||
const field = state._displayFields[fIdx]; const dataKey = getDataKey(field); const newValue = form.data[dataKey];
|
||||
for (let i = minRow; i <= maxRow; i++) {
|
||||
if (state.list[i] && state.list[i][field.id] !== newValue) {
|
||||
state.list[i][field.id] = newValue;
|
||||
if (state.list[i] && state.list[i][dataKey] !== newValue) {
|
||||
state.list[i][dataKey] = newValue;
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
@ -484,7 +574,7 @@ globalThis.Component.register('DataGrid', container => {
|
||||
}
|
||||
}
|
||||
_editorOverlay.style.display = 'none';
|
||||
if (form) { form.state.schema = []; form.data = null; }
|
||||
if (form) { form.state.schema = []; form.state.data = globalThis.NewState({}); form.data = null; }
|
||||
currentEditingNode = null; state.isBulkEdit = null; container.focus();
|
||||
};
|
||||
|
||||
@ -515,22 +605,22 @@ globalThis.Component.register('DataGrid', container => {
|
||||
const rIdx = row._ref?.rIdx ?? Array.from(container.querySelectorAll('.dt-body-row')).indexOf(row);
|
||||
const absoluteRow = rIdx + state._listStartIndex;
|
||||
|
||||
if (item && state.fields[fIdx]) {
|
||||
if (item && state._displayFields[fIdx] && !state._displayFields[fIdx].system) {
|
||||
const bounds = selection.getSelectionBounds();
|
||||
if (bounds && absoluteRow >= bounds.minRow && absoluteRow <= bounds.maxRow && fIdx >= bounds.minCol && fIdx <= bounds.maxCol) {
|
||||
const affectedRows = bounds.maxRow - bounds.minRow + 1;
|
||||
if (affectedRows > 1) {
|
||||
state.isBulkEdit = { ...bounds, fIdx };
|
||||
if (globalThis.UI?.toast) globalThis.UI.toast(`Bulk Edit: Updating ${affectedRows} rows in column "${state.fields[fIdx].name}"`, { type: 'warning' });
|
||||
if (globalThis.UI?.toast) globalThis.UI.toast(`Bulk Edit: Updating ${affectedRows} rows in column "${state._displayFields[fIdx].name}"`, { type: 'warning' });
|
||||
}
|
||||
}
|
||||
container.editCell(item, state.fields[fIdx], cell);
|
||||
container.editCell(item, state._displayFields[fIdx], cell);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
container.addRow = () => {
|
||||
const newRow = {}; state.fields.forEach(f => newRow[f.id] = '');
|
||||
const newRow = {}; state.fields.forEach(f => newRow[getDataKey(f)] = '');
|
||||
state._originalList.push(newRow); state.list = [...state._originalList]; state.isDirty = true;
|
||||
setTimeout(() => { scroll.reset(state.list); container.querySelector('.dt-main').scrollTop = container.querySelector('.dt-main').scrollHeight; }, 50);
|
||||
};
|
||||
@ -541,24 +631,45 @@ globalThis.Component.register('DataGrid', container => {
|
||||
if (await globalThis.Dialog.confirm(`Are you sure you want to delete ${count} row(s)?`)) {
|
||||
const rMin = bounds.minRow, rMax = bounds.maxRow;
|
||||
const removedItems = state.list.slice(rMin, rMax + 1);
|
||||
if (container.apiUrl) {
|
||||
try { await requestApi('remove', { data: removedItems }); }
|
||||
catch (_) { return; }
|
||||
}
|
||||
state.list = state.list.filter((_, i) => !(i >= rMin && i <= rMax));
|
||||
state._originalList = state._originalList.filter(item => !removedItems.includes(item));
|
||||
state.isDirty = true; selection.clearAllActive();
|
||||
state.isDirty = !container.apiUrl; selection.clearAllActive();
|
||||
container.dispatchEvent(new CustomEvent('remove', { detail: { items: removedItems } }));
|
||||
}
|
||||
};
|
||||
|
||||
container.saveChanges = () => { container.dispatchEvent(new CustomEvent('save', { detail: { list: state._originalList, fields: state.fields } })); state.isDirty = false; };
|
||||
container.saveChanges = async () => {
|
||||
if (container.apiUrl) {
|
||||
try { await requestApi('save', { data: state._originalList }); }
|
||||
catch (_) { return; }
|
||||
}
|
||||
container.dispatchEvent(new CustomEvent('save', { detail: { list: state._originalList, fields: state.fields } })); state.isDirty = false;
|
||||
};
|
||||
|
||||
const getFieldSchema = () => {
|
||||
const types = globalThis.DataGrid.getFieldTypes();
|
||||
const baseSchema = [
|
||||
{ name: 'id', label: 'Field ID', type: 'text', setting: { required: true, placeholder: 'e.g. user_name' } },
|
||||
{ name: 'name', label: 'Display Name', type: 'text', setting: { required: true, placeholder: 'e.g. 用户名' } },
|
||||
{ name: 'name', label: 'Field Name', type: 'text', setting: { required: true, placeholder: 'e.g. role' } },
|
||||
{ name: 'user_type', label: 'Field Type', type: 'select', options: types.map(t => ({ label: t.label, value: t.value })) }
|
||||
];
|
||||
const dynamicSchema = types.reduce((acc, t) => acc.concat(t.schema || []), []);
|
||||
return baseSchema.concat(dynamicSchema, [{ name: 'isIndex', label: 'Index', type: 'switch' }, { name: 'memo', label: 'Memo', type: 'text' }]);
|
||||
const advancedSchema = [
|
||||
{ type: 'divider', label: 'Advanced Settings' },
|
||||
{ name: 'db_type', label: 'Database Type', type: 'text', setting: { required: true, placeholder: 'e.g. v100, ff, dt' } },
|
||||
{ name: 'isIndex', label: 'Index', type: 'switch' },
|
||||
{ name: 'memo', label: 'Memo', type: 'text' }
|
||||
];
|
||||
const auditSchema = state.advancedMode ? [
|
||||
{ type: 'divider', label: 'System Information' },
|
||||
{ name: 'id', label: 'Field ID', type: 'label' }, { name: 'tableID', label: 'Table ID', type: 'label' },
|
||||
{ name: 'creator', label: 'Creator', type: 'label' }, { name: 'createTime', label: 'Create Time', type: 'label' },
|
||||
{ name: 'updater', label: 'Updater', type: 'label' }, { name: 'updateTime', label: 'Update Time', type: 'label' }
|
||||
] : [];
|
||||
return baseSchema.concat(dynamicSchema, advancedSchema, auditSchema);
|
||||
};
|
||||
|
||||
const parseOptionsStr = (str) => {
|
||||
@ -568,24 +679,35 @@ globalThis.Component.register('DataGrid', container => {
|
||||
});
|
||||
};
|
||||
const formatOptionsStr = (opts) => { if (!opts) return ''; return opts.map(o => typeof o === 'object' ? `${o.label}:${o.value}` : o).join('\n'); };
|
||||
const bindFieldTypeDefaults = (form, data) => {
|
||||
setTimeout(() => {
|
||||
const typeInput = form.querySelector('select[name="user_type"]');
|
||||
const dbTypeInput = form.querySelector('input[name="db_type"]');
|
||||
const decimalsInput = form.querySelector('input[name="decimals"]');
|
||||
const applyDefault = () => { if (data._dbTypeAuto) data.db_type = getDefaultDbType(data.user_type, data.decimals); };
|
||||
typeInput?.addEventListener('change', () => { data._dbTypeAuto = true; applyDefault(); });
|
||||
dbTypeInput?.addEventListener('input', () => { data._dbTypeAuto = false; });
|
||||
decimalsInput?.addEventListener('input', applyDefault);
|
||||
});
|
||||
};
|
||||
|
||||
container.addField = async () => {
|
||||
container.hideColumnMenu();
|
||||
const data = globalThis.NewState({ id: 'c' + Date.now().toString().slice(-4), name: 'New Field', user_type: 'text', decimals: 0, isIndex: false, memo: '', options_str: '' });
|
||||
const data = globalThis.NewState({ id: 'c' + Date.now().toString().slice(-4), tableID: state.fields[0]?.tableID || '', creator: '', createTime: '', updater: '', updateTime: '', name: 'New Field', user_type: 'text', db_type: getDefaultDbType('text'), _dbTypeAuto: true, decimals: 0, isIndex: false, memo: '', options_str: '' });
|
||||
const d = container.querySelector(`Modal[id="${container.id}_field_modal"]`);
|
||||
if (!d) return;
|
||||
Object.assign(d.state, { title: 'Add Field', buttons: ['Cancel', 'Save'] });
|
||||
const form = d.querySelector('AutoForm');
|
||||
if (form) {
|
||||
form.state.data = data; form.state.schema = getFieldSchema();
|
||||
form.state.data = data; form.state.schema = getFieldSchema(); bindFieldTypeDefaults(form, data);
|
||||
}
|
||||
d.show();
|
||||
const result = await new Promise(resolve => d.addEventListener('change', e => resolve(d.result), { once: true }));
|
||||
if (result === 2) {
|
||||
const typeInfo = globalThis.DataGrid.getFieldTypes().find(t => t.value === data.user_type);
|
||||
let dbType = typeInfo?.typeForDB || 'v1024'; if (data.user_type === 'number') dbType = data.decimals > 0 ? 'ff' : 'bi';
|
||||
const field = { id: data.id, name: data.name, memo: data.memo, isIndex: !!data.isIndex, type: dbType, settings: { formType: data.user_type, decimals: data.decimals, prefix: data.prefix, suffix: data.suffix, thousandSep: data.thousandSep, labelOn: data.labelOn, labelOff: data.labelOff, format: data.format, placeholder: data.placeholder, options: parseOptionsStr(data.options_str) } };
|
||||
state.fields = [...state.fields, field]; state.isDirty = true; container.dispatchEvent(new CustomEvent('savefields', { detail: state.fields })); state.list = [...state.list];
|
||||
const dbType = data.db_type?.trim() || getDefaultDbType(data.user_type, data.decimals);
|
||||
const field = { id: data.id, tableID: data.tableID, name: data.name, memo: data.memo, isIndex: !!data.isIndex, type: dbType, settings: { type: data.user_type, decimals: data.decimals, prefix: data.prefix, suffix: data.suffix, thousandSep: data.thousandSep, labelOn: data.labelOn, labelOff: data.labelOff, format: data.format, placeholder: data.placeholder, options: parseOptionsStr(data.options_str) } };
|
||||
if (container.apiUrl) { try { await requestApi('setField', { fields: [field] }); } catch (_) { return; } }
|
||||
state.fields = [...state.fields, field]; state.isDirty = !container.apiUrl; container.dispatchEvent(new CustomEvent('savefields', { detail: state.fields })); state.list = [...state.list];
|
||||
}
|
||||
};
|
||||
|
||||
@ -593,23 +715,25 @@ globalThis.Component.register('DataGrid', container => {
|
||||
if (!state.activeField) return;
|
||||
container.hideColumnMenu();
|
||||
const f = state.activeField; const s = f.settings || {};
|
||||
const data = globalThis.NewState({ id: f.id, name: f.name, memo: f.memo || '', isIndex: !!f.isIndex, user_type: s.formType || 'text', decimals: s.decimals || 0, prefix: s.prefix || '', suffix: s.suffix || '', thousandSep: !!s.thousandSep, labelOn: s.labelOn || '', labelOff: s.labelOff || '', format: s.format || '', placeholder: s.placeholder || '', options_str: formatOptionsStr(s.options) });
|
||||
const data = globalThis.NewState({ id: f.id, tableID: f.tableID || '', creator: f.creator || '', createTime: f.createTime || '', updater: f.updater || '', updateTime: f.updateTime || '', name: f.name, memo: f.memo || '', isIndex: !!f.isIndex, user_type: getEditorType(f), db_type: f.type || '', _dbTypeAuto: true, decimals: s.decimals || 0, prefix: s.prefix || '', suffix: s.suffix || '', thousandSep: !!s.thousandSep, labelOn: s.labelOn || '', labelOff: s.labelOff || '', format: s.format || '', placeholder: s.placeholder || '', options_str: formatOptionsStr(s.options) });
|
||||
const d = container.querySelector(`Modal[id="${container.id}_field_modal"]`);
|
||||
if (!d) return;
|
||||
Object.assign(d.state, { title: 'Edit Field', buttons: ['Cancel', 'Save'] });
|
||||
const form = d.querySelector('AutoForm');
|
||||
if (form) {
|
||||
form.state.data = data; form.state.schema = getFieldSchema();
|
||||
form.state.data = data; form.state.schema = getFieldSchema(); bindFieldTypeDefaults(form, data);
|
||||
}
|
||||
d.show();
|
||||
const result = await new Promise(resolve => d.addEventListener('change', e => resolve(d.result), { once: true }));
|
||||
if (result === 2) {
|
||||
const idx = state.fields.findIndex(item => item.id === f.id);
|
||||
if (idx !== -1) {
|
||||
const typeInfo = globalThis.DataGrid.getFieldTypes().find(t => t.value === data.user_type);
|
||||
let dbType = typeInfo?.typeForDB || 'v1024'; if (data.user_type === 'number') dbType = data.decimals > 0 ? 'ff' : 'bi';
|
||||
const updatedField = { ...f, id: data.id, name: data.name, memo: data.memo, isIndex: !!data.isIndex, type: dbType, settings: { ...f.settings, formType: data.user_type, decimals: data.decimals, prefix: data.prefix, suffix: data.suffix, thousandSep: data.thousandSep, labelOn: data.labelOn, labelOff: data.labelOff, format: data.format, placeholder: data.placeholder, options: parseOptionsStr(data.options_str) } };
|
||||
state.fields[idx] = updatedField; state.fields = [...state.fields]; state.isDirty = true; container.dispatchEvent(new CustomEvent('savefields', { detail: state.fields })); state.list = [...state.list];
|
||||
// Preserve exact database definitions such as v30 when the
|
||||
// editor is unchanged; only a UI-type switch gets a default.
|
||||
const dbType = data.db_type?.trim() || (data.user_type === getEditorType(f) ? f.type : getDefaultDbType(data.user_type, data.decimals));
|
||||
const updatedField = { ...f, name: data.name, memo: data.memo, isIndex: !!data.isIndex, type: dbType, settings: { ...f.settings, type: data.user_type, decimals: data.decimals, prefix: data.prefix, suffix: data.suffix, thousandSep: data.thousandSep, labelOn: data.labelOn, labelOff: data.labelOff, format: data.format, placeholder: data.placeholder, options: parseOptionsStr(data.options_str) } };
|
||||
if (container.apiUrl) { try { await requestApi('setField', { fields: [updatedField] }); } catch (_) { return; } }
|
||||
state.fields[idx] = updatedField; state.fields = [...state.fields]; state.isDirty = !container.apiUrl; container.dispatchEvent(new CustomEvent('savefields', { detail: state.fields })); state.list = [...state.list];
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -617,9 +741,13 @@ globalThis.Component.register('DataGrid', container => {
|
||||
container.deleteField = async () => {
|
||||
if (!state.activeField) return;
|
||||
container.hideColumnMenu();
|
||||
if (state.activeField.system) return;
|
||||
if (await globalThis.Dialog.confirm(`Are you sure you want to delete field "${state.activeField.name}"?`)) {
|
||||
const idx = state.fields.findIndex(f => f.id === state.activeField.id);
|
||||
if (idx !== -1) { state.fields.splice(idx, 1); state.fields = [...state.fields]; state.isDirty = true; container.dispatchEvent(new CustomEvent('savefields', { detail: state.fields })); state.list = [...state.list]; }
|
||||
if (idx !== -1) {
|
||||
if (container.apiUrl) { try { await requestApi('removeField', { fields: [state.activeField] }); } catch (_) { return; } }
|
||||
state.fields.splice(idx, 1); state.fields = [...state.fields]; state.isDirty = !container.apiUrl; container.dispatchEvent(new CustomEvent('savefields', { detail: state.fields })); state.list = [...state.list];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -649,12 +777,12 @@ globalThis.Component.register('DataGrid', container => {
|
||||
style="overflow-anchor:none; min-height: 0">
|
||||
<div class="dt-header border-bottom bg-light sticky-top" style="z-index:20">
|
||||
<div class="dt-header-row fw-bold text-muted small">
|
||||
<template $each="this.state?.fields || []">
|
||||
<template $each="this.state?._displayFields || []">
|
||||
<div $data-id="item.id" $class="dt-cell dt-col border-end d-flex align-items-center header-cell \${(item.settings?.pinned || item.pinned) ? 'pinned-' + (item.settings?.pinned || item.pinned) : ''}" $style="((item.settings?.pinned || item.pinned) ? 'position: sticky; z-index: 11; background-color: inherit; ' : 'position:relative; ') + 'padding: 0; ' + ((item.settings?.pinned || item.pinned) === 'left' ? 'left: var(--l-' + item.id + '); border-right: 1px solid var(--bs-border-color); box-shadow: 2px 0 5px -2px rgba(0,0,0,0.1);' : ((item.settings?.pinned || item.pinned) === 'right' ? 'right: var(--r-' + item.id + '); border-left: 1px solid var(--bs-border-color); box-shadow: -2px 0 5px -2px rgba(0,0,0,0.1);' : ''))">
|
||||
<div class="d-flex align-items-center overflow-hidden flex-grow-1 h-100 px-2 cursor-pointer" $onclick="this.showColumnMenu(item, event)">
|
||||
<i $if="this.state?.filterConfig?.[item.id] && (this.state.filterConfig[item.id].value || this.state.filterConfig[item.id].selectedValues?.length)" class="bi bi-filter me-1 text-primary"></i>
|
||||
<i $if="this.state?.sortConfig?.fieldId === item.id && this.state.sortConfig.direction" $class="bi bi-sort-\${this.state.sortConfig.direction === 'asc' ? 'down' : 'up-alt'} me-1 text-primary"></i>
|
||||
<span $text="item.name" class="text-truncate flex-grow-1"></span>
|
||||
<span $text="item.settings?.label || item.name" class="text-truncate flex-grow-1"></span>
|
||||
</div>
|
||||
<button class="btn btn-xs btn-link text-muted p-0 border-0 me-1 header-menu-btn" $onclick="this.showColumnMenu(item, event)"><i class="bi bi-chevron-down"></i></button>
|
||||
<Resizer $.target="thisNode.parentElement" style="position:absolute; right:0; top:0; bottom:0; width:4px; z-index:10" min="50" max="1000" $onresizing="this.onColumnResizing(item, event)" $onresize="this.onColumnResize(item, event)"></Resizer>
|
||||
@ -666,7 +794,7 @@ globalThis.Component.register('DataGrid', container => {
|
||||
<div class="dt-spacer-prev flex-shrink-0" style="display:none"></div>
|
||||
<template $each="this.state?._renderedList || []" key="id" index="rIdx">
|
||||
<div class="dt-row dt-body-row border-bottom bg-white" $.="this._initRow(thisNode)">
|
||||
<template $each="this.state?.fields || []" as="f" index="fIdx" key="id"><div $data-fidx="fIdx" $class="dt-cell border-end px-2 d-flex align-items-center \${(f.settings?.pinned || f.pinned) ? 'pinned-' + (f.settings?.pinned || f.pinned) : ''}" $style="((f.settings?.pinned || f.pinned) ? 'position: sticky; z-index: 1; background-color: inherit; ' : '') + ((f.settings?.pinned || f.pinned) === 'left' ? 'left: var(--l-' + f.id + '); border-right: 1px solid var(--bs-border-color); box-shadow: 2px 0 5px -2px rgba(0,0,0,0.1);' : ((f.settings?.pinned || f.pinned) === 'right' ? 'right: var(--r-' + f.id + '); border-left: 1px solid var(--bs-border-color); box-shadow: -2px 0 5px -2px rgba(0,0,0,0.1);' : ''))"><span $text="this.format(item[f.id], f)" class="text-truncate"></span></div></template>
|
||||
<template $each="this.state?._displayFields || []" as="f" index="fIdx" key="id"><div $data-fidx="fIdx" $class="dt-cell border-end px-2 d-flex align-items-center \${(f.settings?.pinned || f.pinned) ? 'pinned-' + (f.settings?.pinned || f.pinned) : ''}" $style="((f.settings?.pinned || f.pinned) ? 'position: sticky; z-index: 1; background-color: inherit; ' : '') + ((f.settings?.pinned || f.pinned) === 'left' ? 'left: var(--l-' + f.id + '); border-right: 1px solid var(--bs-border-color); box-shadow: 2px 0 5px -2px rgba(0,0,0,0.1);' : ((f.settings?.pinned || f.pinned) === 'right' ? 'right: var(--r-' + f.id + '); border-left: 1px solid var(--bs-border-color); box-shadow: -2px 0 5px -2px rgba(0,0,0,0.1);' : ''))"><span $text="this.format(item[this.getDataKey(f)], f)" class="text-truncate"></span></div></template>
|
||||
</div>
|
||||
</template>
|
||||
<div class="dt-spacer-post flex-shrink-0" style="display:none"></div>
|
||||
@ -714,7 +842,7 @@ globalThis.Component.register('DataGrid', container => {
|
||||
<span class="cursor-pointer text-primary small fw-bold" $onclick="this.clearColumnSettings()"><i class="bi bi-x-circle me-1"></i> Clear Filter</span>
|
||||
</div>
|
||||
|
||||
<div $if="this.state?.editable" class="mt-3 pt-2 border-top d-flex gap-1 justify-content-between">
|
||||
<div $if="this.state?.editable && !this.state?.activeField?.system" class="mt-3 pt-2 border-top d-flex gap-1 justify-content-between">
|
||||
<button class="btn btn-sm btn-light border-0 flex-grow-1" title="Edit Field" $onclick="this.editField()"><i class="bi bi-pencil"></i></button>
|
||||
<button class="btn btn-sm btn-light border-0 flex-grow-1" title="Add Field" $onclick="this.addField()"><i class="bi bi-plus-lg"></i></button>
|
||||
<button class="btn btn-sm btn-light border-0 flex-grow-1 text-danger" title="Delete Field" $onclick="this.deleteField()"><i class="bi bi-trash"></i></button>
|
||||
@ -732,15 +860,14 @@ globalThis.Component.register('DataGrid', container => {
|
||||
|
||||
<div class="dt-footer border-top bg-light d-flex align-items-center px-3 py-1 shadow-sm" style="height:40px; z-index: 10">
|
||||
<div class="d-flex align-items-center gap-3 flex-grow-1">
|
||||
<div class="d-flex align-items-center gap-1 text-muted" style="font-size: 0.75rem">
|
||||
<i class="bi bi-check-all fs-6"></i><span $text="(this.state?.selectedRowCount || 0) + ' / ' + (this.apiUrl ? (this.state?.total || 0) : (this.state?.list?.length || 0))"></span>
|
||||
</div>
|
||||
<div $if="this.state?.editable" class="d-flex align-items-center gap-1">
|
||||
<button class="btn btn-sm btn-light border-0 d-flex align-items-center px-2 py-1" $onclick="this.addRow()" title="Add Row"><i class="bi bi-plus-lg text-primary me-1"></i> Add</button>
|
||||
<button class="btn btn-sm btn-light border-0 d-flex align-items-center px-2 py-1" $onclick="this.deleteSelectedRow()" $disabled="!this.state?.selectedRowCount" title="Delete Selected Rows"><i class="bi bi-trash text-danger me-1"></i> Delete</button>
|
||||
</div>
|
||||
<div $if="this.state?.editable" class="vr h-50 my-auto text-muted opacity-25"></div>
|
||||
<div class="d-flex align-items-center gap-2 text-muted" style="font-size: 0.75rem">
|
||||
<i class="bi bi-check-all fs-6"></i>
|
||||
<span $text="(this.state?.selectedRowCount || 0) + ' selected / ' + (this.state?.list?.length || 0) + ' total'"></span>
|
||||
</div>
|
||||
<label class="small"><input class="form-check-input me-1" type="checkbox" $bind="this.state.advancedMode">{#Advanced mode#}</label>
|
||||
</div>
|
||||
<div $if="this.state?.editable" class="d-flex align-items-center gap-2">
|
||||
<button $if="this.state?.isDirty" class="btn btn-sm btn-primary border-0 px-3 shadow-sm d-flex align-items-center fw-bold" $onclick="this.saveChanges()"><i class="bi bi-cloud-upload me-1"></i> Save</button>
|
||||
|
||||
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>
|
||||
<script src="../../ui.js" components="data.DataGrid"></script>
|
||||
<script>
|
||||
const tableID = 'testTable'
|
||||
const field = (id, name, type, settings) => ({ id, tableID, name, type, isIndex: false, memo: '', settings })
|
||||
const gridFields = [
|
||||
{ id: 'id', name: 'ID', type: 'number', settings: { width: 70, formType: 'number', pinned: 'left' } },
|
||||
{ id: 'name', name: 'Name', type: 'text', settings: { width: 160, formType: 'text' } },
|
||||
{ id: 'role', name: 'Role', type: 'text', settings: { width: 120, formType: 'select', options: [{ label: 'Administrator', value: 'admin' }, { label: 'Editor', value: 'editor' }] } },
|
||||
{ id: 'score', name: 'Score', type: 'number', settings: { width: 100, formType: 'number', prefix: '$', decimals: 2 } },
|
||||
{ id: 'active', name: 'Active', type: 'boolean', settings: { width: 90, formType: 'switch' } },
|
||||
{ id: 'choices', name: 'Choices', type: 'object', settings: { width: 130, formType: 'checkbox', options: ['a', 'b'] } },
|
||||
{ id: 'priority', name: 'Priority', type: 'text', settings: { width: 120, formType: 'radio', options: ['low', 'high'] } },
|
||||
{ id: 'note', name: 'Note', type: 'text', settings: { width: 160, formType: 'textarea' } },
|
||||
{ id: 'date', name: 'Date', type: 'date', settings: { width: 120, formType: 'date' } },
|
||||
{ id: 'datetime', name: 'DateTime', type: 'datetime', settings: { width: 180, formType: 'datetime' } },
|
||||
{ id: 'tags', name: 'Tags', type: 'object', settings: { width: 140, formType: 'TagsInput' } },
|
||||
{ id: 'range', name: 'Range', type: 'date', settings: { width: 140, formType: 'DatePicker' } },
|
||||
{ id: 'color', name: 'Color', type: 'text', settings: { width: 110, formType: 'ColorPicker' } },
|
||||
{ id: 'icon', name: 'Icon', type: 'text', settings: { width: 120, formType: 'IconPicker' } }
|
||||
field('f-id', 'id', 'bi', { width: 70, type: 'number', pinned: 'left' }),
|
||||
field('f-name', 'name', 'v100', { width: 160, type: 'text' }),
|
||||
field('f-role', 'role', 'v30', { width: 120, type: 'select', options: [{ label: 'Administrator', value: 'admin' }, { label: 'Editor', value: 'editor' }] }),
|
||||
field('f-score', 'score', 'ff', { width: 100, type: 'number', prefix: '$', decimals: 2 }),
|
||||
field('f-active', 'active', 'b', { width: 90, type: 'switch' }),
|
||||
field('f-choices', 'choices', 'v4096', { width: 130, type: 'checkbox', options: ['a', 'b'] }),
|
||||
field('f-priority', 'priority', 'v100', { width: 120, type: 'radio', options: ['low', 'high'] }),
|
||||
field('f-note', 'note', 't', { width: 160, type: 'textarea' }),
|
||||
field('f-date', 'date', 'd', { width: 120, type: 'date' }),
|
||||
field('f-datetime', 'datetime', 'dt', { width: 180, type: 'datetime' }),
|
||||
field('f-tags', 'tags', 'v4096', { width: 140, type: 'TagsInput' }),
|
||||
field('f-range', 'range', 'd', { width: 140, type: 'DatePicker' }),
|
||||
field('f-color', 'color', 'v100', { width: 110, type: 'ColorPicker' }),
|
||||
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' }))
|
||||
</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>
|
||||
<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>
|
||||
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');
|
||||
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, '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, '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')
|
||||
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')
|
||||
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')
|
||||
assert(r, 'sortConfig', grid.state.sortConfig.fieldId === '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-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 === 'f-name' && grid.state.sortConfig.direction === 'desc', 'sortConfig records field and direction')
|
||||
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 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 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')
|
||||
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
|
||||
|
||||
@ -4,10 +4,16 @@ attributes:
|
||||
editable:
|
||||
type: boolean
|
||||
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:
|
||||
fields: Array of column definitions; assign before list.
|
||||
list: Array of row objects; each key matches a field id.
|
||||
table: Table name sent as request.name when api is configured.
|
||||
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.
|
||||
sortConfig: "{ fieldId, direction }"
|
||||
filterConfig: Per-field filter configuration.
|
||||
@ -17,18 +23,20 @@ state:
|
||||
editing:
|
||||
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.
|
||||
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:
|
||||
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:
|
||||
required: [id, name]
|
||||
required: [id, tableID, name, type, settings]
|
||||
fields:
|
||||
id: Unique column id and row value key.
|
||||
name: Header label.
|
||||
type: Source value type.
|
||||
id: _Fields record ID; used for field metadata operations.
|
||||
tableID: Owning table ID.
|
||||
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.
|
||||
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.
|
||||
|
||||
methods:
|
||||
@ -50,6 +58,20 @@ events:
|
||||
remove:
|
||||
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:
|
||||
DataGrid:
|
||||
methods:
|
||||
@ -69,6 +91,9 @@ related:
|
||||
|
||||
rules:
|
||||
- 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.
|
||||
- Treat underscore-prefixed state fields as internal read-only diagnostics.
|
||||
- Custom field editors must be registered in AutoForm before use.
|
||||
@ -76,12 +101,9 @@ rules:
|
||||
example: |
|
||||
<script>
|
||||
const orderFields = [
|
||||
{ id: 'name', name: 'Name', type: 'text', settings: { formType: 'text' } },
|
||||
{ id: 'total', name: 'Total', type: 'number', settings: { formType: 'number', prefix: '$', decimals: 2 } },
|
||||
{ id: 'status', name: 'Status', type: 'text', settings: { formType: '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' } }
|
||||
{ id: 'f-name', tableID: 'orders', name: 'name', type: 'v100', isIndex: true, memo: '', settings: { type: 'text' } },
|
||||
{ id: 'f-total', tableID: 'orders', name: 'total', type: 'ff', isIndex: false, memo: '', settings: { type: 'number', prefix: '$', decimals: 2 } },
|
||||
{ id: 'f-status', tableID: 'orders', name: 'status', type: 'v30', isIndex: false, memo: '', settings: { type: 'select', options: ['draft', 'paid'] } }
|
||||
]
|
||||
const orders = [{ name: 'Ada', total: 42, status: 'paid', paid: true, tags: ['priority'], due: '2026-07-12' }]
|
||||
const saveRows = rows => console.log(rows)
|
||||
@ -89,6 +111,7 @@ example: |
|
||||
<div style="height: 480px">
|
||||
<DataGrid id="orders" editable $.state.fields="orderFields" $.state.list="orders" $onsave="saveRows(event.detail.list)"></DataGrid>
|
||||
</div>
|
||||
<DataGrid editable api="/admin/table" $.state.table="Hash.table"></DataGrid>
|
||||
|
||||
tests:
|
||||
- DataGrid.test.html
|
||||
|
||||
@ -2113,7 +2113,71 @@ url("data:font/woff;base64,d09GRgABAAAAAsBAAAsAAAAHavgAAQAAAAAAAAAAAAAAAAAAAAAAA
|
||||
.bi-globe-americas-fill::before { content: "\\f91b"; }
|
||||
.bi-globe-asia-australia-fill::before { content: "\\f91c"; }
|
||||
.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);
|
||||
}
|
||||
} catch (e) {
|
||||
@ -7192,15 +7256,17 @@ url("data:font/woff;base64,d09GRgABAAAAAsBAAAsAAAAHavgAAQAAAAAAAAAAAAAAAAAAAAAAA
|
||||
const rgb = Bootstrap._hexToRgb(hex);
|
||||
if (rgb) {
|
||||
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);
|
||||
cssPatch += `
|
||||
.btn-${name} {
|
||||
--bs-btn-bg: var(--bs-${name}) !important;
|
||||
--bs-btn-border-color: var(--bs-${name}) !important;
|
||||
--bs-btn-hover-bg: var(--bs-${name}) !important;
|
||||
--bs-btn-hover-border-color: var(--bs-${name}) !important;
|
||||
--bs-btn-active-bg: var(--bs-${name}) !important;
|
||||
--bs-btn-active-border-color: var(--bs-${name}) !important;
|
||||
--bs-btn-hover-bg: ${hoverColor} !important;
|
||||
--bs-btn-hover-border-color: ${hoverColor} !important;
|
||||
--bs-btn-active-bg: ${activeColor} !important;
|
||||
--bs-btn-active-border-color: ${activeColor} !important;
|
||||
--bs-btn-disabled-bg: 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; }
|
||||
.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-check-input:checked {
|
||||
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);
|
||||
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;
|
||||
},
|
||||
_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") {
|
||||
|
||||
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") {
|
||||
if (node.checked !== (node.value === String(result ?? ""))) node.checked = node.value === String(result ?? "");
|
||||
} else if ("value" in node && node.type !== "file") {
|
||||
Promise.resolve().then(() => {
|
||||
setTimeout(() => {
|
||||
if (node.value !== String(result ?? "")) node.value = result;
|
||||
});
|
||||
} 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",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"version": "1.0.1",
|
||||
"private": false,
|
||||
"scripts": {
|
||||
"build": "node tools/build.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": [
|
||||
"frameworks",
|
||||
|
||||
@ -1,17 +1,13 @@
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { readdir, readFile, stat, writeFile } = require('node:fs/promises');
|
||||
const { copyFile, readdir, readFile, stat, writeFile } = require('node:fs/promises');
|
||||
const { join, relative, sep } = require('node:path');
|
||||
const terser = require('terser');
|
||||
|
||||
const root = join(__dirname, '..');
|
||||
const publicDirs = ['frameworks', 'utilities', 'components'];
|
||||
|
||||
const version = () => {
|
||||
const tags = execFileSync('git', ['tag', '--sort=-v:refname'], { cwd: root, encoding: 'utf8' })
|
||||
.trim().split(/\s+/).filter(tag => /^v\d+\.\d+\.\d+$/.test(tag));
|
||||
if (!tags.length) return '1.0.0';
|
||||
const [major, minor, patch] = tags[0].slice(1).split('.').map(Number);
|
||||
return `${major}.${minor}.${patch + 1}`;
|
||||
const frameworks = {
|
||||
Bootstrap: join(root, '..', 'bootstrap', 'dist', 'bootstrap.js'),
|
||||
State: join(root, '..', 'state', 'dist', 'state.js')
|
||||
};
|
||||
|
||||
const filesBelow = async directory => {
|
||||
@ -23,22 +19,20 @@ const filesBelow = async directory => {
|
||||
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 () => {
|
||||
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 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('/');
|
||||
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 uiSource = await readFile(uiFile, 'utf8');
|
||||
const builtUi = uiSource.replace(
|
||||
/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');
|
||||
await writeFile(uiFile, builtUi);
|
||||
@ -50,9 +44,5 @@ const setVersion = (text, nextVersion) => text
|
||||
};
|
||||
await Promise.all([...sources, uiFile].map(minify));
|
||||
|
||||
for (const name of ['package.json', 'README.md', 'README.zh-CN.md', 'AI.yaml']) {
|
||||
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)`);
|
||||
console.log(`Built @apigo.cc/ui (${sources.length} module files; cache ${buildVersion})`);
|
||||
})();
|
||||
|
||||
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.
|
||||
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;
|
||||
if (!script || document.readyState !== 'loading') {
|
||||
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