From 9cb6ba1cb7b42b2dd4607d4f96e17fdc76a159a6 Mon Sep 17 00:00:00 2001 From: Star Date: Mon, 13 Jul 2026 22:24:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E5=A2=9E=E5=BC=BA=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E7=AE=A1=E7=90=86=E4=B8=8E=E8=87=AA=E5=8A=A8=E5=8C=96?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=88by=20AI=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 + README.md | 4 +- README.zh-CN.md | 4 +- components/base/API.js | 3 +- components/base/API.min.js | 2 +- components/base/API.test.html | 9 +- components/base/API.yaml | 6 +- components/base/AutoForm.js | 39 +- components/base/AutoForm.min.js | 2 +- components/base/AutoForm.test.html | 8 +- components/base/AutoForm.yaml | 9 +- components/base/Modal.js | 2 +- components/base/Modal.min.js | 2 +- components/base/Modal.test.html | 3 +- components/base/Modal.yaml | 3 + components/base/Nav.js | 13 +- components/base/Nav.min.js | 2 +- components/base/Nav.test.html | 5 +- components/base/Nav.yaml | 5 +- components/data/DataGrid.js | 1271 +++++++++++++++------------- components/data/DataGrid.min.js | 2 +- components/data/DataGrid.test.html | 43 +- components/data/DataGrid.yaml | 49 +- frameworks/Bootstrap.js | 91 +- frameworks/Bootstrap.min.js | 2 +- frameworks/State.js | 2 +- frameworks/State.min.js | 2 +- package.json | 7 +- tools/build.cjs | 34 +- tools/test.cjs | 61 ++ ui.js | 2 +- ui.min.js | 2 +- 32 files changed, 1029 insertions(+), 670 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 tools/test.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..002108b --- /dev/null +++ b/CHANGELOG.md @@ -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 长内容滚动。 diff --git a/README.md b/README.md index 6ddf133..8a57eca 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,13 @@ Pages load `ui.js` directly from a CDN and explicitly declare required public APIs: ```html - + ``` `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 `` 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). diff --git a/README.zh-CN.md b/README.zh-CN.md index a21631c..8a534c4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -5,7 +5,7 @@ `@apigo.cc/ui` 是不需要生产构建的原生 JavaScript UI 组件库。页面使用一个 `ui.js` 入口,并且每个页面都显式写出使用的组件: ```html - + ``` `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 包的版本独立于前置框架包。 diff --git a/components/base/API.js b/components/base/API.js index cbd718c..a34e8c0 100644 --- a/components/base/API.js +++ b/components/base/API.js @@ -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 => { diff --git a/components/base/API.min.js b/components/base/API.min.js index 936ad3c..10e18e7 100644 --- a/components/base/API.min.js +++ b/components/base/API.min.js @@ -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})))})}); diff --git a/components/base/API.test.html b/components/base/API.test.html index 92fad26..06355e3 100644 --- a/components/base/API.test.html +++ b/components/base/API.test.html @@ -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 diff --git a/components/base/API.yaml b/components/base/API.yaml index e529e13..2afd389 100644 --- a/components/base/API.yaml +++ b/components/base/API.yaml @@ -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: tests: diff --git a/components/base/AutoForm.js b/components/base/AutoForm.js index 2651d87..5b69739 100644 --- a/components/base/AutoForm.js +++ b/components/base/AutoForm.js @@ -6,8 +6,12 @@ const AUTOFORM_BLUEPRINT = globalThis.Util.makeDom(/*html*/`
-
@@ -61,8 +68,36 @@ const AUTOFORM_STYLE = globalThis.Util.makeDom(/*html*/``) +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 => { diff --git a/components/base/AutoForm.min.js b/components/base/AutoForm.min.js index 4a5a312..07c9475 100644 --- a/components/base/AutoForm.min.js +++ b/components/base/AutoForm.min.js @@ -1 +1 @@ -const AUTOFORM_BLUEPRINT=globalThis.Util.makeDom('\n
\n \n\n \n\n
\n
\n \n
\n \n
\n'),AUTOFORM_STYLE=globalThis.Util.makeDom("");globalThis.Component.register("AutoForm",t=>{t.state.schema||(t.state.schema=[]);const e=e=>{const i=(n=e||{})&&"object"==typeof n&&!n.__isProxy?globalThis.NewState(n):n;var n;t.data=i,t.state.data!==i&&(t.state.data=i)};t.state.__watch("data",e),e(t.state.data),t.vertical=t.hasAttribute("vertical"),t.horizontal=t.hasAttribute("horizontal"),t.inline=t.hasAttribute("inline"),t.nobutton=t.hasAttribute("nobutton"),t.submitlabel=t.getAttribute("submitlabel")||"",t.request={method:"POST"},t.response={},t.result=null,t.form=globalThis.$(t,"form"),t.submit=(e={})=>{if(!t.form.reportValidity())return globalThis.Toast?.show("{#verify failed#}",{type:"danger"});if(!t.dispatchEvent(new CustomEvent("submit",{detail:t.data,cancelable:!0,bubbles:!1})))return;const i={...t.request,data:t.data,noui:!0,...e};let n=null;if(t.api)n=t.api.do(i);else{if(!t.request.url)return console.warn("{#please config .api or .request.url to auto submit#}");n=globalThis.HTTP.request(i)}n.then(e=>{if(t.response=e,t.result=e.result,"object"==typeof e.result&&e.result.error)throw new Error(e.result.error);t.dispatchEvent(new CustomEvent("response",{detail:e,bubbles:!1}))}).catch(e=>{globalThis.Toast?.show(e.message,{type:"danger"}),t.dispatchEvent(new CustomEvent("error",{detail:e,bubbles:!0}))})}},AUTOFORM_BLUEPRINT,AUTOFORM_STYLE);const findAnchorInBlueprint=t=>{let e=t.querySelector("[control-wrapper]");if(e)return e;for(const i of t.querySelectorAll("template"))if(e=findAnchorInBlueprint(i.content),e)return e;return null},AutoForm={customTypes:[],register:(t,e)=>{const i=e||t;AutoForm.customTypes.find(e=>e.name===t)||(AutoForm.customTypes.push({name:t,typeName:i}),AutoForm._addAutoFormComponent(t,i))},_addAutoFormComponent:(t,e)=>{const i=findAnchorInBlueprint(AUTOFORM_BLUEPRINT);if(i){const n=globalThis.Util.makeDom(``);i.appendChild(n)}}};globalThis.AutoForm=AutoForm; +const AUTOFORM_BLUEPRINT=globalThis.Util.makeDom('\n
\n
\n\n \n\n
\n
\n \n
\n
\n
\n'),AUTOFORM_STYLE=globalThis.Util.makeDom(""),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"},getDbFormType=t=>DB_FORM_TYPE_MAP[t]||"text",normalizeSchemaItem=t=>{if(!t?.tableID)return t;const e=t.settings||{};return{...t,name:t.name,label:e.label??t.label??t.name,type:e.type??getDbFormType(t.type),options:e.options??t.options,placeholder:e.placeholder??t.placeholder,setting:e.attrs??t.setting}};globalThis.Component.register("AutoForm",t=>{Array.isArray(t.state.schema)||(t.state.schema=[]);const e=e=>{t.state._schema=Array.isArray(e)?e.map(normalizeSchemaItem):[]};t.state.__watch("schema",e),e(t.state.schema);const a=e=>{const a=(i=e||{})&&"object"==typeof i&&!i.__isProxy?globalThis.NewState(i):i;var i;t.data=a,t.state.data!==a&&(t.state.data=a)};t.state.__watch("data",a),a(t.state.data),t.vertical=t.hasAttribute("vertical"),t.horizontal=t.hasAttribute("horizontal"),t.inline=t.hasAttribute("inline"),t.nobutton=t.hasAttribute("nobutton"),t.submitlabel=t.getAttribute("submitlabel")||"",t.request={method:"POST"},t.response={},t.result=null,t.form=globalThis.$(t,"form"),t.submit=(e={})=>{if(!t.form.reportValidity())return globalThis.Toast?.show("{#verify failed#}",{type:"danger"});if(!t.dispatchEvent(new CustomEvent("submit",{detail:t.data,cancelable:!0,bubbles:!1})))return;const a={...t.request,data:t.data,noui:!0,...e};let i=null;if(t.api)i=t.api.do(a);else{if(!t.request.url)return console.warn("{#please config .api or .request.url to auto submit#}");i=globalThis.HTTP.request(a)}i.then(e=>{if(t.response=e,t.result=e.result,"object"==typeof e.result&&e.result.error)throw new Error(e.result.error);t.dispatchEvent(new CustomEvent("response",{detail:e,bubbles:!1}))}).catch(e=>{globalThis.Toast?.show(e.message,{type:"danger"}),t.dispatchEvent(new CustomEvent("error",{detail:e,bubbles:!0}))})}},AUTOFORM_BLUEPRINT,AUTOFORM_STYLE);const findAnchorInBlueprint=t=>{let e=t.querySelector("[control-wrapper]");if(e)return e;for(const a of t.querySelectorAll("template"))if(e=findAnchorInBlueprint(a.content),e)return e;return null},AutoForm={customTypes:[],register:(t,e)=>{const a=e||t;AutoForm.customTypes.find(e=>e.name===t)||(AutoForm.customTypes.push({name:t,typeName:a}),AutoForm._addAutoFormComponent(t,a))},_addAutoFormComponent:(t,e)=>{const a=findAnchorInBlueprint(AUTOFORM_BLUEPRINT);if(a){const i=globalThis.Util.makeDom(``);a.appendChild(i)}}};globalThis.AutoForm=AutoForm; diff --git a/components/base/AutoForm.test.html b/components/base/AutoForm.test.html index 6f8357e..8841652 100644 --- a/components/base/AutoForm.test.html +++ b/components/base/AutoForm.test.html @@ -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' } @@ -45,11 +49,12 @@ +

   
     
+  database_fields: |
+    const fields = [{ id: 'f-role', tableID: 'users', name: 'Role', type: 'v30', settings: { type: 'select', options: ['Admin', 'User'] } }]
+    const user = { Role: 'Admin' }
+    
   submit_api: |
     
   
 
 

components.Nav

未测试

 
diff --git a/components/base/Nav.yaml b/components/base/Nav.yaml index c2d14ec..266e048 100644 --- a/components/base/Nav.yaml +++ b/components/base/Nav.yaml @@ -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. diff --git a/components/data/DataGrid.js b/components/data/DataGrid.js index 3440c64..4c0529d 100644 --- a/components/data/DataGrid.js +++ b/components/data/DataGrid.js @@ -5,643 +5,771 @@ // Global Configuration const MODE_MAP = { - text: ['contains', 'equals', 'starts', 'ends'], - textarea: ['contains', 'equals', 'starts', 'ends'], - number: ['=', '>', '<', 'between'], - date: ['=', '>', '<', 'between'], - select: ['contains', 'equals'], - TagsInput: ['contains', 'equals', 'starts', 'ends'] + text: ['contains', 'equals', 'starts', 'ends'], + textarea: ['contains', 'equals', 'starts', 'ends'], + number: ['=', '>', '<', 'between'], + date: ['=', '>', '<', 'between'], + select: ['contains', 'equals'], + TagsInput: ['contains', 'equals', 'starts', 'ends'] }; const MODE_ICONS = { - 'contains': 'bi-search', 'equals': 'bi-distribute-vertical', 'starts': 'bi-align-start', 'ends': 'bi-align-end', - '=': 'bi-calculator', '>': 'bi-chevron-right', '<': 'bi-chevron-left', 'between': 'bi-arrows-expand' + 'contains': 'bi-search', 'equals': 'bi-distribute-vertical', 'starts': 'bi-align-start', 'ends': 'bi-align-end', + '=': 'bi-calculator', '>': 'bi-chevron-right', '<': 'bi-chevron-left', 'between': 'bi-arrows-expand' }; const DataGridConfig = { - _fieldTypes: new Map(), - registerFieldType: (config) => { - DataGridConfig._fieldTypes.set(config.value, config); - }, - getFieldTypes: () => Array.from(DataGridConfig._fieldTypes.values()) + _fieldTypes: new Map(), + registerFieldType: (config) => { + DataGridConfig._fieldTypes.set(config.value, config); + }, + 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', - schema: [{ name: 'placeholder', label: 'Placeholder', type: 'text', if: 'this.data.user_type=="text"' }] + value: 'text', label: '{#Text#}', typeForDB: 'v100', + schema: [{ name: 'placeholder', label: 'Placeholder', type: 'text', if: 'this.data.user_type=="text"' }] }); DataGridConfig.registerFieldType({ - value: 'number', label: '{#Number#}', typeForDB: 'ff', - schema: [ - { name: 'decimals', label: 'Decimals', type: 'number', setting: { min: 0, max: 10 }, if: 'this.data.user_type=="number"' }, - { name: 'prefix', label: 'Prefix (e.g. $)', type: 'text', if: 'this.data.user_type=="number"' }, - { name: 'suffix', label: 'Suffix (e.g. %)', type: 'text', if: 'this.data.user_type=="number"' }, - { name: 'thousandSep', label: 'Thousand Sep', type: 'switch', if: 'this.data.user_type=="number"' } - ], - formatter: (val, field) => { - if (val == null || val === '') return ''; - let num = Number(val); - if (isNaN(num)) return val; - const s = field.settings || {}; - if (s.decimals !== undefined) num = num.toFixed(s.decimals); - let str = String(num); - if (s.thousandSep) { - const parts = str.split('.'); - parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ','); - str = parts.join('.'); - } - return (s.prefix || '') + str + (s.suffix || ''); - } + value: 'number', label: '{#Number#}', typeForDB: 'ff', + schema: [ + { name: 'decimals', label: 'Decimals', type: 'number', setting: { min: 0, max: 10 }, if: 'this.data.user_type=="number"' }, + { name: 'prefix', label: 'Prefix (e.g. $)', type: 'text', if: 'this.data.user_type=="number"' }, + { name: 'suffix', label: 'Suffix (e.g. %)', type: 'text', if: 'this.data.user_type=="number"' }, + { name: 'thousandSep', label: 'Thousand Sep', type: 'switch', if: 'this.data.user_type=="number"' } + ], + formatter: (val, field) => { + if (val == null || val === '') return ''; + let num = Number(val); + if (isNaN(num)) return val; + const s = field.settings || {}; + if (s.decimals !== undefined) num = num.toFixed(s.decimals); + let str = String(num); + if (s.thousandSep) { + const parts = str.split('.'); + parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ','); + str = parts.join('.'); + } + return (s.prefix || '') + str + (s.suffix || ''); + } }); DataGridConfig.registerFieldType({ - value: 'select', label: '{#Single Select#}', typeForDB: 'v1024', - 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 ''; - const opts = field.settings?.options || []; - const opt = opts.find(o => typeof o === 'object' ? o.value == val : o == val); - return opt ? (typeof opt === 'object' ? opt.label : opt) : val; - } + 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 ''; + const opts = field.settings?.options || []; + const opt = opts.find(o => typeof o === 'object' ? o.value == val : o == val); + return opt ? (typeof opt === 'object' ? opt.label : opt) : val; + } }); DataGridConfig.registerFieldType({ - value: 'checkbox', label: '{#Multi Select#}', typeForDB: 'v4096', - schema: [{ name: 'options_str', label: 'Options', type: 'textarea', setting: { rows: 3, placeholder: 'Label:Value per line' }, if: 'this.data.user_type=="checkbox"' }], - formatter: (val, field) => { - if (!Array.isArray(val)) return val == null ? '' : String(val); - const opts = field.settings?.options || []; - return val.map(v => { - const opt = opts.find(o => typeof o === 'object' ? o.value == v : o == v); - return opt ? (typeof opt === 'object' ? opt.label : opt) : v; - }).join(', '); - } + value: 'checkbox', label: '{#Multi Select#}', typeForDB: 'v4096', + schema: [{ name: 'options_str', label: 'Options', type: 'textarea', setting: { rows: 3, placeholder: 'Label:Value per line' }, if: 'this.data.user_type=="checkbox"' }], + formatter: (val, field) => { + if (!Array.isArray(val)) return val == null ? '' : String(val); + const opts = field.settings?.options || []; + return val.map(v => { + const opt = opts.find(o => typeof o === 'object' ? o.value == v : o == v); + return opt ? (typeof opt === 'object' ? opt.label : opt) : v; + }).join(', '); + } }); DataGridConfig.registerFieldType({ - value: 'switch', label: '{#Switch#}', typeForDB: 'b', - schema: [ - { name: 'labelOn', label: 'Label On', type: 'text', if: 'this.data.user_type=="switch"' }, - { name: 'labelOff', label: 'Label Off', type: 'text', if: 'this.data.user_type=="switch"' } - ], - formatter: (val, field) => { - const s = field.settings || {}; - return val ? (s.labelOn || 'Yes') : (s.labelOff || 'No'); - } + value: 'switch', label: '{#Switch#}', typeForDB: 'b', + schema: [ + { name: 'labelOn', label: 'Label On', type: 'text', if: 'this.data.user_type=="switch"' }, + { name: 'labelOff', label: 'Label Off', type: 'text', if: 'this.data.user_type=="switch"' } + ], + formatter: (val, field) => { + const s = field.settings || {}; + return val ? (s.labelOn || 'Yes') : (s.labelOff || 'No'); + } }); DataGridConfig.registerFieldType({ - value: 'datetime', label: '{#DateTime#}', typeForDB: 'dt', - schema: [{ name: 'format', label: 'Format', type: 'text', setting: { placeholder: 'YYYY-MM-DD' }, if: 'this.data.user_type=="datetime"' }] + value: 'datetime', label: '{#DateTime#}', typeForDB: 'dt', + schema: [{ name: 'format', label: 'Format', type: 'text', setting: { placeholder: 'YYYY-MM-DD' }, if: 'this.data.user_type=="datetime"' }] }); DataGridConfig.registerFieldType({ - value: 'textarea', label: '{#Long Text#}', typeForDB: 't', - schema: [{ name: 'placeholder', label: 'Placeholder', type: 'text', if: 'this.data.user_type=="textarea"' }] + value: 'textarea', label: '{#Long Text#}', typeForDB: 't', + 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__; - const stats = { refreshTime: 0, refreshCount: 0, scrollCount: 0, totalNodes: 0 }; - if (enabled && !globalThis.__statePerformanceTelemetry) { - globalThis.__statePerformanceTelemetry = { scanCount: 0, reuseCount: 0, moveCount: 0 }; - } - return { - get stats() { return stats; }, - enable: () => { enabled = true; }, - disable: () => { enabled = false; }, - onScroll: () => { if (enabled) stats.scrollCount++; }, - startFrame: () => { - if (!enabled) return null; - return { - start: performance.now(), - scan: globalThis.__statePerformanceTelemetry?.scanCount || 0, - move: globalThis.__statePerformanceTelemetry?.moveCount || 0, - reuse: globalThis.__statePerformanceTelemetry?.reuseCount || 0 - }; - }, - endFrame: (startData, renderedCount) => { - if (!enabled || !startData) return; - stats.refreshCount++; stats.totalNodes += renderedCount; - const elapsed = performance.now() - startData.start; - stats.refreshTime += elapsed; - const stPerf = globalThis.__statePerformanceTelemetry; - if (stPerf) { - const scans = stPerf.scanCount - startData.scan; - const moves = stPerf.moveCount - startData.move; - const reuses = stPerf.reuseCount - startData.reuse; - if (scans > 0 || elapsed > 2) { - console.log(`[DataGrid Frame] Time: ${elapsed.toFixed(2)}ms, Scans: ${scans}, Moves: ${moves}, Reuses: ${reuses}, Rows: ${renderedCount}`); - } - } - } - }; + let enabled = !!globalThis.__DT_PERF_MODE__; + const stats = { refreshTime: 0, refreshCount: 0, scrollCount: 0, totalNodes: 0 }; + if (enabled && !globalThis.__statePerformanceTelemetry) { + globalThis.__statePerformanceTelemetry = { scanCount: 0, reuseCount: 0, moveCount: 0 }; + } + return { + get stats() { return stats; }, + enable: () => { enabled = true; }, + disable: () => { enabled = false; }, + onScroll: () => { if (enabled) stats.scrollCount++; }, + startFrame: () => { + if (!enabled) return null; + return { + start: performance.now(), + scan: globalThis.__statePerformanceTelemetry?.scanCount || 0, + move: globalThis.__statePerformanceTelemetry?.moveCount || 0, + reuse: globalThis.__statePerformanceTelemetry?.reuseCount || 0 + }; + }, + endFrame: (startData, renderedCount) => { + if (!enabled || !startData) return; + stats.refreshCount++; stats.totalNodes += renderedCount; + const elapsed = performance.now() - startData.start; + stats.refreshTime += elapsed; + const stPerf = globalThis.__statePerformanceTelemetry; + if (stPerf) { + const scans = stPerf.scanCount - startData.scan; + const moves = stPerf.moveCount - startData.move; + const reuses = stPerf.reuseCount - startData.reuse; + if (scans > 0 || elapsed > 2) { + console.log(`[DataGrid Frame] Time: ${elapsed.toFixed(2)}ms, Scans: ${scans}, Moves: ${moves}, Reuses: ${reuses}, Rows: ${renderedCount}`); + } + } + } + }; }; const createScrollManager = (container, state, onRenderedListChange) => { - const vs = globalThis.VirtualScroll({ itemHeight: 40 }); - let scrollEl = null; - const refresh = (isLayoutChange = false) => { - if (!scrollEl) return; - const res = vs.calc(scrollEl, state.list); - if (res) { - if (!isLayoutChange && state.prevHeight === res.prevHeight && state.postHeight === res.postHeight && state._listStartIndex === res.listStartIndex && state._renderedList.length === res.renderedList.length) return; - Object.assign(state, { prevHeight: res.prevHeight, postHeight: res.postHeight, _listStartIndex: res.listStartIndex, _renderedList: res.renderedList }); - onRenderedListChange?.(res.renderedList.length, isLayoutChange); - } - }; - 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)); }, - refresh, onScroll: () => refresh(false) - }; + const vs = globalThis.VirtualScroll({ itemHeight: 40 }); + let scrollEl = null; + const refresh = (isLayoutChange = false) => { + if (!scrollEl) return; + const res = vs.calc(scrollEl, state.list); + if (res) { + if (!isLayoutChange && state.prevHeight === res.prevHeight && state.postHeight === res.postHeight && state._listStartIndex === res.listStartIndex && state._renderedList.length === res.renderedList.length) return; + Object.assign(state, { prevHeight: res.prevHeight, postHeight: res.postHeight, _listStartIndex: res.listStartIndex, _renderedList: res.renderedList }); + onRenderedListChange?.(res.renderedList.length, isLayoutChange); + } + }; + return { + init: () => { scrollEl = container.querySelector('.dt-main'); }, + 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) + }; }; const createSelectionManager = (container, state) => { - let activeBounds = null; let startCell = null; let multiSelections = []; - const isCellSelected = (r, c) => { - if (activeBounds && r >= activeBounds.minRow && r <= activeBounds.maxRow && c >= activeBounds.minCol && c <= activeBounds.maxCol) return true; - return multiSelections.some(s => r >= s.minRow && r <= s.maxRow && c >= s.minCol && c <= s.maxCol); - }; - let lastHadSelection = false; - const applySelectionUI = () => { - if (globalThis.__DT_FEATURES__ && !globalThis.__DT_FEATURES__.selection) return; - let boundMinRow = Infinity, boundMaxRow = -Infinity; - if (activeBounds) { boundMinRow = Math.min(boundMinRow, activeBounds.minRow); boundMaxRow = Math.max(boundMaxRow, activeBounds.maxRow); } - multiSelections.forEach(s => { boundMinRow = Math.min(boundMinRow, s.minRow); boundMaxRow = Math.max(boundMaxRow, s.maxRow); }); - const hasSelection = boundMinRow !== Infinity; - if (!hasSelection && !lastHadSelection) return; - lastHadSelection = hasSelection; - const body = container.querySelector('.dt-body'); - if (!body) return; - const rowNodes = body.querySelectorAll('.dt-body-row'); - rowNodes.forEach(rowNode => { - const absoluteRow = (rowNode._ref?.rIdx ?? -1) + state._listStartIndex; - const cells = rowNode.querySelectorAll('.dt-cell'); - if (!hasSelection || absoluteRow < boundMinRow || absoluteRow > boundMaxRow) { cells.forEach(cell => cell.classList.remove('dt-cell-selected')); return; } - cells.forEach((cell, cIdx) => { if (isCellSelected(absoluteRow, cIdx)) cell.classList.add('dt-cell-selected'); else cell.classList.remove('dt-cell-selected'); }); - }); - }; - const updateStatus = () => { - let count = 0; if (activeBounds) count += (activeBounds.maxRow - activeBounds.minRow + 1); - multiSelections.forEach(s => count += (s.maxRow - s.minRow + 1)); - state.selectedRowCount = count; - }; - const clearAllActive = (keepSelection = false) => { if (!keepSelection) { activeBounds = null; startCell = null; multiSelections = []; applySelectionUI(); updateStatus(); } }; - const startSelect = (row, col, e) => { - const alreadySelected = isCellSelected(row, col); - const isRange = (activeBounds && (activeBounds.minRow !== activeBounds.maxRow || activeBounds.minCol !== activeBounds.maxCol)) || multiSelections.length > 0; - if (e.shiftKey && startCell) { activeBounds = { minRow: Math.min(startCell.row, row), maxRow: Math.max(startCell.row, row), minCol: Math.min(startCell.col, col), maxCol: Math.max(startCell.col, col) }; } - else { - if (alreadySelected && !e.ctrlKey && !e.metaKey) { if (!isRange) container._potentialCancel = { row, col }; } - else { if (!e.ctrlKey && !e.metaKey) clearAllActive(); else if (activeBounds && !alreadySelected) multiSelections.push(activeBounds); startCell = { row, col }; activeBounds = { minRow: row, maxRow: row, minCol: col, maxCol: col }; } - state.isSelecting = true; - } - applySelectionUI(); updateStatus(); container.focus(); - }; - const updateSelect = (row, col) => { if (state.isSelecting && startCell) { activeBounds = { minRow: Math.min(startCell.row, row), maxRow: Math.max(startCell.row, row), minCol: Math.min(startCell.col, col), maxCol: Math.max(startCell.col, col) }; container._potentialCancel = null; applySelectionUI(); updateStatus(); } }; - const endSelect = () => { if (container._potentialCancel) { const { row, col } = container._potentialCancel; if (isCellSelected(row, col)) clearAllActive(); container._potentialCancel = null; } state.isSelecting = false; }; - const getSelectionBounds = () => { - if (!activeBounds) return null; - let minRow = activeBounds.minRow, maxRow = activeBounds.maxRow, minCol = activeBounds.minCol, maxCol = activeBounds.maxCol; - multiSelections.forEach(s => { minRow = Math.min(minRow, s.minRow); maxRow = Math.max(maxRow, s.maxRow); minCol = Math.min(minCol, s.minCol); maxCol = Math.max(maxCol, s.maxCol); }); - return { minRow, maxRow, minCol, maxCol }; - }; - 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] ?? ''); - if (val.includes('\t') || val.includes('\n') || val.includes('"')) val = '"' + val.replace(/"/g, '""') + '"'; - return val; - }).join('\t'); - }).join('\n'); - await navigator.clipboard.writeText(text); - }; - const paste = async () => { - try { - const text = await navigator.clipboard.readText(); if (!text) return; - const bounds = getSelectionBounds(); if (!bounds) return; - const rows = text.split(/\r?\n/).filter(line => line.length > 0).map(line => { - const cells = []; let current = '', inQuotes = false; - for (let i = 0; i < line.length; i++) { - const char = line[i]; - if (char === '"') { if (inQuotes && line[i+1] === '"') { current += '"'; i++; } else inQuotes = !inQuotes; } - else if (char === '\t' && !inQuotes) { cells.push(current); current = ''; } - else current += char; - } - cells.push(current); return cells; - }); - let { minRow: startRow, minCol: startCol, maxRow, maxCol } = bounds; - if (minRow === maxRow && minCol === maxCol) { - maxRow = Infinity; - maxCol = Infinity; - } - const body = container.querySelector('.dt-body'); - const rowNodes = body ? Array.from(body.childNodes).filter(n => n.classList?.contains('dt-body-row')) : []; - let anyRowChanged = false; - 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; }); - if (rowChanged) anyRowChanged = true; - }); - if (anyRowChanged) state.list = [...state.list]; - } catch (err) { console.error('Paste Error:', err); } - }; - return { applySelectionUI, clearAllActive, startSelect, updateSelect, endSelect, getSelectionBounds, copy, paste }; + let activeBounds = null; let startCell = null; let multiSelections = []; + const isCellSelected = (r, c) => { + if (activeBounds && r >= activeBounds.minRow && r <= activeBounds.maxRow && c >= activeBounds.minCol && c <= activeBounds.maxCol) return true; + return multiSelections.some(s => r >= s.minRow && r <= s.maxRow && c >= s.minCol && c <= s.maxCol); + }; + let lastHadSelection = false; + const applySelectionUI = () => { + if (globalThis.__DT_FEATURES__ && !globalThis.__DT_FEATURES__.selection) return; + let boundMinRow = Infinity, boundMaxRow = -Infinity; + if (activeBounds) { boundMinRow = Math.min(boundMinRow, activeBounds.minRow); boundMaxRow = Math.max(boundMaxRow, activeBounds.maxRow); } + multiSelections.forEach(s => { boundMinRow = Math.min(boundMinRow, s.minRow); boundMaxRow = Math.max(boundMaxRow, s.maxRow); }); + const hasSelection = boundMinRow !== Infinity; + if (!hasSelection && !lastHadSelection) return; + lastHadSelection = hasSelection; + const body = container.querySelector('.dt-body'); + if (!body) return; + const rowNodes = body.querySelectorAll('.dt-body-row'); + rowNodes.forEach(rowNode => { + const absoluteRow = (rowNode._ref?.rIdx ?? -1) + state._listStartIndex; + const cells = rowNode.querySelectorAll('.dt-cell'); + if (!hasSelection || absoluteRow < boundMinRow || absoluteRow > boundMaxRow) { cells.forEach(cell => cell.classList.remove('dt-cell-selected')); return; } + cells.forEach((cell, cIdx) => { if (isCellSelected(absoluteRow, cIdx)) cell.classList.add('dt-cell-selected'); else cell.classList.remove('dt-cell-selected'); }); + }); + }; + const updateStatus = () => { + let count = 0; if (activeBounds) count += (activeBounds.maxRow - activeBounds.minRow + 1); + multiSelections.forEach(s => count += (s.maxRow - s.minRow + 1)); + state.selectedRowCount = count; + }; + const clearAllActive = (keepSelection = false) => { if (!keepSelection) { activeBounds = null; startCell = null; multiSelections = []; applySelectionUI(); updateStatus(); } }; + const startSelect = (row, col, e) => { + const alreadySelected = isCellSelected(row, col); + const isRange = (activeBounds && (activeBounds.minRow !== activeBounds.maxRow || activeBounds.minCol !== activeBounds.maxCol)) || multiSelections.length > 0; + if (e.shiftKey && startCell) { activeBounds = { minRow: Math.min(startCell.row, row), maxRow: Math.max(startCell.row, row), minCol: Math.min(startCell.col, col), maxCol: Math.max(startCell.col, col) }; } + else { + if (alreadySelected && !e.ctrlKey && !e.metaKey) { if (!isRange) container._potentialCancel = { row, col }; } + else { if (!e.ctrlKey && !e.metaKey) clearAllActive(); else if (activeBounds && !alreadySelected) multiSelections.push(activeBounds); startCell = { row, col }; activeBounds = { minRow: row, maxRow: row, minCol: col, maxCol: col }; } + state.isSelecting = true; + } + applySelectionUI(); updateStatus(); container.focus(); + }; + const updateSelect = (row, col) => { if (state.isSelecting && startCell) { activeBounds = { minRow: Math.min(startCell.row, row), maxRow: Math.max(startCell.row, row), minCol: Math.min(startCell.col, col), maxCol: Math.max(startCell.col, col) }; container._potentialCancel = null; applySelectionUI(); updateStatus(); } }; + const endSelect = () => { if (container._potentialCancel) { const { row, col } = container._potentialCancel; if (isCellSelected(row, col)) clearAllActive(); container._potentialCancel = null; } state.isSelecting = false; }; + const getSelectionBounds = () => { + if (!activeBounds) return null; + let minRow = activeBounds.minRow, maxRow = activeBounds.maxRow, minCol = activeBounds.minCol, maxCol = activeBounds.maxCol; + multiSelections.forEach(s => { minRow = Math.min(minRow, s.minRow); maxRow = Math.max(maxRow, s.maxRow); minCol = Math.min(minCol, s.minCol); maxCol = Math.max(maxCol, s.maxCol); }); + return { minRow, maxRow, minCol, maxCol }; + }; + const copy = async () => { + const bounds = getSelectionBounds(); if (!bounds) return; + const text = state.list.slice(bounds.minRow, bounds.maxRow + 1).map(row => { + 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'); + }).join('\n'); + await navigator.clipboard.writeText(text); + }; + const paste = async () => { + try { + const text = await navigator.clipboard.readText(); if (!text) return; + const bounds = getSelectionBounds(); if (!bounds) return; + const rows = text.split(/\r?\n/).filter(line => line.length > 0).map(line => { + const cells = []; let current = '', inQuotes = false; + for (let i = 0; i < line.length; i++) { + const char = line[i]; + if (char === '"') { if (inQuotes && line[i + 1] === '"') { current += '"'; i++; } else inQuotes = !inQuotes; } + else if (char === '\t' && !inQuotes) { cells.push(current); current = ''; } + else current += char; + } + cells.push(current); return cells; + }); + let { minRow: startRow, minCol: startCol, maxRow, maxCol } = bounds; + if (minRow === maxRow && minCol === maxCol) { + maxRow = Infinity; + maxCol = Infinity; + } + const body = container.querySelector('.dt-body'); + const rowNodes = body ? Array.from(body.childNodes).filter(n => n.classList?.contains('dt-body-row')) : []; + let anyRowChanged = false; + 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._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]; + } catch (err) { console.error('Paste Error:', err); } + }; + return { applySelectionUI, clearAllActive, startSelect, updateSelect, endSelect, getSelectionBounds, copy, paste }; }; // Component Registration globalThis.Component.register('DataGrid', container => { - if (!container.state) container.state = globalThis.NewState({}) - const state = container.state - container.editable = container.hasAttribute('editable') + 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: [], - prevHeight: 0, postHeight: 0, _listStartIndex: 0, - selectedRowCount: 0, - _originalList: [], - sortConfig: { fieldId: null, direction: null }, - filterConfig: {}, - activeFieldId: null, activeField: null, activeModes: [], - _columnStats: {}, _internalUpdate: false, _appliedHash: '', _fieldsDirty: false, _masterCellNodes: null, - isDirty: false, isBulkEdit: null, - editable: container.editable - }) + Object.assign(state, { + list: [], fields: [], _displayFields: [], _renderedList: [], + prevHeight: 0, postHeight: 0, _listStartIndex: 0, + selectedRowCount: 0, + _originalList: [], + sortConfig: { fieldId: null, direction: null }, + filterConfig: {}, + activeFieldId: null, activeField: null, activeModes: [], + _columnStats: {}, _internalUpdate: false, _appliedHash: '', _fieldsDirty: false, _masterCellNodes: null, + isDirty: false, isBulkEdit: null, advancedMode: false, table: state.table || '', apiLoading: false, total: 0, + editable: container.editable + }) - const perf = createPerfMonitor(); - state.perf = perf.stats; - const selection = createSelectionManager(container, state); - const scroll = createScrollManager(container, state, () => setTimeout(() => selection.applySelectionUI(), 50)); + 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 menuNode = container.querySelector('.dt-column-menu'); - if (menuNode) menuNode._thisObj = container; + const perf = createPerfMonitor(); + state.perf = perf.stats; + const selection = createSelectionManager(container, state); + const scroll = createScrollManager(container, state, () => setTimeout(() => selection.applySelectionUI(), 50)); - container.onColumnResizing = (field, e) => container.style.setProperty(`--w-${field.id}`, e.detail.newSize + 'px'); - container.onColumnResize = (field, e) => { - const idx = state.fields.findIndex(f => f.id === field.id); - if (idx !== -1) { state.fields[idx].width = e.detail.newSize; state.fields = [...state.fields]; } - }; + const menuNode = container.querySelector('.dt-column-menu'); + if (menuNode) menuNode._thisObj = container; - let _editorOverlay, currentEditingNode = null; + container.onColumnResizing = (field, e) => container.style.setProperty(`--w-${field.id}`, e.detail.newSize + 'px'); + container.onColumnResize = (field, e) => { + const idx = state.fields.findIndex(f => f.id === field.id); + if (idx !== -1) { state.fields[idx].width = e.detail.newSize; state.fields = [...state.fields]; } + }; - container.format = (val, field) => { - if (field.formatter) return field.formatter(val, field); - const typeInfo = DataGridConfig._fieldTypes.get(field.settings?.formType || field.type || 'text'); - if (typeInfo && typeInfo.formatter) return typeInfo.formatter(val, field); - return val == null ? '' : (typeof val === 'object' ? JSON.stringify(val) : String(val)); - }; + let _editorOverlay, currentEditingNode = null; - container.onScroll = () => { - perf.onScroll(); scroll.refresh(); - container.hideColumnMenu(); - container.hideEditor(true); - const prev = container.querySelector('.dt-spacer-prev'), post = container.querySelector('.dt-spacer-post'); - if (prev) { prev.style.height = (state.prevHeight || 0) + 'px'; prev.style.display = state.prevHeight > 0 ? 'block' : 'none'; } - if (post) { post.style.height = (state.postHeight || 0) + 'px'; post.style.display = state.postHeight > 0 ? 'block' : 'none'; } - }; + container.format = (val, field) => { + if (field.formatter) return field.formatter(val, field); + 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)); + }; - container.applySortFilter = (options = {}) => { - if (state._internalUpdate) return; - 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; + container.onScroll = () => { + perf.onScroll(); scroll.refresh(); + container.hideColumnMenu(); + container.hideEditor(true); + const prev = container.querySelector('.dt-spacer-prev'), post = container.querySelector('.dt-spacer-post'); + if (prev) { prev.style.height = (state.prevHeight || 0) + 'px'; prev.style.display = state.prevHeight > 0 ? 'block' : 'none'; } + if (post) { post.style.height = (state.postHeight || 0) + 'px'; post.style.display = state.postHeight > 0 ? 'block' : 'none'; } + }; - 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]; - if (cfg.selectedValues?.length > 0) return cfg.selectedValues.includes(String(val)); - const search = String(cfg.value).toLowerCase(); - const target = String(val ?? '').toLowerCase(); - switch (cfg.mode) { - case 'contains': return target.includes(search); - case 'equals': return target === search; - case 'starts': return target.startsWith(search); - case 'ends': return target.endsWith(search); - case '=': return Number(val) === Number(cfg.value); - case '>': return Number(val) > Number(cfg.value); - case '<': return Number(val) < Number(cfg.value); - case 'between': return Number(val) >= Number(cfg.value) && Number(val) <= Number(cfg.value2); - default: return true; - } - }); - }); + container.applySortFilter = (options = {}) => { + if (state._internalUpdate) return; + 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 (targetSort.fieldId && targetSort.direction) { - 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; - }); - } + 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 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(); + switch (cfg.mode) { + case 'contains': return target.includes(search); + case 'equals': return target === search; + case 'starts': return target.startsWith(search); + case 'ends': return target.endsWith(search); + case '=': return Number(val) === Number(cfg.value); + case '>': return Number(val) > Number(cfg.value); + case '<': return Number(val) < Number(cfg.value); + case 'between': return Number(val) >= Number(cfg.value) && Number(val) <= Number(cfg.value2); + default: return true; + } + }); + }); - state._internalUpdate = true; - state.filterConfig = targetFilters; - state.sortConfig = targetSort; - state.list = filtered; - state._internalUpdate = false; - }; + if (targetSort.fieldId && targetSort.direction) { + const fId = targetSort.fieldId; + const dir = targetSort.direction === 'asc' ? 1 : -1; + filtered.sort((a, b) => { + 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; + }); + } - container.showColumnMenu = (field, event) => { - const btn = event.currentTarget, menu = container.querySelector('.dt-column-menu'); - if (menu.style.display === 'block' && state.activeFieldId === field.id) { - container.hideColumnMenu(); - container.applySortFilter(); - return; - } - const type = field.settings?.formType || field.type || 'text'; - 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; - menu.style.display = 'block'; - const cellNode = btn.closest('.dt-cell'), rect = cellNode.getBoundingClientRect(), rootRect = container.getBoundingClientRect(); - const menuWidth = menu.offsetWidth || 260; - let leftPos = rect.right - rootRect.left - menuWidth; - if (leftPos < 0) leftPos = Math.max(0, rect.left - rootRect.left); - menu.style.left = leftPos + 'px'; menu.style.top = (rect.bottom - rootRect.top + 5) + 'px'; + state._internalUpdate = true; + state.filterConfig = targetFilters; + state.sortConfig = targetSort; + state.list = filtered; + state._internalUpdate = false; + }; - const onGlobalClick = (ev) => { if (menu.contains(ev.target) || btn.contains(ev.target)) return; container.hideColumnMenu(); container.applySortFilter(); document.removeEventListener('mousedown', onGlobalClick); }; - document.addEventListener('mousedown', onGlobalClick); - setTimeout(() => menu.querySelector('input')?.focus(), 50); - }; + container.showColumnMenu = (field, event) => { + const btn = event.currentTarget, menu = container.querySelector('.dt-column-menu'); + if (menu.style.display === 'block' && state.activeFieldId === field.id) { + container.hideColumnMenu(); + container.applySortFilter(); + return; + } + 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; + menu.style.display = 'block'; + const cellNode = btn.closest('.dt-cell'), rect = cellNode.getBoundingClientRect(), rootRect = container.getBoundingClientRect(); + const menuWidth = menu.offsetWidth || 260; + let leftPos = rect.right - rootRect.left - menuWidth; + if (leftPos < 0) leftPos = Math.max(0, rect.left - rootRect.left); + menu.style.left = leftPos + 'px'; menu.style.top = (rect.bottom - rootRect.top + 5) + 'px'; - container.toggleSelectedValue = (val) => { - const filter = state.filterConfig[state.activeFieldId]; if (!filter) return; - const idx = filter.selectedValues.indexOf(val); if (idx === -1) filter.selectedValues.push(val); else filter.selectedValues.splice(idx, 1); - state.filterConfig = { ...state.filterConfig }; container.applySortFilter(); - }; + const onGlobalClick = (ev) => { if (menu.contains(ev.target) || btn.contains(ev.target)) return; container.hideColumnMenu(); container.applySortFilter(); document.removeEventListener('mousedown', onGlobalClick); }; + document.addEventListener('mousedown', onGlobalClick); + setTimeout(() => menu.querySelector('input')?.focus(), 50); + }; - container.filterOnlyThis = (val) => { state.filterConfig[state.activeFieldId] = { mode: 'contains', value: '', selectedValues: [String(val)] }; state.filterConfig = { ...state.filterConfig }; container.applySortFilter(); }; - container.hideColumnMenu = () => { const menu = container.querySelector('.dt-column-menu'); if (menu) menu.style.display = 'none'; }; - container.setSort = (dir) => { const newDir = state.sortConfig.direction === dir && state.sortConfig.fieldId === state.activeFieldId ? null : dir; container.applySortFilter({ sort: newDir }); }; - container.clearColumnSettings = () => { if (!state.activeFieldId) return; state.filterConfig = { ...state.filterConfig, [state.activeFieldId]: { mode: state.activeModes[0] || 'contains', value: '', value2: '', selectedValues: [] } }; container.applySortFilter(); }; + container.toggleSelectedValue = (val) => { + const filter = state.filterConfig[state.activeFieldId]; if (!filter) return; + const idx = filter.selectedValues.indexOf(val); if (idx === -1) filter.selectedValues.push(val); else filter.selectedValues.splice(idx, 1); + state.filterConfig = { ...state.filterConfig }; container.applySortFilter(); + }; - 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 }; }); - }; + container.filterOnlyThis = (val) => { state.filterConfig[state.activeFieldId] = { mode: 'contains', value: '', selectedValues: [String(val)] }; state.filterConfig = { ...state.filterConfig }; container.applySortFilter(); }; + container.hideColumnMenu = () => { const menu = container.querySelector('.dt-column-menu'); if (menu) menu.style.display = 'none'; }; + container.setSort = (dir) => { const newDir = state.sortConfig.direction === dir && state.sortConfig.fieldId === state.activeFieldId ? null : dir; container.applySortFilter({ sort: newDir }); }; + container.clearColumnSettings = () => { if (!state.activeFieldId) return; state.filterConfig = { ...state.filterConfig, [state.activeFieldId]: { mode: state.activeModes[0] || 'contains', value: '', value2: '', selectedValues: [] } }; container.applySortFilter(); }; - state.__watch('fields', fields => { - if (!fields) return; - 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'); + 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._displayFields[fIdx], fIdx: fIdx }; }); + }; - let leftSum = 0; - fields.forEach(f => { - const pinned = f.settings?.pinned || f.pinned; - if (pinned === 'left') { container.style.setProperty(`--l-${f.id}`, leftSum + 'px'); leftSum += ((f.settings?.width || f.width) || 150); } - }); - let rightSum = 0; - [...fields].reverse().forEach(f => { - const pinned = f.settings?.pinned || f.pinned; - if (pinned === 'right') { container.style.setProperty(`--r-${f.id}`, rightSum + 'px'); rightSum += ((f.settings?.width || f.width) || 150); } - }); + state.__watch('fields', fields => { + 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'); - }); + let leftSum = 0; + fields.forEach(f => { + const pinned = f.settings?.pinned || f.pinned; + if (pinned === 'left') { container.style.setProperty(`--l-${f.id}`, leftSum + 'px'); leftSum += ((f.settings?.width || f.width) || 150); } + }); + let rightSum = 0; + [...fields].reverse().forEach(f => { + const pinned = f.settings?.pinned || f.pinned; + if (pinned === 'right') { container.style.setProperty(`--r-${f.id}`, rightSum + 'px'); rightSum += ((f.settings?.width || f.width) || 150); } + }); - state.__watch('list', list => { - if (!state._internalUpdate) { - state._originalList = [...(list || [])]; - setTimeout(() => { - const stats = {}; - state.fields.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; }); - stats[f.id] = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([val, count]) => ({ val, count })); - }); - state._columnStats = stats; - }, 200); - } - scroll.init(); scroll.reset(list); - }); + }); - 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 form = overlay.querySelector('AutoForm'); - if (form) { - 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; - let leftPos = rect.left - mainRect.left + main.scrollLeft - 1; - let editorWidth = Math.max(rect.width + 2, isComplex ? 300 : 0); - - const maxLeft = main.scrollWidth - editorWidth - 5; - if (leftPos > maxLeft) leftPos = Math.max(0, maxLeft); - - Object.assign(overlay.style, { - display: 'flex', - left: leftPos + 'px', - top: topPos + 'px', - width: editorWidth + 'px', - height: 'auto', - minHeight: (rect.height + 2) + 'px', - maxHeight: Math.max(100, mainRect.height - (rect.top - mainRect.top) - 5) + 'px', - overflow: 'auto', - padding: '0' - }); - setTimeout(() => overlay.querySelector('input, textarea, select, .form-control')?.focus(), 30); - }; + state.__watch('list', list => { + if (!state._internalUpdate) { + state._originalList = [...(list || [])]; + setTimeout(() => { + const stats = {}; + state._displayFields.forEach(f => { + const counts = {}; + 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; + }, 200); + } + 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.hideEditor = (save = true) => { - if (!_editorOverlay) _editorOverlay = container.querySelector('.dt-editor-overlay'); - if (!_editorOverlay || _editorOverlay.style.display === 'none') return; - const form = _editorOverlay.querySelector('AutoForm'); - if (save && form && form.data) { - const input = _editorOverlay.querySelector('input:focus, select:focus, textarea:focus'); - if (input) input.dispatchEvent(new Event(input.type === 'number' || input.tagName === 'SELECT' ? 'change' : 'input', { bubbles: 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 = 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)); + } + const isComplex = ['textarea', 'TagsInput', 'checkbox', 'radio'].includes(formType); + let topPos = rect.top - mainRect.top + main.scrollTop - 1; + let leftPos = rect.left - mainRect.left + main.scrollLeft - 1; + let editorWidth = Math.max(rect.width + 2, isComplex ? 300 : 0); - let hasChanges = false; - const schema = form.state.schema || []; - schema.forEach(field => { - const row = currentEditingNode?.closest('.dt-row')?._ref?.item; - if (row && JSON.stringify(row[field.name]) !== JSON.stringify(form.data[field.name])) { - row[field.name] = form.data[field.name]; - hasChanges = true; - } - }); + const maxLeft = main.scrollWidth - editorWidth - 5; + if (leftPos > maxLeft) leftPos = Math.max(0, maxLeft); - if (state.isBulkEdit) { - const { minRow, maxRow, fIdx } = state.isBulkEdit; - const field = state.fields[fIdx]; const newValue = form.data[field.id]; - for (let i = minRow; i <= maxRow; i++) { - if (state.list[i] && state.list[i][field.id] !== newValue) { - state.list[i][field.id] = newValue; - hasChanges = true; - } - } - } - if (hasChanges) { - state.list = [...state.list]; - state.isDirty = true; - } - } - _editorOverlay.style.display = 'none'; - if (form) { form.state.schema = []; form.data = null; } - currentEditingNode = null; state.isBulkEdit = null; container.focus(); - }; + Object.assign(overlay.style, { + display: 'flex', + left: leftPos + 'px', + top: topPos + 'px', + width: editorWidth + 'px', + height: 'auto', + minHeight: (rect.height + 2) + 'px', + maxHeight: Math.max(100, mainRect.height - (rect.top - mainRect.top) - 5) + 'px', + overflow: 'auto', + padding: '0' + }); + setTimeout(() => overlay.querySelector('input, textarea, select, .form-control')?.focus(), 30); + }; - container.onMainMouseDown = e => { - const cell = e.target.closest('.dt-cell'), row = cell?.closest('.dt-row'); - if (!row || row.classList.contains('dt-header-row')) return; - const fIdx = cell.dataset.fidx ? parseInt(cell.dataset.fidx) : Array.from(row.querySelectorAll('.dt-cell')).indexOf(cell); - const rIdx = row._ref?.rIdx ?? Array.from(container.querySelectorAll('.dt-body-row')).indexOf(row); - selection.startSelect(rIdx + state._listStartIndex, fIdx, e); - }; + container.hideEditor = (save = true) => { + if (!_editorOverlay) _editorOverlay = container.querySelector('.dt-editor-overlay'); + if (!_editorOverlay || _editorOverlay.style.display === 'none') return; + const form = _editorOverlay.querySelector('AutoForm'); + if (save && form && form.data) { + const input = _editorOverlay.querySelector('input:focus, select:focus, textarea:focus'); + if (input) input.dispatchEvent(new Event(input.type === 'number' || input.tagName === 'SELECT' ? 'change' : 'input', { bubbles: true })); - container.onMainMouseOver = e => { - if (state.isSelecting) { - const cell = e.target.closest('.dt-cell'), row = cell?.closest('.dt-row'); - if (row && !row.classList.contains('dt-header-row')) { - const fIdx = cell.dataset.fidx ? parseInt(cell.dataset.fidx) : Array.from(row.querySelectorAll('.dt-cell')).indexOf(cell); - const rIdx = row._ref?.rIdx ?? Array.from(container.querySelectorAll('.dt-body-row')).indexOf(row); - selection.updateSelect(rIdx + state._listStartIndex, fIdx); - } - } - }; + let hasChanges = false; + const schema = form.state.schema || []; + schema.forEach(field => { + const row = currentEditingNode?.closest('.dt-row')?._ref?.item; + if (row && JSON.stringify(row[field.name]) !== JSON.stringify(form.data[field.name])) { + row[field.name] = form.data[field.name]; + hasChanges = true; + } + }); - container.onMainDblClick = e => { - if (!container.editable) return; - const cell = e.target.closest('.dt-cell'), row = cell?.closest('.dt-row'); - if (row && !row.classList.contains('dt-header-row')) { - const item = row._ref?.item, fIdx = cell.dataset.fidx ? parseInt(cell.dataset.fidx) : Array.from(row.querySelectorAll('.dt-cell')).indexOf(cell); - const rIdx = row._ref?.rIdx ?? Array.from(container.querySelectorAll('.dt-body-row')).indexOf(row); - const absoluteRow = rIdx + state._listStartIndex; + if (state.isBulkEdit) { + const { minRow, maxRow, fIdx } = state.isBulkEdit; + 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][dataKey] !== newValue) { + state.list[i][dataKey] = newValue; + hasChanges = true; + } + } + } + if (hasChanges) { + state.list = [...state.list]; + state.isDirty = true; + } + } + _editorOverlay.style.display = 'none'; + if (form) { form.state.schema = []; form.state.data = globalThis.NewState({}); form.data = null; } + currentEditingNode = null; state.isBulkEdit = null; container.focus(); + }; - if (item && state.fields[fIdx]) { - 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' }); - } - } - container.editCell(item, state.fields[fIdx], cell); - } - } - }; + container.onMainMouseDown = e => { + const cell = e.target.closest('.dt-cell'), row = cell?.closest('.dt-row'); + if (!row || row.classList.contains('dt-header-row')) return; + const fIdx = cell.dataset.fidx ? parseInt(cell.dataset.fidx) : Array.from(row.querySelectorAll('.dt-cell')).indexOf(cell); + const rIdx = row._ref?.rIdx ?? Array.from(container.querySelectorAll('.dt-body-row')).indexOf(row); + selection.startSelect(rIdx + state._listStartIndex, fIdx, e); + }; - container.addRow = () => { - const newRow = {}; state.fields.forEach(f => newRow[f.id] = ''); - 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); - }; + container.onMainMouseOver = e => { + if (state.isSelecting) { + const cell = e.target.closest('.dt-cell'), row = cell?.closest('.dt-row'); + if (row && !row.classList.contains('dt-header-row')) { + const fIdx = cell.dataset.fidx ? parseInt(cell.dataset.fidx) : Array.from(row.querySelectorAll('.dt-cell')).indexOf(cell); + const rIdx = row._ref?.rIdx ?? Array.from(container.querySelectorAll('.dt-body-row')).indexOf(row); + selection.updateSelect(rIdx + state._listStartIndex, fIdx); + } + } + }; - container.deleteSelectedRow = async () => { - const bounds = selection.getSelectionBounds(); if (!bounds) return; - const count = bounds.maxRow - bounds.minRow + 1; - 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); - state.list = state.list.filter((_, i) => !(i >= rMin && i <= rMax)); - state._originalList = state._originalList.filter(item => !removedItems.includes(item)); - state.isDirty = true; selection.clearAllActive(); - container.dispatchEvent(new CustomEvent('remove', { detail: { items: removedItems } })); - } - }; + container.onMainDblClick = e => { + if (!container.editable) return; + const cell = e.target.closest('.dt-cell'), row = cell?.closest('.dt-row'); + if (row && !row.classList.contains('dt-header-row')) { + const item = row._ref?.item, fIdx = cell.dataset.fidx ? parseInt(cell.dataset.fidx) : Array.from(row.querySelectorAll('.dt-cell')).indexOf(cell); + const rIdx = row._ref?.rIdx ?? Array.from(container.querySelectorAll('.dt-body-row')).indexOf(row); + const absoluteRow = rIdx + state._listStartIndex; - container.saveChanges = () => { container.dispatchEvent(new CustomEvent('save', { detail: { list: state._originalList, fields: state.fields } })); state.isDirty = false; }; + 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._displayFields[fIdx].name}"`, { type: 'warning' }); + } + } + container.editCell(item, state._displayFields[fIdx], cell); + } + } + }; - 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: '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' }]); - }; + container.addRow = () => { + 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); + }; - const parseOptionsStr = (str) => { - if (!str) return undefined; - return str.split('\n').map(s => s.trim()).filter(Boolean).map(line => { - const idx = line.indexOf(':'); if (idx > -1) return { label: line.slice(0, idx).trim(), value: line.slice(idx + 1).trim() }; return line; - }); - }; - const formatOptionsStr = (opts) => { if (!opts) return ''; return opts.map(o => typeof o === 'object' ? `${o.label}:${o.value}` : o).join('\n'); }; + container.deleteSelectedRow = async () => { + const bounds = selection.getSelectionBounds(); if (!bounds) return; + const count = bounds.maxRow - bounds.minRow + 1; + 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 = !container.apiUrl; selection.clearAllActive(); + container.dispatchEvent(new CustomEvent('remove', { detail: { items: removedItems } })); + } + }; - 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 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(); - } - 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]; - } - }; + 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; + }; - container.editField = async () => { - 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 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(); - } - 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]; - } - } - }; + const getFieldSchema = () => { + const types = globalThis.DataGrid.getFieldTypes(); + const baseSchema = [ + { 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 || []), []); + 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); + }; - container.deleteField = async () => { - if (!state.activeField) return; - container.hideColumnMenu(); - 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]; } - } - }; + const parseOptionsStr = (str) => { + if (!str) return undefined; + return str.split('\n').map(s => s.trim()).filter(Boolean).map(line => { + const idx = line.indexOf(':'); if (idx > -1) return { label: line.slice(0, idx).trim(), value: line.slice(idx + 1).trim() }; return line; + }); + }; + 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); + }); + }; - window.addEventListener('mouseup', selection.endSelect); - document.addEventListener('mousedown', e => { - const overlay = container.querySelector('.dt-editor-overlay'); const menu = container.querySelector('.dt-column-menu'); - if (overlay?.style.display !== 'none' && !overlay.contains(e.target)) setTimeout(() => container.hideEditor(true)); - if (!container.contains(e.target) && !overlay?.contains(e.target) && !menu?.contains(e.target)) selection.clearAllActive(); - }); - - container.addEventListener('keydown', e => { - if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') { - e.preventDefault(); - selection.copy(); - } - if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v') { - e.preventDefault(); - selection.paste(); - } - }); + container.addField = async () => { + container.hideColumnMenu(); + 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(); bindFieldTypeDefaults(form, data); + } + d.show(); + const result = await new Promise(resolve => d.addEventListener('change', e => resolve(d.result), { once: true })); + if (result === 2) { + 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]; + } + }; - state._MODE_ICONS = MODE_ICONS; + container.editField = async () => { + if (!state.activeField) return; + container.hideColumnMenu(); + const f = state.activeField; const s = f.settings || {}; + 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(); 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) { + // 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]; + } + } + }; + + 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) { + 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]; + } + } + }; + + window.addEventListener('mouseup', selection.endSelect); + document.addEventListener('mousedown', e => { + const overlay = container.querySelector('.dt-editor-overlay'); const menu = container.querySelector('.dt-column-menu'); + if (overlay?.style.display !== 'none' && !overlay.contains(e.target)) setTimeout(() => container.hideEditor(true)); + if (!container.contains(e.target) && !overlay?.contains(e.target) && !menu?.contains(e.target)) selection.clearAllActive(); + }); + + container.addEventListener('keydown', e => { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') { + e.preventDefault(); + selection.copy(); + } + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v') { + e.preventDefault(); + selection.paste(); + } + }); + + state._MODE_ICONS = MODE_ICONS; }, globalThis.Util.makeDom(/*html*/`
{ style="overflow-anchor:none; min-height: 0">
-