misc cleanup
This commit is contained in:
parent
10dee07a43
commit
f6118e4d5b
66 changed files with 1372 additions and 3087 deletions
404
deno.lock
generated
404
deno.lock
generated
|
|
@ -9,15 +9,16 @@
|
||||||
"jsr:@std/path@^1.1.1": "1.1.1",
|
"jsr:@std/path@^1.1.1": "1.1.1",
|
||||||
"jsr:@std/uuid@*": "1.0.9",
|
"jsr:@std/uuid@*": "1.0.9",
|
||||||
"jsr:@std/uuid@^1.0.9": "1.0.9",
|
"jsr:@std/uuid@^1.0.9": "1.0.9",
|
||||||
"npm:@deno/vite-plugin@^1.0.5": "1.0.5_vite@7.1.2__picomatch@4.0.3",
|
"npm:@deno/vite-plugin@^1.0.5": "1.0.5_vite@7.1.2__picomatch@4.0.3_sass-embedded@1.91.0",
|
||||||
"npm:@sveltejs/vite-plugin-svelte@^6.1.1": "6.1.1_svelte@5.38.1__acorn@8.15.0_vite@7.1.2__picomatch@4.0.3",
|
"npm:@sveltejs/vite-plugin-svelte@^6.1.1": "6.1.1_svelte@5.38.1__acorn@8.15.0_vite@7.1.2__picomatch@4.0.3_sass-embedded@1.91.0",
|
||||||
"npm:@tsconfig/svelte@^5.0.4": "5.0.4",
|
"npm:@tsconfig/svelte@^5.0.4": "5.0.4",
|
||||||
|
"npm:sass-embedded@^1.91.0": "1.91.0",
|
||||||
"npm:svelte-check@^4.3.1": "4.3.1_svelte@5.38.1__acorn@8.15.0_typescript@5.8.3",
|
"npm:svelte-check@^4.3.1": "4.3.1_svelte@5.38.1__acorn@8.15.0_typescript@5.8.3",
|
||||||
"npm:svelte-language-server@~0.17.19": "0.17.19_prettier@3.3.3_svelte@4.2.20_typescript@5.9.2",
|
"npm:svelte-language-server@~0.17.19": "0.17.19_prettier@3.3.3_svelte@4.2.20_typescript@5.9.2",
|
||||||
"npm:svelte@^5.37.3": "5.38.1_acorn@8.15.0",
|
"npm:svelte@^5.37.3": "5.38.1_acorn@8.15.0",
|
||||||
"npm:typescript@~5.8.3": "5.8.3",
|
"npm:typescript@~5.8.3": "5.8.3",
|
||||||
"npm:uuid@^11.1.0": "11.1.0",
|
"npm:uuid@^11.1.0": "11.1.0",
|
||||||
"npm:vite@^7.1.1": "7.1.2_picomatch@4.0.3",
|
"npm:vite@^7.1.1": "7.1.2_picomatch@4.0.3_sass-embedded@1.91.0",
|
||||||
"npm:zod@^4.0.17": "4.0.17"
|
"npm:zod@^4.0.17": "4.0.17"
|
||||||
},
|
},
|
||||||
"jsr": {
|
"jsr": {
|
||||||
|
|
@ -61,10 +62,19 @@
|
||||||
"@jridgewell/trace-mapping"
|
"@jridgewell/trace-mapping"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"@bufbuild/protobuf@2.7.0": {
|
||||||
|
"integrity": "sha512-qn6tAIZEw5i/wiESBF4nQxZkl86aY4KoO0IkUa2Lh+rya64oTOdJQFlZuMwI1Qz9VBJQrQC4QlSA2DNek5gCOA=="
|
||||||
|
},
|
||||||
"@deno/vite-plugin@1.0.5_vite@7.1.2__picomatch@4.0.3": {
|
"@deno/vite-plugin@1.0.5_vite@7.1.2__picomatch@4.0.3": {
|
||||||
"integrity": "sha512-tLja5n4dyMhcze1NzvSs2iiriBymfBlDCZIrjMTxb9O2ru0gvmV6mn5oBD2teNw5Sd92cj3YJzKwsAs8tMJXlg==",
|
"integrity": "sha512-tLja5n4dyMhcze1NzvSs2iiriBymfBlDCZIrjMTxb9O2ru0gvmV6mn5oBD2teNw5Sd92cj3YJzKwsAs8tMJXlg==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"vite"
|
"vite@7.1.2_picomatch@4.0.3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@deno/vite-plugin@1.0.5_vite@7.1.2__picomatch@4.0.3_sass-embedded@1.91.0": {
|
||||||
|
"integrity": "sha512-tLja5n4dyMhcze1NzvSs2iiriBymfBlDCZIrjMTxb9O2ru0gvmV6mn5oBD2teNw5Sd92cj3YJzKwsAs8tMJXlg==",
|
||||||
|
"dependencies": [
|
||||||
|
"vite@7.1.2_picomatch@4.0.3_sass-embedded@1.91.0"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@emmetio/abbreviation@2.3.3": {
|
"@emmetio/abbreviation@2.3.3": {
|
||||||
|
|
@ -369,6 +379,96 @@
|
||||||
"@jridgewell/sourcemap-codec"
|
"@jridgewell/sourcemap-codec"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"@parcel/watcher-android-arm64@2.5.1": {
|
||||||
|
"integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==",
|
||||||
|
"os": ["android"],
|
||||||
|
"cpu": ["arm64"]
|
||||||
|
},
|
||||||
|
"@parcel/watcher-darwin-arm64@2.5.1": {
|
||||||
|
"integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==",
|
||||||
|
"os": ["darwin"],
|
||||||
|
"cpu": ["arm64"]
|
||||||
|
},
|
||||||
|
"@parcel/watcher-darwin-x64@2.5.1": {
|
||||||
|
"integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==",
|
||||||
|
"os": ["darwin"],
|
||||||
|
"cpu": ["x64"]
|
||||||
|
},
|
||||||
|
"@parcel/watcher-freebsd-x64@2.5.1": {
|
||||||
|
"integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==",
|
||||||
|
"os": ["freebsd"],
|
||||||
|
"cpu": ["x64"]
|
||||||
|
},
|
||||||
|
"@parcel/watcher-linux-arm-glibc@2.5.1": {
|
||||||
|
"integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==",
|
||||||
|
"os": ["linux"],
|
||||||
|
"cpu": ["arm"]
|
||||||
|
},
|
||||||
|
"@parcel/watcher-linux-arm-musl@2.5.1": {
|
||||||
|
"integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==",
|
||||||
|
"os": ["linux"],
|
||||||
|
"cpu": ["arm"]
|
||||||
|
},
|
||||||
|
"@parcel/watcher-linux-arm64-glibc@2.5.1": {
|
||||||
|
"integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==",
|
||||||
|
"os": ["linux"],
|
||||||
|
"cpu": ["arm64"]
|
||||||
|
},
|
||||||
|
"@parcel/watcher-linux-arm64-musl@2.5.1": {
|
||||||
|
"integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==",
|
||||||
|
"os": ["linux"],
|
||||||
|
"cpu": ["arm64"]
|
||||||
|
},
|
||||||
|
"@parcel/watcher-linux-x64-glibc@2.5.1": {
|
||||||
|
"integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==",
|
||||||
|
"os": ["linux"],
|
||||||
|
"cpu": ["x64"]
|
||||||
|
},
|
||||||
|
"@parcel/watcher-linux-x64-musl@2.5.1": {
|
||||||
|
"integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==",
|
||||||
|
"os": ["linux"],
|
||||||
|
"cpu": ["x64"]
|
||||||
|
},
|
||||||
|
"@parcel/watcher-win32-arm64@2.5.1": {
|
||||||
|
"integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==",
|
||||||
|
"os": ["win32"],
|
||||||
|
"cpu": ["arm64"]
|
||||||
|
},
|
||||||
|
"@parcel/watcher-win32-ia32@2.5.1": {
|
||||||
|
"integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==",
|
||||||
|
"os": ["win32"],
|
||||||
|
"cpu": ["ia32"]
|
||||||
|
},
|
||||||
|
"@parcel/watcher-win32-x64@2.5.1": {
|
||||||
|
"integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==",
|
||||||
|
"os": ["win32"],
|
||||||
|
"cpu": ["x64"]
|
||||||
|
},
|
||||||
|
"@parcel/watcher@2.5.1": {
|
||||||
|
"integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==",
|
||||||
|
"dependencies": [
|
||||||
|
"detect-libc",
|
||||||
|
"is-glob",
|
||||||
|
"micromatch",
|
||||||
|
"node-addon-api"
|
||||||
|
],
|
||||||
|
"optionalDependencies": [
|
||||||
|
"@parcel/watcher-android-arm64",
|
||||||
|
"@parcel/watcher-darwin-arm64",
|
||||||
|
"@parcel/watcher-darwin-x64",
|
||||||
|
"@parcel/watcher-freebsd-x64",
|
||||||
|
"@parcel/watcher-linux-arm-glibc",
|
||||||
|
"@parcel/watcher-linux-arm-musl",
|
||||||
|
"@parcel/watcher-linux-arm64-glibc",
|
||||||
|
"@parcel/watcher-linux-arm64-musl",
|
||||||
|
"@parcel/watcher-linux-x64-glibc",
|
||||||
|
"@parcel/watcher-linux-x64-musl",
|
||||||
|
"@parcel/watcher-win32-arm64",
|
||||||
|
"@parcel/watcher-win32-ia32",
|
||||||
|
"@parcel/watcher-win32-x64"
|
||||||
|
],
|
||||||
|
"scripts": true
|
||||||
|
},
|
||||||
"@rollup/rollup-android-arm-eabi@4.46.2": {
|
"@rollup/rollup-android-arm-eabi@4.46.2": {
|
||||||
"integrity": "sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA==",
|
"integrity": "sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA==",
|
||||||
"os": ["android"],
|
"os": ["android"],
|
||||||
|
|
@ -478,23 +578,45 @@
|
||||||
"@sveltejs/vite-plugin-svelte-inspector@5.0.0_@sveltejs+vite-plugin-svelte@6.1.1__svelte@5.38.1___acorn@8.15.0__vite@7.1.2___picomatch@4.0.3_svelte@5.38.1__acorn@8.15.0_vite@7.1.2__picomatch@4.0.3": {
|
"@sveltejs/vite-plugin-svelte-inspector@5.0.0_@sveltejs+vite-plugin-svelte@6.1.1__svelte@5.38.1___acorn@8.15.0__vite@7.1.2___picomatch@4.0.3_svelte@5.38.1__acorn@8.15.0_vite@7.1.2__picomatch@4.0.3": {
|
||||||
"integrity": "sha512-iwQ8Z4ET6ZFSt/gC+tVfcsSBHwsqc6RumSaiLUkAurW3BCpJam65cmHw0oOlDMTO0u+PZi9hilBRYN+LZNHTUQ==",
|
"integrity": "sha512-iwQ8Z4ET6ZFSt/gC+tVfcsSBHwsqc6RumSaiLUkAurW3BCpJam65cmHw0oOlDMTO0u+PZi9hilBRYN+LZNHTUQ==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@sveltejs/vite-plugin-svelte",
|
"@sveltejs/vite-plugin-svelte@6.1.1_svelte@5.38.1__acorn@8.15.0_vite@7.1.2__picomatch@4.0.3",
|
||||||
"debug",
|
"debug",
|
||||||
"svelte@5.38.1_acorn@8.15.0",
|
"svelte@5.38.1_acorn@8.15.0",
|
||||||
"vite"
|
"vite@7.1.2_picomatch@4.0.3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@sveltejs/vite-plugin-svelte-inspector@5.0.0_@sveltejs+vite-plugin-svelte@6.1.1__svelte@5.38.1___acorn@8.15.0__vite@7.1.2___picomatch@4.0.3_svelte@5.38.1__acorn@8.15.0_vite@7.1.2__picomatch@4.0.3_sass-embedded@1.91.0": {
|
||||||
|
"integrity": "sha512-iwQ8Z4ET6ZFSt/gC+tVfcsSBHwsqc6RumSaiLUkAurW3BCpJam65cmHw0oOlDMTO0u+PZi9hilBRYN+LZNHTUQ==",
|
||||||
|
"dependencies": [
|
||||||
|
"@sveltejs/vite-plugin-svelte@6.1.1_svelte@5.38.1__acorn@8.15.0_vite@7.1.2__picomatch@4.0.3_sass-embedded@1.91.0",
|
||||||
|
"debug",
|
||||||
|
"svelte@5.38.1_acorn@8.15.0",
|
||||||
|
"vite@7.1.2_picomatch@4.0.3_sass-embedded@1.91.0"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@sveltejs/vite-plugin-svelte@6.1.1_svelte@5.38.1__acorn@8.15.0_vite@7.1.2__picomatch@4.0.3": {
|
"@sveltejs/vite-plugin-svelte@6.1.1_svelte@5.38.1__acorn@8.15.0_vite@7.1.2__picomatch@4.0.3": {
|
||||||
"integrity": "sha512-vB0Vq47Js7C11L2JrwhncIAoDNkdKDPI500SjLSb34X48dDcsSH5JpLl0cHT0sfO997BrzAS6PKjiZEey/S0VQ==",
|
"integrity": "sha512-vB0Vq47Js7C11L2JrwhncIAoDNkdKDPI500SjLSb34X48dDcsSH5JpLl0cHT0sfO997BrzAS6PKjiZEey/S0VQ==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@sveltejs/vite-plugin-svelte-inspector",
|
"@sveltejs/vite-plugin-svelte-inspector@5.0.0_@sveltejs+vite-plugin-svelte@6.1.1__svelte@5.38.1___acorn@8.15.0__vite@7.1.2___picomatch@4.0.3_svelte@5.38.1__acorn@8.15.0_vite@7.1.2__picomatch@4.0.3",
|
||||||
"debug",
|
"debug",
|
||||||
"deepmerge",
|
"deepmerge",
|
||||||
"kleur",
|
"kleur",
|
||||||
"magic-string",
|
"magic-string",
|
||||||
"svelte@5.38.1_acorn@8.15.0",
|
"svelte@5.38.1_acorn@8.15.0",
|
||||||
"vite",
|
"vite@7.1.2_picomatch@4.0.3",
|
||||||
"vitefu"
|
"vitefu@1.1.1_vite@7.1.2__picomatch@4.0.3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@sveltejs/vite-plugin-svelte@6.1.1_svelte@5.38.1__acorn@8.15.0_vite@7.1.2__picomatch@4.0.3_sass-embedded@1.91.0": {
|
||||||
|
"integrity": "sha512-vB0Vq47Js7C11L2JrwhncIAoDNkdKDPI500SjLSb34X48dDcsSH5JpLl0cHT0sfO997BrzAS6PKjiZEey/S0VQ==",
|
||||||
|
"dependencies": [
|
||||||
|
"@sveltejs/vite-plugin-svelte-inspector@5.0.0_@sveltejs+vite-plugin-svelte@6.1.1__svelte@5.38.1___acorn@8.15.0__vite@7.1.2___picomatch@4.0.3_svelte@5.38.1__acorn@8.15.0_vite@7.1.2__picomatch@4.0.3_sass-embedded@1.91.0",
|
||||||
|
"debug",
|
||||||
|
"deepmerge",
|
||||||
|
"kleur",
|
||||||
|
"magic-string",
|
||||||
|
"svelte@5.38.1_acorn@8.15.0",
|
||||||
|
"vite@7.1.2_picomatch@4.0.3_sass-embedded@1.91.0",
|
||||||
|
"vitefu@1.1.1_vite@7.1.2__picomatch@4.0.3_sass-embedded@1.91.0"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@tsconfig/svelte@5.0.4": {
|
"@tsconfig/svelte@5.0.4": {
|
||||||
|
|
@ -527,6 +649,15 @@
|
||||||
"axobject-query@4.1.0": {
|
"axobject-query@4.1.0": {
|
||||||
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="
|
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="
|
||||||
},
|
},
|
||||||
|
"braces@3.0.3": {
|
||||||
|
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||||
|
"dependencies": [
|
||||||
|
"fill-range"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"buffer-builder@0.2.0": {
|
||||||
|
"integrity": "sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg=="
|
||||||
|
},
|
||||||
"chokidar@4.0.3": {
|
"chokidar@4.0.3": {
|
||||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
|
|
@ -546,6 +677,9 @@
|
||||||
"periscopic"
|
"periscopic"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"colorjs.io@0.5.2": {
|
||||||
|
"integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw=="
|
||||||
|
},
|
||||||
"css-tree@2.3.1": {
|
"css-tree@2.3.1": {
|
||||||
"integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",
|
"integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
|
|
@ -565,6 +699,10 @@
|
||||||
"deepmerge@4.3.1": {
|
"deepmerge@4.3.1": {
|
||||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="
|
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="
|
||||||
},
|
},
|
||||||
|
"detect-libc@1.0.3": {
|
||||||
|
"integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
|
||||||
|
"bin": true
|
||||||
|
},
|
||||||
"emmet@2.4.11": {
|
"emmet@2.4.11": {
|
||||||
"integrity": "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==",
|
"integrity": "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
|
|
@ -626,10 +764,16 @@
|
||||||
"fdir@6.4.6_picomatch@4.0.3": {
|
"fdir@6.4.6_picomatch@4.0.3": {
|
||||||
"integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
|
"integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"picomatch"
|
"picomatch@4.0.3"
|
||||||
],
|
],
|
||||||
"optionalPeers": [
|
"optionalPeers": [
|
||||||
"picomatch"
|
"picomatch@4.0.3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"fill-range@7.1.1": {
|
||||||
|
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||||
|
"dependencies": [
|
||||||
|
"to-regex-range"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"fsevents@2.3.3": {
|
"fsevents@2.3.3": {
|
||||||
|
|
@ -640,6 +784,24 @@
|
||||||
"globrex@0.1.2": {
|
"globrex@0.1.2": {
|
||||||
"integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="
|
"integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="
|
||||||
},
|
},
|
||||||
|
"has-flag@4.0.0": {
|
||||||
|
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
|
||||||
|
},
|
||||||
|
"immutable@5.1.3": {
|
||||||
|
"integrity": "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg=="
|
||||||
|
},
|
||||||
|
"is-extglob@2.1.1": {
|
||||||
|
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="
|
||||||
|
},
|
||||||
|
"is-glob@4.0.3": {
|
||||||
|
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||||
|
"dependencies": [
|
||||||
|
"is-extglob"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"is-number@7.0.0": {
|
||||||
|
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="
|
||||||
|
},
|
||||||
"is-reference@3.0.3": {
|
"is-reference@3.0.3": {
|
||||||
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
|
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
|
|
@ -673,6 +835,13 @@
|
||||||
"mdn-data@2.0.30": {
|
"mdn-data@2.0.30": {
|
||||||
"integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA=="
|
"integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA=="
|
||||||
},
|
},
|
||||||
|
"micromatch@4.0.8": {
|
||||||
|
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||||
|
"dependencies": [
|
||||||
|
"braces",
|
||||||
|
"picomatch@2.3.1"
|
||||||
|
]
|
||||||
|
},
|
||||||
"mri@1.2.0": {
|
"mri@1.2.0": {
|
||||||
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="
|
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="
|
||||||
},
|
},
|
||||||
|
|
@ -690,6 +859,9 @@
|
||||||
"tslib"
|
"tslib"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node-addon-api@7.1.1": {
|
||||||
|
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="
|
||||||
|
},
|
||||||
"pascal-case@3.1.2": {
|
"pascal-case@3.1.2": {
|
||||||
"integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==",
|
"integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
|
|
@ -708,6 +880,9 @@
|
||||||
"picocolors@1.1.1": {
|
"picocolors@1.1.1": {
|
||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
|
||||||
},
|
},
|
||||||
|
"picomatch@2.3.1": {
|
||||||
|
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="
|
||||||
|
},
|
||||||
"picomatch@4.0.3": {
|
"picomatch@4.0.3": {
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="
|
||||||
},
|
},
|
||||||
|
|
@ -763,12 +938,158 @@
|
||||||
],
|
],
|
||||||
"bin": true
|
"bin": true
|
||||||
},
|
},
|
||||||
|
"rxjs@7.8.2": {
|
||||||
|
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||||
|
"dependencies": [
|
||||||
|
"tslib"
|
||||||
|
]
|
||||||
|
},
|
||||||
"sade@1.8.1": {
|
"sade@1.8.1": {
|
||||||
"integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
|
"integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"mri"
|
"mri"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"sass-embedded-all-unknown@1.91.0": {
|
||||||
|
"integrity": "sha512-AXC1oPqDfLnLtcoxM+XwSnbhcQs0TxAiA5JDEstl6+tt6fhFLKxdyl1Hla39SFtxvMfB2QDUYE3Dmx49O59vYg==",
|
||||||
|
"dependencies": [
|
||||||
|
"sass"
|
||||||
|
],
|
||||||
|
"cpu": ["!arm", "!arm64", "!riscv64", "!x64"]
|
||||||
|
},
|
||||||
|
"sass-embedded-android-arm64@1.91.0": {
|
||||||
|
"integrity": "sha512-I8Eeg2CeVcZIhXcQLNEY6ZBRF0m7jc818/fypwMwvIdbxGWBekTzc3aKHTLhdBpFzGnDIyR4s7oB0/OjIpzD1A==",
|
||||||
|
"os": ["android"],
|
||||||
|
"cpu": ["arm64"]
|
||||||
|
},
|
||||||
|
"sass-embedded-android-arm@1.91.0": {
|
||||||
|
"integrity": "sha512-DSh1V8TlLIcpklAbn4NINEFs3yD2OzVTbawEXK93IH990upoGNFVNRTstFQ/gcvlbWph3Y3FjAJvo37zUO485A==",
|
||||||
|
"os": ["android"],
|
||||||
|
"cpu": ["arm"]
|
||||||
|
},
|
||||||
|
"sass-embedded-android-riscv64@1.91.0": {
|
||||||
|
"integrity": "sha512-qmsl1a7IIJL0fCOwzmRB+6nxeJK5m9/W8LReXUrdgyJNH5RyxChDg+wwQPVATFffOuztmWMnlJ5CV2sCLZrXcQ==",
|
||||||
|
"os": ["android"],
|
||||||
|
"cpu": ["riscv64"]
|
||||||
|
},
|
||||||
|
"sass-embedded-android-x64@1.91.0": {
|
||||||
|
"integrity": "sha512-/wN0HBLATOVSeN3Tzg0yxxNTo1IQvOxxxwFv7Ki/1/UCg2AqZPxTpNoZj/mn8tUPtiVogMGbC8qclYMq1aRZsQ==",
|
||||||
|
"os": ["android"],
|
||||||
|
"cpu": ["x64"]
|
||||||
|
},
|
||||||
|
"sass-embedded-darwin-arm64@1.91.0": {
|
||||||
|
"integrity": "sha512-gQ6ScInxAN+BDUXy426BSYLRawkmGYlHpQ9i6iOxorr64dtIb3l6eb9YaBV8lPlroUnugylmwN2B3FU9BuPfhA==",
|
||||||
|
"os": ["darwin"],
|
||||||
|
"cpu": ["arm64"]
|
||||||
|
},
|
||||||
|
"sass-embedded-darwin-x64@1.91.0": {
|
||||||
|
"integrity": "sha512-DSvFMtECL2blYVTFMO5fLeNr5bX437Lrz8R47fdo5438TRyOkSgwKTkECkfh3YbnrL86yJIN2QQlmBMF17Z/iw==",
|
||||||
|
"os": ["darwin"],
|
||||||
|
"cpu": ["x64"]
|
||||||
|
},
|
||||||
|
"sass-embedded-linux-arm64@1.91.0": {
|
||||||
|
"integrity": "sha512-OnKCabD7f420ZEC/6YI9WhCVGMZF+ybZ5NbAB9SsG1xlxrKbWQ1s7CIl0w/6RDALtJ+Fjn8+mrxsxqakoAkeuA==",
|
||||||
|
"os": ["linux"],
|
||||||
|
"cpu": ["arm64"]
|
||||||
|
},
|
||||||
|
"sass-embedded-linux-arm@1.91.0": {
|
||||||
|
"integrity": "sha512-ppAZLp3eZ9oTjYdQDf4nM7EehDpkxq5H1hE8FOrx8LpY7pxn6QF+SRpAbRjdfFChRw0K7vh+IiCnQEMp7uLNAg==",
|
||||||
|
"os": ["linux"],
|
||||||
|
"cpu": ["arm"]
|
||||||
|
},
|
||||||
|
"sass-embedded-linux-musl-arm64@1.91.0": {
|
||||||
|
"integrity": "sha512-VfbPpID1C5TT7rukob6CKgefx/TsLE+XZieMNd00hvfJ8XhqPr5DGvSMCNpXlwaedzTirbJu357m+n2PJI9TFQ==",
|
||||||
|
"os": ["linux"],
|
||||||
|
"cpu": ["arm64"]
|
||||||
|
},
|
||||||
|
"sass-embedded-linux-musl-arm@1.91.0": {
|
||||||
|
"integrity": "sha512-znEsNC2FurPF9+XwQQ6e/fVoic3e5D3/kMB41t/bE8byJVRdaPhkdsszt3pZUE56nNGYoCuieSXUkk7VvyPHsw==",
|
||||||
|
"os": ["linux"],
|
||||||
|
"cpu": ["arm"]
|
||||||
|
},
|
||||||
|
"sass-embedded-linux-musl-riscv64@1.91.0": {
|
||||||
|
"integrity": "sha512-ZfLGldKEEeZjuljKks835LTq7jDRI3gXsKKXXgZGzN6Yymd4UpBOGWiDQlWsWTvw5UwDU2xfFh0wSXbLGHTjVA==",
|
||||||
|
"os": ["linux"],
|
||||||
|
"cpu": ["riscv64"]
|
||||||
|
},
|
||||||
|
"sass-embedded-linux-musl-x64@1.91.0": {
|
||||||
|
"integrity": "sha512-4kSiSGPKFMbLvTRbP/ibyiKheOA3fwsJKWU0SOuekSPmybMdrhNkTm0REp6+nehZRE60kC3lXmEV4a7w8Jrwyg==",
|
||||||
|
"os": ["linux"],
|
||||||
|
"cpu": ["x64"]
|
||||||
|
},
|
||||||
|
"sass-embedded-linux-riscv64@1.91.0": {
|
||||||
|
"integrity": "sha512-Y3Fj94SYYvMX9yo49T78yBgBWXtG3EyYUT5K05XyCYkcdl1mVXJSrEmqmRfe4vQGUCaSe/6s7MmsA9Q+mQez7Q==",
|
||||||
|
"os": ["linux"],
|
||||||
|
"cpu": ["riscv64"]
|
||||||
|
},
|
||||||
|
"sass-embedded-linux-x64@1.91.0": {
|
||||||
|
"integrity": "sha512-XwIUaE7pQP/ezS5te80hlyheYiUlo0FolQ0HBtxohpavM+DVX2fjwFm5LOUJHrLAqP+TLBtChfFeLj1Ie4Aenw==",
|
||||||
|
"os": ["linux"],
|
||||||
|
"cpu": ["x64"]
|
||||||
|
},
|
||||||
|
"sass-embedded-unknown-all@1.91.0": {
|
||||||
|
"integrity": "sha512-Bj6v7ScQp/HtO91QBy6ood9AArSIN7/RNcT4E7P9QoY3o+e6621Vd28lV81vdepPrt6u6PgJoVKmLNODqB6Q+A==",
|
||||||
|
"dependencies": [
|
||||||
|
"sass"
|
||||||
|
],
|
||||||
|
"os": ["!android", "!darwin", "!linux", "!win32"]
|
||||||
|
},
|
||||||
|
"sass-embedded-win32-arm64@1.91.0": {
|
||||||
|
"integrity": "sha512-yDCwTiPRex03i1yo7LwiAl1YQ21UyfOxPobD7UjI8AE8ZcB0mQ28VVX66lsZ+qm91jfLslNFOFCD4v79xCG9hA==",
|
||||||
|
"os": ["win32"],
|
||||||
|
"cpu": ["arm64"]
|
||||||
|
},
|
||||||
|
"sass-embedded-win32-x64@1.91.0": {
|
||||||
|
"integrity": "sha512-wiuMz/cx4vsk6rYCnNyoGE5pd73aDJ/zF3qJDose3ZLT1/vV943doJE5pICnS/v5DrUqzV6a1CNq4fN+xeSgFQ==",
|
||||||
|
"os": ["win32"],
|
||||||
|
"cpu": ["x64"]
|
||||||
|
},
|
||||||
|
"sass-embedded@1.91.0": {
|
||||||
|
"integrity": "sha512-VTckYcH1AglrZ3VpPETilTo3Ef472XKwP13lrNfbOHSR6Eo5p27XTkIi+6lrCbuhBFFGAmy+4BRoLaeFUgn+eg==",
|
||||||
|
"dependencies": [
|
||||||
|
"@bufbuild/protobuf",
|
||||||
|
"buffer-builder",
|
||||||
|
"colorjs.io",
|
||||||
|
"immutable",
|
||||||
|
"rxjs",
|
||||||
|
"supports-color",
|
||||||
|
"sync-child-process",
|
||||||
|
"varint"
|
||||||
|
],
|
||||||
|
"optionalDependencies": [
|
||||||
|
"sass-embedded-all-unknown",
|
||||||
|
"sass-embedded-android-arm",
|
||||||
|
"sass-embedded-android-arm64",
|
||||||
|
"sass-embedded-android-riscv64",
|
||||||
|
"sass-embedded-android-x64",
|
||||||
|
"sass-embedded-darwin-arm64",
|
||||||
|
"sass-embedded-darwin-x64",
|
||||||
|
"sass-embedded-linux-arm",
|
||||||
|
"sass-embedded-linux-arm64",
|
||||||
|
"sass-embedded-linux-musl-arm",
|
||||||
|
"sass-embedded-linux-musl-arm64",
|
||||||
|
"sass-embedded-linux-musl-riscv64",
|
||||||
|
"sass-embedded-linux-musl-x64",
|
||||||
|
"sass-embedded-linux-riscv64",
|
||||||
|
"sass-embedded-linux-x64",
|
||||||
|
"sass-embedded-unknown-all",
|
||||||
|
"sass-embedded-win32-arm64",
|
||||||
|
"sass-embedded-win32-x64"
|
||||||
|
],
|
||||||
|
"bin": true
|
||||||
|
},
|
||||||
|
"sass@1.91.0": {
|
||||||
|
"integrity": "sha512-aFOZHGf+ur+bp1bCHZ+u8otKGh77ZtmFyXDo4tlYvT7PWql41Kwd8wdkPqhhT+h2879IVblcHFglIMofsFd1EA==",
|
||||||
|
"dependencies": [
|
||||||
|
"chokidar",
|
||||||
|
"immutable",
|
||||||
|
"source-map-js"
|
||||||
|
],
|
||||||
|
"optionalDependencies": [
|
||||||
|
"@parcel/watcher"
|
||||||
|
],
|
||||||
|
"bin": true
|
||||||
|
},
|
||||||
"semver@7.7.2": {
|
"semver@7.7.2": {
|
||||||
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
|
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
|
||||||
"bin": true
|
"bin": true
|
||||||
|
|
@ -776,6 +1097,12 @@
|
||||||
"source-map-js@1.2.1": {
|
"source-map-js@1.2.1": {
|
||||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
|
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
|
||||||
},
|
},
|
||||||
|
"supports-color@8.1.1": {
|
||||||
|
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
||||||
|
"dependencies": [
|
||||||
|
"has-flag"
|
||||||
|
]
|
||||||
|
},
|
||||||
"svelte-check@4.3.1_svelte@5.38.1__acorn@8.15.0_typescript@5.8.3": {
|
"svelte-check@4.3.1_svelte@5.38.1__acorn@8.15.0_typescript@5.8.3": {
|
||||||
"integrity": "sha512-lkh8gff5gpHLjxIV+IaApMxQhTGnir2pNUAqcNgeKkvK5bT/30Ey/nzBxNLDlkztCH4dP7PixkMt9SWEKFPBWg==",
|
"integrity": "sha512-lkh8gff5gpHLjxIV+IaApMxQhTGnir2pNUAqcNgeKkvK5bT/30Ey/nzBxNLDlkztCH4dP7PixkMt9SWEKFPBWg==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
|
|
@ -861,11 +1188,26 @@
|
||||||
"zimmerframe"
|
"zimmerframe"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"sync-child-process@1.0.2": {
|
||||||
|
"integrity": "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==",
|
||||||
|
"dependencies": [
|
||||||
|
"sync-message-port"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sync-message-port@1.1.3": {
|
||||||
|
"integrity": "sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg=="
|
||||||
|
},
|
||||||
"tinyglobby@0.2.14_picomatch@4.0.3": {
|
"tinyglobby@0.2.14_picomatch@4.0.3": {
|
||||||
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
|
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"fdir",
|
"fdir",
|
||||||
"picomatch"
|
"picomatch@4.0.3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"to-regex-range@5.0.1": {
|
||||||
|
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||||
|
"dependencies": [
|
||||||
|
"is-number"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"tslib@2.8.1": {
|
"tslib@2.8.1": {
|
||||||
|
|
@ -889,12 +1231,15 @@
|
||||||
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||||
"bin": true
|
"bin": true
|
||||||
},
|
},
|
||||||
|
"varint@6.0.0": {
|
||||||
|
"integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg=="
|
||||||
|
},
|
||||||
"vite@7.1.2_picomatch@4.0.3": {
|
"vite@7.1.2_picomatch@4.0.3": {
|
||||||
"integrity": "sha512-J0SQBPlQiEXAF7tajiH+rUooJPo0l8KQgyg4/aMunNtrOa7bwuZJsJbDWzeljqQpgftxuq5yNJxQ91O9ts29UQ==",
|
"integrity": "sha512-J0SQBPlQiEXAF7tajiH+rUooJPo0l8KQgyg4/aMunNtrOa7bwuZJsJbDWzeljqQpgftxuq5yNJxQ91O9ts29UQ==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"esbuild",
|
"esbuild",
|
||||||
"fdir",
|
"fdir",
|
||||||
"picomatch",
|
"picomatch@4.0.3",
|
||||||
"postcss",
|
"postcss",
|
||||||
"rollup",
|
"rollup",
|
||||||
"tinyglobby"
|
"tinyglobby"
|
||||||
|
|
@ -904,13 +1249,41 @@
|
||||||
],
|
],
|
||||||
"bin": true
|
"bin": true
|
||||||
},
|
},
|
||||||
|
"vite@7.1.2_picomatch@4.0.3_sass-embedded@1.91.0": {
|
||||||
|
"integrity": "sha512-J0SQBPlQiEXAF7tajiH+rUooJPo0l8KQgyg4/aMunNtrOa7bwuZJsJbDWzeljqQpgftxuq5yNJxQ91O9ts29UQ==",
|
||||||
|
"dependencies": [
|
||||||
|
"esbuild",
|
||||||
|
"fdir",
|
||||||
|
"picomatch@4.0.3",
|
||||||
|
"postcss",
|
||||||
|
"rollup",
|
||||||
|
"sass-embedded",
|
||||||
|
"tinyglobby"
|
||||||
|
],
|
||||||
|
"optionalDependencies": [
|
||||||
|
"fsevents"
|
||||||
|
],
|
||||||
|
"optionalPeers": [
|
||||||
|
"sass-embedded"
|
||||||
|
],
|
||||||
|
"bin": true
|
||||||
|
},
|
||||||
"vitefu@1.1.1_vite@7.1.2__picomatch@4.0.3": {
|
"vitefu@1.1.1_vite@7.1.2__picomatch@4.0.3": {
|
||||||
"integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==",
|
"integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"vite"
|
"vite@7.1.2_picomatch@4.0.3"
|
||||||
],
|
],
|
||||||
"optionalPeers": [
|
"optionalPeers": [
|
||||||
"vite"
|
"vite@7.1.2_picomatch@4.0.3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"vitefu@1.1.1_vite@7.1.2__picomatch@4.0.3_sass-embedded@1.91.0": {
|
||||||
|
"integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==",
|
||||||
|
"dependencies": [
|
||||||
|
"vite@7.1.2_picomatch@4.0.3_sass-embedded@1.91.0"
|
||||||
|
],
|
||||||
|
"optionalPeers": [
|
||||||
|
"vite@7.1.2_picomatch@4.0.3_sass-embedded@1.91.0"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"vscode-css-languageservice@6.3.7": {
|
"vscode-css-languageservice@6.3.7": {
|
||||||
|
|
@ -981,6 +1354,7 @@
|
||||||
"npm:@deno/vite-plugin@^1.0.5",
|
"npm:@deno/vite-plugin@^1.0.5",
|
||||||
"npm:@sveltejs/vite-plugin-svelte@^6.1.1",
|
"npm:@sveltejs/vite-plugin-svelte@^6.1.1",
|
||||||
"npm:@tsconfig/svelte@^5.0.4",
|
"npm:@tsconfig/svelte@^5.0.4",
|
||||||
|
"npm:sass-embedded@^1.91.0",
|
||||||
"npm:svelte-check@^4.3.1",
|
"npm:svelte-check@^4.3.1",
|
||||||
"npm:svelte-language-server@~0.17.19",
|
"npm:svelte-language-server@~0.17.19",
|
||||||
"npm:svelte@^5.37.3",
|
"npm:svelte@^5.37.3",
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,6 @@ create table if not exists fields (
|
||||||
lens_id uuid not null references lenses(id) on delete cascade,
|
lens_id uuid not null references lenses(id) on delete cascade,
|
||||||
name text not null,
|
name text not null,
|
||||||
label text,
|
label text,
|
||||||
field_type jsonb not null,
|
presentation jsonb not null,
|
||||||
width_px int not null default 200
|
width_px int not null default 200
|
||||||
);
|
);
|
||||||
|
|
|
||||||
45
interim-models/src/encodable.rs
Normal file
45
interim-models/src/encodable.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::Postgres;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||||
|
#[serde(tag = "t", content = "c")]
|
||||||
|
pub enum Encodable {
|
||||||
|
Text(Option<String>),
|
||||||
|
Timestamp(Option<DateTime<Utc>>),
|
||||||
|
Uuid(Option<Uuid>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Encodable {
|
||||||
|
// TODO: Can something similar be achieved with a generic return type?
|
||||||
|
/// Bind this as a parameter to a sqlx query.
|
||||||
|
pub fn bind_onto<'a>(
|
||||||
|
self,
|
||||||
|
query: sqlx::query::Query<'a, Postgres, <sqlx::Postgres as sqlx::Database>::Arguments<'a>>,
|
||||||
|
) -> sqlx::query::Query<'a, Postgres, <sqlx::Postgres as sqlx::Database>::Arguments<'a>> {
|
||||||
|
match self {
|
||||||
|
Self::Text(value) => query.bind(value),
|
||||||
|
Self::Timestamp(value) => query.bind(value),
|
||||||
|
Self::Uuid(value) => query.bind(value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transform the contained value into a serde_json::Value.
|
||||||
|
pub fn inner_as_value(&self) -> serde_json::Value {
|
||||||
|
let serialized = serde_json::to_value(self).unwrap();
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Tagged {
|
||||||
|
c: serde_json::Value,
|
||||||
|
}
|
||||||
|
let deserialized: Tagged = serde_json::from_value(serialized).unwrap();
|
||||||
|
deserialized.c
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_none(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::Text(None) | Self::Timestamp(None) | Self::Uuid(None) => true,
|
||||||
|
Self::Text(_) | Self::Timestamp(_) | Self::Uuid(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@ use std::fmt::Display;
|
||||||
use interim_pgtypes::escape_identifier;
|
use interim_pgtypes::escape_identifier;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::field::Encodable;
|
use crate::encodable::Encodable;
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct QueryFragment {
|
pub struct QueryFragment {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use derive_builder::Builder;
|
use derive_builder::Builder;
|
||||||
use interim_pgtypes::pg_attribute::PgAttribute;
|
use interim_pgtypes::pg_attribute::PgAttribute;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
@ -7,45 +6,48 @@ use thiserror::Error;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::client::AppDbClient;
|
use crate::client::AppDbClient;
|
||||||
|
use crate::encodable::Encodable;
|
||||||
|
use crate::presentation::Presentation;
|
||||||
|
|
||||||
pub const RFC_3339_S: &str = "%Y-%m-%dT%H:%M:%S";
|
/// A materialization of a database column, fit for consumption by an end user.
|
||||||
|
///
|
||||||
|
/// There may be zero or more fields per column/attribute in a Postgres view.
|
||||||
|
/// There may in some case also be fields with no underlying column, if it has
|
||||||
|
/// been removed or altered.
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
pub struct Field {
|
pub struct Field {
|
||||||
|
/// Internal ID for application use.
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
|
||||||
|
/// Name of the database column.
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
|
||||||
|
/// Optional human friendly label.
|
||||||
pub label: Option<String>,
|
pub label: Option<String>,
|
||||||
pub field_type: sqlx::types::Json<FieldType>,
|
|
||||||
|
/// Refer to documentation for `Presentation`.
|
||||||
|
pub presentation: sqlx::types::Json<Presentation>,
|
||||||
|
|
||||||
|
/// Width of UI table column in pixels.
|
||||||
pub width_px: i32,
|
pub width_px: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Field {
|
impl Field {
|
||||||
pub fn insertable_builder() -> InsertableFieldBuilder {
|
/// Constructs a brand new field to be inserted into the application db.
|
||||||
|
pub fn insert() -> InsertableFieldBuilder {
|
||||||
InsertableFieldBuilder::default()
|
InsertableFieldBuilder::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn default_from_attr(attr: &PgAttribute) -> Self {
|
/// Generate a default field config based on an existing column's name and
|
||||||
Self {
|
/// type.
|
||||||
|
pub fn default_from_attr(attr: &PgAttribute) -> Option<Self> {
|
||||||
|
Presentation::default_from_attr(attr).map(|presentation| Self {
|
||||||
id: Uuid::now_v7(),
|
id: Uuid::now_v7(),
|
||||||
name: attr.attname.clone(),
|
name: attr.attname.clone(),
|
||||||
label: None,
|
label: None,
|
||||||
field_type: sqlx::types::Json(FieldType::default_from_attr(attr)),
|
presentation: sqlx::types::Json(presentation),
|
||||||
width_px: 200,
|
width_px: 200,
|
||||||
}
|
})
|
||||||
}
|
|
||||||
|
|
||||||
pub fn webc_tag(&self) -> &str {
|
|
||||||
match self.field_type.0 {
|
|
||||||
FieldType::InterimUser {} => "cell-interim-user",
|
|
||||||
FieldType::Text {} => "cell-text",
|
|
||||||
FieldType::Timestamp { .. } => "cell-timestamp",
|
|
||||||
FieldType::Uuid {} => "cell-uuid",
|
|
||||||
FieldType::Unknown => "cell-unknown",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn webc_custom_attrs(&self) -> Vec<(String, String)> {
|
|
||||||
vec![]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_value_encodable(&self, row: &PgRow) -> Result<Encodable, ParseError> {
|
pub fn get_value_encodable(&self, row: &PgRow) -> Result<Encodable, ParseError> {
|
||||||
|
|
@ -54,6 +56,7 @@ impl Field {
|
||||||
.or(Err(ParseError::FieldNotFound))?;
|
.or(Err(ParseError::FieldNotFound))?;
|
||||||
let type_info = value_ref.type_info();
|
let type_info = value_ref.type_info();
|
||||||
let ty = type_info.name();
|
let ty = type_info.name();
|
||||||
|
dbg!(&ty);
|
||||||
Ok(match ty {
|
Ok(match ty {
|
||||||
"TEXT" | "VARCHAR" => {
|
"TEXT" | "VARCHAR" => {
|
||||||
Encodable::Text(<Option<String> as Decode<Postgres>>::decode(value_ref).unwrap())
|
Encodable::Text(<Option<String> as Decode<Postgres>>::decode(value_ref).unwrap())
|
||||||
|
|
@ -84,7 +87,7 @@ select
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
label,
|
label,
|
||||||
field_type as "field_type: sqlx::types::Json<FieldType>",
|
presentation as "presentation: sqlx::types::Json<Presentation>",
|
||||||
width_px
|
width_px
|
||||||
from fields
|
from fields
|
||||||
where lens_id = $1
|
where lens_id = $1
|
||||||
|
|
@ -96,59 +99,13 @@ where lens_id = $1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
||||||
#[serde(tag = "t", content = "c")]
|
|
||||||
pub enum FieldType {
|
|
||||||
InterimUser {},
|
|
||||||
Text {},
|
|
||||||
Timestamp {
|
|
||||||
format: String,
|
|
||||||
},
|
|
||||||
Uuid {},
|
|
||||||
/// A special variant for when the field type is not specified and cannot be
|
|
||||||
/// inferred. This isn't represented as an error, because we still want to
|
|
||||||
/// be able to define display behavior via the .render() method.
|
|
||||||
Unknown,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FieldType {
|
|
||||||
pub fn default_from_attr(attr: &PgAttribute) -> Self {
|
|
||||||
match attr.regtype.as_str() {
|
|
||||||
"text" => Self::Text {},
|
|
||||||
"timestamp" => Self::Timestamp {
|
|
||||||
format: RFC_3339_S.to_owned(),
|
|
||||||
},
|
|
||||||
"uuid" => Self::Uuid {},
|
|
||||||
_ => Self::Unknown,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a SQL fragment for the default data type for creating or
|
|
||||||
/// altering a backing column, such as "integer", or "timestamptz". Returns
|
|
||||||
/// None if the field type is Unknown.
|
|
||||||
pub fn attr_data_type_fragment(&self) -> Option<&'static str> {
|
|
||||||
match self {
|
|
||||||
Self::InterimUser {} | Self::Text {} => Some("text"),
|
|
||||||
Self::Timestamp { .. } => Some("timestamptz"),
|
|
||||||
Self::Uuid { .. } => Some("uuid"),
|
|
||||||
Self::Unknown => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Error)]
|
|
||||||
#[error("field type is unknown")]
|
|
||||||
pub struct FieldTypeUnknownError {}
|
|
||||||
|
|
||||||
// -------- Insertable --------
|
|
||||||
|
|
||||||
#[derive(Builder, Clone, Debug)]
|
#[derive(Builder, Clone, Debug)]
|
||||||
pub struct InsertableField {
|
pub struct InsertableField {
|
||||||
lens_id: Uuid,
|
lens_id: Uuid,
|
||||||
name: String,
|
name: String,
|
||||||
#[builder(default)]
|
#[builder(default)]
|
||||||
label: Option<String>,
|
label: Option<String>,
|
||||||
field_type: FieldType,
|
presentation: Presentation,
|
||||||
#[builder(default = 200)]
|
#[builder(default = 200)]
|
||||||
width_px: i32,
|
width_px: i32,
|
||||||
}
|
}
|
||||||
|
|
@ -159,20 +116,20 @@ impl InsertableField {
|
||||||
Field,
|
Field,
|
||||||
r#"
|
r#"
|
||||||
insert into fields
|
insert into fields
|
||||||
(id, lens_id, name, label, field_type, width_px)
|
(id, lens_id, name, label, presentation, width_px)
|
||||||
values ($1, $2, $3, $4, $5, $6)
|
values ($1, $2, $3, $4, $5, $6)
|
||||||
returning
|
returning
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
label,
|
label,
|
||||||
field_type as "field_type: sqlx::types::Json<FieldType>",
|
presentation as "presentation: sqlx::types::Json<Presentation>",
|
||||||
width_px
|
width_px
|
||||||
"#,
|
"#,
|
||||||
Uuid::now_v7(),
|
Uuid::now_v7(),
|
||||||
self.lens_id,
|
self.lens_id,
|
||||||
self.name,
|
self.name,
|
||||||
self.label,
|
self.label,
|
||||||
sqlx::types::Json::<_>(self.field_type) as sqlx::types::Json<FieldType>,
|
sqlx::types::Json::<_>(self.presentation) as sqlx::types::Json<Presentation>,
|
||||||
self.width_px,
|
self.width_px,
|
||||||
)
|
)
|
||||||
.fetch_one(&mut *app_db.conn)
|
.fetch_one(&mut *app_db.conn)
|
||||||
|
|
@ -184,14 +141,12 @@ impl InsertableFieldBuilder {
|
||||||
pub fn default_from_attr(attr: &PgAttribute) -> Self {
|
pub fn default_from_attr(attr: &PgAttribute) -> Self {
|
||||||
Self {
|
Self {
|
||||||
name: Some(attr.attname.clone()),
|
name: Some(attr.attname.clone()),
|
||||||
field_type: Some(FieldType::default_from_attr(attr)),
|
presentation: Presentation::default_from_attr(attr),
|
||||||
..Self::default()
|
..Self::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------- Errors --------
|
|
||||||
|
|
||||||
/// Error when parsing a sqlx value to JSON
|
/// Error when parsing a sqlx value to JSON
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum ParseError {
|
pub enum ParseError {
|
||||||
|
|
@ -202,45 +157,3 @@ pub enum ParseError {
|
||||||
#[error("unknown postgres type")]
|
#[error("unknown postgres type")]
|
||||||
UnknownType,
|
UnknownType,
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------- Encodable --------
|
|
||||||
// TODO this should probably be moved to another crate
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
|
||||||
#[serde(tag = "t", content = "c")]
|
|
||||||
pub enum Encodable {
|
|
||||||
Text(Option<String>),
|
|
||||||
Timestamp(Option<DateTime<Utc>>),
|
|
||||||
Uuid(Option<Uuid>),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Encodable {
|
|
||||||
pub fn bind_onto<'a>(
|
|
||||||
self,
|
|
||||||
query: sqlx::query::Query<'a, Postgres, <sqlx::Postgres as sqlx::Database>::Arguments<'a>>,
|
|
||||||
) -> sqlx::query::Query<'a, Postgres, <sqlx::Postgres as sqlx::Database>::Arguments<'a>> {
|
|
||||||
match self {
|
|
||||||
Self::Text(value) => query.bind(value),
|
|
||||||
Self::Timestamp(value) => query.bind(value),
|
|
||||||
Self::Uuid(value) => query.bind(value),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Transform the contained value into a serde_json::Value.
|
|
||||||
pub fn inner_as_value(&self) -> serde_json::Value {
|
|
||||||
let serialized = serde_json::to_value(self).unwrap();
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct Tagged {
|
|
||||||
c: serde_json::Value,
|
|
||||||
}
|
|
||||||
let deserialized: Tagged = serde_json::from_value(serialized).unwrap();
|
|
||||||
deserialized.c
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_none(&self) -> bool {
|
|
||||||
match self {
|
|
||||||
Self::Text(None) | Self::Timestamp(None) | Self::Uuid(None) => true,
|
|
||||||
Self::Text(_) | Self::Timestamp(_) | Self::Uuid(_) => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
pub mod base;
|
pub mod base;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
|
pub mod encodable;
|
||||||
pub mod expression;
|
pub mod expression;
|
||||||
pub mod field;
|
pub mod field;
|
||||||
pub mod lens;
|
pub mod lens;
|
||||||
|
pub mod presentation;
|
||||||
pub mod rel_invitation;
|
pub mod rel_invitation;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
|
||||||
|
|
|
||||||
61
interim-models/src/presentation.rs
Normal file
61
interim-models/src/presentation.rs
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
use interim_pgtypes::pg_attribute::PgAttribute;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub const RFC_3339_S: &str = "%Y-%m-%dT%H:%M:%S";
|
||||||
|
|
||||||
|
/// Struct defining how a field's is displayed and how it accepts input in UI.
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(tag = "t", content = "c")]
|
||||||
|
pub enum Presentation {
|
||||||
|
Array { inner: Box<Presentation> },
|
||||||
|
Dropdown { allow_custom: bool },
|
||||||
|
Text { input_mode: TextInputMode },
|
||||||
|
Timestamp { format: String },
|
||||||
|
Uuid {},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Presentation {
|
||||||
|
/// Returns a SQL fragment for the default data type for creating or
|
||||||
|
/// altering a backing column, such as "integer", or "timestamptz".
|
||||||
|
pub fn attr_data_type_fragment(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Self::Array { inner } => format!("{0}[]", inner.attr_data_type_fragment()),
|
||||||
|
Self::Dropdown { .. } | Self::Text { .. } => "text".to_owned(),
|
||||||
|
Self::Timestamp { .. } => "timestamptz".to_owned(),
|
||||||
|
Self::Uuid { .. } => "uuid".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate the default presentation based on an existing column's type.
|
||||||
|
/// Returns None if no default presentation exists.
|
||||||
|
pub fn default_from_attr(attr: &PgAttribute) -> Option<Self> {
|
||||||
|
match attr.regtype.to_lowercase().as_str() {
|
||||||
|
"text" => Some(Self::Text {
|
||||||
|
input_mode: TextInputMode::MultiLine {},
|
||||||
|
}),
|
||||||
|
"timestamp" => Some(Self::Timestamp {
|
||||||
|
format: RFC_3339_S.to_owned(),
|
||||||
|
}),
|
||||||
|
"uuid" => Some(Self::Uuid {}),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bet the web component tag name to use for rendering a UI cell.
|
||||||
|
pub fn cell_webc_tag(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Self::Array { .. } => todo!(),
|
||||||
|
Self::Dropdown { .. } => "cell-dropdown".to_owned(),
|
||||||
|
Self::Text { .. } => "cell-text".to_owned(),
|
||||||
|
Self::Timestamp { .. } => "cell-timestamp".to_owned(),
|
||||||
|
Self::Uuid {} => "cell-uuid".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(tag = "t", content = "c")]
|
||||||
|
pub enum TextInputMode {
|
||||||
|
SingleLine {},
|
||||||
|
MultiLine {},
|
||||||
|
}
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
use derive_builder::Builder;
|
|
||||||
use interim_pgtypes::pg_attribute::PgAttribute;
|
|
||||||
use regex::Regex;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sqlx::{PgExecutor, query_as};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::field::{Field, FieldType};
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct Selection {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub attr_filters: sqlx::types::Json<Vec<AttrFilter>>,
|
|
||||||
pub label: Option<String>,
|
|
||||||
pub field_type: Option<sqlx::types::Json<FieldType>>,
|
|
||||||
pub visible: bool,
|
|
||||||
pub width_px: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Selection {
|
|
||||||
pub fn insertable_builder() -> InsertableSelectionBuilder {
|
|
||||||
InsertableSelectionBuilder::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn resolve_fields_from_attrs(&self, all_attrs: &[PgAttribute]) -> Vec<Field> {
|
|
||||||
if self.visible {
|
|
||||||
let mut filtered_attrs = all_attrs.to_owned();
|
|
||||||
for attr_filter in self.attr_filters.0.clone() {
|
|
||||||
filtered_attrs.retain(|attr| attr_filter.matches(attr));
|
|
||||||
}
|
|
||||||
filtered_attrs
|
|
||||||
.into_iter()
|
|
||||||
.map(|attr| Field {
|
|
||||||
name: attr.attname.clone(),
|
|
||||||
label: self.label.clone(),
|
|
||||||
field_type: self
|
|
||||||
.field_type
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| sqlx::types::Json(FieldType::default_from_attr(&attr))),
|
|
||||||
width_px: self.width_px,
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
} else {
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
||||||
pub enum AttrFilter {
|
|
||||||
NameEq(String),
|
|
||||||
NameMatches(String),
|
|
||||||
TypeEq(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AttrFilter {
|
|
||||||
pub fn matches(&self, attr: &PgAttribute) -> bool {
|
|
||||||
match self {
|
|
||||||
Self::NameEq(name) => &attr.attname == name,
|
|
||||||
Self::NameMatches(pattern) => Regex::new(pattern)
|
|
||||||
.map(|re| re.is_match(&attr.attname))
|
|
||||||
.unwrap_or(false),
|
|
||||||
Self::TypeEq(_) => todo!("attr type filter is not yet implemented"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Builder, Clone, Debug)]
|
|
||||||
pub struct InsertableSelection {
|
|
||||||
lens_id: Uuid,
|
|
||||||
attr_filters: Vec<AttrFilter>,
|
|
||||||
#[builder(default, setter(strip_option))]
|
|
||||||
label: Option<String>,
|
|
||||||
#[builder(default, setter(strip_option))]
|
|
||||||
field_type: Option<FieldType>,
|
|
||||||
#[builder(default = true)]
|
|
||||||
visible: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InsertableSelection {
|
|
||||||
pub async fn insert<'a, E: PgExecutor<'a>>(self, app_db: E) -> Result<Selection, sqlx::Error> {
|
|
||||||
query_as!(
|
|
||||||
Selection,
|
|
||||||
r#"
|
|
||||||
insert into lens_selections
|
|
||||||
(id, lens_id, attr_filters, label, field_type, visible)
|
|
||||||
values ($1, $2, $3, $4, $5, $6)
|
|
||||||
returning
|
|
||||||
id,
|
|
||||||
attr_filters as "attr_filters: sqlx::types::Json<Vec<AttrFilter>>",
|
|
||||||
label,
|
|
||||||
field_type as "field_type?: sqlx::types::Json<FieldType>",
|
|
||||||
visible,
|
|
||||||
width_px
|
|
||||||
"#,
|
|
||||||
Uuid::now_v7(),
|
|
||||||
self.lens_id,
|
|
||||||
sqlx::types::Json::<_>(self.attr_filters) as sqlx::types::Json<Vec<AttrFilter>>,
|
|
||||||
self.label,
|
|
||||||
self.field_type.map(|value| sqlx::types::Json::<_>(value))
|
|
||||||
as Option<sqlx::types::Json<FieldType>>,
|
|
||||||
self.visible,
|
|
||||||
)
|
|
||||||
.fetch_one(app_db)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -17,6 +17,10 @@ pub struct PgAttribute {
|
||||||
pub attlen: i16,
|
pub attlen: i16,
|
||||||
/// The number of the column. Ordinary columns are numbered from 1 up. System columns, such as ctid, have (arbitrary) negative numbers.
|
/// The number of the column. Ordinary columns are numbered from 1 up. System columns, such as ctid, have (arbitrary) negative numbers.
|
||||||
pub attnum: i16,
|
pub attnum: i16,
|
||||||
|
/// Number of dimensions, if the column is an array type; otherwise 0.
|
||||||
|
/// (Presently, the number of dimensions of an array is not enforced, so any
|
||||||
|
/// nonzero value effectively means “it's an array”.)
|
||||||
|
pub attndims: i16,
|
||||||
/// This represents a not-null constraint.
|
/// This represents a not-null constraint.
|
||||||
pub attnotnull: Option<bool>,
|
pub attnotnull: Option<bool>,
|
||||||
/// This column has a default expression or generation expression, in which case there will be a corresponding entry in the pg_attrdef catalog that actually defines the expression. (Check attgenerated to determine whether this is a default or a generation expression.)
|
/// This column has a default expression or generation expression, in which case there will be a corresponding entry in the pg_attrdef catalog that actually defines the expression. (Check attgenerated to determine whether this is a default or a generation expression.)
|
||||||
|
|
@ -66,6 +70,7 @@ select
|
||||||
atttypid::regtype::text as "regtype!",
|
atttypid::regtype::text as "regtype!",
|
||||||
attlen,
|
attlen,
|
||||||
attnum,
|
attnum,
|
||||||
|
attndims,
|
||||||
attnotnull as "attnotnull?",
|
attnotnull as "attnotnull?",
|
||||||
atthasdef,
|
atthasdef,
|
||||||
atthasmissing,
|
atthasmissing,
|
||||||
|
|
@ -102,6 +107,7 @@ select
|
||||||
atttypid::regtype::text as "regtype!",
|
atttypid::regtype::text as "regtype!",
|
||||||
a.attlen as attlen,
|
a.attlen as attlen,
|
||||||
a.attnum as attnum,
|
a.attnum as attnum,
|
||||||
|
a.attndims as attndims,
|
||||||
a.attnotnull as "attnotnull?",
|
a.attnotnull as "attnotnull?",
|
||||||
a.atthasdef as atthasdef,
|
a.atthasdef as atthasdef,
|
||||||
a.atthasmissing as atthasmissing,
|
a.atthasmissing as atthasmissing,
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ mod auth;
|
||||||
mod base_pooler;
|
mod base_pooler;
|
||||||
mod base_user_perms;
|
mod base_user_perms;
|
||||||
mod cli;
|
mod cli;
|
||||||
mod field;
|
mod field_info;
|
||||||
mod middleware;
|
mod middleware;
|
||||||
mod navbar;
|
mod navbar;
|
||||||
mod navigator;
|
mod navigator;
|
||||||
|
|
|
||||||
|
|
@ -69,10 +69,6 @@ pub fn new_router(state: AppState) -> Router<()> {
|
||||||
"/d/{base_id}/r/{class_oid}/l/{lens_id}/get-data",
|
"/d/{base_id}/r/{class_oid}/l/{lens_id}/get-data",
|
||||||
get(routes::lenses::get_data_page_get),
|
get(routes::lenses::get_data_page_get),
|
||||||
)
|
)
|
||||||
// .route(
|
|
||||||
// "/d/{base_id}/r/{class_oid}/l/{lens_id}/add-selection",
|
|
||||||
// post(routes::lenses::add_selection_page_post),
|
|
||||||
// )
|
|
||||||
.route(
|
.route(
|
||||||
"/d/{base_id}/r/{class_oid}/l/{lens_id}/add-column",
|
"/d/{base_id}/r/{class_oid}/l/{lens_id}/add-column",
|
||||||
post(routes::lenses::add_column_page_post),
|
post(routes::lenses::add_column_page_post),
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ pub async fn lens_page_get(
|
||||||
let attr_names: Vec<String> = attrs.iter().map(|attr| attr.attname.clone()).collect();
|
let attr_names: Vec<String> = attrs.iter().map(|attr| attr.attname.clone()).collect();
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "lens0_2.html")]
|
#[template(path = "lens.html")]
|
||||||
struct ResponseTemplate {
|
struct ResponseTemplate {
|
||||||
attr_names: Vec<String>,
|
attr_names: Vec<String>,
|
||||||
filter: Option<PgExpressionAny>,
|
filter: Option<PgExpressionAny>,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ use axum::{
|
||||||
response::Response,
|
response::Response,
|
||||||
};
|
};
|
||||||
use axum_extra::extract::Form;
|
use axum_extra::extract::Form;
|
||||||
use interim_models::{field::Encodable, lens::Lens};
|
use interim_models::{encodable::Encodable, lens::Lens};
|
||||||
use interim_pgtypes::{escape_identifier, pg_class::PgClass};
|
use interim_pgtypes::{escape_identifier, pg_class::PgClass};
|
||||||
use sqlx::{postgres::types::Oid, query};
|
use sqlx::{postgres::types::Oid, query};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ use super::LensPagePath;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct FormBody {
|
pub struct FormBody {
|
||||||
filter_expression: String,
|
filter_expression: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn lens_set_filter_page_post(
|
pub async fn lens_set_filter_page_post(
|
||||||
|
|
@ -23,7 +23,8 @@ pub async fn lens_set_filter_page_post(
|
||||||
|
|
||||||
let lens = Lens::with_id(lens_id).fetch_one(&mut app_db).await?;
|
let lens = Lens::with_id(lens_id).fetch_one(&mut app_db).await?;
|
||||||
|
|
||||||
let filter: Option<PgExpressionAny> = serde_json::from_str(&body.filter_expression)?;
|
let filter: Option<PgExpressionAny> =
|
||||||
|
serde_json::from_str(&body.filter_expression.unwrap_or("null".to_owned()))?;
|
||||||
Lens::update()
|
Lens::update()
|
||||||
.id(lens.id)
|
.id(lens.id)
|
||||||
.filter(filter)
|
.filter(filter)
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,10 @@ use axum::{
|
||||||
use axum_extra::extract::Form;
|
use axum_extra::extract::Form;
|
||||||
use interim_models::{
|
use interim_models::{
|
||||||
base::Base,
|
base::Base,
|
||||||
field::{Encodable, Field, FieldType, InsertableFieldBuilder, RFC_3339_S},
|
encodable::Encodable,
|
||||||
|
field::{Field, InsertableFieldBuilder},
|
||||||
lens::{Lens, LensDisplayType},
|
lens::{Lens, LensDisplayType},
|
||||||
|
presentation::{Presentation, RFC_3339_S},
|
||||||
};
|
};
|
||||||
use interim_pgtypes::{escape_identifier, pg_attribute::PgAttribute, pg_class::PgClass};
|
use interim_pgtypes::{escape_identifier, pg_attribute::PgAttribute, pg_class::PgClass};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
@ -25,8 +27,7 @@ use crate::{
|
||||||
app_error::{AppError, bad_request},
|
app_error::{AppError, bad_request},
|
||||||
app_state::AppDbConn,
|
app_state::AppDbConn,
|
||||||
base_pooler::{BasePooler, RoleAssignment},
|
base_pooler::{BasePooler, RoleAssignment},
|
||||||
field::FieldInfo,
|
field_info::FieldInfo,
|
||||||
navbar::{NavLocation, Navbar, RelLocation},
|
|
||||||
navigator::Navigator,
|
navigator::Navigator,
|
||||||
settings::Settings,
|
settings::Settings,
|
||||||
user::CurrentUser,
|
user::CurrentUser,
|
||||||
|
|
@ -232,7 +233,8 @@ pub async fn get_data_page_get(
|
||||||
for row in rows.iter() {
|
for row in rows.iter() {
|
||||||
let mut pkey_values: HashMap<String, Encodable> = HashMap::new();
|
let mut pkey_values: HashMap<String, Encodable> = HashMap::new();
|
||||||
for attr in pkey_attrs.clone() {
|
for attr in pkey_attrs.clone() {
|
||||||
let field = Field::default_from_attr(&attr);
|
let field = Field::default_from_attr(&attr)
|
||||||
|
.ok_or(anyhow::anyhow!("unsupported primary key column type"))?;
|
||||||
pkey_values.insert(field.name.clone(), field.get_value_encodable(row)?);
|
pkey_values.insert(field.name.clone(), field.get_value_encodable(row)?);
|
||||||
}
|
}
|
||||||
let pkey = serde_json::to_string(&pkey_values)?;
|
let pkey = serde_json::to_string(&pkey_values)?;
|
||||||
|
|
@ -265,21 +267,21 @@ pub async fn get_data_page_get(
|
||||||
pub struct AddColumnPageForm {
|
pub struct AddColumnPageForm {
|
||||||
name: String,
|
name: String,
|
||||||
label: String,
|
label: String,
|
||||||
field_type: String,
|
presentation_tag: String,
|
||||||
timestamp_format: Option<String>,
|
timestamp_format: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn try_field_type_from_form(form: &AddColumnPageForm) -> Result<FieldType, AppError> {
|
fn try_presentation_from_form(form: &AddColumnPageForm) -> Result<Presentation, AppError> {
|
||||||
let serialized = match form.field_type.as_str() {
|
let serialized = match form.presentation_tag.as_str() {
|
||||||
"Timestamp" => {
|
"Timestamp" => {
|
||||||
json!({
|
json!({
|
||||||
"t": form.field_type,
|
"t": form.presentation_tag,
|
||||||
"c": {
|
"c": {
|
||||||
"format": form.timestamp_format.clone().unwrap_or(RFC_3339_S.to_owned()),
|
"format": form.timestamp_format.clone().unwrap_or(RFC_3339_S.to_owned()),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
_ => json!({"t": form.field_type}),
|
_ => json!({"t": form.presentation_tag}),
|
||||||
};
|
};
|
||||||
serde_json::from_value(serialized).or(Err(bad_request!("unable to parse field type")))
|
serde_json::from_value(serialized).or(Err(bad_request!("unable to parse field type")))
|
||||||
}
|
}
|
||||||
|
|
@ -307,10 +309,8 @@ pub async fn add_column_page_post(
|
||||||
.fetch_one(&mut base_client)
|
.fetch_one(&mut base_client)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let field_type = try_field_type_from_form(&form)?;
|
let presentation = try_presentation_from_form(&form)?;
|
||||||
let data_type_fragment = field_type.attr_data_type_fragment().ok_or(bad_request!(
|
let data_type_fragment = presentation.attr_data_type_fragment();
|
||||||
"cannot create column with type specified as Unknown"
|
|
||||||
))?;
|
|
||||||
|
|
||||||
query(&format!(
|
query(&format!(
|
||||||
r#"
|
r#"
|
||||||
|
|
@ -324,7 +324,7 @@ add column if not exists {1} {2}
|
||||||
.execute(base_client.get_conn())
|
.execute(base_client.get_conn())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Field::insertable_builder()
|
Field::insert()
|
||||||
.lens_id(lens.id)
|
.lens_id(lens.id)
|
||||||
.name(form.name)
|
.name(form.name)
|
||||||
.label(if form.label.is_empty() {
|
.label(if form.label.is_empty() {
|
||||||
|
|
@ -332,7 +332,7 @@ add column if not exists {1} {2}
|
||||||
} else {
|
} else {
|
||||||
Some(form.label)
|
Some(form.label)
|
||||||
})
|
})
|
||||||
.field_type(field_type)
|
.presentation(presentation)
|
||||||
.build()?
|
.build()?
|
||||||
.insert(&mut app_db)
|
.insert(&mut app_db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ pub async fn list_relations_page(
|
||||||
let all_rels = PgClass::with_kind_in([PgRelKind::OrdinaryTable])
|
let all_rels = PgClass::with_kind_in([PgRelKind::OrdinaryTable])
|
||||||
.fetch_all(&mut client)
|
.fetch_all(&mut client)
|
||||||
.await?;
|
.await?;
|
||||||
|
dbg!(&all_rels);
|
||||||
let accessible_rels: Vec<PgClass> = all_rels
|
let accessible_rels: Vec<PgClass> = all_rels
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|rel| {
|
.filter(|rel| {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="input-url">Database URL:</label>
|
<label for="input-url">Database URL:</label>
|
||||||
<input type="text" name="url" value="{{ base.url }}">
|
<input autocomplete="off" type="text" name="url" value="{{ base.url }}">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit">Save</button>
|
<button type="submit">Save</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -3,81 +3,18 @@
|
||||||
{% block main %}
|
{% block main %}
|
||||||
<link rel="stylesheet" href="{{ settings.root_path }}/css_dist/viewer.css">
|
<link rel="stylesheet" href="{{ settings.root_path }}/css_dist/viewer.css">
|
||||||
<div class="page-grid">
|
<div class="page-grid">
|
||||||
<div class="page-grid__toolbar"></div>
|
<div class="page-grid__toolbar">
|
||||||
|
<filter-menu identifier-hints="{{ attr_names | json }}" initial-value="{{ filter | json }}"></filter-menu>
|
||||||
|
</div>
|
||||||
<div class="page-grid__sidebar">
|
<div class="page-grid__sidebar">
|
||||||
{{ navbar | safe }}
|
{{ navbar | safe }}
|
||||||
</div>
|
</div>
|
||||||
<main class="page-grid__main">
|
<main class="page-grid__main">
|
||||||
<viewer-controller root-path="{{ settings.root_path }}" pkeys="{{ pkeys | json }}" fields="{{ fields | json }}">
|
<table-viewer columns="{{ attr_names | json }}" root-path="{{ settings.root_path }}"></table-viewer>
|
||||||
<table class="viewer-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
{% for field in fields %}
|
|
||||||
<th class="viewer-table__column-header" width="{{ field.width_px }}" scope="col">
|
|
||||||
{{ field.label.clone().unwrap_or(field.name.clone()) }}
|
|
||||||
</th>
|
|
||||||
{% endfor %}
|
|
||||||
<th class="viewer-table__actions-header">
|
|
||||||
<field-adder root-path="{{ settings.root_path }}" columns="{{ all_columns | json }}"></field-adder>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for (i, row) in rows.iter().enumerate() %}
|
|
||||||
{# TODO: store primary keys in a Vec separate from rows #}
|
|
||||||
<tr>
|
|
||||||
{% for (j, field) in fields.iter().enumerate() %}
|
|
||||||
{# Setting max-width is required for overflow to work properly. #}
|
|
||||||
<td
|
|
||||||
class="viewer-table__td"
|
|
||||||
style="width: {{ field.width_px }}px; max-width: {{ field.width_px }}px;"
|
|
||||||
>
|
|
||||||
{% match field.get_value_encodable(row) %}
|
|
||||||
{% when Ok with (encodable) %}
|
|
||||||
<{{ field.webc_tag() | safe }}
|
|
||||||
{% for (k, v) in field.webc_custom_attrs() %}
|
|
||||||
{{ k }}="{{ v }}"
|
|
||||||
{% endfor %}
|
|
||||||
row="{{ i }}"
|
|
||||||
column="{{ j }}"
|
|
||||||
value="{{ encodable | json }}"
|
|
||||||
class="cell"
|
|
||||||
>
|
|
||||||
{{ encodable.inner_as_value() | json }}
|
|
||||||
</{{ field.webc_tag() | safe }}
|
|
||||||
{% when Err with (err) %}
|
|
||||||
<span class="pg-value-error">{{ err }}</span>
|
|
||||||
{% endmatch %}
|
|
||||||
</td>
|
|
||||||
{% endfor %}
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
<tr class="viewer-table__insertable-row">
|
|
||||||
{% for (i, field) in fields.iter().enumerate() %}
|
|
||||||
<td class="viewer-table__td viewer-table__td--insertable">
|
|
||||||
<{{ field.webc_tag() | safe }}
|
|
||||||
{% for (k, v) in field.webc_custom_attrs() %}
|
|
||||||
{{ k }}="{{ v }}"
|
|
||||||
{% endfor %}
|
|
||||||
row="{{ pkeys.len() }}"
|
|
||||||
column="{{ i }}"
|
|
||||||
class="cell"
|
|
||||||
insertable="true"
|
|
||||||
value="{{ field.field_type.default_for_insert()? | json }}"
|
|
||||||
>
|
|
||||||
</{{ field.webc_tag() | safe }}
|
|
||||||
</td>
|
|
||||||
{% endfor %}
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<viewer-hoverbar root-path="{{ settings.root_path }}"></viewer-hoverbar>
|
|
||||||
</viewer-controller>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="{{ settings.root_path }}/js_dist/field_adder_component.mjs"></script>
|
<script type="module" src="{{ settings.root_path }}/js_dist/table-viewer.webc.mjs"></script>
|
||||||
<script type="module" src="{{ settings.root_path }}/js_dist/viewer_controller_component.mjs"></script>
|
<script type="module" src="{{ settings.root_path }}/js_dist/field-adder.webc.mjs"></script>
|
||||||
<script type="module" src="{{ settings.root_path }}/js_dist/cell_text_component.mjs"></script>
|
<script type="module" src="{{ settings.root_path }}/js_dist/filter-menu.webc.mjs"></script>
|
||||||
<script type="module" src="{{ settings.root_path }}/js_dist/cell_uuid_component.mjs"></script>
|
|
||||||
<script type="module" src="{{ settings.root_path }}/js_dist/viewer_hoverbar_component.mjs"></script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block main %}
|
|
||||||
<link rel="stylesheet" href="{{ settings.root_path }}/css_dist/viewer.css">
|
|
||||||
<div class="page-grid">
|
|
||||||
<div class="page-grid__toolbar">
|
|
||||||
<filter-menu identifier-hints="{{ attr_names | json }}" initial-value="{{ filter | json }}"></filter-menu>
|
|
||||||
</div>
|
|
||||||
<div class="page-grid__sidebar">
|
|
||||||
{{ navbar | safe }}
|
|
||||||
</div>
|
|
||||||
<main class="page-grid__main">
|
|
||||||
<table-viewer root-path="{{ settings.root_path }}"></table-viewer>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
<script type="module" src="{{ settings.root_path }}/js_dist/table-viewer.webc.mjs"></script>
|
|
||||||
<script type="module" src="{{ settings.root_path }}/js_dist/filter-menu.webc.mjs"></script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
|
|
@ -83,5 +83,5 @@
|
||||||
{% endfor -%}
|
{% endfor -%}
|
||||||
</menu>
|
</menu>
|
||||||
</section>
|
</section>
|
||||||
<script type="module" src="{{ root_path }}/js_dist/collapsible_menu_component.mjs"></script>
|
<script type="module" src="{{ root_path }}/js_dist/collapsible-menu.webc.mjs"></script>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
|
|
@ -22,11 +22,6 @@ run = "deno run -A npm:vite build"
|
||||||
dir = "./svelte"
|
dir = "./svelte"
|
||||||
sources = ["svelte/src/**/*.ts", "svelte/src/**/*.svelte"]
|
sources = ["svelte/src/**/*.ts", "svelte/src/**/*.svelte"]
|
||||||
|
|
||||||
[tasks.build-gleam]
|
|
||||||
run = "sh build.sh"
|
|
||||||
dir = "./webc"
|
|
||||||
sources = ["webc/src/**/*.gleam", "webc/src/**/*.mjs"]
|
|
||||||
|
|
||||||
[tasks.build-css]
|
[tasks.build-css]
|
||||||
run = "sass sass/:css_dist/"
|
run = "sass sass/:css_dist/"
|
||||||
sources = ["sass/**/*.scss"]
|
sources = ["sass/**/*.scss"]
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@deno/vite-plugin": "^1.0.5",
|
"@deno/vite-plugin": "^1.0.5",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.1.1",
|
"@sveltejs/vite-plugin-svelte": "^6.1.1",
|
||||||
|
"sass-embedded": "^1.91.0",
|
||||||
"svelte-language-server": "^0.17.19",
|
"svelte-language-server": "^0.17.19",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"vite": "^7.1.1",
|
"vite": "^7.1.1",
|
||||||
|
|
|
||||||
30
sass/_field-adder.scss
Normal file
30
sass/_field-adder.scss
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
@use 'globals';
|
||||||
|
@use 'viewer-shared';
|
||||||
|
|
||||||
|
.field-adder {
|
||||||
|
&__container {
|
||||||
|
align-items: stretch;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header-lookalike {
|
||||||
|
@include viewer-shared.th;
|
||||||
|
border-bottom-style: dashed;
|
||||||
|
border-right-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label-input {
|
||||||
|
@include globals.reset-input;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__popover:popover-open {
|
||||||
|
@include globals.popover;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__summary-buttons {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -106,3 +106,15 @@ $hover-lightness-scale-factor: -10%;
|
||||||
@mixin rounded {
|
@mixin rounded {
|
||||||
border-radius: $border-radius-rounded;
|
border-radius: $border-radius-rounded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@mixin popover {
|
||||||
|
@include rounded;
|
||||||
|
inset: unset;
|
||||||
|
border: $popover-border;
|
||||||
|
margin: 0;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: $popover-shadow;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
@use '../globals';
|
|
||||||
@use '../viewer-shared';
|
|
||||||
|
|
||||||
field-adder {
|
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
&::part(expander) {
|
|
||||||
@include globals.reset-button;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::part(th-lookalike) {
|
|
||||||
@include viewer-shared.th;
|
|
||||||
border-right-style: dashed;
|
|
||||||
border-bottom-style: dashed;
|
|
||||||
border-left: none;
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::part(label-input) {
|
|
||||||
appearance: none;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
font-weight: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::part(config-popover) {
|
|
||||||
@include globals.rounded-sm;
|
|
||||||
position: fixed;
|
|
||||||
inset: unset;
|
|
||||||
margin: 0;
|
|
||||||
width: 20rem;
|
|
||||||
padding: 1rem;
|
|
||||||
font-weight: normal;
|
|
||||||
border: globals.$popover-border;
|
|
||||||
filter: drop-shadow(globals.$popover-shadow);
|
|
||||||
|
|
||||||
&:popover-open {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&::part(form-section-header) {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
@use 'globals';
|
@use 'globals';
|
||||||
@use 'modern-normalize';
|
@use 'modern-normalize';
|
||||||
|
@use 'forms';
|
||||||
|
|
||||||
html {
|
html {
|
||||||
font-family: "Averia Serif Libre", "Open Sans", "Helvetica Neue", Arial, sans-serif;
|
font-family: "Averia Serif Libre", "Open Sans", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
|
@ -52,6 +53,10 @@ button, input[type="submit"] {
|
||||||
&--secondary {
|
&--secondary {
|
||||||
@include globals.button-secondary;
|
@include globals.button-secondary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--clear {
|
||||||
|
@include globals.button-clear;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-grid {
|
.page-grid {
|
||||||
|
|
@ -151,18 +156,8 @@ button, input[type="submit"] {
|
||||||
|
|
||||||
&__popover {
|
&__popover {
|
||||||
&:popover-open {
|
&:popover-open {
|
||||||
@include globals.rounded;
|
@include globals.popover;
|
||||||
inset: unset;
|
|
||||||
border: globals.$popover-border;
|
|
||||||
margin: 0;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
position: fixed;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 16rem;
|
width: 16rem;
|
||||||
padding: 0;
|
|
||||||
background: #fff;
|
|
||||||
box-shadow: globals.$popover-shadow;
|
|
||||||
// FIXME: This makes button border radius work correctly, but also hides
|
// FIXME: This makes button border radius work correctly, but also hides
|
||||||
// the outline that appears when each button is focused, particularly
|
// the outline that appears when each button is focused, particularly
|
||||||
// when there is only one button present.
|
// when there is only one button present.
|
||||||
|
|
@ -178,3 +173,23 @@ button, input[type="submit"] {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.combobox {
|
||||||
|
&__popover:popover-open {
|
||||||
|
@include globals.popover;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__completion {
|
||||||
|
@include globals.reset-button;
|
||||||
|
display: block;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-weight: normal;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&:hover, &:focus {
|
||||||
|
background: #0000001f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
@use 'globals';
|
@use 'globals';
|
||||||
|
@use 'collapsible_menu';
|
||||||
|
|
||||||
$background-current-item: #0001;
|
$background-current-item: #0001;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
@use 'globals';
|
@use 'globals';
|
||||||
@use 'sass:color';
|
@use 'sass:color';
|
||||||
@use 'condition-editor';
|
@use 'condition-editor';
|
||||||
|
@use 'field-adder';
|
||||||
|
|
||||||
$table-border-color: #ccc;
|
$table-border-color: #ccc;
|
||||||
|
|
||||||
|
|
@ -31,19 +32,6 @@ $table-border-color: #ccc;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__header {
|
|
||||||
display: flex;
|
|
||||||
flex: none;
|
|
||||||
align-items: center;
|
|
||||||
border: solid 1px #ccc;
|
|
||||||
border-top: none;
|
|
||||||
border-left: none;
|
|
||||||
font-family: "Funnel Sans";
|
|
||||||
background: #0001;
|
|
||||||
padding: 0.5rem;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__main {
|
&__main {
|
||||||
grid-area: main;
|
grid-area: main;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
@ -52,13 +40,16 @@ $table-border-color: #ccc;
|
||||||
&__row {
|
&__row {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
height: 2.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__cell {
|
&__cell {
|
||||||
flex: none;
|
align-items: stretch;
|
||||||
border: solid 1px $table-border-color;
|
border: solid 1px $table-border-color;
|
||||||
border-left: none;
|
border-left: none;
|
||||||
border-top: none;
|
border-top: none;
|
||||||
|
display: flex;
|
||||||
|
flex: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
||||||
&--insertable {
|
&--insertable {
|
||||||
|
|
@ -79,6 +70,8 @@ $table-border-color: #ccc;
|
||||||
&__container {
|
&__container {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: none;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
&--selected {
|
&--selected {
|
||||||
outline: 3px solid #37f;
|
outline: 3px solid #37f;
|
||||||
|
|
@ -94,7 +87,7 @@ $table-border-color: #ccc;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
padding: 0.5rem;
|
padding: 0 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--uuid {
|
&--uuid {
|
||||||
|
|
@ -102,15 +95,21 @@ $table-border-color: #ccc;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
padding: 0.5rem;
|
padding: 0 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--null {
|
&--null {
|
||||||
color: color.scale(#000, $lightness: 50%, $space: hsl);
|
align-items: center;
|
||||||
|
color: color.scale(#000, $lightness: 65%, $space: hsl);
|
||||||
|
display: flex;
|
||||||
font-family: globals.$font-family-data;
|
font-family: globals.$font-family-data;
|
||||||
font-style: oblique;
|
font-style: oblique;
|
||||||
text-align: center;
|
justify-content: center;
|
||||||
padding: 0.5rem;
|
padding: 0 0.25rem;
|
||||||
|
|
||||||
|
svg path {
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,7 +120,48 @@ $table-border-color: #ccc;
|
||||||
padding: 0 0.25rem;
|
padding: 0 0.25rem;
|
||||||
|
|
||||||
svg path {
|
svg path {
|
||||||
stroke: currentColor;
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-header {
|
||||||
|
&__container {
|
||||||
|
align-items: center;
|
||||||
|
background: #0001;
|
||||||
|
border: solid 1px #ccc;
|
||||||
|
border-top: none;
|
||||||
|
border-left: none;
|
||||||
|
display: flex;
|
||||||
|
flex: none;
|
||||||
|
font-family: globals.$font-family-data;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.25rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__type-indicator {
|
||||||
|
@include globals.button-clear;
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
height: 2rem;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
width: 2rem;
|
||||||
|
|
||||||
|
svg path {
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__popover {
|
||||||
|
&:popover-open {
|
||||||
|
@include globals.popover;
|
||||||
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -155,7 +195,7 @@ $table-border-color: #ccc;
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
|
|
||||||
svg path {
|
svg path {
|
||||||
stroke: currentColor;
|
fill: currentColor;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
51
svelte/src/collapsible-menu.webc.svelte
Normal file
51
svelte/src/collapsible-menu.webc.svelte
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<svelte:options
|
||||||
|
customElement={{
|
||||||
|
props: { expanded: { type: "Boolean" } },
|
||||||
|
shadow: "none",
|
||||||
|
tag: "collapsible-menu",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
@component
|
||||||
|
An accordion-style collapsible list, designed for vertical navigation.
|
||||||
|
|
||||||
|
It exposes two named slots, "summary" (clickable and always visible) and
|
||||||
|
"content" (visible only when expanded).
|
||||||
|
-->
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
type Props = {
|
||||||
|
expanded?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// `expanded` is marked as $bindable to make it clear that it takes the place
|
||||||
|
// of dynamic (that is, uncontrolled) internal state as well as an externally
|
||||||
|
// controllable and inspectable value. In practice this may be used to set an
|
||||||
|
// initial value for `expanded` without the need for, e.g., an
|
||||||
|
// `initial_expanded` prop as well as an internal variable to keep track of
|
||||||
|
// the actual current state.
|
||||||
|
let { expanded = $bindable(true) }: Props = $props();
|
||||||
|
|
||||||
|
function handle_summary_click() {
|
||||||
|
expanded = !expanded;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="collapsible-menu">
|
||||||
|
<button
|
||||||
|
class="collapsible-menu__sumary"
|
||||||
|
onclick={handle_summary_click}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<slot name="summary"></slot>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
class={[
|
||||||
|
"collapsible-menu__content",
|
||||||
|
expanded && "collapsible-menu__content--expanded",
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<slot name="content"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
90
svelte/src/combobox.svelte
Normal file
90
svelte/src/combobox.svelte
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
<script lang="ts">
|
||||||
|
type CssClass = string | (string | false | null | undefined)[];
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
completions: string[];
|
||||||
|
popover_class?: CssClass;
|
||||||
|
search_input_class?: CssClass;
|
||||||
|
search_input_element?: HTMLInputElement;
|
||||||
|
search_value: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
completions,
|
||||||
|
popover_class,
|
||||||
|
search_input_class,
|
||||||
|
search_input_element = $bindable(),
|
||||||
|
search_value = $bindable(),
|
||||||
|
value = $bindable(),
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let focused = $state(false);
|
||||||
|
let popover_element = $state<HTMLDivElement | undefined>();
|
||||||
|
|
||||||
|
function handle_component_focusin() {
|
||||||
|
focused = true;
|
||||||
|
popover_element?.showPopover();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle_component_focusout() {
|
||||||
|
focused = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
// TODO: There's still an edge case, where a click with a
|
||||||
|
// mousedown-to-mouseup duration greater than the delay here will cause
|
||||||
|
// the popover to hide.
|
||||||
|
if (!focused) {
|
||||||
|
popover_element?.hidePopover();
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle_completion_click(completion: string) {
|
||||||
|
value = completion;
|
||||||
|
search_input_element?.focus();
|
||||||
|
popover_element?.hidePopover();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle_search_keydown(ev: KeyboardEvent) {
|
||||||
|
if (ev.key === "Escape") {
|
||||||
|
popover_element?.hidePopover();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle_search_input() {
|
||||||
|
popover_element?.showPopover();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="combobox__container"
|
||||||
|
onfocusin={handle_component_focusin}
|
||||||
|
onfocusout={handle_component_focusout}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
bind:this={search_input_element}
|
||||||
|
bind:value={search_value}
|
||||||
|
class={search_input_class}
|
||||||
|
oninput={handle_search_input}
|
||||||
|
onkeydown={handle_search_keydown}
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
bind:this={popover_element}
|
||||||
|
class={popover_class ?? "combobox__popover"}
|
||||||
|
popover="manual"
|
||||||
|
role="listbox"
|
||||||
|
>
|
||||||
|
{#each completions as completion}
|
||||||
|
<button
|
||||||
|
aria-selected={value === completion}
|
||||||
|
class="combobox__completion"
|
||||||
|
onclick={() => handle_completion_click(completion)}
|
||||||
|
role="option"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{completion}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import * as uuid from "uuid";
|
import * as uuid from "uuid";
|
||||||
|
|
||||||
import { type Encodable, type FieldType } from "./field.svelte.ts";
|
import { type Encodable } from "./encodable.svelte.ts";
|
||||||
|
import { type Presentation } from "./presentation.svelte.ts";
|
||||||
|
|
||||||
type Assert<_T extends true> = void;
|
type Assert<_T extends true> = void;
|
||||||
|
|
||||||
// This should be a discriminated union type, but TypeScript isn't
|
// This should be a discriminated union type, but TypeScript isn't
|
||||||
// sophisticated enough to discriminate based on the nested field_type's tag,
|
// sophisticated enough to discriminate based on the nested presentation's tag,
|
||||||
// causing a huge pain in the ass.
|
// causing a huge pain in the ass.
|
||||||
export type EditorState = {
|
export type EditorState = {
|
||||||
date_value: string;
|
date_value: string;
|
||||||
|
|
@ -54,16 +55,16 @@ export function editor_state_from_encodable(value: Encodable): EditorState {
|
||||||
|
|
||||||
export function encodable_from_editor_state(
|
export function encodable_from_editor_state(
|
||||||
value: EditorState,
|
value: EditorState,
|
||||||
field_type: FieldType,
|
presentation: Presentation,
|
||||||
): Encodable | undefined {
|
): Encodable | undefined {
|
||||||
if (field_type.t === "Text") {
|
if (presentation.t === "Text") {
|
||||||
return { t: "Text", c: value.text_value };
|
return { t: "Text", c: value.text_value };
|
||||||
}
|
}
|
||||||
if (field_type.t === "Timestamp") {
|
if (presentation.t === "Timestamp") {
|
||||||
// FIXME
|
// FIXME
|
||||||
throw new Error("not yet implemented");
|
throw new Error("not yet implemented");
|
||||||
}
|
}
|
||||||
if (field_type.t === "Uuid") {
|
if (presentation.t === "Uuid") {
|
||||||
try {
|
try {
|
||||||
return { t: "Uuid", c: uuid.stringify(uuid.parse(value.text_value)) };
|
return { t: "Uuid", c: uuid.stringify(uuid.parse(value.text_value)) };
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -71,6 +72,6 @@ export function encodable_from_editor_state(
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
type _ = Assert<typeof field_type extends never ? true : false>;
|
type _ = Assert<typeof presentation extends never ? true : false>;
|
||||||
throw new Error("this should be unreachable");
|
throw new Error("this should be unreachable");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@
|
||||||
onclick={handle_type_selector_menu_button_click}
|
onclick={handle_type_selector_menu_button_click}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{field_info.field.field_type.t}
|
{field_info.field.presentation.t}
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
bind:this={type_selector_popover_element}
|
bind:this={type_selector_popover_element}
|
||||||
|
|
@ -52,16 +52,16 @@
|
||||||
handle_type_selector_field_button_click(assignable_field_info)}
|
handle_type_selector_field_button_click(assignable_field_info)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{assignable_field_info.field.field_type.t}
|
{assignable_field_info.field.presentation.t}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="encodable-editor__content">
|
<div class="encodable-editor__content">
|
||||||
{#if field_info.field.field_type.t === "Text" || field_info.field.field_type.t === "Uuid"}
|
{#if field_info.field.presentation.t === "Text" || field_info.field.presentation.t === "Uuid"}
|
||||||
<input bind:value={editor_state.text_value} type="text" />
|
<input bind:value={editor_state.text_value} type="text" />
|
||||||
{:else if field_info.field.field_type.t === "Timestamp"}
|
{:else if field_info.field.presentation.t === "Timestamp"}
|
||||||
<input bind:value={editor_state.date_value} type="date" />
|
<input bind:value={editor_state.date_value} type="date" />
|
||||||
<input bind:value={editor_state.time_value} type="time" />
|
<input bind:value={editor_state.time_value} type="time" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
39
svelte/src/encodable.svelte.ts
Normal file
39
svelte/src/encodable.svelte.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
type Assert<_T extends true> = void;
|
||||||
|
|
||||||
|
// -------- Encodable -------- //
|
||||||
|
|
||||||
|
export const all_encodable_tags = [
|
||||||
|
"Text",
|
||||||
|
"Timestamp",
|
||||||
|
"Uuid",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// Type checking to ensure that all valid enum tags are included.
|
||||||
|
type _ = Assert<
|
||||||
|
Encodable["t"] extends (typeof all_encodable_tags)[number] ? true : false
|
||||||
|
>;
|
||||||
|
|
||||||
|
const encodable_text_schema = z.object({
|
||||||
|
t: z.literal("Text"),
|
||||||
|
c: z.string().nullish().transform((x) => x ?? undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
const encodable_timestamp_schema = z.object({
|
||||||
|
t: z.literal("Timestamp"),
|
||||||
|
c: z.coerce.date().nullish().transform((x) => x ?? undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
const encodable_uuid_schema = z.object({
|
||||||
|
t: z.literal("Uuid"),
|
||||||
|
c: z.string().nullish().transform((x) => x ?? undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const encodable_schema = z.union([
|
||||||
|
encodable_text_schema,
|
||||||
|
encodable_timestamp_schema,
|
||||||
|
encodable_uuid_schema,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type Encodable = z.infer<typeof encodable_schema>;
|
||||||
|
|
@ -20,20 +20,21 @@
|
||||||
type EditorState,
|
type EditorState,
|
||||||
encodable_from_editor_state,
|
encodable_from_editor_state,
|
||||||
} from "./editor-state.svelte";
|
} from "./editor-state.svelte";
|
||||||
import { type FieldInfo, type FieldType } from "./field.svelte";
|
import { type FieldInfo } from "./field.svelte";
|
||||||
|
import { type Presentation } from "./presentation.svelte";
|
||||||
|
|
||||||
const ASSIGNABLE_FIELD_TYPES: FieldType[] = [
|
const ASSIGNABLE_PRESENTATIONS: Presentation[] = [
|
||||||
{ t: "Text", c: {} },
|
{ t: "Text", c: { input_mode: { t: "MultiLine", c: {} } } },
|
||||||
{ t: "Timestamp", c: {} },
|
{ t: "Timestamp", c: {} },
|
||||||
{ t: "Uuid", c: {} },
|
{ t: "Uuid", c: {} },
|
||||||
];
|
];
|
||||||
const ASSIGNABLE_FIELDS: FieldInfo[] = ASSIGNABLE_FIELD_TYPES.map(
|
const ASSIGNABLE_FIELDS: FieldInfo[] = ASSIGNABLE_PRESENTATIONS.map(
|
||||||
(field_type) => ({
|
(presentation) => ({
|
||||||
field: {
|
field: {
|
||||||
id: "",
|
id: "",
|
||||||
label: "",
|
label: "",
|
||||||
name: "",
|
name: "",
|
||||||
field_type,
|
presentation,
|
||||||
width_px: -1,
|
width_px: -1,
|
||||||
},
|
},
|
||||||
not_null: true,
|
not_null: true,
|
||||||
|
|
@ -59,7 +60,7 @@
|
||||||
if (value?.t === "Literal" && editor_field_info) {
|
if (value?.t === "Literal" && editor_field_info) {
|
||||||
const encodable_value = encodable_from_editor_state(
|
const encodable_value = encodable_from_editor_state(
|
||||||
editor_state,
|
editor_state,
|
||||||
editor_field_info.field.field_type,
|
editor_field_info.field.presentation,
|
||||||
);
|
);
|
||||||
if (encodable_value) {
|
if (encodable_value) {
|
||||||
value.c = encodable_value;
|
value.c = encodable_value;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import cube_icon from "../assets/heroicons/20/solid/cube.svg?raw";
|
||||||
import cube_transparent_icon from "../assets/heroicons/20/solid/cube-transparent.svg?raw";
|
import cube_transparent_icon from "../assets/heroicons/20/solid/cube-transparent.svg?raw";
|
||||||
import hashtag_icon from "../assets/heroicons/20/solid/hashtag.svg?raw";
|
import hashtag_icon from "../assets/heroicons/20/solid/hashtag.svg?raw";
|
||||||
import variable_icon from "../assets/heroicons/20/solid/variable.svg?raw";
|
import variable_icon from "../assets/heroicons/20/solid/variable.svg?raw";
|
||||||
import { encodable_schema } from "./field.svelte.ts";
|
import { encodable_schema } from "./encodable.svelte.ts";
|
||||||
|
|
||||||
export const all_expression_types = [
|
export const all_expression_types = [
|
||||||
"Comparison",
|
"Comparison",
|
||||||
|
|
|
||||||
128
svelte/src/field-adder.webc.svelte
Normal file
128
svelte/src/field-adder.webc.svelte
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
<svelte:options
|
||||||
|
customElement={{
|
||||||
|
props: {
|
||||||
|
columns: { type: "Array" },
|
||||||
|
},
|
||||||
|
shadow: "none",
|
||||||
|
tag: "field-adder",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
@component
|
||||||
|
An interactive UI that sits in the header row of a table and allows the user to
|
||||||
|
quickly add a new field based on an existing column or configure a field backed
|
||||||
|
by a new column to be added to the relation in the database.
|
||||||
|
|
||||||
|
Note: The form interface is implemented with a literal HTML <form> element with
|
||||||
|
method="post", meaning that the parent page is reloaded upon field creation. It
|
||||||
|
is not necessary to update the table DOM dynamically upon successful form
|
||||||
|
submission.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!--TODO: disable new column creation if the relation is a Postgres view.-->
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import icon_ellipsis_vertical from "../assets/heroicons/20/solid/ellipsis-vertical.svg?raw";
|
||||||
|
import icon_plus from "../assets/heroicons/20/solid/plus.svg?raw";
|
||||||
|
import icon_x_mark from "../assets/heroicons/20/solid/x-mark.svg?raw";
|
||||||
|
import Combobox from "./combobox.svelte";
|
||||||
|
import FieldDetails from "./field-details.svelte";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/**
|
||||||
|
* An array of all existing column names visible in the current relation,
|
||||||
|
* to the current user. This is used to populate the autocomplete combobox.
|
||||||
|
*/
|
||||||
|
columns?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
let { columns = [] }: Props = $props();
|
||||||
|
|
||||||
|
let expanded = $state(false);
|
||||||
|
let name_value = $state("");
|
||||||
|
let label_value = $state("");
|
||||||
|
let name_customized = $state(false);
|
||||||
|
let popover_element = $state<HTMLDivElement | undefined>();
|
||||||
|
let search_input_element = $state<HTMLInputElement | undefined>();
|
||||||
|
|
||||||
|
// If the database-friendly column name has not been explicitly set, keep it
|
||||||
|
// synchronized with the human-friendly field label as typed in the table
|
||||||
|
// header cell.
|
||||||
|
$effect(() => {
|
||||||
|
if (!name_customized) {
|
||||||
|
name_value = label_value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handle_summary_toggle_button_click() {
|
||||||
|
expanded = !expanded;
|
||||||
|
if (expanded) {
|
||||||
|
setTimeout(() => {
|
||||||
|
search_input_element?.focus();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle_field_options_button_click() {
|
||||||
|
popover_element?.showPopover();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle_name_input() {
|
||||||
|
name_customized = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="field-adder__container">
|
||||||
|
<form method="post" action="add-column">
|
||||||
|
<div
|
||||||
|
class="field-adder__header-lookalike"
|
||||||
|
style:display={expanded ? "block" : "none"}
|
||||||
|
>
|
||||||
|
<Combobox
|
||||||
|
bind:search_value={label_value}
|
||||||
|
bind:search_input_element
|
||||||
|
bind:value={label_value}
|
||||||
|
completions={columns.filter((col) =>
|
||||||
|
col
|
||||||
|
.toLocaleLowerCase("en-US")
|
||||||
|
.includes(label_value.toLocaleLowerCase("en-US")),
|
||||||
|
)}
|
||||||
|
search_input_class="field-adder__label-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="field-adder__summary-buttons">
|
||||||
|
<button
|
||||||
|
aria-label="more field options"
|
||||||
|
class="button--clear"
|
||||||
|
onclick={handle_field_options_button_click}
|
||||||
|
style:display={expanded ? "block" : "none"}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{@html icon_ellipsis_vertical}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="toggle field adder"
|
||||||
|
class="button--clear"
|
||||||
|
onclick={handle_summary_toggle_button_click}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{@html expanded ? icon_x_mark : icon_plus}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div bind:this={popover_element} class="field-adder__popover" popover="auto">
|
||||||
|
<!--
|
||||||
|
The "advanced" details for creating a new column or customizing the behavior
|
||||||
|
of a field backed by an existing column overlap with the controls exposed when
|
||||||
|
editing the configuration of an existing field.
|
||||||
|
-->
|
||||||
|
<FieldDetails
|
||||||
|
bind:name_value
|
||||||
|
bind:label_value
|
||||||
|
on_name_input={handle_name_input}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
135
svelte/src/field-details.svelte
Normal file
135
svelte/src/field-details.svelte
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
<!--
|
||||||
|
@component
|
||||||
|
UI for inspecting and altering field configuration when creating or editing a
|
||||||
|
field. This is typically rendered within a popover component, and within an HTML
|
||||||
|
<form> element defining the appropriate action for form submission.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
type Presentation,
|
||||||
|
all_presentation_tags,
|
||||||
|
all_text_input_modes,
|
||||||
|
} from "./presentation.svelte";
|
||||||
|
|
||||||
|
type Assert<_T extends true> = void;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
label_value: string;
|
||||||
|
name_input_disabled?: boolean;
|
||||||
|
name_value: string;
|
||||||
|
on_name_input?(
|
||||||
|
ev: Event & { currentTarget: EventTarget & HTMLInputElement },
|
||||||
|
): void;
|
||||||
|
presentation?: Presentation;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
presentation = $bindable(get_empty_presentation("Text")),
|
||||||
|
name_input_disabled = false,
|
||||||
|
name_value = $bindable(),
|
||||||
|
label_value = $bindable(),
|
||||||
|
on_name_input,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
function handle_presentation_tag_change(
|
||||||
|
ev: Event & { currentTarget: EventTarget & HTMLSelectElement },
|
||||||
|
) {
|
||||||
|
const tag = ev.currentTarget
|
||||||
|
.value as (typeof all_presentation_tags)[number];
|
||||||
|
presentation = get_empty_presentation(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
function get_empty_presentation(
|
||||||
|
tag: (typeof all_presentation_tags)[number],
|
||||||
|
): Presentation {
|
||||||
|
if (tag === "Text") {
|
||||||
|
return { t: "Text", c: { input_mode: { t: "SingleLine", c: {} } } };
|
||||||
|
}
|
||||||
|
if (tag === "Timestamp") {
|
||||||
|
return { t: "Timestamp", c: {} };
|
||||||
|
}
|
||||||
|
if (tag === "Uuid") {
|
||||||
|
return { t: "Uuid", c: {} };
|
||||||
|
}
|
||||||
|
type _ = Assert<typeof tag extends never ? true : false>;
|
||||||
|
throw new Error("this should be unreachable");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle_text_input_mode_change(
|
||||||
|
ev: Event & { currentTarget: EventTarget & HTMLSelectElement },
|
||||||
|
) {
|
||||||
|
if (presentation.t === "Text") {
|
||||||
|
const tag = ev.currentTarget
|
||||||
|
.value as (typeof all_text_input_modes)[number];
|
||||||
|
if (tag === "SingleLine") {
|
||||||
|
presentation.c.input_mode = {
|
||||||
|
t: "SingleLine",
|
||||||
|
c: {},
|
||||||
|
};
|
||||||
|
} else if (tag === "MultiLine") {
|
||||||
|
presentation.c.input_mode = {
|
||||||
|
t: "MultiLine",
|
||||||
|
c: {},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
type _ = Assert<typeof tag extends never ? true : false>;
|
||||||
|
throw new Error("this should be unreachable");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h2 class="form-section__heading">Field Details</h2>
|
||||||
|
<label class="form-section">
|
||||||
|
<div class="form-section__label">SQL-friendly Name</div>
|
||||||
|
<input
|
||||||
|
bind:value={name_value}
|
||||||
|
class="form-section__input form-section__input--text"
|
||||||
|
disabled={name_input_disabled}
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="form-section">
|
||||||
|
<div class="form-section__label">Human-friendly Label</div>
|
||||||
|
<input
|
||||||
|
bind:value={label_value}
|
||||||
|
class="form-section__input form-section__input--text"
|
||||||
|
name="label"
|
||||||
|
oninput={on_name_input}
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="form-section">
|
||||||
|
<div class="form-section__label">Data Type</div>
|
||||||
|
<select
|
||||||
|
class="form-section__input"
|
||||||
|
name="field-type"
|
||||||
|
onchange={handle_presentation_tag_change}
|
||||||
|
value={presentation?.t}
|
||||||
|
>
|
||||||
|
{#each all_presentation_tags as presentation_tag}
|
||||||
|
<option value={presentation_tag}>
|
||||||
|
{presentation_tag}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{#if presentation?.t === "Text"}
|
||||||
|
<label class="form-section">
|
||||||
|
<div class="form-section__label">Input Mode</div>
|
||||||
|
<select
|
||||||
|
class="form-section__input"
|
||||||
|
name="input-mode"
|
||||||
|
onchange={handle_text_input_mode_change}
|
||||||
|
value={presentation.c.input_mode.t}
|
||||||
|
>
|
||||||
|
{#each all_text_input_modes as input_mode}
|
||||||
|
<option value={input_mode}>
|
||||||
|
{input_mode}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
|
@ -1,19 +1,77 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import calendar_days_icon from "../assets/heroicons/20/solid/calendar-days.svg?raw";
|
||||||
|
import document_text_icon from "../assets/heroicons/20/solid/document-text.svg?raw";
|
||||||
|
import identification_icon from "../assets/heroicons/20/solid/identification.svg?raw";
|
||||||
import { type FieldInfo } from "./field.svelte";
|
import { type FieldInfo } from "./field.svelte";
|
||||||
|
import FieldDetails from "./field-details.svelte";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
field: FieldInfo;
|
field: FieldInfo;
|
||||||
index: number;
|
index: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { field, index }: Props = $props();
|
let { field = $bindable(), index }: Props = $props();
|
||||||
|
|
||||||
|
const original_label_value = field.field.label;
|
||||||
|
|
||||||
|
let type_indicator_element = $state<HTMLButtonElement | undefined>();
|
||||||
|
let popover_element = $state<HTMLDivElement | undefined>();
|
||||||
|
let name_value = $state(field.field.name);
|
||||||
|
let label_value = $state(field.field.label ?? "");
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
field.field.label = label_value === "" ? undefined : label_value;
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
popover_element?.addEventListener("toggle", handle_popover_toggle);
|
||||||
|
return () => {
|
||||||
|
popover_element?.removeEventListener("toggle", handle_popover_toggle);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function handle_type_indicator_element_click() {
|
||||||
|
popover_element?.togglePopover();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle_popover_toggle(ev: ToggleEvent) {
|
||||||
|
if (ev.newState === "closed") {
|
||||||
|
type_indicator_element?.focus();
|
||||||
|
field.field.label = original_label_value;
|
||||||
|
label_value = original_label_value ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
aria-colindex={index}
|
aria-colindex={index}
|
||||||
class="lens-table__header"
|
class="field-header__container"
|
||||||
role="columnheader"
|
role="columnheader"
|
||||||
style:width={`${field.field.width_px}px`}
|
style:width={`${field.field.width_px}px`}
|
||||||
>
|
>
|
||||||
<div>{field.field.label ?? field.field.name}</div>
|
<div class="field-header__label">
|
||||||
|
{field.field.label ?? field.field.name}
|
||||||
|
</div>
|
||||||
|
<div class="field-header__menu-container">
|
||||||
|
<button
|
||||||
|
bind:this={type_indicator_element}
|
||||||
|
class="field-header__type-indicator"
|
||||||
|
onclick={handle_type_indicator_element_click}
|
||||||
|
>
|
||||||
|
{#if field.field.presentation.t === "Text"}
|
||||||
|
{@html document_text_icon}
|
||||||
|
{:else if field.field.presentation.t === "Timestamp"}
|
||||||
|
{@html calendar_days_icon}
|
||||||
|
{:else if field.field.presentation.t === "Uuid"}
|
||||||
|
{@html identification_icon}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
bind:this={popover_element}
|
||||||
|
class="field-header__popover"
|
||||||
|
popover="auto"
|
||||||
|
>
|
||||||
|
<FieldDetails bind:name_value bind:label_value name_input_disabled />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,106 +1,13 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
type Assert<_T extends true> = void;
|
import { type Encodable } from "./encodable.svelte.ts";
|
||||||
|
import { presentation_schema } from "./presentation.svelte.ts";
|
||||||
// -------- Encodable -------- //
|
|
||||||
|
|
||||||
export const all_encodable_types = [
|
|
||||||
"Text",
|
|
||||||
"Timestamp",
|
|
||||||
"Uuid",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
// Type checking to ensure that all valid enum tags are included.
|
|
||||||
type _1 = Assert<
|
|
||||||
Encodable["t"] extends (typeof all_encodable_types)[number] ? true : false
|
|
||||||
>;
|
|
||||||
|
|
||||||
const encodable_text_schema = z.object({
|
|
||||||
t: z.literal("Text"),
|
|
||||||
c: z.string().nullish().transform((x) => x ?? undefined),
|
|
||||||
});
|
|
||||||
|
|
||||||
const encodable_timestamp_schema = z.object({
|
|
||||||
t: z.literal("Timestamp"),
|
|
||||||
c: z.coerce.date().nullish().transform((x) => x ?? undefined),
|
|
||||||
});
|
|
||||||
|
|
||||||
const encodable_uuid_schema = z.object({
|
|
||||||
t: z.literal("Uuid"),
|
|
||||||
c: z.string().nullish().transform((x) => x ?? undefined),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const encodable_schema = z.union([
|
|
||||||
encodable_text_schema,
|
|
||||||
encodable_timestamp_schema,
|
|
||||||
encodable_uuid_schema,
|
|
||||||
]);
|
|
||||||
|
|
||||||
export type Encodable = z.infer<typeof encodable_schema>;
|
|
||||||
|
|
||||||
// -------- FieldType -------- //
|
|
||||||
|
|
||||||
export const all_field_types = [
|
|
||||||
"Text",
|
|
||||||
"Timestamp",
|
|
||||||
"Uuid",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
// Type checking to ensure that all valid enum tags are included.
|
|
||||||
type _2 = Assert<
|
|
||||||
FieldType["t"] extends (typeof all_field_types)[number] ? true : false
|
|
||||||
>;
|
|
||||||
|
|
||||||
const field_type_text_schema = z.object({
|
|
||||||
t: z.literal("Text"),
|
|
||||||
c: z.unknown(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type FieldTypeText = z.infer<typeof field_type_text_schema>;
|
|
||||||
|
|
||||||
const field_type_timestamp_schema = z.object({
|
|
||||||
t: z.literal("Timestamp"),
|
|
||||||
c: z.unknown(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type FieldTypeTimestamp = z.infer<typeof field_type_timestamp_schema>;
|
|
||||||
|
|
||||||
const field_type_uuid_schema = z.object({
|
|
||||||
t: z.literal("Uuid"),
|
|
||||||
c: z.unknown(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type FieldTypeUuid = z.infer<typeof field_type_uuid_schema>;
|
|
||||||
|
|
||||||
export const field_type_schema = z.union([
|
|
||||||
field_type_text_schema,
|
|
||||||
field_type_timestamp_schema,
|
|
||||||
field_type_uuid_schema,
|
|
||||||
]);
|
|
||||||
|
|
||||||
export type FieldType = z.infer<typeof field_type_schema>;
|
|
||||||
|
|
||||||
export function get_empty_encodable_for(field_type: FieldType): Encodable {
|
|
||||||
if (field_type.t === "Timestamp") {
|
|
||||||
return { t: "Timestamp", c: undefined };
|
|
||||||
}
|
|
||||||
if (field_type.t === "Text") {
|
|
||||||
return { t: "Text", c: undefined };
|
|
||||||
}
|
|
||||||
if (field_type.t === "Uuid") {
|
|
||||||
return { t: "Uuid", c: undefined };
|
|
||||||
}
|
|
||||||
type _ = Assert<typeof field_type extends never ? true : false>;
|
|
||||||
throw new Error("this should be unreachable");
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------- Field -------- //
|
|
||||||
|
|
||||||
export const field_schema = z.object({
|
export const field_schema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
label: z.string().nullish().transform((x) => x ?? undefined),
|
label: z.string().nullish().transform((x) => x ?? undefined),
|
||||||
field_type: field_type_schema,
|
presentation: presentation_schema,
|
||||||
width_px: z.number(),
|
width_px: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
88
svelte/src/presentation.svelte.ts
Normal file
88
svelte/src/presentation.svelte.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { type Encodable } from "./encodable.svelte.ts";
|
||||||
|
|
||||||
|
type Assert<_T extends true> = void;
|
||||||
|
|
||||||
|
export const all_presentation_tags = [
|
||||||
|
"Text",
|
||||||
|
"Timestamp",
|
||||||
|
"Uuid",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// Type checking to ensure that all valid enum tags are included.
|
||||||
|
type _PresentationTagsAssertion = Assert<
|
||||||
|
Presentation["t"] extends (typeof all_presentation_tags)[number] ? true
|
||||||
|
: false
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const all_text_input_modes = [
|
||||||
|
"SingleLine",
|
||||||
|
"MultiLine",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// Type checking to ensure that all valid enum tags are included.
|
||||||
|
type _TextInputModesAssertion = Assert<
|
||||||
|
TextInputMode["t"] extends (typeof all_text_input_modes)[number] ? true
|
||||||
|
: false
|
||||||
|
>;
|
||||||
|
|
||||||
|
const text_input_mode_schema = z.union([
|
||||||
|
z.object({
|
||||||
|
t: z.literal("SingleLine"),
|
||||||
|
c: z.object({}),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
t: z.literal("MultiLine"),
|
||||||
|
c: z.object({}),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type TextInputMode = z.infer<typeof text_input_mode_schema>;
|
||||||
|
|
||||||
|
const presentation_text_schema = z.object({
|
||||||
|
t: z.literal("Text"),
|
||||||
|
c: z.object({
|
||||||
|
input_mode: text_input_mode_schema,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PresentationText = z.infer<typeof presentation_text_schema>;
|
||||||
|
|
||||||
|
const presentation_timestamp_schema = z.object({
|
||||||
|
t: z.literal("Timestamp"),
|
||||||
|
c: z.unknown(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PresentationTimestamp = z.infer<
|
||||||
|
typeof presentation_timestamp_schema
|
||||||
|
>;
|
||||||
|
|
||||||
|
const presentation_uuid_schema = z.object({
|
||||||
|
t: z.literal("Uuid"),
|
||||||
|
c: z.unknown(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PresentationUuid = z.infer<typeof presentation_uuid_schema>;
|
||||||
|
|
||||||
|
export const presentation_schema = z.union([
|
||||||
|
presentation_text_schema,
|
||||||
|
presentation_timestamp_schema,
|
||||||
|
presentation_uuid_schema,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type Presentation = z.infer<typeof presentation_schema>;
|
||||||
|
|
||||||
|
export function get_empty_encodable_for(presentation: Presentation): Encodable {
|
||||||
|
if (presentation.t === "Timestamp") {
|
||||||
|
return { t: "Timestamp", c: undefined };
|
||||||
|
}
|
||||||
|
if (presentation.t === "Text") {
|
||||||
|
return { t: "Text", c: undefined };
|
||||||
|
}
|
||||||
|
if (presentation.t === "Uuid") {
|
||||||
|
return { t: "Uuid", c: undefined };
|
||||||
|
}
|
||||||
|
type _ = Assert<typeof presentation extends never ? true : false>;
|
||||||
|
throw new Error("this should be unreachable");
|
||||||
|
}
|
||||||
|
|
@ -1,29 +1,42 @@
|
||||||
<svelte:options customElement={{ shadow: "none", tag: "table-viewer" }} />
|
<svelte:options
|
||||||
|
customElement={{
|
||||||
|
props: {
|
||||||
|
columns: { type: "Array" },
|
||||||
|
},
|
||||||
|
shadow: "none",
|
||||||
|
tag: "table-viewer",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as uuid from "uuid";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import icon_cloud_arrow_up from "../assets/heroicons/24/outline/cloud-arrow-up.svg?raw";
|
import icon_cloud_arrow_up from "../assets/heroicons/20/solid/cloud-arrow-up.svg?raw";
|
||||||
import icon_exclamation_circle from "../assets/heroicons/24/outline/exclamation-circle.svg?raw";
|
import icon_cube_transparent from "../assets/heroicons/20/solid/cube-transparent.svg?raw";
|
||||||
import {
|
import icon_exclamation_circle from "../assets/heroicons/20/solid/exclamation-circle.svg?raw";
|
||||||
type Coords,
|
import icon_sparkles from "../assets/heroicons/20/solid/sparkles.svg?raw";
|
||||||
type Encodable,
|
import { type Encodable, encodable_schema } from "./encodable.svelte";
|
||||||
type Row,
|
|
||||||
type FieldInfo,
|
|
||||||
type FieldType,
|
|
||||||
coords_eq,
|
|
||||||
encodable_schema,
|
|
||||||
field_info_schema,
|
|
||||||
get_empty_encodable_for,
|
|
||||||
} from "./field.svelte";
|
|
||||||
import FieldHeader from "./field-header.svelte";
|
|
||||||
import EncodableEditor from "./encodable-editor.svelte";
|
import EncodableEditor from "./encodable-editor.svelte";
|
||||||
import {
|
import {
|
||||||
DEFAULT_EDITOR_STATE,
|
DEFAULT_EDITOR_STATE,
|
||||||
encodable_from_editor_state,
|
encodable_from_editor_state,
|
||||||
type EditorState,
|
type EditorState,
|
||||||
} from "./editor-state.svelte";
|
} from "./editor-state.svelte";
|
||||||
|
import {
|
||||||
|
type Coords,
|
||||||
|
type Row,
|
||||||
|
type FieldInfo,
|
||||||
|
coords_eq,
|
||||||
|
field_info_schema,
|
||||||
|
} from "./field.svelte";
|
||||||
|
import FieldHeader from "./field-header.svelte";
|
||||||
|
import { get_empty_encodable_for } from "./presentation.svelte";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
columns?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
let { columns = [] }: Props = $props();
|
||||||
|
|
||||||
type CommittedChange = {
|
type CommittedChange = {
|
||||||
coords_initial: Coords;
|
coords_initial: Coords;
|
||||||
|
|
@ -207,7 +220,7 @@
|
||||||
const [sel] = selections;
|
const [sel] = selections;
|
||||||
const parsed = encodable_from_editor_state(
|
const parsed = encodable_from_editor_state(
|
||||||
editor_state,
|
editor_state,
|
||||||
lazy_data.fields[sel.coords[1]].field.field_type,
|
lazy_data.fields[sel.coords[1]].field.presentation,
|
||||||
);
|
);
|
||||||
if (parsed !== undefined) {
|
if (parsed !== undefined) {
|
||||||
if (sel.region === "main") {
|
if (sel.region === "main") {
|
||||||
|
|
@ -235,7 +248,7 @@
|
||||||
const field = lazy_data.fields[sel.coords[1]];
|
const field = lazy_data.fields[sel.coords[1]];
|
||||||
const parsed = encodable_from_editor_state(
|
const parsed = encodable_from_editor_state(
|
||||||
editor_state,
|
editor_state,
|
||||||
field.field.field_type,
|
field.field.presentation,
|
||||||
);
|
);
|
||||||
if (parsed !== undefined) {
|
if (parsed !== undefined) {
|
||||||
if (sel.region === "main") {
|
if (sel.region === "main") {
|
||||||
|
|
@ -323,8 +336,8 @@
|
||||||
...inserter_rows,
|
...inserter_rows,
|
||||||
{
|
{
|
||||||
key: inserter_rows.length,
|
key: inserter_rows.length,
|
||||||
data: lazy_data.fields.map(({ field: { field_type } }) =>
|
data: lazy_data.fields.map(({ field: { presentation } }) =>
|
||||||
get_empty_encodable_for(field_type),
|
get_empty_encodable_for(presentation),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -417,8 +430,8 @@
|
||||||
inserter_rows = [
|
inserter_rows = [
|
||||||
{
|
{
|
||||||
key: 0,
|
key: 0,
|
||||||
data: body.fields.map(({ field: { field_type } }) =>
|
data: body.fields.map(({ field: { presentation } }) =>
|
||||||
get_empty_encodable_for(field_type),
|
get_empty_encodable_for(presentation),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -444,10 +457,10 @@
|
||||||
(sel) =>
|
(sel) =>
|
||||||
sel.region === region_name && coords_eq(sel.coords, cell_coords),
|
sel.region === region_name && coords_eq(sel.coords, cell_coords),
|
||||||
)}
|
)}
|
||||||
{@const null_value_text =
|
{@const null_value_html =
|
||||||
region_name === "inserter" && field.has_default
|
region_name === "inserter" && field.has_default
|
||||||
? "Default"
|
? icon_sparkles
|
||||||
: "Null"}
|
: icon_cube_transparent}
|
||||||
{@const invalid_value =
|
{@const invalid_value =
|
||||||
field.not_null && !field.has_default && cell_data.c === undefined}
|
field.not_null && !field.has_default && cell_data.c === undefined}
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
|
@ -476,7 +489,11 @@
|
||||||
cell_data.c === undefined && "lens-cell__content--null",
|
cell_data.c === undefined && "lens-cell__content--null",
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{cell_data.c ?? null_value_text}
|
{#if cell_data.c === undefined}
|
||||||
|
{@html null_value_html}
|
||||||
|
{:else}
|
||||||
|
{cell_data.c}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else if cell_data.t === "Uuid"}
|
{:else if cell_data.t === "Uuid"}
|
||||||
<div
|
<div
|
||||||
|
|
@ -486,7 +503,11 @@
|
||||||
cell_data.c === undefined && "lens-cell__content--null",
|
cell_data.c === undefined && "lens-cell__content--null",
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{cell_data.c ?? null_value_text}
|
{#if cell_data.c === undefined}
|
||||||
|
{@html null_value_html}
|
||||||
|
{:else}
|
||||||
|
{cell_data.c}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
|
|
@ -519,10 +540,15 @@
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<div class={["lens-table__headers"]}>
|
<div class={["lens-table__headers"]}>
|
||||||
{#each lazy_data.fields as field, field_index}
|
{#each lazy_data.fields as _, field_index}
|
||||||
<FieldHeader {field} index={field_index} />
|
<FieldHeader
|
||||||
|
bind:field={lazy_data.fields[field_index]}
|
||||||
|
index={field_index}
|
||||||
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
<div class="lens-table__header-actions">TODO</div>
|
<div class="lens-table__header-actions">
|
||||||
|
<field-adder {columns}></field-adder>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="lens-table__main">
|
<div class="lens-table__main">
|
||||||
{@render table_region({
|
{@render table_region({
|
||||||
|
|
|
||||||
4
webc/.gitignore
vendored
4
webc/.gitignore
vendored
|
|
@ -1,4 +0,0 @@
|
||||||
*.beam
|
|
||||||
*.ez
|
|
||||||
/build
|
|
||||||
erl_crash.dump
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
# glm
|
|
||||||
|
|
||||||
[](https://hex.pm/packages/glm)
|
|
||||||
[](https://hexdocs.pm/glm/)
|
|
||||||
|
|
||||||
```sh
|
|
||||||
gleam add glm@1
|
|
||||||
```
|
|
||||||
```gleam
|
|
||||||
import glm
|
|
||||||
|
|
||||||
pub fn main() -> Nil {
|
|
||||||
// TODO: An example of the project in use
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Further documentation can be found at <https://hexdocs.pm/glm>.
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
```sh
|
|
||||||
gleam run # Run the project
|
|
||||||
gleam test # Run the tests
|
|
||||||
```
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
# Compile each .gleam file in src/ as a separate component module.
|
|
||||||
ls src | \
|
|
||||||
grep '_component.gleam' | \
|
|
||||||
xargs -I {} basename {} .gleam | \
|
|
||||||
xargs -I {} gleam run -m lustre/dev build component {} --outdir=../js_dist
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
name = "webc"
|
|
||||||
version = "1.0.0"
|
|
||||||
target = "javascript"
|
|
||||||
|
|
||||||
# Fill out these fields if you intend to generate HTML documentation or publish
|
|
||||||
# your project to the Hex package manager.
|
|
||||||
#
|
|
||||||
# description = ""
|
|
||||||
# licences = ["Apache-2.0"]
|
|
||||||
# repository = { type = "github", user = "", repo = "" }
|
|
||||||
# links = [{ title = "Website", href = "" }]
|
|
||||||
#
|
|
||||||
# For a full reference of all the available options, you can have a look at
|
|
||||||
# https://gleam.run/writing-gleam/gleam-toml/.
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
gleam_stdlib = ">= 0.44.0 and < 2.0.0"
|
|
||||||
lustre = ">= 5.2.1 and < 6.0.0"
|
|
||||||
gleam_json = ">= 3.0.2 and < 4.0.0"
|
|
||||||
gleam_regexp = ">= 1.1.1 and < 2.0.0"
|
|
||||||
plinth = ">= 0.7.1 and < 1.0.0"
|
|
||||||
gleam_javascript = ">= 1.0.0 and < 2.0.0"
|
|
||||||
rsvp = ">= 1.1.2 and < 2.0.0"
|
|
||||||
gleam_http = ">= 4.1.1 and < 5.0.0"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
gleeunit = ">= 1.0.0 and < 2.0.0"
|
|
||||||
lustre_dev_tools = ">= 1.9.0 and < 2.0.0"
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
# This file was generated by Gleam
|
|
||||||
# You typically do not need to edit this file
|
|
||||||
|
|
||||||
packages = [
|
|
||||||
{ name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" },
|
|
||||||
{ name = "directories", version = "1.2.0", build_tools = ["gleam"], requirements = ["envoy", "gleam_stdlib", "platform", "simplifile"], otp_app = "directories", source = "hex", outer_checksum = "D13090CFCDF6759B87217E8DDD73A75903A700148A82C1D33799F333E249BF9E" },
|
|
||||||
{ name = "envoy", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "95FD059345AA982E89A0B6E2A3BF1CF43E17A7048DCD85B5B65D3B9E4E39D359" },
|
|
||||||
{ name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" },
|
|
||||||
{ name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" },
|
|
||||||
{ name = "fs", version = "11.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "fs", source = "hex", outer_checksum = "DD00A61D89EAC01D16D3FC51D5B0EB5F0722EF8E3C1A3A547CD086957F3260A9" },
|
|
||||||
{ name = "gleam_community_ansi", version = "1.4.3", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "8A62AE9CC6EA65BEA630D95016D6C07E4F9973565FA3D0DE68DC4200D8E0DD27" },
|
|
||||||
{ name = "gleam_community_colour", version = "2.0.2", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "E34DD2C896AC3792151EDA939DA435FF3B69922F33415ED3C4406C932FBE9634" },
|
|
||||||
{ name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" },
|
|
||||||
{ name = "gleam_deque", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_deque", source = "hex", outer_checksum = "64D77068931338CF0D0CB5D37522C3E3CCA7CB7D6C5BACB41648B519CC0133C7" },
|
|
||||||
{ name = "gleam_erlang", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "F91CE62A2D011FA13341F3723DB7DB118541AAA5FE7311BD2716D018F01EF9E3" },
|
|
||||||
{ name = "gleam_fetch", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_javascript", "gleam_stdlib"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "2CBF9F2E1C71AEBBFB13A9D5720CD8DB4263EB02FE60C5A7A1C6E17B0151C20C" },
|
|
||||||
{ name = "gleam_http", version = "4.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "DD0271B32C356FB684EC7E9F48B1E835D0480168848581F68983C0CC371405D4" },
|
|
||||||
{ name = "gleam_httpc", version = "4.1.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C670EBD46FC1472AD5F1F74F1D3938D1D0AC1C7531895ED1D4DDCB6F07279F43" },
|
|
||||||
{ name = "gleam_javascript", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "EF6C77A506F026C6FB37941889477CD5E4234FCD4337FF0E9384E297CB8F97EB" },
|
|
||||||
{ name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" },
|
|
||||||
{ name = "gleam_otp", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "7020E652D18F9ABAC9C877270B14160519FA0856EE80126231C505D719AD68DA" },
|
|
||||||
{ name = "gleam_package_interface", version = "3.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_package_interface", source = "hex", outer_checksum = "8F2D19DE9876D9401BB0626260958A6B1580BB233489C32831FE74CE0ACAE8B4" },
|
|
||||||
{ name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" },
|
|
||||||
{ name = "gleam_stdlib", version = "0.62.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "DC8872BC0B8550F6E22F0F698CFE7F1E4BDA7312FDEB40D6C3F44C5B706C8310" },
|
|
||||||
{ name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" },
|
|
||||||
{ name = "gleeunit", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "63022D81C12C17B7F1A60E029964E830A4CBD846BBC6740004FC1F1031AE0326" },
|
|
||||||
{ name = "glint", version = "1.2.1", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_community_colour", "gleam_stdlib", "snag"], otp_app = "glint", source = "hex", outer_checksum = "2214C7CEFDE457CEE62140C3D4899B964E05236DA74E4243DFADF4AF29C382BB" },
|
|
||||||
{ name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" },
|
|
||||||
{ name = "gramps", version = "3.0.3", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "75F0F20C867A6217CBB632A7E563568D6A6366B850815041E8E0B4F179681E53" },
|
|
||||||
{ name = "houdini", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "houdini", source = "hex", outer_checksum = "5BA517E5179F132F0471CB314F27FE210A10407387DA1EA4F6FD084F74469FC2" },
|
|
||||||
{ name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" },
|
|
||||||
{ name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" },
|
|
||||||
{ name = "lustre", version = "5.2.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "DCD121F8E6B7E179B27D9A8AEB6C828D8380E26DF2E16D078511EDAD1CA9F2A7" },
|
|
||||||
{ name = "lustre_dev_tools", version = "1.9.0", build_tools = ["gleam"], requirements = ["argv", "filepath", "fs", "gleam_community_ansi", "gleam_crypto", "gleam_deque", "gleam_erlang", "gleam_http", "gleam_httpc", "gleam_json", "gleam_otp", "gleam_package_interface", "gleam_regexp", "gleam_stdlib", "glint", "glisten", "mist", "repeatedly", "simplifile", "term_size", "tom", "wisp"], otp_app = "lustre_dev_tools", source = "hex", outer_checksum = "2132E6B2B7E89ED87C138FFE1F2CD70D859258D67222F26B5793CDACE9B07D75" },
|
|
||||||
{ name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" },
|
|
||||||
{ name = "mist", version = "5.0.2", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "0716CE491EA13E1AA1EFEC4B427593F8EB2B953B6EBDEBE41F15BE3D06A22918" },
|
|
||||||
{ name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" },
|
|
||||||
{ name = "plinth", version = "0.7.1", build_tools = ["gleam"], requirements = ["gleam_javascript", "gleam_json", "gleam_stdlib"], otp_app = "plinth", source = "hex", outer_checksum = "63BB36AACCCCB82FBE46A862CF85CB88EBE4EF280ECDBAC4B6CB042340B9E1D8" },
|
|
||||||
{ name = "repeatedly", version = "2.1.2", build_tools = ["gleam"], requirements = [], otp_app = "repeatedly", source = "hex", outer_checksum = "93AE1938DDE0DC0F7034F32C1BF0D4E89ACEBA82198A1FE21F604E849DA5F589" },
|
|
||||||
{ name = "rsvp", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_fetch", "gleam_http", "gleam_httpc", "gleam_javascript", "gleam_json", "gleam_stdlib", "lustre"], otp_app = "rsvp", source = "hex", outer_checksum = "0C0732577712E7CB0E55F057637E62CD36F35306A5E830DC4874B83DA8CE4638" },
|
|
||||||
{ name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" },
|
|
||||||
{ name = "snag", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "7E9F06390040EB5FAB392CE642771484136F2EC103A92AE11BA898C8167E6E17" },
|
|
||||||
{ name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" },
|
|
||||||
{ name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" },
|
|
||||||
{ name = "tom", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "tom", source = "hex", outer_checksum = "0910EE688A713994515ACAF1F486A4F05752E585B9E3209D8F35A85B234C2719" },
|
|
||||||
{ name = "wisp", version = "1.8.0", build_tools = ["gleam"], requirements = ["directories", "exception", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "houdini", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "0FE9049AFFB7C8D5FC0B154EEE2704806F4D51B97F44925D69349B3F4F192957" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[requirements]
|
|
||||||
gleam_http = { version = ">= 4.1.1 and < 5.0.0" }
|
|
||||||
gleam_javascript = { version = ">= 1.0.0 and < 2.0.0" }
|
|
||||||
gleam_json = { version = ">= 3.0.2 and < 4.0.0" }
|
|
||||||
gleam_regexp = { version = ">= 1.1.1 and < 2.0.0" }
|
|
||||||
gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" }
|
|
||||||
gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
|
|
||||||
lustre = { version = ">= 5.2.1 and < 6.0.0" }
|
|
||||||
lustre_dev_tools = { version = ">= 1.9.0 and < 2.0.0" }
|
|
||||||
plinth = { version = ">= 0.7.1 and < 1.0.0" }
|
|
||||||
rsvp = { version = ">= 1.1.2 and < 2.0.0" }
|
|
||||||
|
|
@ -1,154 +0,0 @@
|
||||||
import gleam/dynamic
|
|
||||||
import gleam/dynamic/decode
|
|
||||||
import gleam/int
|
|
||||||
import gleam/json
|
|
||||||
import gleam/result
|
|
||||||
import lustre/attribute as attr
|
|
||||||
import lustre/component
|
|
||||||
import lustre/effect.{type Effect}
|
|
||||||
import lustre/element.{type Element}
|
|
||||||
import lustre/element/html
|
|
||||||
import lustre/event
|
|
||||||
|
|
||||||
import context
|
|
||||||
|
|
||||||
pub fn options_common(
|
|
||||||
map_msg: fn(CommonMsg) -> msg,
|
|
||||||
) -> List(component.Option(msg)) {
|
|
||||||
[
|
|
||||||
component.on_attribute_change("row", fn(value) {
|
|
||||||
use value <- result.map(int.parse(value))
|
|
||||||
ParentChangedRow(value) |> CommonMsg |> map_msg
|
|
||||||
}),
|
|
||||||
component.on_attribute_change("column", fn(value) {
|
|
||||||
use value <- result.map(int.parse(value))
|
|
||||||
ParentChangedColumn(value) |> CommonMsg |> map_msg
|
|
||||||
}),
|
|
||||||
component.on_attribute_change("selected", fn(value) {
|
|
||||||
ParentChangedSelected(value != "") |> CommonMsg |> map_msg |> Ok
|
|
||||||
}),
|
|
||||||
component.on_attribute_change("insertable", fn(value) {
|
|
||||||
ParentChangedInsertable(value != "") |> CommonMsg |> map_msg |> Ok
|
|
||||||
}),
|
|
||||||
component.on_attribute_change("has-default", fn(value) {
|
|
||||||
ParentChangedInsertable(value != "") |> CommonMsg |> map_msg |> Ok
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type ModelCommon {
|
|
||||||
ModelCommon(
|
|
||||||
root_path: String,
|
|
||||||
row: Int,
|
|
||||||
column: Int,
|
|
||||||
selected: Bool,
|
|
||||||
insertable: Bool,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type Msg {
|
|
||||||
AncestorChangedRootPath(String)
|
|
||||||
ParentChangedRow(Int)
|
|
||||||
ParentChangedColumn(Int)
|
|
||||||
ParentChangedSelected(Bool)
|
|
||||||
ParentChangedInsertable(Bool)
|
|
||||||
UserClickedCell
|
|
||||||
UserDoubleClickedCell
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn init(_) -> #(ModelCommon, Effect(CommonMsg)) {
|
|
||||||
#(
|
|
||||||
ModelCommon(
|
|
||||||
root_path: "",
|
|
||||||
selected: False,
|
|
||||||
row: -1,
|
|
||||||
column: -1,
|
|
||||||
insertable: False,
|
|
||||||
),
|
|
||||||
context.request_context(
|
|
||||||
context: dynamic.string("root_path"),
|
|
||||||
subscribe: False,
|
|
||||||
decoder: decode.string
|
|
||||||
|> decode.map(fn(value) {
|
|
||||||
value |> AncestorChangedRootPath |> CommonMsg
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type CommonMsg {
|
|
||||||
CommonMsg(msg: Msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update(
|
|
||||||
model: ModelCommon,
|
|
||||||
msg_common: CommonMsg,
|
|
||||||
) -> #(ModelCommon, Effect(msg)) {
|
|
||||||
case msg_common.msg {
|
|
||||||
AncestorChangedRootPath(root_path) -> #(
|
|
||||||
ModelCommon(..model, root_path:),
|
|
||||||
effect.none(),
|
|
||||||
)
|
|
||||||
ParentChangedRow(row) -> #(ModelCommon(..model, row:), effect.none())
|
|
||||||
ParentChangedColumn(column) -> #(
|
|
||||||
ModelCommon(..model, column:),
|
|
||||||
effect.none(),
|
|
||||||
)
|
|
||||||
ParentChangedSelected(selected) -> #(
|
|
||||||
ModelCommon(..model, selected:),
|
|
||||||
effect.none(),
|
|
||||||
)
|
|
||||||
ParentChangedInsertable(insertable) -> #(
|
|
||||||
ModelCommon(..model, insertable:),
|
|
||||||
effect.none(),
|
|
||||||
)
|
|
||||||
UserClickedCell -> #(
|
|
||||||
model,
|
|
||||||
event.emit(
|
|
||||||
"cell-click",
|
|
||||||
json.object([
|
|
||||||
#("row", json.int(model.row)),
|
|
||||||
#("column", json.int(model.column)),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
UserDoubleClickedCell -> #(
|
|
||||||
model,
|
|
||||||
event.emit(
|
|
||||||
"cell-dblclick",
|
|
||||||
json.object([
|
|
||||||
#("row", json.int(model.row)),
|
|
||||||
#("column", json.int(model.column)),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn view(
|
|
||||||
model model: ModelCommon,
|
|
||||||
map_msg map_msg: fn(CommonMsg) -> msg,
|
|
||||||
inner inner: fn() -> Element(msg),
|
|
||||||
) -> Element(msg) {
|
|
||||||
element.fragment([
|
|
||||||
html.link([
|
|
||||||
attr.rel("stylesheet"),
|
|
||||||
attr.href(model.root_path <> "/css_dist/cells.css"),
|
|
||||||
]),
|
|
||||||
html.div(
|
|
||||||
[
|
|
||||||
attr.class("cell__container"),
|
|
||||||
case model.selected {
|
|
||||||
True -> attr.class("cell__container--selected")
|
|
||||||
False -> attr.none()
|
|
||||||
},
|
|
||||||
event.on_click(CommonMsg(UserClickedCell) |> map_msg()),
|
|
||||||
event.on(
|
|
||||||
"dblclick",
|
|
||||||
CommonMsg(UserDoubleClickedCell) |> map_msg() |> decode.success(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
[inner()],
|
|
||||||
),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
import gleam/dynamic/decode
|
|
||||||
import gleam/json
|
|
||||||
import gleam/option.{type Option, None}
|
|
||||||
import gleam/result
|
|
||||||
import lustre.{type App}
|
|
||||||
import lustre/attribute as attr
|
|
||||||
import lustre/component
|
|
||||||
import lustre/effect.{type Effect}
|
|
||||||
import lustre/element.{type Element}
|
|
||||||
import lustre/element/html
|
|
||||||
|
|
||||||
import cell_common.{type CommonMsg, type ModelCommon}
|
|
||||||
import encodable
|
|
||||||
|
|
||||||
pub const name: String = "cell-text"
|
|
||||||
|
|
||||||
pub fn component() -> App(Nil, Model, Msg) {
|
|
||||||
lustre.component(init, update, view, [
|
|
||||||
component.on_attribute_change("value", fn(value) {
|
|
||||||
json.parse(
|
|
||||||
value,
|
|
||||||
encodable.decoder()
|
|
||||||
|> decode.map(fn(encodable) {
|
|
||||||
case encodable {
|
|
||||||
encodable.Text(content) -> ParentChangedValue(content)
|
|
||||||
_ -> ParentChangedValue(None)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|> result.map_error(fn(_) { Nil })
|
|
||||||
}),
|
|
||||||
..cell_common.options_common(Common)
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type Model {
|
|
||||||
Model(common: ModelCommon, value: Option(String))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn init(_) -> #(Model, Effect(Msg)) {
|
|
||||||
let #(model_common, effect_common) = cell_common.init(Nil)
|
|
||||||
#(
|
|
||||||
Model(common: model_common, value: option.None),
|
|
||||||
effect_common
|
|
||||||
|> effect.map(fn(effect_common) { Common(effect_common) }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type Msg {
|
|
||||||
Common(CommonMsg)
|
|
||||||
ParentChangedValue(Option(String))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
|
|
||||||
case msg {
|
|
||||||
Common(sub_msg) -> {
|
|
||||||
let #(common, effect) = cell_common.update(model.common, sub_msg)
|
|
||||||
#(Model(..model, common:), effect)
|
|
||||||
}
|
|
||||||
ParentChangedValue(value) -> #(Model(..model, value:), effect.none())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(model: Model) -> Element(Msg) {
|
|
||||||
cell_common.view(model: model.common, map_msg: Common, inner: fn() {
|
|
||||||
html.div(
|
|
||||||
[
|
|
||||||
attr.class("cell__content cell__content--padded"),
|
|
||||||
case option.is_none(model.value) {
|
|
||||||
True -> attr.class("cell__content--null")
|
|
||||||
False -> attr.none()
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[html.text(model.value |> option.unwrap("Null"))],
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
import gleam/dynamic/decode
|
|
||||||
import gleam/json
|
|
||||||
import gleam/option.{type Option, None}
|
|
||||||
import gleam/result
|
|
||||||
import lustre.{type App}
|
|
||||||
import lustre/attribute as attr
|
|
||||||
import lustre/component
|
|
||||||
import lustre/effect.{type Effect}
|
|
||||||
import lustre/element.{type Element}
|
|
||||||
import lustre/element/html
|
|
||||||
|
|
||||||
import cell_common.{type CommonMsg, type ModelCommon}
|
|
||||||
import encodable
|
|
||||||
|
|
||||||
pub const name: String = "cell-uuid"
|
|
||||||
|
|
||||||
pub fn component() -> App(Nil, Model, Msg) {
|
|
||||||
lustre.component(init, update, view, [
|
|
||||||
component.on_attribute_change("value", fn(value) {
|
|
||||||
json.parse(
|
|
||||||
value,
|
|
||||||
encodable.decoder()
|
|
||||||
|> decode.map(fn(encodable) {
|
|
||||||
case encodable {
|
|
||||||
encodable.Uuid(content) -> ParentChangedValue(content)
|
|
||||||
_ -> ParentChangedValue(None)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|> result.map_error(fn(_) { Nil })
|
|
||||||
}),
|
|
||||||
..cell_common.options_common(Common)
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type Model {
|
|
||||||
Model(common: ModelCommon, value: Option(String), has_default: Bool)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn init(_) -> #(Model, Effect(Msg)) {
|
|
||||||
let #(model_common, effect_common) = cell_common.init(Nil)
|
|
||||||
#(
|
|
||||||
Model(common: model_common, value: None, has_default: False),
|
|
||||||
effect_common
|
|
||||||
|> effect.map(fn(effect_common) { Common(effect_common) }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type Msg {
|
|
||||||
Common(CommonMsg)
|
|
||||||
ParentChangedValue(Option(String))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
|
|
||||||
case msg {
|
|
||||||
Common(sub_msg) -> {
|
|
||||||
let #(common, effect) = cell_common.update(model.common, sub_msg)
|
|
||||||
#(Model(..model, common:), effect)
|
|
||||||
}
|
|
||||||
ParentChangedValue(value) -> #(Model(..model, value:), effect.none())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(model: Model) -> Element(Msg) {
|
|
||||||
cell_common.view(model: model.common, map_msg: Common, inner: fn() {
|
|
||||||
html.div(
|
|
||||||
[
|
|
||||||
attr.class("cell__content cell__content--padded"),
|
|
||||||
case option.is_none(model.value) {
|
|
||||||
True -> attr.class("cell__content--null")
|
|
||||||
False -> attr.class("cell__content--uuid")
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
html.text(
|
|
||||||
model.value
|
|
||||||
|> option.unwrap(
|
|
||||||
case model.common.insertable && model.common.has_default {
|
|
||||||
True -> "Default"
|
|
||||||
False -> "Null"
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
import gleam/dynamic.{type Dynamic}
|
|
||||||
import gleam/dynamic/decode
|
|
||||||
import gleam/json
|
|
||||||
import gleam/regexp
|
|
||||||
import gleam/result
|
|
||||||
import gleam/string
|
|
||||||
import lustre.{type App}
|
|
||||||
import lustre/attribute as attr
|
|
||||||
import lustre/component
|
|
||||||
import lustre/effect.{type Effect}
|
|
||||||
import lustre/element.{type Element}
|
|
||||||
import lustre/element/html
|
|
||||||
import lustre/event
|
|
||||||
|
|
||||||
pub const name: String = "collapsible-menu"
|
|
||||||
|
|
||||||
pub fn component() -> App(Nil, Model, Msg) {
|
|
||||||
lustre.component(init, update, view, [
|
|
||||||
component.on_attribute_change("root-path", fn(value) {
|
|
||||||
ParentChangedRootPath(value) |> Ok
|
|
||||||
}),
|
|
||||||
component.on_attribute_change("expanded", fn(value) {
|
|
||||||
ParentChangedExpanded(value == "true") |> Ok
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type Model {
|
|
||||||
Model(root_path: String, expanded: Bool)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn init(_) -> #(Model, Effect(Msg)) {
|
|
||||||
#(Model(root_path: "", expanded: True), effect.none())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type Msg {
|
|
||||||
ParentChangedRootPath(String)
|
|
||||||
ParentChangedExpanded(Bool)
|
|
||||||
UserClickedSummary
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
|
|
||||||
case msg {
|
|
||||||
ParentChangedRootPath(root_path) -> #(
|
|
||||||
Model(..model, root_path:),
|
|
||||||
effect.none(),
|
|
||||||
)
|
|
||||||
ParentChangedExpanded(expanded) -> #(
|
|
||||||
Model(..model, expanded:),
|
|
||||||
effect.none(),
|
|
||||||
)
|
|
||||||
UserClickedSummary -> #(
|
|
||||||
Model(..model, expanded: !model.expanded),
|
|
||||||
effect.none(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(model: Model) -> Element(Msg) {
|
|
||||||
element.fragment([
|
|
||||||
html.link([
|
|
||||||
attr.rel("stylesheet"),
|
|
||||||
attr.href(model.root_path <> "/css_dist/collapsible_menu.css"),
|
|
||||||
]),
|
|
||||||
html.div([attr.class("collapsible-menu")], [
|
|
||||||
html.button(
|
|
||||||
[
|
|
||||||
attr.class("collapsible-menu__summary"),
|
|
||||||
event.on_click(UserClickedSummary),
|
|
||||||
],
|
|
||||||
[component.named_slot("summary", [], [])],
|
|
||||||
),
|
|
||||||
html.div(
|
|
||||||
[
|
|
||||||
attr.class("collapsible-menu__content"),
|
|
||||||
case model.expanded {
|
|
||||||
True -> attr.class("collapsible-menu__content--expanded")
|
|
||||||
False -> attr.none()
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[component.named_slot("content", [], [])],
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
class ContextRequestEvent extends Event {
|
|
||||||
constructor(context, subscribe, callback) {
|
|
||||||
super("context-request", { bubbles: true, composed: true });
|
|
||||||
this.context = context;
|
|
||||||
this.subscribe = subscribe;
|
|
||||||
this.callback = callback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function emitContextRequest(root, context, subscribe, callback) {
|
|
||||||
root.dispatchEvent(new ContextRequestEvent(context, subscribe, callback));
|
|
||||||
}
|
|
||||||
|
|
||||||
// FFI shim so that Gleam can pass around Javascript functions as dynamic values
|
|
||||||
export function callCallback(callback, value) {
|
|
||||||
callback(value);
|
|
||||||
}
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
import gleam/dynamic.{type Dynamic}
|
|
||||||
import gleam/dynamic/decode.{type Decoder}
|
|
||||||
import gleam/option.{type Option}
|
|
||||||
import lustre/attribute.{type Attribute}
|
|
||||||
import lustre/effect.{type Effect}
|
|
||||||
import lustre/event
|
|
||||||
|
|
||||||
/// WARNING: Lifecycle hooks in Lustre are currently limited to non-existent,
|
|
||||||
/// so it's not possible to unsubscribe from context updates on component
|
|
||||||
/// teardown. Be cautious using this effect with components that are not
|
|
||||||
/// expected to be permanent fixtures on a page. (Refer to
|
|
||||||
/// https://github.com/lustre-labs/lustre/issues/320.)
|
|
||||||
pub fn request_context(
|
|
||||||
context context: Dynamic,
|
|
||||||
subscribe subscribe: Bool,
|
|
||||||
decoder decoder: Decoder(msg),
|
|
||||||
) -> Effect(msg) {
|
|
||||||
use dispatch, root <- effect.before_paint
|
|
||||||
use value, _unsubscribe <- emit_context_request(root, context, subscribe)
|
|
||||||
case decode.run(value, decoder) {
|
|
||||||
Ok(msg) -> {
|
|
||||||
dispatch(msg)
|
|
||||||
}
|
|
||||||
Error(_) -> Nil
|
|
||||||
}
|
|
||||||
Nil
|
|
||||||
}
|
|
||||||
|
|
||||||
@external(javascript, "./context.ffi.mjs", "emitContextRequest")
|
|
||||||
fn emit_context_request(
|
|
||||||
root _root: Dynamic,
|
|
||||||
context _context: Dynamic,
|
|
||||||
subscribe _subscribe: Bool,
|
|
||||||
callback _callback: fn(Dynamic, Option(fn() -> Nil)) -> Nil,
|
|
||||||
) -> Nil {
|
|
||||||
Nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Capture "context-request" events querying a particular context type.
|
|
||||||
/// Intended to be used in a context provider, on a child element near the
|
|
||||||
/// component root.
|
|
||||||
pub fn on_context_request(
|
|
||||||
context match_context: Dynamic,
|
|
||||||
handler handler: fn(Dynamic, Bool) -> msg,
|
|
||||||
) -> Attribute(msg) {
|
|
||||||
event.advanced("context-request", {
|
|
||||||
use ev_context <- decode.field("context", decode.dynamic)
|
|
||||||
use subscribe <- decode.field("subscribe", decode.bool)
|
|
||||||
use callback <- decode.field("callback", decode.dynamic)
|
|
||||||
case ev_context == match_context {
|
|
||||||
True -> {
|
|
||||||
decode.success(event.handler(
|
|
||||||
handler(callback, subscribe),
|
|
||||||
False,
|
|
||||||
ev_context == match_context,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
False ->
|
|
||||||
// returning a DecodeError seems like the only way to not trigger a message dispatch
|
|
||||||
decode.failure(
|
|
||||||
event.handler(handler(callback, subscribe), False, False),
|
|
||||||
"Context",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Effect for passing context value back to consumers who have requested it.
|
|
||||||
pub fn update_consumers(
|
|
||||||
callbacks callbacks: List(Dynamic),
|
|
||||||
value value: Dynamic,
|
|
||||||
) -> Effect(msg) {
|
|
||||||
use _dispatch <- effect.from
|
|
||||||
do_update_consumers(callbacks:, value:)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn do_update_consumers(
|
|
||||||
callbacks callbacks: List(Dynamic),
|
|
||||||
value value: Dynamic,
|
|
||||||
) -> Nil {
|
|
||||||
case callbacks {
|
|
||||||
[] -> Nil
|
|
||||||
[cb, ..rest] -> {
|
|
||||||
call_callback(callback: cb, value:)
|
|
||||||
do_update_consumers(rest, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@external(javascript, "./context.ffi.mjs", "callCallback")
|
|
||||||
fn call_callback(callback _callback: Dynamic, value _value: Dynamic) -> Nil {
|
|
||||||
Nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
import gleam/dynamic/decode.{type Decoder}
|
|
||||||
import gleam/json.{type Json}
|
|
||||||
import gleam/option.{type Option, None, Some}
|
|
||||||
|
|
||||||
pub type Encodable {
|
|
||||||
Integer(Option(Int))
|
|
||||||
Text(Option(String))
|
|
||||||
Timestamptz(Option(String))
|
|
||||||
Uuid(Option(String))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decoder() -> Decoder(Encodable) {
|
|
||||||
use t <- decode.field("t", decode.string)
|
|
||||||
case t {
|
|
||||||
"Integer" -> {
|
|
||||||
use c <- decode.field("c", decode.optional(decode.int))
|
|
||||||
decode.success(Integer(c))
|
|
||||||
}
|
|
||||||
"Text" -> {
|
|
||||||
use c <- decode.field("c", decode.optional(decode.string))
|
|
||||||
decode.success(Text(c))
|
|
||||||
}
|
|
||||||
"Timestamptz" -> {
|
|
||||||
use c <- decode.field("c", decode.optional(decode.string))
|
|
||||||
decode.success(Timestamptz(c))
|
|
||||||
}
|
|
||||||
"Uuid" -> {
|
|
||||||
use c <- decode.field("c", decode.optional(decode.string))
|
|
||||||
decode.success(Uuid(c))
|
|
||||||
}
|
|
||||||
_ -> decode.failure(Text(Some("")), "Encodable")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn to_json(value: Encodable) -> Json {
|
|
||||||
json.object([
|
|
||||||
#(
|
|
||||||
"t",
|
|
||||||
json.string(case value {
|
|
||||||
Integer(_) -> "Integer"
|
|
||||||
Text(_) -> "Text"
|
|
||||||
Timestamptz(_) -> "Timestamptz"
|
|
||||||
Uuid(_) -> "Uuid"
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
#("c", case value {
|
|
||||||
Integer(Some(inner)) -> json.int(inner)
|
|
||||||
Integer(None) -> json.null()
|
|
||||||
Text(Some(inner)) -> json.string(inner)
|
|
||||||
Text(None) -> json.null()
|
|
||||||
Timestamptz(Some(inner)) -> json.string(inner)
|
|
||||||
Timestamptz(None) -> json.null()
|
|
||||||
Uuid(Some(inner)) -> json.string(inner)
|
|
||||||
Uuid(None) -> json.null()
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
import gleam/dynamic/decode
|
|
||||||
import gleam/option.{type Option}
|
|
||||||
|
|
||||||
import field_type.{type FieldType}
|
|
||||||
|
|
||||||
pub type Field {
|
|
||||||
Field(
|
|
||||||
id: String,
|
|
||||||
name: String,
|
|
||||||
label: Option(String),
|
|
||||||
field_type: FieldType,
|
|
||||||
width_px: Int,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decoder() -> decode.Decoder(Field) {
|
|
||||||
use id <- decode.field("id", decode.string)
|
|
||||||
use name <- decode.field("name", decode.string)
|
|
||||||
use label <- decode.field("label", decode.optional(decode.string))
|
|
||||||
use field_type <- decode.field("field_type", field_type.decoder())
|
|
||||||
use width_px <- decode.field("width_px", decode.int)
|
|
||||||
decode.success(Field(id:, name:, label:, field_type:, width_px:))
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { Error, Ok } from "./gleam.mjs";
|
|
||||||
|
|
||||||
export function focusElement(selector, root) {
|
|
||||||
const element = root.querySelector(selector);
|
|
||||||
if (element) {
|
|
||||||
element.focus();
|
|
||||||
return new Ok(undefined);
|
|
||||||
}
|
|
||||||
return new Error(undefined);
|
|
||||||
}
|
|
||||||
|
|
@ -1,237 +0,0 @@
|
||||||
import gleam/dynamic.{type Dynamic}
|
|
||||||
import gleam/dynamic/decode
|
|
||||||
import gleam/json
|
|
||||||
import gleam/regexp
|
|
||||||
import gleam/result
|
|
||||||
import gleam/string
|
|
||||||
import lustre.{type App}
|
|
||||||
import lustre/attribute as attr
|
|
||||||
import lustre/component
|
|
||||||
import lustre/effect.{type Effect}
|
|
||||||
import lustre/element.{type Element}
|
|
||||||
import lustre/element/html
|
|
||||||
import lustre/event
|
|
||||||
|
|
||||||
pub const name: String = "field-adder"
|
|
||||||
|
|
||||||
pub fn component() -> App(Nil, Model, Msg) {
|
|
||||||
lustre.component(init, update, view, [
|
|
||||||
component.on_attribute_change("columns", fn(value) {
|
|
||||||
json.parse(from: value, using: decode.list(of: decode.string))
|
|
||||||
|> result.unwrap([])
|
|
||||||
|> ParentChangedColumns
|
|
||||||
|> Ok
|
|
||||||
}),
|
|
||||||
component.on_attribute_change("root-path", fn(value) {
|
|
||||||
ParentChangedRootPath(value) |> Ok
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type Model {
|
|
||||||
Model(
|
|
||||||
columns: List(String),
|
|
||||||
root_path: String,
|
|
||||||
expanded: Bool,
|
|
||||||
label_value: String,
|
|
||||||
name_value: String,
|
|
||||||
name_customized: Bool,
|
|
||||||
field_type: String,
|
|
||||||
submitting: Bool,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn init(_) -> #(Model, Effect(Msg)) {
|
|
||||||
#(
|
|
||||||
Model(
|
|
||||||
columns: [],
|
|
||||||
root_path: "",
|
|
||||||
expanded: False,
|
|
||||||
label_value: "",
|
|
||||||
name_value: "",
|
|
||||||
name_customized: False,
|
|
||||||
field_type: "text",
|
|
||||||
submitting: False,
|
|
||||||
),
|
|
||||||
effect.none(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type Msg {
|
|
||||||
ParentChangedColumns(List(String))
|
|
||||||
ParentChangedRootPath(String)
|
|
||||||
UserClickedCancel
|
|
||||||
UserExpandedComponent
|
|
||||||
UserUpdatedName(String)
|
|
||||||
UserUpdatedLabel(String)
|
|
||||||
UserUpdatedFieldType(String)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
|
|
||||||
case msg {
|
|
||||||
ParentChangedColumns(columns) -> #(Model(..model, columns:), effect.none())
|
|
||||||
ParentChangedRootPath(root_path) -> #(
|
|
||||||
Model(..model, root_path:),
|
|
||||||
effect.none(),
|
|
||||||
)
|
|
||||||
UserClickedCancel -> #(
|
|
||||||
Model(
|
|
||||||
..model,
|
|
||||||
expanded: False,
|
|
||||||
label_value: "",
|
|
||||||
name_value: "",
|
|
||||||
name_customized: False,
|
|
||||||
field_type: "Text",
|
|
||||||
),
|
|
||||||
effect.none(),
|
|
||||||
)
|
|
||||||
UserExpandedComponent -> #(
|
|
||||||
Model(..model, expanded: True),
|
|
||||||
focus_element("#label-input"),
|
|
||||||
)
|
|
||||||
UserUpdatedName(name_value) -> #(
|
|
||||||
Model(..model, name_value:, name_customized: True),
|
|
||||||
effect.none(),
|
|
||||||
)
|
|
||||||
UserUpdatedLabel(label_value) -> #(
|
|
||||||
Model(..model, label_value:, name_value: case model.name_customized {
|
|
||||||
True -> model.name_value
|
|
||||||
False -> label_value |> to_idiomatic_column_name
|
|
||||||
}),
|
|
||||||
effect.none(),
|
|
||||||
)
|
|
||||||
UserUpdatedFieldType(field_type) -> #(
|
|
||||||
Model(..model, field_type:),
|
|
||||||
effect.none(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn to_idiomatic_column_name(label: String) -> String {
|
|
||||||
let assert Ok(re) = regexp.from_string("[^a-z0-9]")
|
|
||||||
regexp.replace(each: re, in: label |> string.lowercase, with: "_")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn focus_element(selector: String) -> Effect(Msg) {
|
|
||||||
use _, shadow_root <- effect.before_paint
|
|
||||||
do_focus_element(selector:, in: shadow_root) |> result.unwrap(Nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@external(javascript, "./field_adder_component.ffi.mjs", "focusElement")
|
|
||||||
fn do_focus_element(
|
|
||||||
selector _selector: String,
|
|
||||||
in _root: Dynamic,
|
|
||||||
) -> Result(Nil, Nil) {
|
|
||||||
Error(Nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(model: Model) -> Element(Msg) {
|
|
||||||
element.fragment([
|
|
||||||
html.link([
|
|
||||||
attr.rel("stylesheet"),
|
|
||||||
attr.href(model.root_path <> "/css_dist/field_adder/index.css"),
|
|
||||||
]),
|
|
||||||
case model.expanded {
|
|
||||||
False ->
|
|
||||||
html.button(
|
|
||||||
[
|
|
||||||
attr.type_("button"),
|
|
||||||
attr.class("expander__button"),
|
|
||||||
event.on_click(UserExpandedComponent),
|
|
||||||
],
|
|
||||||
[html.text("+")],
|
|
||||||
)
|
|
||||||
True ->
|
|
||||||
html.div([attr.class("header")], [
|
|
||||||
html.form([attr.method("post"), attr.action("add-column")], [
|
|
||||||
label_input(value: model.label_value, on_input: UserUpdatedLabel),
|
|
||||||
html.button(
|
|
||||||
[attr.type_("button"), attr.popovertarget("config-popover")],
|
|
||||||
[html.text("...")],
|
|
||||||
),
|
|
||||||
config_popover(model),
|
|
||||||
]),
|
|
||||||
])
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn label_input(
|
|
||||||
value value: String,
|
|
||||||
on_input handle_input: fn(String) -> Msg,
|
|
||||||
) -> Element(Msg) {
|
|
||||||
html.input([
|
|
||||||
attr.type_("text"),
|
|
||||||
attr.id("label-input"),
|
|
||||||
attr.name("label"),
|
|
||||||
attr.class("header__input"),
|
|
||||||
attr.placeholder("My New Column"),
|
|
||||||
attr.value(value),
|
|
||||||
event.on_input(handle_input),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn config_popover(model: Model) -> Element(Msg) {
|
|
||||||
html.div(
|
|
||||||
[
|
|
||||||
attr.id("config-popover"),
|
|
||||||
attr.class("config-popover__container"),
|
|
||||||
attr.popover("auto"),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
html.h2([attr.class("form-section__heading")], [
|
|
||||||
html.text("Field Details"),
|
|
||||||
]),
|
|
||||||
html.label([attr.class("form-section__label"), attr.for("name-input")], [
|
|
||||||
html.text("SQL-friendly Name"),
|
|
||||||
]),
|
|
||||||
html.input([
|
|
||||||
attr.type_("text"),
|
|
||||||
attr.name("name"),
|
|
||||||
attr.class("form-section__input form-section__input--text"),
|
|
||||||
attr.id("name-input"),
|
|
||||||
attr.value(model.name_value),
|
|
||||||
event.on_input(UserUpdatedName),
|
|
||||||
]),
|
|
||||||
html.label(
|
|
||||||
[attr.for("field-type-select"), attr.class("form-section__label")],
|
|
||||||
[html.text("Data Type")],
|
|
||||||
),
|
|
||||||
html.select(
|
|
||||||
[
|
|
||||||
attr.name("field_type"),
|
|
||||||
attr.class("form-section__input"),
|
|
||||||
attr.id("field-type-select"),
|
|
||||||
event.on_change(UserUpdatedFieldType),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
html.option(
|
|
||||||
[attr.value("Text"), attr.checked(model.field_type == "Text")],
|
|
||||||
"Text",
|
|
||||||
),
|
|
||||||
html.option(
|
|
||||||
[attr.value("Decimal"), attr.checked(model.field_type == "Decimal")],
|
|
||||||
"Decimal",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
html.div([attr.class("form-buttons")], [
|
|
||||||
html.button(
|
|
||||||
[
|
|
||||||
attr.type_("button"),
|
|
||||||
attr.class("form-buttons__button form-buttons__button--cancel"),
|
|
||||||
event.on_click(UserClickedCancel),
|
|
||||||
],
|
|
||||||
[html.text("Cancel")],
|
|
||||||
),
|
|
||||||
html.button(
|
|
||||||
[
|
|
||||||
attr.type_("submit"),
|
|
||||||
attr.class("form-buttons__button form-buttons__button--submit"),
|
|
||||||
],
|
|
||||||
[html.text("Create")],
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
import gleam/dynamic/decode
|
|
||||||
import gleam/json.{type Json}
|
|
||||||
|
|
||||||
pub type FieldType {
|
|
||||||
Integer
|
|
||||||
InterimUser
|
|
||||||
Text
|
|
||||||
Timestamp(format: String)
|
|
||||||
Uuid
|
|
||||||
Unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn to_json(field_type: FieldType) -> Json {
|
|
||||||
case field_type {
|
|
||||||
Integer -> json.object([#("t", json.string("Integer"))])
|
|
||||||
InterimUser -> json.object([#("t", json.string("Interim_user"))])
|
|
||||||
Text -> json.object([#("t", json.string("Text"))])
|
|
||||||
Timestamp(format:) ->
|
|
||||||
json.object([
|
|
||||||
#("t", json.string("Timestamp")),
|
|
||||||
#("c", json.object([#("format", json.string(format))])),
|
|
||||||
])
|
|
||||||
Uuid -> json.object([#("t", json.string("Uuid"))])
|
|
||||||
Unknown -> json.object([#("t", json.string("Unknown"))])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decoder() -> decode.Decoder(FieldType) {
|
|
||||||
use variant <- decode.field("t", decode.string)
|
|
||||||
case variant {
|
|
||||||
"Integer" -> decode.success(Integer)
|
|
||||||
"InterimUser" -> decode.success(InterimUser)
|
|
||||||
"Text" -> decode.success(Text)
|
|
||||||
"Timestamp" -> {
|
|
||||||
use format <- decode.subfield(["c", "format"], decode.string)
|
|
||||||
decode.success(Timestamp(format:))
|
|
||||||
}
|
|
||||||
"Uuid" -> decode.success(Uuid)
|
|
||||||
"Unknown" -> decode.success(Unknown)
|
|
||||||
_ -> decode.failure(Unknown, "FieldType")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
export function showPopover(root) {
|
|
||||||
const controlPanel = root.querySelector(".control-panel__container");
|
|
||||||
if (controlPanel) {
|
|
||||||
controlPanel.showPopover();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
import gleam/dynamic.{type Dynamic}
|
|
||||||
import gleam/dynamic/decode
|
|
||||||
import gleam/list
|
|
||||||
import gleam/option.{type Option, None, Some}
|
|
||||||
import lustre/attribute as attr
|
|
||||||
import lustre/effect.{type Effect}
|
|
||||||
import lustre/element.{type Element}
|
|
||||||
import lustre/element/html
|
|
||||||
import lustre/event
|
|
||||||
|
|
||||||
pub type Model {
|
|
||||||
Model(tab_key: String, control_panel_open: Bool)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type TabItem {
|
|
||||||
TabItem(icon: String, label: String, key: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn init(_) -> #(Model, Effect(Msg)) {
|
|
||||||
#(Model(tab_key: "", control_panel_open: False), effect.none())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type Msg {
|
|
||||||
UserClickedControlBar
|
|
||||||
UserClickedTabBox
|
|
||||||
UserClickedTab(String)
|
|
||||||
SomeoneToggledControlPanel(Bool)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
|
|
||||||
case msg {
|
|
||||||
UserClickedControlBar | UserClickedTabBox -> #(model, show_popover())
|
|
||||||
UserClickedTab(tab_key) -> #(Model(..model, tab_key:), show_popover())
|
|
||||||
SomeoneToggledControlPanel(control_panel_open) -> #(
|
|
||||||
Model(..model, control_panel_open:),
|
|
||||||
effect.none(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn show_popover() -> Effect(Msg) {
|
|
||||||
use _, root <- effect.before_paint
|
|
||||||
do_show_popover(root)
|
|
||||||
}
|
|
||||||
|
|
||||||
@external(javascript, "./hoverbar.ffi.mjs", "showPopover")
|
|
||||||
fn do_show_popover(in _root: Dynamic) -> Nil {
|
|
||||||
Nil
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn view(
|
|
||||||
model model: Model,
|
|
||||||
map_msg map_msg: fn(Msg) -> msg,
|
|
||||||
tabs tabs: List(TabItem),
|
|
||||||
control_bar control_bar: fn(String) -> Element(msg),
|
|
||||||
control_panel control_panel: fn(String) -> Option(Element(msg)),
|
|
||||||
) -> Element(msg) {
|
|
||||||
html.div([attr.class("container-positioner")], [
|
|
||||||
html.div([attr.class("container")], [
|
|
||||||
tab_box(model, map_msg, tabs),
|
|
||||||
view_control_bar(model, map_msg, control_bar(model.tab_key)),
|
|
||||||
view_control_panel(model, map_msg, control_panel(model.tab_key)),
|
|
||||||
]),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tab_box(
|
|
||||||
model: Model,
|
|
||||||
map_msg: fn(Msg) -> msg,
|
|
||||||
tabs: List(TabItem),
|
|
||||||
) -> Element(msg) {
|
|
||||||
html.ul(
|
|
||||||
[attr.class("tab-box"), event.on_click(UserClickedTabBox |> map_msg)],
|
|
||||||
tabs
|
|
||||||
|> list.map(fn(tab) {
|
|
||||||
html.li([], [
|
|
||||||
html.button(
|
|
||||||
[
|
|
||||||
attr.type_("button"),
|
|
||||||
attr.class("tab-box__button"),
|
|
||||||
case model.tab_key == tab.key {
|
|
||||||
True -> attr.class("tab-box__button--active")
|
|
||||||
False -> attr.none()
|
|
||||||
},
|
|
||||||
event.on_click(UserClickedTab(tab.key) |> map_msg),
|
|
||||||
],
|
|
||||||
[html.img([attr.src(tab.icon), attr.alt(tab.label)])],
|
|
||||||
),
|
|
||||||
])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view_control_bar(
|
|
||||||
model: Model,
|
|
||||||
map_msg: fn(Msg) -> msg,
|
|
||||||
inner: Element(msg),
|
|
||||||
) -> Element(msg) {
|
|
||||||
html.div(
|
|
||||||
[
|
|
||||||
attr.class("control-bar"),
|
|
||||||
case model.control_panel_open {
|
|
||||||
True -> attr.class("control-bar--open")
|
|
||||||
False -> attr.none()
|
|
||||||
},
|
|
||||||
event.on_click(UserClickedControlBar |> map_msg),
|
|
||||||
],
|
|
||||||
[inner],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view_control_panel(
|
|
||||||
_: Model,
|
|
||||||
map_msg: fn(Msg) -> msg,
|
|
||||||
inner: Option(Element(msg)),
|
|
||||||
) -> Element(msg) {
|
|
||||||
case inner {
|
|
||||||
Some(inner_element) ->
|
|
||||||
html.div([attr.class("control-panel-positioner")], [
|
|
||||||
html.div(
|
|
||||||
[
|
|
||||||
attr.class("control-panel__container"),
|
|
||||||
attr.popover("auto"),
|
|
||||||
event.on("toggle", {
|
|
||||||
use new_state <- decode.field("newState", decode.string)
|
|
||||||
decode.success(
|
|
||||||
SomeoneToggledControlPanel(new_state == "open") |> map_msg,
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
[html.div([attr.class("control-panel")], [inner_element])],
|
|
||||||
),
|
|
||||||
])
|
|
||||||
None -> element.none()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,465 +0,0 @@
|
||||||
import gleam/dict.{type Dict}
|
|
||||||
import gleam/dynamic.{type Dynamic}
|
|
||||||
import gleam/dynamic/decode
|
|
||||||
import gleam/http/response.{type Response}
|
|
||||||
import gleam/io
|
|
||||||
import gleam/json
|
|
||||||
import gleam/list
|
|
||||||
import gleam/option.{None, Some}
|
|
||||||
import gleam/pair
|
|
||||||
import gleam/regexp
|
|
||||||
import gleam/result
|
|
||||||
import gleam/set.{type Set}
|
|
||||||
import lustre.{type App}
|
|
||||||
import lustre/component
|
|
||||||
import lustre/effect.{type Effect}
|
|
||||||
import lustre/element.{type Element}
|
|
||||||
import lustre/element/html
|
|
||||||
import lustre/event
|
|
||||||
import plinth/browser/document
|
|
||||||
import plinth/browser/event as plinth_event
|
|
||||||
import rsvp
|
|
||||||
|
|
||||||
import context
|
|
||||||
import encodable.{type Encodable}
|
|
||||||
import field.{type Field}
|
|
||||||
import field_type.{type FieldType}
|
|
||||||
|
|
||||||
pub const name: String = "table-viewer"
|
|
||||||
|
|
||||||
pub fn component() -> App(Nil, Model, Msg) {
|
|
||||||
lustre.component(init, update, view, [
|
|
||||||
component.on_attribute_change("root-path", fn(value) {
|
|
||||||
ParentChangedRootPath(value) |> Ok
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------- Model -------- //
|
|
||||||
|
|
||||||
type Coord {
|
|
||||||
Coord(pkey: String, field: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
type Cell {
|
|
||||||
Cell(value_committed: Encodable, value_current: Encodable)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type Model {
|
|
||||||
Model(
|
|
||||||
root_path: String,
|
|
||||||
selections: List(Coord),
|
|
||||||
editing: Bool,
|
|
||||||
fields: Dict(String, Field),
|
|
||||||
field_names: List(String),
|
|
||||||
history: List(HistoryEvent),
|
|
||||||
pkeys: List(String),
|
|
||||||
data: Dict(Coord, Encodable),
|
|
||||||
// Whether OS, ctrl, or alt keys are being held. If so, simultaneously
|
|
||||||
// pressing a typing key should not trigger an overwrite.
|
|
||||||
modifiers_held: Set(String),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type HistoryEvent {
|
|
||||||
CommitCellValue(from: Encodable, to: Encodable)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn apply_history(ev: HistoryEvent) -> Effect(Msg) {
|
|
||||||
case ev {
|
|
||||||
CommitCellValue -> {
|
|
||||||
todo
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn init(_) -> #(Model, Effect(Msg)) {
|
|
||||||
#(
|
|
||||||
Model(
|
|
||||||
root_path: "",
|
|
||||||
selections: [],
|
|
||||||
editing: False,
|
|
||||||
fields: dict.new(),
|
|
||||||
field_names: [],
|
|
||||||
pkeys: [],
|
|
||||||
values: dict.new(),
|
|
||||||
modifiers_held: set.new(),
|
|
||||||
),
|
|
||||||
effect.before_paint(fn(dispatch, _) -> Nil {
|
|
||||||
document.add_event_listener("keydown", fn(ev) -> Nil {
|
|
||||||
let key = plinth_event.key(ev)
|
|
||||||
case key {
|
|
||||||
"ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown" ->
|
|
||||||
dispatch(UserPressedDirectionalKey(key))
|
|
||||||
"Enter" -> dispatch(UserPressedEnterKey)
|
|
||||||
"Shift", "Meta" | "Alt" | "Control" ->
|
|
||||||
dispatch(UserPressedModifierKey(key))
|
|
||||||
_ ->
|
|
||||||
case is_typing_key(key) {
|
|
||||||
True -> dispatch(UserPressedTypingKey(key))
|
|
||||||
False -> Nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
document.add_event_listener("keyup", fn(ev) -> Nil {
|
|
||||||
let key = plinth_event.key(ev)
|
|
||||||
case key {
|
|
||||||
"Meta" | "Alt" | "Control" -> dispatch(UserReleasedModifierKey(key))
|
|
||||||
_ -> Nil
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_typing_key(key: String) -> Bool {
|
|
||||||
let assert Ok(re) =
|
|
||||||
regexp.from_string("^[a-zA-Z0-9!@#$%^&*._/?<>{}[\\]'\"~`-]$")
|
|
||||||
regexp.check(key, with: re)
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------- Update -------- //
|
|
||||||
|
|
||||||
pub type EditorMsg {
|
|
||||||
EditStarted
|
|
||||||
EditStateChanged(EditState)
|
|
||||||
EditFinished(Encodable)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type ApiMsg {
|
|
||||||
CellValueCommitted(Coord)
|
|
||||||
RowInserted
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type MouseMsg {
|
|
||||||
UserClickedCell(Coord)
|
|
||||||
UserDoubleClickedCell(Coord)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type KeyboardMsg {
|
|
||||||
UserPressedDirectionalKey(String)
|
|
||||||
UserPressedEnterKey
|
|
||||||
UserPressedTypingKey(String)
|
|
||||||
UserPressedModifierKey(String)
|
|
||||||
UserReleasedModifierKey(String)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type Msg {
|
|
||||||
ParentChangedRootPath(String)
|
|
||||||
EditorMessage(EditorMsg)
|
|
||||||
ApiMessage(ApiMsg)
|
|
||||||
MouseMessage(MouseMsg)
|
|
||||||
KeyboardMessage(KeyboardMsg)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
|
|
||||||
case msg {
|
|
||||||
ParentChangedRootPath(root_path) -> #(
|
|
||||||
Model(..model, root_path:),
|
|
||||||
context.update_consumers(
|
|
||||||
model.root_path_consumers,
|
|
||||||
dynamic.string(root_path),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
EditorMessage(sub_msg) -> editor_update(model, sub_msg)
|
|
||||||
MouseEvent(sub_msg) -> mouse_update(model, sub_msg)
|
|
||||||
KeyboardMessage(sub_msg) -> keyboard_update(model, sub_msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn editor_update(model: Model, msg: EditorMsg) -> #(Model, Effect(Msg)) {
|
|
||||||
case msg {
|
|
||||||
EditStarted -> #(Model(..model, editing: True), effect.none())
|
|
||||||
EditStateChanged(_) -> todo
|
|
||||||
EditFinished -> todo
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn api_update(model: Model, msg: ApiMsg) -> #(Model, Effect(Msg)) {
|
|
||||||
case msg {
|
|
||||||
CellValueCommitted(response) -> {
|
|
||||||
case response {
|
|
||||||
Ok(_) -> #(Model(..model, editing: False), blur_hoverbar())
|
|
||||||
Error(rsvp.HttpError(response)) -> {
|
|
||||||
io.println_error("HTTP error while updating value: " <> response.body)
|
|
||||||
#(model, effect.none())
|
|
||||||
}
|
|
||||||
Error(err) -> {
|
|
||||||
echo err
|
|
||||||
#(model, effect.none())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mouse_update(model: Model, msg: ApiMsg) -> #(Model, Effect(Msg)) {
|
|
||||||
case msg {
|
|
||||||
UserClickedCell(row, column) -> assign_selection(model:, to: #(row, column))
|
|
||||||
// TODO
|
|
||||||
UserDoubleClickedCell(_row, _column) -> #(model, case model.editing {
|
|
||||||
True -> effect.none()
|
|
||||||
False -> focus_hoverbar()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn keyboard_update(model: Model, msg: KeyboardMsg) -> #(Model, Effect(Msg)) {
|
|
||||||
case msg {
|
|
||||||
UserPressedDirectionalKey(key) -> {
|
|
||||||
case model.editing, model.selections {
|
|
||||||
False, [#(selected_row, selected_col)] -> {
|
|
||||||
let first_row_selected = selected_row == 0
|
|
||||||
// No need to subtract 1 from pkeys size, because there's another
|
|
||||||
// row for "insert".
|
|
||||||
let last_row_selected = selected_row == dict.size(model.pkeys)
|
|
||||||
let first_col_selected = selected_col == 0
|
|
||||||
let last_col_selected = selected_col == dict.size(model.fields) - 1
|
|
||||||
case
|
|
||||||
key,
|
|
||||||
first_row_selected,
|
|
||||||
last_row_selected,
|
|
||||||
first_col_selected,
|
|
||||||
last_col_selected
|
|
||||||
{
|
|
||||||
"ArrowLeft", _, _, False, _ ->
|
|
||||||
assign_selection(model:, to: #(selected_row, selected_col - 1))
|
|
||||||
"ArrowRight", _, _, _, False ->
|
|
||||||
assign_selection(model:, to: #(selected_row, selected_col + 1))
|
|
||||||
"ArrowUp", False, _, _, _ ->
|
|
||||||
assign_selection(model:, to: #(selected_row - 1, selected_col))
|
|
||||||
"ArrowDown", _, False, _, _ ->
|
|
||||||
assign_selection(model:, to: #(selected_row + 1, selected_col))
|
|
||||||
_, _, _, _, _ -> #(model, effect.none())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
False, [] ->
|
|
||||||
case dict.size(model.pkeys) > 0 && dict.size(model.fields) > 0 {
|
|
||||||
True -> assign_selection(model:, to: #(0, 0))
|
|
||||||
False -> #(model, effect.none())
|
|
||||||
}
|
|
||||||
_, _ -> #(model, effect.none())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
UserPressedEnterKey -> #(model, case model.editing {
|
|
||||||
True -> effect.none()
|
|
||||||
False -> focus_hoverbar()
|
|
||||||
})
|
|
||||||
UserPressedTypingKey(key) ->
|
|
||||||
case model.editing, model.selections, set.is_empty(model.modifiers_held) {
|
|
||||||
False, [_], True -> #(model, overwrite_in_hoverbar(value: key))
|
|
||||||
_, _, _ -> #(model, effect.none())
|
|
||||||
}
|
|
||||||
UserPressedModifierKey(key) -> #(
|
|
||||||
Model(..model, modifiers_held: model.modifiers_held |> set.insert(key)),
|
|
||||||
effect.none(),
|
|
||||||
)
|
|
||||||
UserReleasedModifierKey(key) -> #(
|
|
||||||
Model(..model, modifiers_held: model.modifiers_held |> set.delete(key)),
|
|
||||||
effect.none(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn overwrite_in_hoverbar(value value: String) -> Effect(Msg) {
|
|
||||||
use _, _ <- effect.before_paint()
|
|
||||||
do_overwrite_in_hoverbar(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
@external(javascript, "./viewer_controller_component.ffi.mjs", "overwriteInHoverbar")
|
|
||||||
fn do_overwrite_in_hoverbar(value _value: String) -> Nil {
|
|
||||||
Nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fn commit_change(model model: Model, value value: Encodable) -> Effect(Msg) {
|
|
||||||
case model.selections {
|
|
||||||
[#(row, col)] ->
|
|
||||||
rsvp.post(
|
|
||||||
"./update-value",
|
|
||||||
json.object([
|
|
||||||
#(
|
|
||||||
"column",
|
|
||||||
model.fields
|
|
||||||
|> dict.get(col)
|
|
||||||
|> result.map(fn(field) { field.name })
|
|
||||||
|> result.unwrap("")
|
|
||||||
|> json.string(),
|
|
||||||
),
|
|
||||||
#(
|
|
||||||
"pkeys",
|
|
||||||
model.pkeys
|
|
||||||
|> dict.get(row)
|
|
||||||
|> result.unwrap(dict.new())
|
|
||||||
|> json.dict(fn(x) { x }, encodable.to_json),
|
|
||||||
),
|
|
||||||
#("value", encodable.to_json(value)),
|
|
||||||
]),
|
|
||||||
rsvp.expect_ok_response(ServerUpdateValuePost),
|
|
||||||
)
|
|
||||||
_ -> todo
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Updater for when selection is being assigned to a single cell.
|
|
||||||
fn assign_selection(
|
|
||||||
model model: Model,
|
|
||||||
to coords: #(Int, Int),
|
|
||||||
) -> #(Model, Effect(Msg)) {
|
|
||||||
// Multiple sets of conditions fall back to the "selection actually changed"
|
|
||||||
// scenario, so this is a little awkward to write without a return keyword.
|
|
||||||
let early_return = case model.selections {
|
|
||||||
[prev_coords] ->
|
|
||||||
case prev_coords == coords {
|
|
||||||
True -> Some(#(model, effect.none()))
|
|
||||||
False -> None
|
|
||||||
}
|
|
||||||
_ -> None
|
|
||||||
}
|
|
||||||
case early_return {
|
|
||||||
Some(value) -> value
|
|
||||||
None -> #(
|
|
||||||
Model(..model, selections: [coords]),
|
|
||||||
effect.batch([
|
|
||||||
sync_cell_value_to_hoverbar(
|
|
||||||
coords,
|
|
||||||
model.fields
|
|
||||||
|> dict.get(pair.second(coords))
|
|
||||||
|> result.map(fn(f) { f.field_type })
|
|
||||||
|> result.unwrap(field_type.Unknown),
|
|
||||||
),
|
|
||||||
change_selected_attrs([coords]),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------- Effects -------- //
|
|
||||||
|
|
||||||
fn focus_hoverbar() -> Effect(Msg) {
|
|
||||||
use _dispatch, _root <- effect.before_paint()
|
|
||||||
do_focus_hoverbar()
|
|
||||||
}
|
|
||||||
|
|
||||||
@external(javascript, "./viewer_controller_component.ffi.mjs", "focusHoverbar")
|
|
||||||
fn do_focus_hoverbar() -> Nil {
|
|
||||||
Nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fn blur_hoverbar() -> Effect(Msg) {
|
|
||||||
use _dispatch, _root <- effect.before_paint()
|
|
||||||
do_blur_hoverbar()
|
|
||||||
}
|
|
||||||
|
|
||||||
@external(javascript, "./viewer_controller_component.ffi.mjs", "blurHoverbar")
|
|
||||||
fn do_blur_hoverbar() -> Nil {
|
|
||||||
Nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Effect that changes [selected=] attributes on assigned children. Should only
|
|
||||||
/// be called when the new selection is different than the previous one (that
|
|
||||||
/// is, not when selection is "moving" from a cell to the same cell).
|
|
||||||
fn change_selected_attrs(to_coords coords: List(#(Int, Int))) -> Effect(msg) {
|
|
||||||
use _, _ <- effect.before_paint()
|
|
||||||
do_clear_selected_attrs()
|
|
||||||
coords
|
|
||||||
|> list.map(fn(coords) {
|
|
||||||
let #(row, column) = coords
|
|
||||||
do_set_cell_attr(row:, column:, key: "selected", value: "true")
|
|
||||||
})
|
|
||||||
Nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_cell_value(coords coords: #(Int, Int), value value: Encodable) {
|
|
||||||
use _, _ <- effect.before_paint()
|
|
||||||
let #(row, col) = coords
|
|
||||||
do_set_cell_attr(
|
|
||||||
row,
|
|
||||||
col,
|
|
||||||
"value",
|
|
||||||
value |> encodable.to_json() |> json.to_string(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@external(javascript, "./viewer_controller_component.ffi.mjs", "clearSelectedAttrs")
|
|
||||||
fn do_clear_selected_attrs() -> Nil {
|
|
||||||
Nil
|
|
||||||
}
|
|
||||||
|
|
||||||
@external(javascript, "./viewer_controller_component.ffi.mjs", "setCellAttr")
|
|
||||||
fn do_set_cell_attr(
|
|
||||||
row _row: Int,
|
|
||||||
column _column: Int,
|
|
||||||
key _key: String,
|
|
||||||
value _value: String,
|
|
||||||
) -> Nil {
|
|
||||||
Nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sync_cell_value_to_hoverbar(
|
|
||||||
coords coords: #(Int, Int),
|
|
||||||
field_type f_type: FieldType,
|
|
||||||
) -> Effect(Msg) {
|
|
||||||
use _, _root <- effect.before_paint()
|
|
||||||
let #(row, column) = coords
|
|
||||||
do_sync_cell_value_to_hoverbar(
|
|
||||||
row:,
|
|
||||||
column:,
|
|
||||||
field_type: f_type |> field_type.to_json() |> json.to_string(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@external(javascript, "./viewer_controller_component.ffi.mjs", "syncCellValueToHoverbar")
|
|
||||||
fn do_sync_cell_value_to_hoverbar(
|
|
||||||
row _row: Int,
|
|
||||||
column _column: Int,
|
|
||||||
field_type _field_type: String,
|
|
||||||
) -> Nil {
|
|
||||||
Nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------- View -------- //
|
|
||||||
|
|
||||||
fn view(_: Model) -> Element(Msg) {
|
|
||||||
html.div(
|
|
||||||
[
|
|
||||||
context.on_context_request(
|
|
||||||
dynamic.string("root_path"),
|
|
||||||
ChildRequestedRootPath,
|
|
||||||
),
|
|
||||||
event.on("cell-click", {
|
|
||||||
use msg <- decode.field("detail", {
|
|
||||||
use row <- decode.field("row", decode.int)
|
|
||||||
use column <- decode.field("column", decode.int)
|
|
||||||
decode.success(UserClickedCell(row, column))
|
|
||||||
})
|
|
||||||
decode.success(msg)
|
|
||||||
}),
|
|
||||||
event.on("cell-dblclick", {
|
|
||||||
use msg <- decode.field("detail", {
|
|
||||||
use row <- decode.field("row", decode.int)
|
|
||||||
use column <- decode.field("column", decode.int)
|
|
||||||
decode.success(UserDoubleClickedCell(row, column))
|
|
||||||
})
|
|
||||||
decode.success(msg)
|
|
||||||
}),
|
|
||||||
event.on("edit-start", decode.success(ChildEmittedEditStartEvent)),
|
|
||||||
event.on("edit-update", {
|
|
||||||
use original <- decode.subfield(
|
|
||||||
["detail", "original"],
|
|
||||||
encodable.decoder(),
|
|
||||||
)
|
|
||||||
use edited <- decode.subfield(["detail", "edited"], encodable.decoder())
|
|
||||||
decode.success(ChildEmittedEditUpdateEvent(original:, edited:))
|
|
||||||
}),
|
|
||||||
event.on("edit-end", {
|
|
||||||
use original <- decode.subfield(
|
|
||||||
["detail", "original"],
|
|
||||||
encodable.decoder(),
|
|
||||||
)
|
|
||||||
use edited <- decode.subfield(["detail", "edited"], encodable.decoder())
|
|
||||||
decode.success(ChildEmittedEditEndEvent(original:, edited:))
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
[component.default_slot([], [])],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
export function focusHoverbar() {
|
|
||||||
const hoverbar = document.querySelector("viewer-hoverbar");
|
|
||||||
hoverbar?.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function blurHoverbar() {
|
|
||||||
const hoverbar = document.querySelector("viewer-hoverbar");
|
|
||||||
hoverbar?.blur();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function overwriteInHoverbar(value) {
|
|
||||||
const hoverbar = document.querySelector("viewer-hoverbar");
|
|
||||||
hoverbar?.overwrite(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearSelectedAttrs() {
|
|
||||||
document.querySelectorAll(
|
|
||||||
".viewer-table__td > [selected='true']",
|
|
||||||
)
|
|
||||||
.forEach((element) => element.setAttribute("selected", ""));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setCellAttr(row, column, key, value) {
|
|
||||||
const cell = queryCell(row, column);
|
|
||||||
if (cell) {
|
|
||||||
cell.setAttribute(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function syncCellValueToHoverbar(row, column, fieldType) {
|
|
||||||
const cell = queryCell(row, column);
|
|
||||||
if (cell) {
|
|
||||||
const value = cell.getAttribute("value") ?? "null";
|
|
||||||
const hoverbar = document.querySelector("viewer-hoverbar");
|
|
||||||
if (hoverbar) {
|
|
||||||
hoverbar.setAttribute("value", value);
|
|
||||||
hoverbar.setAttribute("field-type", fieldType);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function queryCell(row, column) {
|
|
||||||
const tr = document.querySelectorAll(".viewer-table > tbody > tr")[row];
|
|
||||||
if (tr) {
|
|
||||||
return [...tr.querySelectorAll(":scope > td > *")][column];
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
@ -1,460 +0,0 @@
|
||||||
import gleam/dict.{type Dict}
|
|
||||||
import gleam/dynamic.{type Dynamic}
|
|
||||||
import gleam/dynamic/decode
|
|
||||||
import gleam/http/response.{type Response}
|
|
||||||
import gleam/io
|
|
||||||
import gleam/json
|
|
||||||
import gleam/list
|
|
||||||
import gleam/option.{None, Some}
|
|
||||||
import gleam/pair
|
|
||||||
import gleam/regexp
|
|
||||||
import gleam/result
|
|
||||||
import gleam/set.{type Set}
|
|
||||||
import lustre.{type App}
|
|
||||||
import lustre/component
|
|
||||||
import lustre/effect.{type Effect}
|
|
||||||
import lustre/element.{type Element}
|
|
||||||
import lustre/element/html
|
|
||||||
import lustre/event
|
|
||||||
import plinth/browser/document
|
|
||||||
import plinth/browser/event as plinth_event
|
|
||||||
import rsvp
|
|
||||||
|
|
||||||
import context
|
|
||||||
import encodable.{type Encodable}
|
|
||||||
import field.{type Field}
|
|
||||||
import field_type.{type FieldType}
|
|
||||||
|
|
||||||
pub const name: String = "viewer-controller"
|
|
||||||
|
|
||||||
pub fn component() -> App(Nil, Model, Msg) {
|
|
||||||
lustre.component(init, update, view, [
|
|
||||||
component.on_attribute_change("root-path", fn(value) {
|
|
||||||
ParentChangedRootPath(value) |> Ok
|
|
||||||
}),
|
|
||||||
component.on_attribute_change("pkeys", fn(value) {
|
|
||||||
value
|
|
||||||
|> json.parse(
|
|
||||||
decode.list(decode.dict(decode.string, encodable.decoder())),
|
|
||||||
)
|
|
||||||
|> result.map(fn(lst) {
|
|
||||||
list.zip(list.range(0, list.length(lst)), lst)
|
|
||||||
|> dict.from_list()
|
|
||||||
|> ParentChangedPkeys()
|
|
||||||
})
|
|
||||||
|> result.replace_error(Nil)
|
|
||||||
}),
|
|
||||||
component.on_attribute_change("fields", fn(value) {
|
|
||||||
value
|
|
||||||
|> json.parse(decode.list(field.decoder()))
|
|
||||||
|> result.map(fn(lst) {
|
|
||||||
list.zip(list.range(0, list.length(lst)), lst)
|
|
||||||
|> dict.from_list()
|
|
||||||
|> ParentChangedFields()
|
|
||||||
})
|
|
||||||
|> result.replace_error(Nil)
|
|
||||||
}),
|
|
||||||
component.on_attribute_change("root-path", fn(value) {
|
|
||||||
ParentChangedRootPath(value) |> Ok
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------- Model -------- //
|
|
||||||
|
|
||||||
pub type Model {
|
|
||||||
Model(
|
|
||||||
root_path: String,
|
|
||||||
root_path_consumers: List(Dynamic),
|
|
||||||
selections: List(#(Int, Int)),
|
|
||||||
editing: Bool,
|
|
||||||
pkeys: Dict(Int, Dict(String, Encodable)),
|
|
||||||
fields: Dict(Int, Field),
|
|
||||||
// Whether OS, ctrl, or alt keys are being held. If so, simultaneously
|
|
||||||
// pressing a typing key should not trigger an overwrite.
|
|
||||||
modifiers_held: Set(String),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn init(_) -> #(Model, Effect(Msg)) {
|
|
||||||
#(
|
|
||||||
Model(
|
|
||||||
root_path: "",
|
|
||||||
root_path_consumers: [],
|
|
||||||
selections: [],
|
|
||||||
editing: False,
|
|
||||||
pkeys: dict.new(),
|
|
||||||
fields: dict.new(),
|
|
||||||
modifiers_held: set.new(),
|
|
||||||
),
|
|
||||||
effect.before_paint(fn(dispatch, _) -> Nil {
|
|
||||||
document.add_event_listener("keydown", fn(ev) -> Nil {
|
|
||||||
let key = plinth_event.key(ev)
|
|
||||||
case key {
|
|
||||||
"ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown" ->
|
|
||||||
dispatch(UserPressedDirectionalKey(key))
|
|
||||||
"Enter" -> dispatch(UserPressedEnterKey)
|
|
||||||
"Meta" | "Alt" | "Control" -> dispatch(UserPressedModifierKey(key))
|
|
||||||
_ ->
|
|
||||||
case is_typing_key(key) {
|
|
||||||
True -> dispatch(UserPressedTypingKey(key))
|
|
||||||
False -> Nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
document.add_event_listener("keyup", fn(ev) -> Nil {
|
|
||||||
let key = plinth_event.key(ev)
|
|
||||||
case key {
|
|
||||||
"Meta" | "Alt" | "Control" -> dispatch(UserReleasedModifierKey(key))
|
|
||||||
_ -> Nil
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_typing_key(key: String) -> Bool {
|
|
||||||
let assert Ok(re) =
|
|
||||||
regexp.from_string("^[a-zA-Z0-9!@#$%^&*._/?<>{}[\\]'\"~`-]$")
|
|
||||||
regexp.check(key, with: re)
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------- Update -------- //
|
|
||||||
|
|
||||||
pub type Msg {
|
|
||||||
ParentChangedRootPath(String)
|
|
||||||
ParentChangedPkeys(Dict(Int, Dict(String, Encodable)))
|
|
||||||
ParentChangedFields(Dict(Int, Field))
|
|
||||||
ChildEmittedEditStartEvent
|
|
||||||
ChildEmittedEditUpdateEvent(original: Encodable, edited: Encodable)
|
|
||||||
ChildEmittedEditEndEvent(original: Encodable, edited: Encodable)
|
|
||||||
ChildRequestedRootPath(callback: Dynamic, subscribe: Bool)
|
|
||||||
ServerUpdateValuePost(Result(Response(String), rsvp.Error))
|
|
||||||
UserClickedCell(Int, Int)
|
|
||||||
UserDoubleClickedCell(Int, Int)
|
|
||||||
UserPressedDirectionalKey(String)
|
|
||||||
UserPressedEnterKey
|
|
||||||
UserPressedTypingKey(String)
|
|
||||||
UserPressedModifierKey(String)
|
|
||||||
UserReleasedModifierKey(String)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
|
|
||||||
case msg {
|
|
||||||
ParentChangedRootPath(root_path) -> #(
|
|
||||||
Model(..model, root_path:),
|
|
||||||
context.update_consumers(
|
|
||||||
model.root_path_consumers,
|
|
||||||
dynamic.string(root_path),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
ParentChangedPkeys(pkeys) -> #(Model(..model, pkeys:), effect.none())
|
|
||||||
ParentChangedFields(fields) -> #(Model(..model, fields:), effect.none())
|
|
||||||
ChildEmittedEditStartEvent -> #(
|
|
||||||
Model(..model, editing: True),
|
|
||||||
effect.none(),
|
|
||||||
)
|
|
||||||
ChildEmittedEditUpdateEvent(_original, edited) -> {
|
|
||||||
case model.selections {
|
|
||||||
[coords] -> #(model, set_cell_value(coords, edited))
|
|
||||||
_ -> todo
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ChildEmittedEditEndEvent(_original, edited) ->
|
|
||||||
case model.selections {
|
|
||||||
[#(selected_row, _)] ->
|
|
||||||
case selected_row == dict.size(model.pkeys) {
|
|
||||||
// Cell under edit is in "insert" row
|
|
||||||
True -> #(Model(..model, editing: False), effect.none())
|
|
||||||
False -> #(model, commit_change(model:, value: edited))
|
|
||||||
}
|
|
||||||
_ -> todo
|
|
||||||
}
|
|
||||||
|
|
||||||
ChildRequestedRootPath(callback, subscribe) -> #(
|
|
||||||
case subscribe {
|
|
||||||
True ->
|
|
||||||
Model(..model, root_path_consumers: [
|
|
||||||
callback,
|
|
||||||
..model.root_path_consumers
|
|
||||||
])
|
|
||||||
False -> model
|
|
||||||
},
|
|
||||||
context.update_consumers([callback], dynamic.string(model.root_path)),
|
|
||||||
)
|
|
||||||
ServerUpdateValuePost(response) -> {
|
|
||||||
case response {
|
|
||||||
Ok(_) -> #(Model(..model, editing: False), blur_hoverbar())
|
|
||||||
Error(rsvp.HttpError(response)) -> {
|
|
||||||
io.println_error("HTTP error while updating value: " <> response.body)
|
|
||||||
#(model, effect.none())
|
|
||||||
}
|
|
||||||
Error(err) -> {
|
|
||||||
echo err
|
|
||||||
#(model, effect.none())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
UserClickedCell(row, column) -> assign_selection(model:, to: #(row, column))
|
|
||||||
// TODO
|
|
||||||
UserDoubleClickedCell(_row, _column) -> #(model, case model.editing {
|
|
||||||
True -> effect.none()
|
|
||||||
False -> focus_hoverbar()
|
|
||||||
})
|
|
||||||
UserPressedDirectionalKey(key) -> {
|
|
||||||
case model.editing, model.selections {
|
|
||||||
False, [#(selected_row, selected_col)] -> {
|
|
||||||
let first_row_selected = selected_row == 0
|
|
||||||
// No need to subtract 1 from pkeys size, because there's another
|
|
||||||
// row for "insert".
|
|
||||||
let last_row_selected = selected_row == dict.size(model.pkeys)
|
|
||||||
let first_col_selected = selected_col == 0
|
|
||||||
let last_col_selected = selected_col == dict.size(model.fields) - 1
|
|
||||||
case
|
|
||||||
key,
|
|
||||||
first_row_selected,
|
|
||||||
last_row_selected,
|
|
||||||
first_col_selected,
|
|
||||||
last_col_selected
|
|
||||||
{
|
|
||||||
"ArrowLeft", _, _, False, _ ->
|
|
||||||
assign_selection(model:, to: #(selected_row, selected_col - 1))
|
|
||||||
"ArrowRight", _, _, _, False ->
|
|
||||||
assign_selection(model:, to: #(selected_row, selected_col + 1))
|
|
||||||
"ArrowUp", False, _, _, _ ->
|
|
||||||
assign_selection(model:, to: #(selected_row - 1, selected_col))
|
|
||||||
"ArrowDown", _, False, _, _ ->
|
|
||||||
assign_selection(model:, to: #(selected_row + 1, selected_col))
|
|
||||||
_, _, _, _, _ -> #(model, effect.none())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
False, [] ->
|
|
||||||
case dict.size(model.pkeys) > 0 && dict.size(model.fields) > 0 {
|
|
||||||
True -> assign_selection(model:, to: #(0, 0))
|
|
||||||
False -> #(model, effect.none())
|
|
||||||
}
|
|
||||||
_, _ -> #(model, effect.none())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
UserPressedEnterKey -> #(model, case model.editing {
|
|
||||||
True -> effect.none()
|
|
||||||
False -> focus_hoverbar()
|
|
||||||
})
|
|
||||||
UserPressedTypingKey(key) ->
|
|
||||||
case model.editing, model.selections, set.is_empty(model.modifiers_held) {
|
|
||||||
False, [_], True -> #(model, overwrite_in_hoverbar(value: key))
|
|
||||||
_, _, _ -> #(model, effect.none())
|
|
||||||
}
|
|
||||||
UserPressedModifierKey(key) -> #(
|
|
||||||
Model(..model, modifiers_held: model.modifiers_held |> set.insert(key)),
|
|
||||||
effect.none(),
|
|
||||||
)
|
|
||||||
UserReleasedModifierKey(key) -> #(
|
|
||||||
Model(..model, modifiers_held: model.modifiers_held |> set.delete(key)),
|
|
||||||
effect.none(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn overwrite_in_hoverbar(value value: String) -> Effect(Msg) {
|
|
||||||
use _, _ <- effect.before_paint()
|
|
||||||
do_overwrite_in_hoverbar(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
@external(javascript, "./viewer_controller_component.ffi.mjs", "overwriteInHoverbar")
|
|
||||||
fn do_overwrite_in_hoverbar(value _value: String) -> Nil {
|
|
||||||
Nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fn commit_change(model model: Model, value value: Encodable) -> Effect(Msg) {
|
|
||||||
case model.selections {
|
|
||||||
[#(row, col)] ->
|
|
||||||
rsvp.post(
|
|
||||||
"./update-value",
|
|
||||||
json.object([
|
|
||||||
#(
|
|
||||||
"column",
|
|
||||||
model.fields
|
|
||||||
|> dict.get(col)
|
|
||||||
|> result.map(fn(field) { field.name })
|
|
||||||
|> result.unwrap("")
|
|
||||||
|> json.string(),
|
|
||||||
),
|
|
||||||
#(
|
|
||||||
"pkeys",
|
|
||||||
model.pkeys
|
|
||||||
|> dict.get(row)
|
|
||||||
|> result.unwrap(dict.new())
|
|
||||||
|> json.dict(fn(x) { x }, encodable.to_json),
|
|
||||||
),
|
|
||||||
#("value", encodable.to_json(value)),
|
|
||||||
]),
|
|
||||||
rsvp.expect_ok_response(ServerUpdateValuePost),
|
|
||||||
)
|
|
||||||
_ -> todo
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Updater for when selection is being assigned to a single cell.
|
|
||||||
fn assign_selection(
|
|
||||||
model model: Model,
|
|
||||||
to coords: #(Int, Int),
|
|
||||||
) -> #(Model, Effect(Msg)) {
|
|
||||||
// Multiple sets of conditions fall back to the "selection actually changed"
|
|
||||||
// scenario, so this is a little awkward to write without a return keyword.
|
|
||||||
let early_return = case model.selections {
|
|
||||||
[prev_coords] ->
|
|
||||||
case prev_coords == coords {
|
|
||||||
True -> Some(#(model, effect.none()))
|
|
||||||
False -> None
|
|
||||||
}
|
|
||||||
_ -> None
|
|
||||||
}
|
|
||||||
case early_return {
|
|
||||||
Some(value) -> value
|
|
||||||
None -> #(
|
|
||||||
Model(..model, selections: [coords]),
|
|
||||||
effect.batch([
|
|
||||||
sync_cell_value_to_hoverbar(
|
|
||||||
coords,
|
|
||||||
model.fields
|
|
||||||
|> dict.get(pair.second(coords))
|
|
||||||
|> result.map(fn(f) { f.field_type })
|
|
||||||
|> result.unwrap(field_type.Unknown),
|
|
||||||
),
|
|
||||||
change_selected_attrs([coords]),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------- Effects -------- //
|
|
||||||
|
|
||||||
fn focus_hoverbar() -> Effect(Msg) {
|
|
||||||
use _dispatch, _root <- effect.before_paint()
|
|
||||||
do_focus_hoverbar()
|
|
||||||
}
|
|
||||||
|
|
||||||
@external(javascript, "./viewer_controller_component.ffi.mjs", "focusHoverbar")
|
|
||||||
fn do_focus_hoverbar() -> Nil {
|
|
||||||
Nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fn blur_hoverbar() -> Effect(Msg) {
|
|
||||||
use _dispatch, _root <- effect.before_paint()
|
|
||||||
do_blur_hoverbar()
|
|
||||||
}
|
|
||||||
|
|
||||||
@external(javascript, "./viewer_controller_component.ffi.mjs", "blurHoverbar")
|
|
||||||
fn do_blur_hoverbar() -> Nil {
|
|
||||||
Nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Effect that changes [selected=] attributes on assigned children. Should only
|
|
||||||
/// be called when the new selection is different than the previous one (that
|
|
||||||
/// is, not when selection is "moving" from a cell to the same cell).
|
|
||||||
fn change_selected_attrs(to_coords coords: List(#(Int, Int))) -> Effect(msg) {
|
|
||||||
use _, _ <- effect.before_paint()
|
|
||||||
do_clear_selected_attrs()
|
|
||||||
coords
|
|
||||||
|> list.map(fn(coords) {
|
|
||||||
let #(row, column) = coords
|
|
||||||
do_set_cell_attr(row:, column:, key: "selected", value: "true")
|
|
||||||
})
|
|
||||||
Nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_cell_value(coords coords: #(Int, Int), value value: Encodable) {
|
|
||||||
use _, _ <- effect.before_paint()
|
|
||||||
let #(row, col) = coords
|
|
||||||
do_set_cell_attr(
|
|
||||||
row,
|
|
||||||
col,
|
|
||||||
"value",
|
|
||||||
value |> encodable.to_json() |> json.to_string(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@external(javascript, "./viewer_controller_component.ffi.mjs", "clearSelectedAttrs")
|
|
||||||
fn do_clear_selected_attrs() -> Nil {
|
|
||||||
Nil
|
|
||||||
}
|
|
||||||
|
|
||||||
@external(javascript, "./viewer_controller_component.ffi.mjs", "setCellAttr")
|
|
||||||
fn do_set_cell_attr(
|
|
||||||
row _row: Int,
|
|
||||||
column _column: Int,
|
|
||||||
key _key: String,
|
|
||||||
value _value: String,
|
|
||||||
) -> Nil {
|
|
||||||
Nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sync_cell_value_to_hoverbar(
|
|
||||||
coords coords: #(Int, Int),
|
|
||||||
field_type f_type: FieldType,
|
|
||||||
) -> Effect(Msg) {
|
|
||||||
use _, _root <- effect.before_paint()
|
|
||||||
let #(row, column) = coords
|
|
||||||
do_sync_cell_value_to_hoverbar(
|
|
||||||
row:,
|
|
||||||
column:,
|
|
||||||
field_type: f_type |> field_type.to_json() |> json.to_string(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@external(javascript, "./viewer_controller_component.ffi.mjs", "syncCellValueToHoverbar")
|
|
||||||
fn do_sync_cell_value_to_hoverbar(
|
|
||||||
row _row: Int,
|
|
||||||
column _column: Int,
|
|
||||||
field_type _field_type: String,
|
|
||||||
) -> Nil {
|
|
||||||
Nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------- View -------- //
|
|
||||||
|
|
||||||
fn view(_: Model) -> Element(Msg) {
|
|
||||||
html.div(
|
|
||||||
[
|
|
||||||
context.on_context_request(
|
|
||||||
dynamic.string("root_path"),
|
|
||||||
ChildRequestedRootPath,
|
|
||||||
),
|
|
||||||
event.on("cell-click", {
|
|
||||||
use msg <- decode.field("detail", {
|
|
||||||
use row <- decode.field("row", decode.int)
|
|
||||||
use column <- decode.field("column", decode.int)
|
|
||||||
decode.success(UserClickedCell(row, column))
|
|
||||||
})
|
|
||||||
decode.success(msg)
|
|
||||||
}),
|
|
||||||
event.on("cell-dblclick", {
|
|
||||||
use msg <- decode.field("detail", {
|
|
||||||
use row <- decode.field("row", decode.int)
|
|
||||||
use column <- decode.field("column", decode.int)
|
|
||||||
decode.success(UserDoubleClickedCell(row, column))
|
|
||||||
})
|
|
||||||
decode.success(msg)
|
|
||||||
}),
|
|
||||||
event.on("edit-start", decode.success(ChildEmittedEditStartEvent)),
|
|
||||||
event.on("edit-update", {
|
|
||||||
use original <- decode.subfield(
|
|
||||||
["detail", "original"],
|
|
||||||
encodable.decoder(),
|
|
||||||
)
|
|
||||||
use edited <- decode.subfield(["detail", "edited"], encodable.decoder())
|
|
||||||
decode.success(ChildEmittedEditUpdateEvent(original:, edited:))
|
|
||||||
}),
|
|
||||||
event.on("edit-end", {
|
|
||||||
use original <- decode.subfield(
|
|
||||||
["detail", "original"],
|
|
||||||
encodable.decoder(),
|
|
||||||
)
|
|
||||||
use edited <- decode.subfield(["detail", "edited"], encodable.decoder())
|
|
||||||
decode.success(ChildEmittedEditEndEvent(original:, edited:))
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
[component.default_slot([], [])],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
export function registerComponentMethod(root, name, callback) {
|
|
||||||
root.host[name] = callback;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function focusAtEnd(root) {
|
|
||||||
const input = root.querySelector('[tabindex="0"]');
|
|
||||||
if (input) {
|
|
||||||
const tmp = input.value;
|
|
||||||
input.value = "";
|
|
||||||
input.focus();
|
|
||||||
input.value = tmp;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function blurAll(root) {
|
|
||||||
root.querySelectorAll("input").forEach((element) => {
|
|
||||||
element.blur();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,358 +0,0 @@
|
||||||
import gleam/dynamic.{type Dynamic}
|
|
||||||
import gleam/dynamic/decode
|
|
||||||
import gleam/int
|
|
||||||
import gleam/io
|
|
||||||
import gleam/json
|
|
||||||
import gleam/option.{type Option, None, Some}
|
|
||||||
import gleam/result
|
|
||||||
import lustre.{type App}
|
|
||||||
import lustre/attribute as attr
|
|
||||||
import lustre/component
|
|
||||||
import lustre/effect.{type Effect}
|
|
||||||
import lustre/element.{type Element}
|
|
||||||
import lustre/element/html
|
|
||||||
import lustre/event
|
|
||||||
|
|
||||||
import encodable.{type Encodable}
|
|
||||||
import field_type.{type FieldType}
|
|
||||||
import hoverbar
|
|
||||||
|
|
||||||
pub const name: String = "viewer-hoverbar"
|
|
||||||
|
|
||||||
pub fn component() -> App(Nil, Model, Msg) {
|
|
||||||
lustre.component(init, update, view, [
|
|
||||||
component.on_attribute_change("root-path", fn(value) {
|
|
||||||
ParentChangedRootPath(value) |> Ok
|
|
||||||
}),
|
|
||||||
component.on_attribute_change("field-type", fn(value) {
|
|
||||||
json.parse(value, field_type.decoder())
|
|
||||||
|> result.map(ParentChangedFieldType)
|
|
||||||
|> result.replace_error(Nil)
|
|
||||||
}),
|
|
||||||
component.on_attribute_change("value", fn(value) {
|
|
||||||
json.parse(value, decode.optional(encodable.decoder()))
|
|
||||||
|> result.map(ParentChangedValue)
|
|
||||||
|> result.replace_error(Nil)
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type Model {
|
|
||||||
Model(
|
|
||||||
hoverbar_model: hoverbar.Model,
|
|
||||||
root_path: String,
|
|
||||||
field_type: FieldType,
|
|
||||||
value: Option(Encodable),
|
|
||||||
edit_state: EditState,
|
|
||||||
editing: Bool,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type EditState {
|
|
||||||
Text(value: Option(String))
|
|
||||||
Inactive
|
|
||||||
}
|
|
||||||
|
|
||||||
fn to_edit_state(value: Encodable, _: FieldType) -> EditState {
|
|
||||||
case value {
|
|
||||||
encodable.Integer(Some(value)) -> Text(Some(value |> int.to_string))
|
|
||||||
encodable.Integer(None) -> Text(None)
|
|
||||||
encodable.Text(Some(value)) -> Text(Some(value))
|
|
||||||
encodable.Text(None) -> Text(None)
|
|
||||||
encodable.Timestamptz(Some(value)) -> Text(Some(value))
|
|
||||||
encodable.Timestamptz(None) -> Text(None)
|
|
||||||
encodable.Uuid(Some(value)) -> Text(Some(value))
|
|
||||||
encodable.Uuid(None) -> Text(None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_edit_state(
|
|
||||||
edit_state: EditState,
|
|
||||||
f_type: FieldType,
|
|
||||||
) -> Result(Encodable, Nil) {
|
|
||||||
case f_type, edit_state {
|
|
||||||
field_type.Text, Text(value) -> Ok(encodable.Text(value))
|
|
||||||
field_type.Integer, Text(Some(value)) ->
|
|
||||||
value |> int.parse |> result.map(Some) |> result.map(encodable.Integer)
|
|
||||||
field_type.Integer, Text(None) -> Ok(encodable.Integer(None))
|
|
||||||
field_type.InterimUser, _ -> todo
|
|
||||||
// TODO validate uuid format
|
|
||||||
field_type.Uuid, Text(value) -> Ok(encodable.Uuid(value))
|
|
||||||
field_type.Timestamp(_format), _ -> todo
|
|
||||||
field_type.Unknown, _ -> Error(Nil)
|
|
||||||
_, Inactive -> Error(Nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn init(_) -> #(Model, Effect(Msg)) {
|
|
||||||
let #(hoverbar_model, hoverbar_effect) = hoverbar.init(Nil)
|
|
||||||
#(
|
|
||||||
Model(
|
|
||||||
hoverbar_model: hoverbar.Model(..hoverbar_model, tab_key: "editor"),
|
|
||||||
root_path: "",
|
|
||||||
field_type: field_type.Unknown,
|
|
||||||
value: None,
|
|
||||||
edit_state: Inactive,
|
|
||||||
editing: False,
|
|
||||||
),
|
|
||||||
effect.batch([
|
|
||||||
hoverbar_effect |> effect.map(HoverbarMsg),
|
|
||||||
register_component_methods(),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn register_component_methods() -> Effect(Msg) {
|
|
||||||
use dispatch, root <- effect.before_paint()
|
|
||||||
register_component_method(root, "focus", fn() {
|
|
||||||
dispatch(ParentRequestedFocus)
|
|
||||||
})
|
|
||||||
register_component_method(root, "blur", fn() { dispatch(ParentRequestedBlur) })
|
|
||||||
register_component_method(root, "overwrite", fn(value: String) {
|
|
||||||
dispatch(ParentRequestedOverwrite(value))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
@external(javascript, "./viewer_hoverbar_component.ffi.mjs", "registerComponentMethod")
|
|
||||||
fn register_component_method(
|
|
||||||
on _root: Dynamic,
|
|
||||||
name _name: String,
|
|
||||||
callback _callback: cb,
|
|
||||||
) -> Nil {
|
|
||||||
Nil
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type Msg {
|
|
||||||
HoverbarMsg(hoverbar.Msg)
|
|
||||||
ComponentCycledInputValue
|
|
||||||
ParentChangedRootPath(String)
|
|
||||||
ParentChangedFieldType(FieldType)
|
|
||||||
ParentChangedValue(Option(Encodable))
|
|
||||||
ParentRequestedFocus
|
|
||||||
ParentRequestedBlur
|
|
||||||
ParentRequestedOverwrite(String)
|
|
||||||
UserBlurredInput
|
|
||||||
UserChangedInput(String)
|
|
||||||
UserFocusedInput
|
|
||||||
UserPressedKey(String)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
|
|
||||||
case msg {
|
|
||||||
HoverbarMsg(hoverbar_msg) -> {
|
|
||||||
let #(hoverbar_model, hoverbar_effect) =
|
|
||||||
hoverbar.update(model.hoverbar_model, hoverbar_msg)
|
|
||||||
#(
|
|
||||||
Model(..model, hoverbar_model:),
|
|
||||||
hoverbar_effect |> effect.map(HoverbarMsg),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ComponentCycledInputValue -> #(
|
|
||||||
Model(..model, editing: True),
|
|
||||||
emit_edit_start_event(),
|
|
||||||
)
|
|
||||||
ParentChangedRootPath(root_path) -> #(
|
|
||||||
Model(..model, root_path:),
|
|
||||||
effect.none(),
|
|
||||||
)
|
|
||||||
ParentChangedFieldType(f_type) -> {
|
|
||||||
#(Model(..model, field_type: f_type), effect.none())
|
|
||||||
}
|
|
||||||
ParentChangedValue(value) -> #(
|
|
||||||
Model(
|
|
||||||
..model,
|
|
||||||
value:,
|
|
||||||
edit_state: value
|
|
||||||
|> option.map(to_edit_state(_, model.field_type))
|
|
||||||
|> option.unwrap(Text(None)),
|
|
||||||
),
|
|
||||||
effect.none(),
|
|
||||||
)
|
|
||||||
ParentRequestedFocus -> {
|
|
||||||
#(model, focus_at_end())
|
|
||||||
}
|
|
||||||
ParentRequestedBlur -> {
|
|
||||||
#(model, blur_all())
|
|
||||||
}
|
|
||||||
ParentRequestedOverwrite(value) -> {
|
|
||||||
case model.edit_state {
|
|
||||||
Text(_) -> {
|
|
||||||
let new_model = Model(..model, edit_state: Text(Some(value)))
|
|
||||||
#(
|
|
||||||
new_model,
|
|
||||||
effect.batch([focus_at_end(), try_emit_edit_update_event(new_model)]),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
_ -> #(model, effect.none())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Place responsibility for committing the value on the viewer-controller.
|
|
||||||
// If the blur was due to the user clicking on a different cell, for
|
|
||||||
// example, the controller may wish to prevent the selection change if the
|
|
||||||
// edited value cannot be committed.
|
|
||||||
UserBlurredInput -> try_emit_edit_end_event(model)
|
|
||||||
UserChangedInput(value) -> {
|
|
||||||
case model.editing {
|
|
||||||
True -> {
|
|
||||||
let new_model = Model(..model, edit_state: Text(Some(value)))
|
|
||||||
#(new_model, try_emit_edit_update_event(new_model))
|
|
||||||
}
|
|
||||||
False -> #(model, effect.none())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
UserFocusedInput -> #(
|
|
||||||
Model(..model, editing: True),
|
|
||||||
emit_edit_start_event(),
|
|
||||||
)
|
|
||||||
UserPressedKey(key) ->
|
|
||||||
case key {
|
|
||||||
"Enter" -> try_emit_edit_end_event(model)
|
|
||||||
"Escape" -> cancel_edit(model)
|
|
||||||
_ -> #(model, effect.none())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cycle the value of the input element so that the cursor is placed at the end.
|
|
||||||
fn focus_at_end() -> Effect(Msg) {
|
|
||||||
use _dispatch, root <- effect.before_paint()
|
|
||||||
do_focus_at_end(in: root)
|
|
||||||
}
|
|
||||||
|
|
||||||
@external(javascript, "./viewer_hoverbar_component.ffi.mjs", "focusAtEnd")
|
|
||||||
fn do_focus_at_end(in _root: Dynamic) -> Nil {
|
|
||||||
Nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fn blur_all() -> Effect(Msg) {
|
|
||||||
use _dispatch, root <- effect.before_paint()
|
|
||||||
do_blur_all(root)
|
|
||||||
}
|
|
||||||
|
|
||||||
@external(javascript, "./viewer_hoverbar_component.ffi.mjs", "blurAll")
|
|
||||||
fn do_blur_all(in _root: Dynamic) -> Nil {
|
|
||||||
Nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fn emit_edit_start_event() -> Effect(Msg) {
|
|
||||||
event.emit("edit-start", json.null())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn try_emit_edit_update_event(model: Model) -> Effect(Msg) {
|
|
||||||
case model.value, from_edit_state(model.edit_state, model.field_type) {
|
|
||||||
Some(original_value), Ok(edited_value) -> {
|
|
||||||
event.emit(
|
|
||||||
"edit-update",
|
|
||||||
json.object([
|
|
||||||
#("original", original_value |> encodable.to_json),
|
|
||||||
#("edited", edited_value |> encodable.to_json),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
_, _ -> effect.none()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn try_emit_edit_end_event(model: Model) -> #(Model, Effect(Msg)) {
|
|
||||||
case model.value, from_edit_state(model.edit_state, model.field_type) {
|
|
||||||
Some(original_value), Ok(edited_value) -> #(
|
|
||||||
Model(..model, editing: False),
|
|
||||||
event.emit(
|
|
||||||
"edit-end",
|
|
||||||
json.object([
|
|
||||||
#("original", original_value |> encodable.to_json),
|
|
||||||
#("edited", edited_value |> encodable.to_json),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
// TODO flag error to user
|
|
||||||
_, _ -> #(model, effect.none())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cancel_edit(model: Model) -> #(Model, Effect(Msg)) {
|
|
||||||
let new_model =
|
|
||||||
Model(
|
|
||||||
..model,
|
|
||||||
edit_state: model.value
|
|
||||||
|> option.map(to_edit_state(_, model.field_type))
|
|
||||||
|> option.unwrap(Inactive),
|
|
||||||
)
|
|
||||||
#(
|
|
||||||
new_model,
|
|
||||||
effect.batch([try_emit_edit_update_event(new_model), blur_all()]),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(model: Model) -> Element(Msg) {
|
|
||||||
element.fragment([
|
|
||||||
html.link([
|
|
||||||
attr.rel("stylesheet"),
|
|
||||||
attr.href(model.root_path <> "/css_dist/viewer_hoverbar.css"),
|
|
||||||
]),
|
|
||||||
hoverbar.view(
|
|
||||||
model: model.hoverbar_model,
|
|
||||||
map_msg: HoverbarMsg,
|
|
||||||
tabs: [
|
|
||||||
hoverbar.TabItem(
|
|
||||||
icon: model.root_path <> "/heroicons/16/solid/chart-bar.svg",
|
|
||||||
label: "Editor",
|
|
||||||
key: "editor",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
control_bar: fn(tab_key) {
|
|
||||||
case tab_key {
|
|
||||||
"editor" -> editor_control_bar(model)
|
|
||||||
_ -> element.none()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
control_panel: fn(_) { None },
|
|
||||||
),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn editor_control_bar(model: Model) -> Element(Msg) {
|
|
||||||
case model.edit_state {
|
|
||||||
Text(value) ->
|
|
||||||
html.div([attr.class("text-editor")], [
|
|
||||||
html.input([
|
|
||||||
attr.type_("text"),
|
|
||||||
attr.class("text-editor__input"),
|
|
||||||
attr.value(value |> option.unwrap("")),
|
|
||||||
attr.placeholder(case value |> option.is_none() {
|
|
||||||
True -> "Null"
|
|
||||||
False -> "Empty string"
|
|
||||||
}),
|
|
||||||
attr.tabindex(0),
|
|
||||||
event.on_focus(UserFocusedInput),
|
|
||||||
event.on_blur(UserBlurredInput),
|
|
||||||
event.on_input(UserChangedInput),
|
|
||||||
event.on_keydown(UserPressedKey),
|
|
||||||
]),
|
|
||||||
html.div([attr.class("toggle__container")], [
|
|
||||||
html.input([
|
|
||||||
attr.type_("checkbox"),
|
|
||||||
attr.id("null-checkbox"),
|
|
||||||
attr.class("toggle__checkbox"),
|
|
||||||
case value {
|
|
||||||
Some("") | None -> attr.none()
|
|
||||||
_ -> attr.disabled(True)
|
|
||||||
},
|
|
||||||
attr.checked(value |> option.is_none()),
|
|
||||||
]),
|
|
||||||
html.label(
|
|
||||||
[
|
|
||||||
attr.class("toggle__label"),
|
|
||||||
case value {
|
|
||||||
Some("") | None -> attr.none()
|
|
||||||
_ -> attr.class("toggle__label--disabled")
|
|
||||||
},
|
|
||||||
attr.for("null-checkbox"),
|
|
||||||
],
|
|
||||||
[html.text("Null")],
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
])
|
|
||||||
Inactive -> html.div([attr.class("editor")], [])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Add table
Reference in a new issue