misc cleanup

This commit is contained in:
Brent Schroeter 2025-09-08 15:56:57 -07:00
parent 10dee07a43
commit f6118e4d5b
66 changed files with 1372 additions and 3087 deletions

404
deno.lock generated
View file

@ -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",

View file

@ -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
); );

View 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,
}
}
}

View file

@ -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 {

View file

@ -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,
}
}
}

View file

@ -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;

View 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 {},
}

View file

@ -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
}
}

View file

@ -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,

View file

@ -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;

View file

@ -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),

View file

@ -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>,

View file

@ -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};

View file

@ -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)

View file

@ -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?;

View file

@ -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| {

View file

@ -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>

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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>

View file

@ -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"]

View file

@ -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
View 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;
}
}

View file

@ -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;
}

View file

@ -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;
}
}

View file

@ -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;
}
}
}

View file

@ -1,4 +1,5 @@
@use 'globals'; @use 'globals';
@use 'collapsible_menu';
$background-current-item: #0001; $background-current-item: #0001;

View file

@ -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;
} }
} }
} }

View 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>

View 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>

View file

@ -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");
} }

View file

@ -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}

View 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>;

View file

@ -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;

View file

@ -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",

View 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>

View 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}

View file

@ -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>

View file

@ -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(),
}); });

View 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");
}

View file

@ -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
View file

@ -1,4 +0,0 @@
*.beam
*.ez
/build
erl_crash.dump

View file

@ -1,24 +0,0 @@
# glm
[![Package Version](https://img.shields.io/hexpm/v/glm)](https://hex.pm/packages/glm)
[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](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
```

View file

@ -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

View file

@ -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"

View file

@ -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" }

View file

@ -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()],
),
])
}

View file

@ -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"))],
)
})
}

View file

@ -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"
},
),
),
],
)
})
}

View file

@ -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", [], [])],
),
]),
])
}

View file

@ -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);
}

View file

@ -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
}

View file

@ -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()
}),
])
}

View file

@ -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:))
}

View file

@ -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);
}

View file

@ -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")],
),
]),
],
)
}

View file

@ -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")
}
}

View file

@ -1 +0,0 @@

View file

@ -1,6 +0,0 @@
export function showPopover(root) {
const controlPanel = root.querySelector(".control-panel__container");
if (controlPanel) {
controlPanel.showPopover();
}
}

View file

@ -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()
}
}

View file

@ -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([], [])],
)
}

View file

@ -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;
}

View file

@ -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([], [])],
)
}

View file

@ -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();
});
}

View file

@ -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")], [])
}
}