+ + {/* Header with title */} + + {toolName} + + Explore data with visualizations, powered by AI agents. + - - Explore data with visualizations, powered by AI agents. - {actionButtons} - {/* Interactive Features Carousel */} - - - {/* Left Arrow */} - + {features.map((feature, index) => ( + - - - - {/* Feature Content */} - {/* Text Content */} - {features[currentFeature].title} + {feature.title} - {features[currentFeature].description} + {feature.description} - - {/* Feature Indicators */} - - {features.map((_, index) => ( - setCurrentFeature(index)} - sx={{ - width: 32, - height: 4, - borderRadius: 2, - bgcolor: index === currentFeature - ? theme.palette.primary.main - : alpha(theme.palette.text.secondary, 0.2), - cursor: 'pointer', - '&:hover': { - bgcolor: index === currentFeature - ? theme.palette.primary.main - : alpha(theme.palette.text.secondary, 0.4), - } - }} - /> - ))} - {/* Media Content */} @@ -292,30 +177,18 @@ export const About: FC<{}> = function About({ }) { flex: 1, borderRadius: 2, overflow: 'hidden', - boxShadow: '0 4px 12px rgba(0,0,0,0.1)', - minWidth: 300, - maxWidth: 500, + border: '1px solid rgba(0,0,0,0.1)', }}> - {features[currentFeature].mediaType === 'video' ? ( + {feature.mediaType === 'video' ? ( { - const video = e.currentTarget as HTMLVideoElement; - if (video.duration && !isNaN(video.duration)) { - videoDurationsRef.current.set( - features[currentFeature].media, - video.duration - ); - } - }} + aria-label={`Video demonstration: ${feature.title}`} sx={{ width: '100%', height: 'auto', @@ -325,8 +198,8 @@ export const About: FC<{}> = function About({ }) { ) : ( = function About({ }) { )} - - {/* Right Arrow */} - - - - - - - {/* Screenshots Carousel Section */} - - - {/* Screenshot Container */} - setCurrentScreenshot((currentScreenshot + 1) % screenshots.length)} - sx={{ - height: 680, - width: 'auto', - borderRadius: 8, - cursor: 'pointer', - overflow: 'hidden', - border: '1px solid rgba(0,0,0,0.1)', - boxShadow: '0 4px 12px rgba(0,0,0,0.1)', - position: 'relative', - display: 'flex', - justifyContent: 'center', - textDecoration: 'none', - animation: 'fadeSlideIn 0.1s ease-out', - '&:hover': { - boxShadow: '0 8px 24px rgba(0,0,0,0.2)', - '& .description-overlay': { - opacity: 1, - } - } - }} - > - - - - {screenshots[currentScreenshot].description} - - - - - {/* Screenshot Indicators */} - - {screenshots.map((_, index) => ( - setCurrentScreenshot(index)} - sx={{ - width: 32, - height: 4, - borderRadius: 2, - bgcolor: index === currentScreenshot - ? theme.palette.primary.main - : alpha(theme.palette.text.secondary, 0.2), - cursor: 'pointer', - transition: 'all 0.3s ease', - '&:hover': { - bgcolor: index === currentScreenshot - ? theme.palette.primary.main - : alpha(theme.palette.text.secondary, 0.4), - } - }} - /> - ))} - - + ))} - - - How does Data Formulator handle your data? - - -
  • Data Storage: Uploaded data (csv, xlsx, json, clipboard, messy data etc.) is stored in browser's local storage only
  • -
  • Data Processing: Local installation runs Python on your machine; online demo sends the data to server for data transformations (but not stored)
  • -
  • Database: Only available for locally installed Data Formulator (a DuckDB database file is created in temp directory to store data); not available in online demo
  • -
  • LLM Endpoints: Small data samples are sent to LLM endpoints along with the prompt. Use your trusted model provider if working with private data.
  • -
    - - Research Prototype from Microsoft Research - -
    + + Data handling: Data stored in browser only • Local install runs Python locally; online demo processes server-side (not stored) • LLM receives small samples with prompts + + + Research Prototype from Microsoft Research +
    {/* Footer */} - + - + ) } diff --git a/src/views/DataFormulator.tsx b/src/views/DataFormulator.tsx index ead4e94..0ea145b 100644 --- a/src/views/DataFormulator.tsx +++ b/src/views/DataFormulator.tsx @@ -331,7 +331,7 @@ export const DataFormulatorFC = ({ }) => { zIndex: 1000, }}> - + {toolName} From dedf01627dbd0dbd6513a4a3d35c01218eba2361 Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Tue, 9 Dec 2025 11:51:14 -0800 Subject: [PATCH 02/21] update vega-lite --- package.json | 2 +- yarn.lock | 154 +++++++++++++++++++++++---------------------------- 2 files changed, 71 insertions(+), 85 deletions(-) diff --git a/package.json b/package.json index 66bfcfd..f8a7b0e 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "validator": "^13.15.20", "vega": "^6.2.0", "vega-embed": "^6.21.0", - "vega-lite": "^5.5.0", + "vega-lite": "6.4.1", "vm-browserify": "^1.1.2" }, "scripts": { diff --git a/yarn.lock b/yarn.lock index f1d9bfb..069c709 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1134,7 +1134,7 @@ dependencies: dompurify "*" -"@types/estree@1.0.8", "@types/estree@^1.0.0", "@types/estree@^1.0.6", "@types/estree@^1.0.8": +"@types/estree@1.0.8", "@types/estree@^1.0.6", "@types/estree@^1.0.8": version "1.0.8" resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz" integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== @@ -1367,10 +1367,10 @@ allotment@^1.20.4: lodash.isequal "^4.5.0" use-resize-observer "^9.0.0" -ansi-regex@^5.0.1: - version "5.0.1" - resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz" - integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== +ansi-regex@^6.0.1: + version "6.2.2" + resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1" + integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== ansi-styles@^3.2.1: version "3.2.1" @@ -1379,13 +1379,18 @@ ansi-styles@^3.2.1: dependencies: color-convert "^1.9.0" -ansi-styles@^4.0.0, ansi-styles@^4.1.0: +ansi-styles@^4.1.0: version "4.3.0" resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz" integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== dependencies: color-convert "^2.0.1" +ansi-styles@^6.2.1: + version "6.2.3" + resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041" + integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== + archiver-utils@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz" @@ -1733,14 +1738,14 @@ classnames@^2.3.0: resolved "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz" integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow== -cliui@^8.0.1: - version "8.0.1" - resolved "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz" - integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== +cliui@^9.0.1: + version "9.0.1" + resolved "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz#6f7890f386f6f1f79953adc1f78dec46fcc2d291" + integrity sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w== dependencies: - string-width "^4.2.0" - strip-ansi "^6.0.1" - wrap-ansi "^7.0.0" + string-width "^7.2.0" + strip-ansi "^7.1.0" + wrap-ansi "^9.0.0" clsx@^2.0.0, clsx@^2.1.1: version "2.1.1" @@ -2320,10 +2325,10 @@ duplexer2@~0.1.4: dependencies: readable-stream "^2.0.2" -emoji-regex@^8.0.0: - version "8.0.0" - resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz" - integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== +emoji-regex@^10.3.0: + version "10.6.0" + resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz#bf3d6e8f7f8fd22a65d9703475bc0147357a6b0d" + integrity sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A== emoji-regex@^9.2.2: version "9.2.2" @@ -2824,6 +2829,11 @@ get-caller-file@^2.0.5: resolved "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== +get-east-asian-width@^1.0.0: + version "1.4.0" + resolved "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz#9bc4caa131702b4b61729cb7e42735bc550c9ee6" + integrity sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q== + get-intrinsic@^1.2.4, get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.2.7, get-intrinsic@^1.3.0: version "1.3.0" resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz" @@ -3147,11 +3157,6 @@ is-finalizationregistry@^1.1.0: dependencies: call-bound "^1.0.3" -is-fullwidth-code-point@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz" - integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== - is-generator-function@^1.0.10: version "1.1.2" resolved "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz" @@ -4171,11 +4176,6 @@ regexp.prototype.flags@^1.5.3, regexp.prototype.flags@^1.5.4: gopd "^1.2.0" set-function-name "^2.0.2" -require-directory@^2.1.1: - version "2.1.1" - resolved "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz" - integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== - reselect@^4.1.8: version "4.1.8" resolved "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz" @@ -4458,14 +4458,14 @@ stop-iteration-iterator@^1.1.0: es-errors "^1.3.0" internal-slot "^1.1.0" -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== +string-width@^7.0.0, string-width@^7.2.0: + version "7.2.0" + resolved "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz#b5bb8e2165ce275d4d43476dd2700ad9091db6dc" + integrity sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ== dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" + emoji-regex "^10.3.0" + get-east-asian-width "^1.0.0" + strip-ansi "^7.1.0" string.prototype.includes@^2.0.1: version "2.0.1" @@ -4549,12 +4549,12 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== +strip-ansi@^7.1.0: + version "7.1.2" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba" + integrity sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA== dependencies: - ansi-regex "^5.0.1" + ansi-regex "^6.0.1" strip-json-comments@^3.1.1: version "3.1.1" @@ -4801,10 +4801,10 @@ uuid@^8.3.0: resolved "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== -validator@^13.15.22: - version "13.15.22" - resolved "https://registry.yarnpkg.com/validator/-/validator-13.15.22.tgz#5f847cf4a799107e5716fc87e5cf2a337a71eb14" - integrity sha512-uT/YQjiyLJP7HSrv/dPZqK9L28xf8hsNca01HSz1dfmI0DgMfjopp1rO/z13NeGF1tVystF0Ejx3y4rUKPw+bQ== +validator@^13.15.20: + version "13.15.23" + resolved "https://registry.npmjs.org/validator/-/validator-13.15.23.tgz#59a874f84e4594588e3409ab1edbe64e96d0c62d" + integrity sha512-4yoz1kEWqUjzi5zsPbAS/903QXSYp0UOtHsPpp7p9rHAw/W+dkInskAE386Fat3oKRROwO98d9ZB0G4cObgUyw== vega-canvas@^2.0.0: version "2.0.0" @@ -4871,11 +4871,6 @@ vega-event-selector@^4.0.0, vega-event-selector@~4.0.0: resolved "https://registry.yarnpkg.com/vega-event-selector/-/vega-event-selector-4.0.0.tgz#425e9f2671e858a1a45b4b6a7fc452ca0b22abbf" integrity sha512-CcWF4m4KL/al1Oa5qSzZ5R776q8lRxCj3IafCHs5xipoEHrkgu1BWa7F/IH5HrDNXeIDnqOpSV1pFsAWRak4gQ== -vega-event-selector@~3.0.1: - version "3.0.1" - resolved "https://registry.npmjs.org/vega-event-selector/-/vega-event-selector-3.0.1.tgz" - integrity sha512-K5zd7s5tjr1LiOOkjGpcVls8GsH/f2CWCrWcpKy74gTCp+llCdwz0Enqo013ZlGaRNjfgD/o1caJRt3GSaec4A== - vega-expression@^6.1.0, vega-expression@~6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/vega-expression/-/vega-expression-6.1.0.tgz#6ce358a39b9b953806bff200f6f84f44163c9e38" @@ -4884,14 +4879,6 @@ vega-expression@^6.1.0, vega-expression@~6.1.0: "@types/estree" "^1.0.8" vega-util "^2.1.0" -vega-expression@~5.1.1: - version "5.1.2" - resolved "https://registry.npmjs.org/vega-expression/-/vega-expression-5.1.2.tgz" - integrity sha512-fFeDTh4UtOxlZWL54jf1ZqJHinyerWq/ROiqrQxqLkNJRJ86RmxYTgXwt65UoZ/l4VUv9eAd2qoJeDEf610Umw== - dependencies: - "@types/estree" "^1.0.0" - vega-util "^1.17.3" - vega-force@~5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/vega-force/-/vega-force-5.1.0.tgz#aa7cf8edbe2ae3bada070f343565dfb841e501a9" @@ -4969,17 +4956,17 @@ vega-label@~2.1.0: vega-scenegraph "^5.1.0" vega-util "^2.1.0" -vega-lite@^5.5.0: - version "5.23.0" - resolved "https://registry.npmjs.org/vega-lite/-/vega-lite-5.23.0.tgz" - integrity sha512-l4J6+AWE3DIjvovEoHl2LdtCUkfm4zs8Xxx7INwZEAv+XVb6kR6vIN1gt3t2gN2gs/y4DYTs/RPoTeYAuEg6mA== +vega-lite@6.4.1: + version "6.4.1" + resolved "https://registry.npmjs.org/vega-lite/-/vega-lite-6.4.1.tgz#549634ecaefd46d00f17e7922577d0c97a4663c5" + integrity sha512-KO3ybHNouRK4A0al/+2fN9UqgTEfxrd/ntGLY933Hg5UOYotDVQdshR3zn7OfXwQ7uj0W96Vfa5R+QxO8am3IQ== dependencies: json-stringify-pretty-compact "~4.0.0" tslib "~2.8.1" - vega-event-selector "~3.0.1" - vega-expression "~5.1.1" - vega-util "~1.17.2" - yargs "~17.7.2" + vega-event-selector "~4.0.0" + vega-expression "~6.1.0" + vega-util "~2.1.0" + yargs "~18.0.0" vega-loader@^5.1.0, vega-loader@~5.1.0: version "5.1.0" @@ -5130,7 +5117,7 @@ vega-typings@~2.1.0: vega-expression "^6.1.0" vega-util "^2.1.0" -vega-util@^1.13.1, vega-util@^1.17.2, vega-util@^1.17.3, vega-util@^1.17.4, vega-util@~1.17.2: +vega-util@^1.13.1, vega-util@^1.17.2, vega-util@^1.17.4: version "1.17.4" resolved "https://registry.npmjs.org/vega-util/-/vega-util-1.17.4.tgz" integrity sha512-+y3ZW7dEqM8Ck+KRsd+jkMfxfE7MrQxUyIpNjkfhIpGEreym+aTn7XUw1DKXqclr8mqTQvbilPo16B3lnBr0wA== @@ -5332,14 +5319,14 @@ word-wrap@^1.2.5: resolved "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== +wrap-ansi@^9.0.0: + version "9.0.2" + resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz#956832dea9494306e6d209eb871643bb873d7c98" + integrity sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww== dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" + ansi-styles "^6.2.1" + string-width "^7.0.0" + strip-ansi "^7.1.0" wrappy@1: version "1.0.2" @@ -5361,23 +5348,22 @@ yaml@^1.10.0: resolved "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz" integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== -yargs-parser@^21.1.1: - version "21.1.1" - resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz" - integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== +yargs-parser@^22.0.0: + version "22.0.0" + resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz#87b82094051b0567717346ecd00fd14804b357c8" + integrity sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw== -yargs@~17.7.2: - version "17.7.2" - resolved "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz" - integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== +yargs@~18.0.0: + version "18.0.0" + resolved "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz#6c84259806273a746b09f579087b68a3c2d25bd1" + integrity sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg== dependencies: - cliui "^8.0.1" + cliui "^9.0.1" escalade "^3.1.1" get-caller-file "^2.0.5" - require-directory "^2.1.1" - string-width "^4.2.3" + string-width "^7.2.0" y18n "^5.0.5" - yargs-parser "^21.1.1" + yargs-parser "^22.0.0" yocto-queue@^0.1.0: version "0.1.0" From 125290237bf02aaa02ec47a1f760bf8f4af85963 Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Fri, 16 Jan 2026 17:52:07 -0800 Subject: [PATCH 03/21] planning on some data input redesign --- .../agents/agent_py_data_rec.py | 13 +- .../agents/agent_py_data_transform.py | 2 +- py-src/data_formulator/tables_routes.py | 82 +++ pyproject.toml | 2 +- src/app/App.tsx | 75 +- src/app/dfSlice.tsx | 23 + src/app/utils.tsx | 3 + src/data/utils.ts | 66 +- src/scss/App.scss | 23 + src/views/DBTableManager.tsx | 283 +------- src/views/DataFormulator.tsx | 34 +- src/views/DataLoadingChat.tsx | 85 +-- src/views/DataThread.tsx | 280 +++++++- src/views/DerivedDataDialog.tsx | 191 ----- src/views/TableSelectionView.tsx | 666 +----------------- src/views/VisualizationView.tsx | 2 +- 16 files changed, 491 insertions(+), 1339 deletions(-) delete mode 100644 src/views/DerivedDataDialog.tsx diff --git a/py-src/data_formulator/agents/agent_py_data_rec.py b/py-src/data_formulator/agents/agent_py_data_rec.py index 3fb1e5a..2fce791 100644 --- a/py-src/data_formulator/agents/agent_py_data_rec.py +++ b/py-src/data_formulator/agents/agent_py_data_rec.py @@ -33,7 +33,7 @@ "display_instruction": "..." // string, the even shorter verb phrase describing the users' goal. "recommendation": "..." // string, explain why this recommendation is made "output_fields": [...] // string[], describe the desired output fields that the output data should have (i.e., the goal of transformed data), it's a good idea to preseve intermediate fields here - "chart_type": "" // string, one of "point", "bar", "line", "area", "heatmap", "group_bar". "chart_type" should either be inferred from user instruction, or recommend if the user didn't specify any. + "chart_type": "" // string, one of "point", "bar", "line", "area", "heatmap", "group_bar", 'boxplot'. "chart_type" should either be inferred from user instruction, or recommend if the user didn't specify any. "chart_encodings": { "x": "", "y": "", @@ -65,7 +65,7 @@ - if you mention column names from the input or the output data, highlight the text in **bold**. * the column can either be a column in the input data, or a new column that will be computed in the output data. * the mention don't have to be exact match, it can be semantically matching, e.g., if you mentioned "average score" in the text while the column to be computed is "Avg_Score", you should still highlight "**average score**" in the text. - - "chart_type" must be one of "point", "bar", "line", "area", "heatmap", "group_bar" + - "chart_type" must be one of "point", "bar", "line", "area", "heatmap", "group_bar", "boxplot" - "chart_encodings" should specify which fields should be used to create the visualization - decide which visual channels should be used to create the visualization appropriate for the chart type. - point: x, y, color, size, facet @@ -75,6 +75,7 @@ - area: x, y, color, facet - heatmap: x, y, color, facet - group_bar: x, y, color, facet + - boxplot: x, y, color, facet - note that all fields used in "chart_encodings" should be included in "output_fields". - all fields you need for visualizations should be transformed into the output fields! - "output_fields" should include important intermediate fields that are not used in visualization but are used for data transformation. @@ -108,6 +109,10 @@ - best for: Trends over time, continuous data - (heatmap) Heatmaps: x,y: Categorical (you need to convert quantitative to nominal), color: Quantitative intensity, - best for: Pattern discovery in matrix data + - (boxplot) Box plots: x: Categorical (nominal/ordinal), y: Quantitative, color: Categorical (optional for creating grouped boxplots), + - best for: Distribution of a quantitative field + - use x values directly if x values are categorical, and transform the data into bins if the field values are quantitative. + - when color is specified, the boxplot will be grouped automatically (items with the same x values will be grouped). - facet channel is available for all chart types, it supports a categorical field with small cardinality to visualize the data in different facets. - if you really need additional legend fields: - you can use opacity for legend (support Quantitative and Categorical). @@ -135,7 +140,7 @@ 2. Then, write a python function based on the inferred goal, the function input is a dataframe "df" (or multiple dataframes based on tables presented in the [CONTEXT] section) and the output is the transformed dataframe "transformed_df". "transformed_df" should contain all "output_fields" from the refined user intent in the json object. -The python function must follow the template provided in [TEMPLATE], do not import any other libraries or modify function name. The function should be as simple as possible and easily readable. +The python function must follow the template provided in [TEMPLATE]. The function should be as simple as possible and easily readable. If there is no data transformation needed based on "output_fields", the transformation function can simply "return df". [TEMPLATE] @@ -144,7 +149,7 @@ import pandas as pd import collections import numpy as np -from sklearn import ... # import necessary libraries from sklearn if needed +# from sklearn import ... # import from sklearn if you need it. def transform_data(df1, df2, ...): # complete the template here diff --git a/py-src/data_formulator/agents/agent_py_data_transform.py b/py-src/data_formulator/agents/agent_py_data_transform.py index 6bed538..b492857 100644 --- a/py-src/data_formulator/agents/agent_py_data_transform.py +++ b/py-src/data_formulator/agents/agent_py_data_transform.py @@ -89,7 +89,7 @@ import pandas as pd import collections import numpy as np -from sklearn import ... # import necessary libraries from sklearn if needed +# from sklearn import ... # import from sklearn if you need it. def transform_data(df1, df2, ...): # complete the template here diff --git a/py-src/data_formulator/tables_routes.py b/py-src/data_formulator/tables_routes.py index 74f31f2..b065e04 100644 --- a/py-src/data_formulator/tables_routes.py +++ b/py-src/data_formulator/tables_routes.py @@ -841,4 +841,86 @@ def data_loader_ingest_data_from_query(): return jsonify({ "status": "error", "message": safe_msg + }), status_code + + +@tables_bp.route('/refresh-derived-data', methods=['POST']) +def refresh_derived_data(): + """ + Re-run Python transformation code with new input data to refresh a derived table. + + This endpoint takes: + - input_tables: list of {name: string, rows: list} objects representing the parent tables + - code: the Python transformation code to execute + + Returns: + - status: 'ok' or 'error' + - rows: the resulting rows if successful + - message: error message if failed + """ + try: + from data_formulator.py_sandbox import run_transform_in_sandbox2020 + from flask import current_app + + data = request.get_json() + input_tables = data.get('input_tables', []) + code = data.get('code', '') + + if not input_tables: + return jsonify({ + "status": "error", + "message": "No input tables provided" + }), 400 + + if not code: + return jsonify({ + "status": "error", + "message": "No transformation code provided" + }), 400 + + # Convert input tables to pandas DataFrames + df_list = [] + for table in input_tables: + table_name = table.get('name', '') + table_rows = table.get('rows', []) + + if not table_rows: + return jsonify({ + "status": "error", + "message": f"Table '{table_name}' has no rows" + }), 400 + + df = pd.DataFrame.from_records(table_rows) + df_list.append(df) + + # Get exec_python_in_subprocess setting from app config + exec_python_in_subprocess = current_app.config.get('CLI_ARGS', {}).get('exec_python_in_subprocess', False) + + # Run the transformation code + result = run_transform_in_sandbox2020(code, df_list, exec_python_in_subprocess) + + if result['status'] == 'ok': + result_df = result['content'] + + # Convert result DataFrame to list of records + rows = json.loads(result_df.to_json(orient='records', date_format='iso')) + + return jsonify({ + "status": "ok", + "rows": rows, + "message": "Successfully refreshed derived data" + }) + else: + return jsonify({ + "status": "error", + "message": result.get('content', 'Unknown error during transformation') + }), 400 + + except Exception as e: + logger.error(f"Error refreshing derived data: {str(e)}") + logger.error(traceback.format_exc()) + safe_msg, status_code = sanitize_db_error_message(e) + return jsonify({ + "status": "error", + "message": safe_msg }), status_code \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 71e11f8..37c8b16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "data_formulator" -version = "0.5.1" +version = "0.5.1.1" requires-python = ">=3.9" authors = [ diff --git a/src/app/App.tsx b/src/app/App.tsx index fdf0396..0bcfea5 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -64,16 +64,17 @@ import { DictTable } from '../components/ComponentType'; import { AppDispatch } from './store'; import dfLogo from '../assets/df-logo.png'; import { ModelSelectionButton } from '../views/ModelSelectionDialog'; -import { TableCopyDialogV2 } from '../views/TableSelectionView'; -import { TableUploadDialog } from '../views/TableSelectionView'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import ContentPasteIcon from '@mui/icons-material/ContentPaste'; import UploadFileIcon from '@mui/icons-material/UploadFile'; import DownloadIcon from '@mui/icons-material/Download'; -import { DBTableSelectionDialog, handleDBDownload } from '../views/DBTableManager'; +import { handleDBDownload } from '../views/DBTableManager'; import CloudQueueIcon from '@mui/icons-material/CloudQueue'; import { getUrls } from './utils'; -import { DataLoadingChatDialog } from '../views/DataLoadingChat'; +import { UnifiedDataUploadDialog, UploadTabType } from '../views/UnifiedDataUploadDialog'; +import LinkIcon from '@mui/icons-material/Link'; +import ImageSearchIcon from '@mui/icons-material/ImageSearch'; +import ExploreIcon from '@mui/icons-material/Explore'; import ChatIcon from '@mui/icons-material/Chat'; import { AgentRulesDialog } from '../views/AgentRulesDialog'; import ArticleIcon from '@mui/icons-material/Article'; @@ -221,18 +222,14 @@ export interface AppFCProps { // Extract menu components into separate components to prevent full app re-renders const TableMenu: React.FC = () => { const [anchorEl, setAnchorEl] = useState(null); - const [openDialog, setOpenDialog] = useState<'database' | 'extract' | 'paste' | 'upload' | null>(null); - const fileInputRef = React.useRef(null); + const [dialogOpen, setDialogOpen] = useState(false); + const [initialTab, setInitialTab] = useState('menu'); const open = Boolean(anchorEl); - const handleOpenDialog = (dialog: 'database' | 'extract' | 'paste' | 'upload') => { + const handleOpenDialog = (tab: UploadTabType) => { setAnchorEl(null); - if (dialog === 'upload') { - // For file upload, trigger the hidden file input - fileInputRef.current?.click(); - } else { - setOpenDialog(dialog); - } + setInitialTab(tab); + setDialogOpen(true); }; return ( @@ -262,43 +259,45 @@ const TableMenu: React.FC = () => { '& .MuiTypography-root': { fontSize: 14, display: 'flex', alignItems: 'center', textTransform: 'none', gap: 1 } }} > - handleOpenDialog('database')}> + handleOpenDialog('upload')}> - connect to database + upload file (csv/json/xlsx) - handleOpenDialog('extract')}> + handleOpenDialog('paste')}> - extract data (image/messy text) + paste data (csv/tsv/json) - handleOpenDialog('paste')}> - - paste data (csv/tsv) + handleOpenDialog('url')}> + + from URL - handleOpenDialog('upload')}> - - upload data file (csv/tsv/json) + + handleOpenDialog('database')}> + + database + + + handleOpenDialog('extract')}> + + extract data (image/text) + + + + handleOpenDialog('explore')}> + + explore samples - {/* Dialogs rendered outside the Menu to avoid keyboard event issues */} - setOpenDialog(null)} - /> - setOpenDialog(null)} - /> - setOpenDialog(null)} - /> - setDialogOpen(false)} + initialTab={initialTab} /> ); diff --git a/src/app/dfSlice.tsx b/src/app/dfSlice.tsx index 393d134..7664b0f 100644 --- a/src/app/dfSlice.tsx +++ b/src/app/dfSlice.tsx @@ -475,6 +475,29 @@ export const dataFormulatorSlice = createSlice({ let attachedMetadata = action.payload.attachedMetadata; state.tables = state.tables.map(t => t.id == tableId ? {...t, attachedMetadata} : t); }, + updateTableRows: (state, action: PayloadAction<{tableId: string, rows: any[]}>) => { + // Update the rows of a table while preserving all other table properties + // This is used for refreshing data in original (non-derived) tables + let tableId = action.payload.tableId; + let newRows = action.payload.rows; + + state.tables = state.tables.map(t => { + if (t.id == tableId) { + // Update metadata type inference based on new data + let newMetadata = { ...t.metadata }; + for (let name of t.names) { + if (newRows.length > 0 && name in newRows[0]) { + newMetadata[name] = { + ...newMetadata[name], + type: inferTypeFromValueArray(newRows.map(r => r[name])), + }; + } + } + return { ...t, rows: newRows, metadata: newMetadata }; + } + return t; + }); + }, extendTableWithNewFields: (state, action: PayloadAction<{tableId: string, columnName: string, values: any[], previousName: string | undefined, parentIDs: string[]}>) => { // extend the existing extTable with new columns from the new table let newValues = action.payload.values; diff --git a/src/app/utils.tsx b/src/app/utils.tsx index 8344af1..aeee817 100644 --- a/src/app/utils.tsx +++ b/src/app/utils.tsx @@ -56,6 +56,9 @@ export function getUrls() { QUERY_COMPLETION: `/api/agent/query-completion`, GET_RECOMMENDATION_QUESTIONS: `/api/agent/get-recommendation-questions`, GENERATE_REPORT_STREAM: `/api/agent/generate-report-stream`, + + // Refresh data endpoint + REFRESH_DERIVED_DATA: `/api/tables/refresh-derived-data`, }; } diff --git a/src/data/utils.ts b/src/data/utils.ts index 4c9743f..1b98deb 100644 --- a/src/data/utils.ts +++ b/src/data/utils.ts @@ -204,35 +204,47 @@ export const loadBinaryDataWrapper = async (title: string, arrayBuffer: ArrayBuf // Create tables for each sheet const tables: DictTable[] = []; - workbook.eachSheet((worksheet, sheetId) => { - const jsonData: any[] = []; - - // Get the first row as headers - const headerRow = worksheet.getRow(1); - const headers: string[] = []; - headerRow.eachCell((cell, colNumber) => { - headers[colNumber - 1] = cell.value?.toString() || `Column${colNumber}`; - }); - - // Process data rows (skip header row) - worksheet.eachRow((row, rowNumber) => { - if (rowNumber === 1) return; // Skip header row - - const rowData: any = {}; - row.eachCell((cell, colNumber) => { - const header = headers[colNumber - 1] || `Column${colNumber}`; - rowData[header] = cell.value; + workbook.eachSheet((worksheet) => { + try { + const jsonData: any[] = []; + + // Get the first row as headers + const headerRow = worksheet.getRow(1); + const headers: string[] = []; + headerRow.eachCell((cell, colNumber) => { + headers[colNumber - 1] = cell.value?.toString() || `Column${colNumber}`; + }); + + if (headers.length === 0) { + return; + } + + // Process data rows (skip header row) + worksheet.eachRow((row, rowNumber) => { + if (rowNumber === 1) return; // Skip header row + + const rowData: any = {}; + row.eachCell((cell, colNumber) => { + const header = headers[colNumber - 1] || `Column${colNumber}`; + rowData[header] = cell.value; + }); + + // Only add row if it has data + if (Object.keys(rowData).length > 0) { + jsonData.push(rowData); + } }); - - // Only add row if it has data - if (Object.keys(rowData).length > 0) { - jsonData.push(rowData); + + if (jsonData.length === 0) { + return; } - }); - - // Create a table from the JSON data with sheet name included in the title - const sheetTable = createTableFromFromObjectArray(`${title}-${worksheet.name}`, jsonData, true); - tables.push(sheetTable); + + // Create a table from the JSON data with sheet name included in the title + const sheetTable = createTableFromFromObjectArray(`${title}-${worksheet.name}`, jsonData, true); + tables.push(sheetTable); + } catch (error) { + console.error(`Error processing sheet ${worksheet.name}:`, error); + } }); return tables; diff --git a/src/scss/App.scss b/src/scss/App.scss index 28edf46..31ca838 100644 --- a/src/scss/App.scss +++ b/src/scss/App.scss @@ -148,3 +148,26 @@ h2.view-title { transform-origin: bottom right; animation: writing-pencil 0.8s ease-in-out infinite; } + +// Fix MUI Dialog bottom scrolling issues +.MuiDialog-paper { + display: flex; + flex-direction: column; + max-height: calc(100% - 64px); + + .MuiDialogTitle-root { + flex-shrink: 0; + } + + .MuiDialogContent-root { + flex: 1 1 auto; + overflow-y: auto; + overflow-x: hidden; + box-sizing: border-box; + min-height: 0; // Ensures content can shrink in flex container + } + + .MuiDialogActions-root { + flex-shrink: 0; + } +} diff --git a/src/views/DBTableManager.tsx b/src/views/DBTableManager.tsx index da5bc8c..b883aa1 100644 --- a/src/views/DBTableManager.tsx +++ b/src/views/DBTableManager.tsx @@ -31,8 +31,6 @@ import { Chip, Collapse, styled, - ToggleButtonGroup, - ToggleButton, useTheme, Link, Checkbox @@ -58,13 +56,8 @@ import { alpha } from '@mui/material'; import { DataFormulatorState } from '../app/dfSlice'; import { fetchFieldSemanticType } from '../app/dfSlice'; import { AppDispatch } from '../app/store'; -import Editor from 'react-simple-code-editor'; import Markdown from 'markdown-to-jsx'; -import Prism from 'prismjs' -import 'prismjs/components/prism-javascript' // Language -import 'prismjs/themes/prism.css'; //Example style, you can use another -import PrecisionManufacturingIcon from '@mui/icons-material/PrecisionManufacturing'; import CheckIcon from '@mui/icons-material/Check'; import MuiMarkdown from 'mui-markdown'; import CleaningServicesIcon from '@mui/icons-material/CleaningServices'; @@ -1050,43 +1043,13 @@ export const DataLoaderForm: React.FC<{ const [displayAuthInstructions, setDisplayAuthInstructions] = useState(false); let [isConnecting, setIsConnecting] = useState(false); - let [mode, setMode] = useState<"view tables" | "query">("view tables"); const toggleDisplaySamples = (tableName: string) => { setDisplaySamples({...displaySamples, [tableName]: !displaySamples[tableName]}); } - const handleModeChange = (event: React.MouseEvent, newMode: "view tables" | "query") => { - if (newMode != null) { - setMode(newMode); - } - }; - let tableMetadataBox = [ - - - View Tables - Query Data - - - , - mode === "view tables" && + {Object.entries(tableMetadata).map(([tableName, metadata]) => { @@ -1154,7 +1117,7 @@ export const DataLoaderForm: React.FC<{
    , - mode === "view tables" && Object.keys(tableMetadata).length > 0 && + Object.keys(tableMetadata).length > 0 && - , - mode === "query" && ({name: t, fields: tableMetadata[t].columns.map((c: any) => c.name)}))} - dataLoaderParams={params} onImport={onImport} onFinish={onFinish} /> + ] return ( @@ -1338,240 +1297,4 @@ export const DataLoaderForm: React.FC<{ {Object.keys(tableMetadata).length > 0 && tableMetadataBox }
    ); -} - -export const DataQueryForm: React.FC<{ - dataLoaderType: string, - availableTables: {name: string, fields: string[]}[], - dataLoaderParams: Record, - onImport: () => void, - onFinish: (status: "success" | "error", message: string) => void -}> = ({dataLoaderType, availableTables, dataLoaderParams, onImport, onFinish}) => { - - let activeModel = useSelector(dfSelectors.getActiveModel); - - const [selectedTables, setSelectedTables] = useState(availableTables.map(t => t.name).slice(0, 5)); - - const [waiting, setWaiting] = useState(false); - - const [query, setQuery] = useState("-- query the data source / describe your goal and ask AI to help you write the query\n"); - const [queryResult, setQueryResult] = useState<{ - status: string, - message: string, - sample: any[], - code: string, - } | undefined>(undefined); - const [queryResultName, setQueryResultName] = useState(""); - - const aiCompleteQuery = (query: string) => { - if (queryResult?.status === "error") { - setQueryResult(undefined); - } - let data = { - data_source_metadata: { - data_loader_type: dataLoaderType, - tables: availableTables.filter(t => selectedTables.includes(t.name)) - }, - query: query, - model: activeModel - } - setWaiting(true); - fetch(getUrls().QUERY_COMPLETION, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(data) - }) - .then(response => response.json()) - .then(data => { - setWaiting(false); - if (data.status === "ok") { - setQuery(data.query); - } else { - onFinish("error", data.reasoning); - } - }) - .catch(error => { - setWaiting(false); - onFinish("error", `Failed to complete query please try again.`); - }); - } - - const handleViewQuerySample = (query: string) => { - setQueryResult(undefined); - setWaiting(true); - fetch(getUrls().DATA_LOADER_VIEW_QUERY_SAMPLE, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - data_loader_type: dataLoaderType, - data_loader_params: dataLoaderParams, - query: query - }) - }) - .then(response => response.json()) - .then(data => { - setWaiting(false); - if (data.status === "success") { - setQueryResult({ - status: "success", - message: "Data loaded successfully", - sample: data.sample, - code: query - }); - let newName = `r_${Math.random().toString(36).substring(2, 4)}`; - setQueryResultName(newName); - } else { - setQueryResult({ - status: "error", - message: data.message, - sample: [], - code: query - }); - } - }) - .catch(error => { - setWaiting(false); - setQueryResult({ - status: "error", - message: `Failed to view query sample, please try again.`, - sample: [], - code: query - }); - }); - } - - const handleImportQueryResult = () => { - setWaiting(true); - fetch(getUrls().DATA_LOADER_INGEST_DATA_FROM_QUERY, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - data_loader_type: dataLoaderType, - data_loader_params: dataLoaderParams, - query: queryResult?.code ?? query, - name_as: queryResultName - }) - }) - .then(response => response.json()) - .then(data => { - setWaiting(false); - if (data.status === "success") { - onFinish("success", "Data imported successfully"); - } else { - onFinish("error", data.reasoning); - } - }) - .catch(error => { - setWaiting(false); - onFinish("error", `Failed to import data, please try again.`); - }); - } - - let queryResultBox = queryResult?.status === "success" && queryResult.sample.length > 0 ? [ - - ({id: t, label: t}))} rowsPerPageNum={-1} compact={false} /> - , - - - setQueryResultName(event.target.value)} - /> - - - ] : []; - - return ( - - {waiting && - - } - - - query from tables: - - {availableTables.map((table) => ( - : undefined} - color={selectedTables.includes(table.name) ? "primary" : "default"} variant="outlined" - sx={{ fontSize: 11, margin: 0.25, - height: 20, borderRadius: 0.5, - borderColor: selectedTables.includes(table.name) ? "primary.main" : "rgba(0, 0, 0, 0.1)", - color: selectedTables.includes(table.name) ? "primary.main" : "text.secondary", - '&:hover': { - backgroundColor: "rgba(0, 0, 0, 0.07)", - } - }} - size="small" - onClick={() => { - setSelectedTables(selectedTables.includes(table.name) ? selectedTables.filter(t => t !== table.name) : [...selectedTables, table.name]); - }} - /> - ))} - - - - { - setQuery(tempCode); - }} - highlight={code => Prism.highlight(code, Prism.languages.sql, 'sql')} - padding={10} - style={{ - minHeight: queryResult ? 60 : 200, - fontFamily: '"Fira code", "Fira Mono", monospace', - fontSize: 12, - paddingBottom: '24px', - backgroundColor: "rgba(0, 0, 0, 0.03)", - overflowY: "auto" - }} - /> - - {queryResult?.status === "error" && - - {queryResult?.message} - - } - - - {queryResult?.status === "error" && } - - - {queryResult && queryResultBox} - - - ) } \ No newline at end of file diff --git a/src/views/DataFormulator.tsx b/src/views/DataFormulator.tsx index 0ea145b..20aa40e 100644 --- a/src/views/DataFormulator.tsx +++ b/src/views/DataFormulator.tsx @@ -41,17 +41,14 @@ import { VisualizationViewFC } from './VisualizationView'; import { ConceptShelf } from './ConceptShelf'; import { DndProvider } from 'react-dnd' import { HTML5Backend } from 'react-dnd-html5-backend' -import { TableCopyDialogV2, DatasetSelectionDialog } from './TableSelectionView'; -import { TableUploadDialog } from './TableSelectionView'; import { toolName } from '../app/App'; import { DataThread } from './DataThread'; import dfLogo from '../assets/df-logo.png'; import exampleImageTable from "../assets/example-image-table.png"; import { ModelSelectionButton } from './ModelSelectionDialog'; -import { DBTableSelectionDialog } from './DBTableManager'; import { getUrls } from '../app/utils'; -import { DataLoadingChatDialog } from './DataLoadingChat'; +import { UnifiedDataUploadDialog, UploadTabType } from './UnifiedDataUploadDialog'; import { ReportView } from './ReportView'; import { ExampleSession, exampleSessions, ExampleSessionCard } from './ExampleSessions'; @@ -65,6 +62,15 @@ export const DataFormulatorFC = ({ }) => { const dispatch = useDispatch(); + // State for unified data upload dialog + const [uploadDialogOpen, setUploadDialogOpen] = useState(false); + const [uploadDialogInitialTab, setUploadDialogInitialTab] = useState('menu'); + + const openUploadDialog = (tab: UploadTabType) => { + setUploadDialogInitialTab(tab); + setUploadDialogOpen(true); + }; + const handleLoadExampleSession = (session: ExampleSession) => { dispatch(dfActions.addMessages({ timestamp: Date.now(), @@ -274,19 +280,23 @@ export const DataFormulatorFC = ({ }) => { + maxWidth: 1100, fontSize: 32, color: alpha(theme.palette.text.primary, 0.8), + '& span': { textDecoration: 'underline', textUnderlineOffset: '0.2em', cursor: 'pointer', color: theme.palette.primary.main }}}> To begin, - extract}/>{' '} + {' '} openUploadDialog('extract')}>extract{' '} data from images or text documents, load {' '} - examples}/>, + {' '} openUploadDialog('explore')}>examples{' '}, upload data from{' '} - clipboard} disabled={false}/> or {' '} - files} disabled={false}/>, - + {' '} openUploadDialog('paste')}>clipboard or {' '} + {' '} openUploadDialog('upload')}>files{' '}, or connect to a{' '} - database}/>. + {' '} openUploadDialog('database')}>database{' '}. + setUploadDialogOpen(false)} + initialTab={uploadDialogInitialTab} + /> diff --git a/src/views/DataLoadingChat.tsx b/src/views/DataLoadingChat.tsx index a3273a5..1a014ba 100644 --- a/src/views/DataLoadingChat.tsx +++ b/src/views/DataLoadingChat.tsx @@ -4,9 +4,7 @@ import * as React from 'react'; import { useEffect, useRef, useState } from 'react'; -import { Box, Button, Divider, IconButton, Typography, Dialog, DialogTitle, DialogContent, Tooltip, CircularProgress } from '@mui/material'; -import RestartAltIcon from '@mui/icons-material/RestartAlt'; -import CloseIcon from '@mui/icons-material/Close'; +import { Box, Button, Divider, IconButton, Typography, Tooltip, CircularProgress } from '@mui/material'; import { useDispatch, useSelector } from 'react-redux'; @@ -266,84 +264,3 @@ export const DataLoadingChat: React.FC = () => { return chatCard; }; -export interface DataLoadingChatDialogProps { - buttonElement?: any; - disabled?: boolean; - onOpen?: () => void; - // Controlled mode props - open?: boolean; - onClose?: () => void; -} - -export const DataLoadingChatDialog: React.FC = ({ - buttonElement, - disabled = false, - onOpen, - open: controlledOpen, - onClose, -}) => { - const [internalOpen, setInternalOpen] = useState(false); - const dispatch = useDispatch(); - const dataCleanBlocks = useSelector((state: DataFormulatorState) => state.dataCleanBlocks); - - // Support both controlled and uncontrolled modes - const isControlled = controlledOpen !== undefined; - const dialogOpen = isControlled ? controlledOpen : internalOpen; - const setDialogOpen = isControlled - ? (open: boolean) => { if (!open && onClose) onClose(); } - : setInternalOpen; - - return ( - <> - {buttonElement && ( - - )} - setDialogOpen(false)} - open={dialogOpen} - sx={{ '& .MuiDialog-paper': { maxWidth: '100%', maxHeight: 840, minWidth: 800 } }} - > - - Extract Data - {dataCleanBlocks.length > 0 && - { - dispatch(dfActions.resetDataCleanBlocks()); - }}> - - - } - setDialogOpen(false)} - aria-label="close" - > - - - - - - - - - ); -}; - - diff --git a/src/views/DataThread.tsx b/src/views/DataThread.tsx index d873edb..df772a5 100644 --- a/src/views/DataThread.tsx +++ b/src/views/DataThread.tsx @@ -22,7 +22,9 @@ import { Popper, Paper, ClickAwayListener, - Badge + Badge, + Menu, + MenuItem, } from '@mui/material'; import { VegaLite } from 'react-vega' @@ -61,10 +63,15 @@ import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import CloudQueueIcon from '@mui/icons-material/CloudQueue'; import AttachFileIcon from '@mui/icons-material/AttachFile'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; +import RefreshIcon from '@mui/icons-material/Refresh'; import { alpha } from '@mui/material/styles'; import { dfSelectors } from '../app/dfSlice'; +import { RefreshDataDialog } from './RefreshDataDialog'; +import { getUrls } from '../app/utils'; +import { AppDispatch } from '../app/store'; export const ThinkingBanner = (message: string, sx?: SxProps) => ( (null); const [metadataAnchorEl, setMetadataAnchorEl] = useState(null); + // Table menu state + const [tableMenuAnchorEl, setTableMenuAnchorEl] = useState(null); + const [selectedTableForMenu, setSelectedTableForMenu] = useState(null); + + // Refresh data dialog state + const [refreshDialogOpen, setRefreshDialogOpen] = useState(false); + const [selectedTableForRefresh, setSelectedTableForRefresh] = useState(null); + const [isRefreshing, setIsRefreshing] = useState(false); + + const activeModel = useSelector(dfSelectors.getActiveModel); let handleUpdateTableDisplayId = (tableId: string, displayId: string) => { dispatch(dfActions.updateTableDisplayId({ @@ -519,6 +536,127 @@ let SingleThreadGroupView: FC<{ } }; + // Table menu handlers + const handleOpenTableMenu = (table: DictTable, anchorEl: HTMLElement) => { + setSelectedTableForMenu(table); + setTableMenuAnchorEl(anchorEl); + }; + + const handleCloseTableMenu = () => { + setTableMenuAnchorEl(null); + setSelectedTableForMenu(null); + }; + + // Refresh data handlers + const handleOpenRefreshDialog = (table: DictTable) => { + setSelectedTableForRefresh(table); + setRefreshDialogOpen(true); + handleCloseTableMenu(); + }; + + const handleCloseRefreshDialog = () => { + setRefreshDialogOpen(false); + setSelectedTableForRefresh(null); + }; + + // Function to refresh derived tables + const refreshDerivedTables = async (sourceTableId: string, newRows: any[]) => { + // Find all tables that are derived from this source table + const derivedTables = tables.filter(t => t.derive?.source?.includes(sourceTableId)); + + for (const derivedTable of derivedTables) { + if (derivedTable.derive && derivedTable.derive.code) { + // Gather all parent tables for this derived table + const parentTableData = derivedTable.derive.source.map(sourceId => { + const sourceTable = tables.find(t => t.id === sourceId); + if (sourceTable) { + // Use the new rows if this is the table being refreshed + const rows = sourceId === sourceTableId ? newRows : sourceTable.rows; + return { + name: sourceTable.id, + rows: rows + }; + } + return null; + }).filter(t => t !== null); + + if (parentTableData.length > 0) { + try { + const response = await fetch(getUrls().REFRESH_DERIVED_DATA, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + input_tables: parentTableData, + code: derivedTable.derive.code + }) + }); + + const result = await response.json(); + if (result.status === 'ok' && result.rows) { + // Update the derived table with new rows + dispatch(dfActions.updateTableRows({ + tableId: derivedTable.id, + rows: result.rows + })); + + // Recursively refresh tables derived from this one + await refreshDerivedTables(derivedTable.id, result.rows); + } else { + console.error(`Failed to refresh derived table ${derivedTable.id}:`, result.message); + dispatch(dfActions.addMessages({ + timestamp: Date.now(), + type: 'error', + component: 'data refresh', + value: `Failed to refresh derived table "${derivedTable.displayId || derivedTable.id}": ${result.message || 'Unknown error'}` + })); + } + } catch (error) { + console.error(`Error refreshing derived table ${derivedTable.id}:`, error); + dispatch(dfActions.addMessages({ + timestamp: Date.now(), + type: 'error', + component: 'data refresh', + value: `Error refreshing derived table "${derivedTable.displayId || derivedTable.id}"` + })); + } + } + } + } + }; + + const handleRefreshComplete = async (newRows: any[]) => { + if (!selectedTableForRefresh) return; + + setIsRefreshing(true); + try { + // Update the source table with new rows + dispatch(dfActions.updateTableRows({ + tableId: selectedTableForRefresh.id, + rows: newRows + })); + + // Refresh all derived tables + await refreshDerivedTables(selectedTableForRefresh.id, newRows); + + dispatch(dfActions.addMessages({ + timestamp: Date.now(), + type: 'success', + component: 'data refresh', + value: `Successfully refreshed data for "${selectedTableForRefresh.displayId || selectedTableForRefresh.id}" and updated derived tables.` + })); + } catch (error) { + console.error('Error during refresh:', error); + dispatch(dfActions.addMessages({ + timestamp: Date.now(), + type: 'error', + component: 'data refresh', + value: `Error refreshing data: ${error}` + })); + } finally { + setIsRefreshing(false); + } + }; + let buildTriggerCard = (trigger: Trigger) => { let selectedClassName = trigger.chart?.id == focusedChartId ? 'selected-card' : ''; @@ -673,43 +811,8 @@ let SingleThreadGroupView: FC<{ - {table?.derive == undefined && - { - event.stopPropagation(); - handleOpenMetadataPopup(table!, event.currentTarget); - }} - > - - - } - - {tableDeleteEnabled && - { - event.stopPropagation(); - dispatch(dfActions.deleteTable(tableId)); - }} - > - - - } - - + + {/* For non-derived, non-virtual tables: show dropdown menu with metadata, refresh, delete */} + {table?.derive == undefined && !table?.virtual && ( + + { + event.stopPropagation(); + handleOpenTableMenu(table!, event.currentTarget); + }} + > + + + + )} + + {/* For derived tables or virtual tables: show individual buttons */} + {(table?.derive != undefined || table?.virtual) && ( + <> + {tableDeleteEnabled && + { + event.stopPropagation(); + dispatch(dfActions.deleteTable(tableId)); + }} + > + + + } + + )} @@ -888,6 +1033,67 @@ let SingleThreadGroupView: FC<{ initialValue={selectedTableForMetadata?.attachedMetadata || ''} tableName={selectedTableForMetadata?.displayId || selectedTableForMetadata?.id || ''} /> + + {/* Table actions menu for non-derived, non-virtual tables */} + e.stopPropagation()} + > + { + e.stopPropagation(); + if (selectedTableForMenu) { + handleOpenMetadataPopup(selectedTableForMenu, tableMenuAnchorEl!); + } + handleCloseTableMenu(); + }} + sx={{ fontSize: '12px', display: 'flex', alignItems: 'center', gap: 1 }} + > + + {selectedTableForMenu?.attachedMetadata ? "Edit metadata" : "Attach metadata"} + + { + e.stopPropagation(); + if (selectedTableForMenu) { + handleOpenRefreshDialog(selectedTableForMenu); + } + }} + sx={{ fontSize: '12px', display: 'flex', alignItems: 'center', gap: 1 }} + > + + Refresh data + + { + e.stopPropagation(); + if (selectedTableForMenu) { + dispatch(dfActions.deleteTable(selectedTableForMenu.id)); + } + handleCloseTableMenu(); + }} + disabled={selectedTableForMenu ? tables.some(t => t.derive?.trigger.tableId === selectedTableForMenu.id) : true} + sx={{ fontSize: '12px', display: 'flex', alignItems: 'center', gap: 1, color: 'warning.main' }} + > + + Delete table + + + + {/* Refresh data dialog */} + {selectedTableForRefresh && ( + + )}
    } diff --git a/src/views/DerivedDataDialog.tsx b/src/views/DerivedDataDialog.tsx deleted file mode 100644 index aeb7785..0000000 --- a/src/views/DerivedDataDialog.tsx +++ /dev/null @@ -1,191 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { FC } from 'react' -import { - Card, - Box, - Typography, - Dialog, - DialogTitle, - DialogContent, - DialogActions, - Button, - Radio, - styled, - FormControlLabel, - CardContent, - ButtonGroup, -} from '@mui/material'; - -import React from 'react'; - -import { assembleVegaChart } from '../app/utils'; -import { Chart } from '../components/ComponentType'; -import { useSelector } from 'react-redux'; -import { DataFormulatorState } from '../app/dfSlice'; - -import { createDictTable, DictTable } from '../components/ComponentType'; -import { CodeBox } from './VisualizationView'; -import embed from 'vega-embed'; -import { CustomReactTable } from './ReactTable'; - -import DeleteIcon from '@mui/icons-material/Delete'; -import SaveIcon from '@mui/icons-material/Save'; - -export interface DerivedDataDialogProps { - chart: Chart, - candidateTables: DictTable[], - open: boolean, - handleCloseDialog: () => void, - handleSelection: (selectIndex: number) => void, - handleDeleteChart: () => void, - bodyOnly?: boolean, -} - -export const DerivedDataDialog: FC = function DerivedDataDialog({ - chart, candidateTables, open, handleCloseDialog, handleSelection, handleDeleteChart, bodyOnly }) { - - let direction = candidateTables.length > 1 ? "horizontal" : "horizontal" ; - - let [selectionIdx, setSelectionIdx] = React.useState(0); - const conceptShelfItems = useSelector((state: DataFormulatorState) => state.conceptShelfItems); - - let body = - - - {candidateTables.map((table, idx) => { - let code = table.derive?.code || ""; - let extTable = structuredClone(table.rows); - - let assembledChart: any = assembleVegaChart(chart.chartType, chart.encodingMap, conceptShelfItems, extTable, table.metadata); - assembledChart["background"] = "transparent"; - // chart["autosize"] = { - // "type": "fit", - // "contains": "padding" - // }; - const id = `chart-dialog-element-${idx}`; - - const element = - setSelectionIdx(idx)}> - ; - - embed('#' + id, assembledChart, { actions: false, renderer: "canvas" }).then(function (result) { - // Access the Vega view instance (https://vega.github.io/vega/docs/api/view/) as result.view - if (result.view.container()?.getElementsByTagName("canvas")) { - let comp = result.view.container()?.getElementsByTagName("canvas")[0]; - - // Doesn't seem like width & height are actual numbers here on Edge bug - // let width = parseInt(comp?.style.width as string); - // let height = parseInt(comp?.style.height as string); - if (comp) { - const { width, height } = comp.getBoundingClientRect(); - //console.log(`THUMB: width = ${width} height = ${height}`); - if (width > 240 || height > 180) { - let ratio = width / height; - let fixedWidth = width; - if (ratio * 180 < width) { - fixedWidth = ratio * 180; - } - if (fixedWidth > 240) { - fixedWidth = 240; - } - comp?.setAttribute("style", `max-width: 240px; max-height: 180px; width: ${Math.round(fixedWidth)}px; height: ${Math.round(fixedWidth / ratio)}px; `); - } - - } else { - console.log("THUMB: Could not get Canvas HTML5 element") - } - } - }).catch((reason) => { - // console.log(reason) - // console.error(reason) - }); - - let simpleTableView = (t: DictTable) => { - let colDefs = t.names.map(name => { - return { - id: name, label: name, minWidth: 30, align: undefined, - format: (value: any) => `${value}`, source: conceptShelfItems.find(f => f.name == name)?.source - } - }) - return - - - } - - return {setSelectionIdx(idx)}} - sx={{minWidth: "280px", maxWidth: "1920px", display: "flex", flexGrow: 1, margin: "6px", - border: selectionIdx == idx ? "2px solid rgb(2 136 209 / 0.7)": "1px solid rgba(33, 33, 33, 0.1)"}}> - - } - label={{`candidate-${idx+1} (${candidateTables[idx].id})`}} /> - - - {element} - - - - {simpleTableView(createDictTable(table.id, extTable))} - - - - - - - - - })} - - - - if (bodyOnly) { - return - - Transformation from {candidateTables[0].derive?.source} - - {body} - - - - {/* */} - - - - - - ; - } - - return ( - - Derived Data Candidates - - {body} - - - - - - - ); -} \ No newline at end of file diff --git a/src/views/TableSelectionView.tsx b/src/views/TableSelectionView.tsx index 4245025..79a127f 100644 --- a/src/views/TableSelectionView.tsx +++ b/src/views/TableSelectionView.tsx @@ -2,63 +2,13 @@ // Licensed under the MIT License. import * as React from 'react'; -import validator from 'validator'; -import DOMPurify from 'dompurify'; +import { useEffect } from 'react'; -import Tabs from '@mui/material/Tabs'; -import Tab from '@mui/material/Tab'; import Typography from '@mui/material/Typography'; import Box from '@mui/material/Box'; -import { alpha, Button, Collapse, Dialog, DialogActions, DialogContent, DialogTitle, Divider, - IconButton, Input, CircularProgress, LinearProgress, Paper, TextField, useTheme, - Card, Tooltip, Link} from '@mui/material'; +import { Button, Paper } from '@mui/material'; import { CustomReactTable } from './ReactTable'; -import { DictTable } from "../components/ComponentType"; - -import DeleteIcon from '@mui/icons-material/Delete'; -import { getUrls } from '../app/utils'; -import { createTableFromFromObjectArray, createTableFromText, loadTextDataWrapper, loadBinaryDataWrapper } from '../data/utils'; - -import CloseIcon from '@mui/icons-material/Close'; - -import { DataFormulatorState, dfActions, dfSelectors, fetchFieldSemanticType } from '../app/dfSlice'; -import { useDispatch, useSelector } from 'react-redux'; -import { useEffect, useState, useCallback } from 'react'; -import { AppDispatch } from '../app/store'; - -interface TabPanelProps { - children?: React.ReactNode; - index: number; - value: number; -} - -function TabPanel(props: TabPanelProps) { - const { children, value, index, ...other } = props; - - return ( - - ); -} - -function a11yProps(index: number) { - return { - id: `vertical-tab-${index}`, - 'aria-controls': `vertical-tabpanel-${index}`, - }; -} +import { createTableFromFromObjectArray } from '../data/utils'; // Update the interface to support multiple tables per dataset export interface DatasetMetadata { @@ -197,613 +147,3 @@ export const DatasetSelectionView: React.FC = functio
    ); } - -export const DatasetSelectionDialog: React.FC<{ buttonElement: any }> = function DatasetSelectionDialog({ buttonElement }) { - - const [datasetPreviews, setDatasetPreviews] = React.useState([]); - const [tableDialogOpen, setTableDialogOpen] = useState(false); - - React.useEffect(() => { - // Show a loading animation/message while loading - fetch(`${getUrls().EXAMPLE_DATASETS}`) - .then((response) => response.json()) - .then((result) => { - let datasets : DatasetMetadata[] = result.map((info: any) => { - let tables = info["tables"].map((table: any) => { - - if (table["format"] == "json") { - return { - table_name: table["name"], - url: table["url"], - format: table["format"], - sample: table["sample"], - } - } - else if (table["format"] == "csv" || table["format"] == "tsv") { - const delimiter = table["format"] === "csv" ? "," : "\t"; - const rows = table["sample"] - .split("\n") - .map((row: string) => row.split(delimiter)); - - // Treat first row as headers and convert to object array - if (rows.length > 0) { - const headers = rows[0]; - const dataRows = rows.slice(1); - const sampleData = dataRows.map((row: string[]) => { - const obj: any = {}; - headers.forEach((header: string, index: number) => { - obj[header] = row[index] || ''; - }); - return obj; - }); - - return { - table_name: table["name"], - url: table["url"], - format: table["format"], - sample: sampleData, - }; - } - - return { - table_name: table["name"], - url: table["url"], - format: table["format"], - sample: [], - }; - } - }) - return {tables: tables, name: info["name"], description: info["description"], source: info["source"]} - }).filter((t : DatasetMetadata | undefined) => t != undefined); - setDatasetPreviews(datasets); - }); - }, []); - - let dispatch = useDispatch(); - - return <> - - {setTableDialogOpen(false)}} - open={tableDialogOpen} - sx={{ '& .MuiDialog-paper': { maxWidth: '100%', maxHeight: 840, minWidth: 800 } }} - > - Explore - {setTableDialogOpen(false)}} - aria-label="close" - > - - - - - { - setTableDialogOpen(false); - for (let table of dataset.tables) { - fetch(table.url) - .then(res => res.text()) - .then(textData => { - let tableName = table.url.split("/").pop()?.split(".")[0] || 'table-' + Date.now().toString().substring(0, 8); - let dictTable; - if (table.format == "csv") { - dictTable = createTableFromText(tableName, textData); - } else if (table.format == "json") { - dictTable = createTableFromFromObjectArray(tableName, JSON.parse(textData), true); - } - if (dictTable) { - dispatch(dfActions.loadTable(dictTable)); - dispatch(fetchFieldSemanticType(dictTable)); - } - - }); - } - }}/> - - - -} - -export interface TableUploadDialogProps { - buttonElement?: any; - disabled?: boolean; - onOpen?: () => void; - // For external control of file input - fileInputRef?: React.RefObject; -} - -const getUniqueTableName = (baseName: string, existingNames: Set): string => { - let uniqueName = baseName; - let counter = 1; - while (existingNames.has(uniqueName)) { - uniqueName = `${baseName}_${counter}`; - counter++; - } - return uniqueName; -}; - -export const TableUploadDialog: React.FC = ({ buttonElement, disabled, onOpen, fileInputRef }) => { - const dispatch = useDispatch(); - const internalRef = React.useRef(null); - const inputRef = fileInputRef || internalRef; - const existingTables = useSelector((state: DataFormulatorState) => state.tables); - const existingNames = new Set(existingTables.map(t => t.id)); - const serverConfig = useSelector((state: DataFormulatorState) => state.serverConfig); - - let handleFileUpload = (event: React.ChangeEvent): void => { - const files = event.target.files; - - if (files) { - for (let file of files) { - const uniqueName = getUniqueTableName(file.name, existingNames); - - // Check if file is a text type (csv, tsv, json) - if (file.type === 'text/csv' || - file.type === 'text/tab-separated-values' || - file.type === 'application/json' || - file.name.endsWith('.csv') || - file.name.endsWith('.tsv') || - file.name.endsWith('.json')) { - - // Check if file is larger than 5MB - const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB in bytes - if (file.size > MAX_FILE_SIZE) { - dispatch(dfActions.addMessages({ - "timestamp": Date.now(), - "type": "error", - "component": "data loader", - "value": `File ${file.name} is too large (${(file.size / (1024 * 1024)).toFixed(2)}MB), upload it via DATABASE option instead.` - })); - continue; // Skip this file and process the next one - } - - // Handle text files - file.text().then((text) => { - let table = loadTextDataWrapper(uniqueName, text, file.type); - if (table) { - dispatch(dfActions.loadTable(table)); - dispatch(fetchFieldSemanticType(table)); - } - }); - } else if (file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' || - file.type === 'application/vnd.ms-excel' || - file.name.endsWith('.xlsx') || - file.name.endsWith('.xls')) { - // Handle Excel files - const reader = new FileReader(); - reader.onload = async (e) => { - const arrayBuffer = e.target?.result as ArrayBuffer; - if (arrayBuffer) { - try { - let tables = await loadBinaryDataWrapper(uniqueName, arrayBuffer); - for (let table of tables) { - dispatch(dfActions.loadTable(table)); - dispatch(fetchFieldSemanticType(table)); - } - if (tables.length == 0) { - dispatch(dfActions.addMessages({ - "timestamp": Date.now(), - "type": "error", - "component": "data loader", - "value": `Failed to parse Excel file ${file.name}. Please check the file format.` - })); - } - } catch (error) { - console.error('Error processing Excel file:', error); - dispatch(dfActions.addMessages({ - "timestamp": Date.now(), - "type": "error", - "component": "data loader", - "value": `Failed to parse Excel file ${file.name}. Please check the file format.` - })); - } - } - }; - reader.readAsArrayBuffer(file); - } else { - // Unsupported file type - dispatch(dfActions.addMessages({ - "timestamp": Date.now(), - "type": "error", - "component": "data loader", - "value": `Unsupported file format: ${file.name}. Please use CSV, TSV, JSON, or Excel files.` - })); - } - } - } - if (inputRef.current) { - inputRef.current.value = ''; - } - }; - - return ( - <> - - {buttonElement && ( - - Install Data Formulator locally to enable file upload.
    - Link: e.stopPropagation()} - > - https://github.com/microsoft/data-formulator - - - ) : ""} - placement="top" - > - - - -
    - )} - - ); -} - - -export interface TableCopyDialogProps { - buttonElement?: any; - disabled?: boolean; - onOpen?: () => void; - // Controlled mode props - open?: boolean; - onClose?: () => void; -} - -export interface TableURLDialogProps { - buttonElement: any; - disabled: boolean; -} - -export const TableURLDialog: React.FC = ({ buttonElement, disabled }) => { - - const [dialogOpen, setDialogOpen] = useState(false); - const [tableURL, setTableURL] = useState(""); - - const dispatch = useDispatch(); - - let handleSubmitContent = (): void => { - - let parts = tableURL.split('/'); - - // Get the last part of the URL, which should be the file name with extension - const tableName = parts[parts.length - 1]; - - fetch(tableURL) - .then(res => res.text()) - .then(content => { - let table : undefined | DictTable = undefined; - try { - let jsonContent = JSON.parse(content); - table = createTableFromFromObjectArray(tableName || 'dataset', jsonContent, true); - } catch (error) { - table = createTableFromText(tableName || 'dataset', content); - } - - if (table) { - dispatch(dfActions.loadTable(table)); - dispatch(fetchFieldSemanticType(table)); - } - }) - }; - - let hasValidSuffix = tableURL.endsWith('.csv') || tableURL.endsWith('.tsv') || tableURL.endsWith(".json"); - - let dialog = {setDialogOpen(false)}} open={dialogOpen} - sx={{ '& .MuiDialog-paper': { maxWidth: '80%', maxHeight: 800, minWidth: 800 } }} disableRestoreFocus - > - Upload data URL - { setDialogOpen(false); }} - aria-label="close" - > - - - - - { setTableURL(event.target.value.trim()); }} - id="dataset-url" label="data url" variant="outlined" /> - - - - - - ; - - return <> - - {dialog} - ; -} - - -export const TableCopyDialogV2: React.FC = ({ - buttonElement, - disabled, - onOpen, - open: controlledOpen, - onClose, -}) => { - - const [internalOpen, setInternalOpen] = useState(false); - - // Support both controlled and uncontrolled modes - const isControlled = controlledOpen !== undefined; - const dialogOpen = isControlled ? controlledOpen : internalOpen; - - const [tableContent, setTableContent] = useState(""); - const [tableContentType, setTableContentType] = useState<'text' | 'image'>('text'); - - const [cleaningInProgress, setCleaningInProgress] = useState(false); - - // Add new state for display optimization - const [displayContent, setDisplayContent] = useState(""); - const [isLargeContent, setIsLargeContent] = useState(false); - const [showFullContent, setShowFullContent] = useState(false); - const [isOverSizeLimit, setIsOverSizeLimit] = useState(false); - - // Constants for content size limits - const MAX_DISPLAY_LINES = 20; // Reduced from 30 - const LARGE_CONTENT_THRESHOLD = 50000; // ~50KB threshold - const MAX_CONTENT_SIZE = 2 * 1024 * 1024; // 2MB in bytes (same as file upload limit) - - const dispatch = useDispatch(); - const existingTables = useSelector((state: DataFormulatorState) => state.tables); - const existingNames = new Set(existingTables.map(t => t.id)); - - let handleSubmitContent = (tableStr: string): void => { - let table: undefined | DictTable = undefined; - - // Generate a short unique name based on content and time if no name provided - const defaultName = (() => { - const hashStr = tableStr.substring(0, 100) + Date.now(); - const hashCode = hashStr.split('').reduce((acc, char) => { - return ((acc << 5) - acc) + char.charCodeAt(0) | 0; - }, 0); - const shortHash = Math.abs(hashCode).toString(36).substring(0, 4); - return `data-${shortHash}`; - })(); - - const baseName = defaultName; - const uniqueName = getUniqueTableName(baseName, existingNames); - - try { - let content = JSON.parse(tableStr); - table = createTableFromFromObjectArray(uniqueName, content, true); - } catch (error) { - table = createTableFromText(uniqueName, tableStr); - } - if (table) { - dispatch(dfActions.loadTable(table)); - dispatch(fetchFieldSemanticType(table)); - } - }; - - // Optimized content change handler - const handleContentChange = useCallback((event: React.ChangeEvent) => { - const newContent = event.target.value; - setTableContent(newContent); - - // Check if content exceeds size limit - const contentSizeBytes = new Blob([newContent]).size; - const isOverLimit = contentSizeBytes > MAX_CONTENT_SIZE; - setIsOverSizeLimit(isOverLimit); - - // Check if content is large - const isLarge = newContent.length > LARGE_CONTENT_THRESHOLD; - setIsLargeContent(isLarge); - - if (isLarge && !showFullContent) { - // For large content, only show a preview in the TextField - const lines = newContent.split('\n'); - const previewLines = lines.slice(0, MAX_DISPLAY_LINES); - const preview = previewLines.join('\n') + (lines.length > MAX_DISPLAY_LINES ? '\n... (truncated for performance)' : ''); - setDisplayContent(preview); - } else { - setDisplayContent(newContent); - } - }, [showFullContent, dispatch, MAX_CONTENT_SIZE]); - - // Toggle between preview and full content - const toggleFullContent = useCallback(() => { - setShowFullContent(!showFullContent); - if (!showFullContent) { - setDisplayContent(tableContent); - } else { - const lines = tableContent.split('\n'); - const previewLines = lines.slice(0, MAX_DISPLAY_LINES); - const preview = previewLines.join('\n') + (lines.length > MAX_DISPLAY_LINES ? '\n... (truncated for performance)' : ''); - setDisplayContent(preview); - } - }, [showFullContent, tableContent]); - - - const handleCloseDialog = useCallback(() => { - if (isControlled) { - onClose?.(); - } else { - setInternalOpen(false); - } - // Reset state when closing - setTableContent(""); - setDisplayContent(""); - setIsLargeContent(false); - setIsOverSizeLimit(false); - setShowFullContent(false); - }, [isControlled, onClose]); - - let dialog = - Paste & Upload Data - - - - - - - {cleaningInProgress && tableContentType == "text" ? : ""} - - {/* Size limit warning */} - {isOverSizeLimit && ( - - - ⚠️ Content exceeds {(MAX_CONTENT_SIZE / (1024 * 1024)).toFixed(0)}MB size limit. - Current size: {(new Blob([tableContent]).size / (1024 * 1024)).toFixed(2)}MB. - Please use the DATABASE option for large datasets. - - - )} - {/* Content size indicator */} - {isLargeContent && !isOverSizeLimit && ( - - - Large content detected ({Math.round(tableContent.length / 1000)}KB). - {showFullContent ? 'Showing full content (may be slow)' : 'Showing preview for performance'} - - - - )} - - { - if (e.clipboardData.files.length > 0) { - let file = e.clipboardData.files[0]; - let read = new FileReader(); - - read.readAsDataURL(file); - read.onloadend = function(){ - let res = read.result; - console.log(res); - if (res) { - setTableContent(res as string); - setTableContentType("image"); - } - } - } - }} - autoComplete='off' - label="data content" - variant="outlined" - multiline - /> - - - - - - - - - - - - - ; - - return <> - {buttonElement && ( - - )} - {dialog} - ; -} - diff --git a/src/views/VisualizationView.tsx b/src/views/VisualizationView.tsx index 07f9819..fa55183 100644 --- a/src/views/VisualizationView.tsx +++ b/src/views/VisualizationView.tsx @@ -553,7 +553,7 @@ export const ChartEditorFC: FC<{}> = function ChartEditorFC({}) { setVisTableTotalRowCount(table.virtual?.rowCount || table.rows.length); setDataVersion(versionId); } - }, [dataRequirements]) + }, [dataRequirements, table.rows]) From f92c55fd27a9c7687f7116fca581956720c7864c Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Fri, 16 Jan 2026 17:52:35 -0800 Subject: [PATCH 04/21] redesign data loader --- src/views/RefreshDataDialog.tsx | 487 +++++++ src/views/UnifiedDataUploadDialog.tsx | 1720 +++++++++++++++++++++++++ 2 files changed, 2207 insertions(+) create mode 100644 src/views/RefreshDataDialog.tsx create mode 100644 src/views/UnifiedDataUploadDialog.tsx diff --git a/src/views/RefreshDataDialog.tsx b/src/views/RefreshDataDialog.tsx new file mode 100644 index 0000000..92fb32b --- /dev/null +++ b/src/views/RefreshDataDialog.tsx @@ -0,0 +1,487 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import React, { useState, useCallback, useRef } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + IconButton, + Typography, + Box, + TextField, + Tabs, + Tab, + LinearProgress, + Input, + Alert, + Tooltip, + Link, +} from '@mui/material'; +import CloseIcon from '@mui/icons-material/Close'; +import { useDispatch, useSelector } from 'react-redux'; +import { AppDispatch } from '../app/store'; +import { DataFormulatorState, dfActions, dfSelectors, fetchFieldSemanticType } from '../app/dfSlice'; +import { DictTable } from '../components/ComponentType'; +import { createTableFromFromObjectArray, createTableFromText, loadTextDataWrapper, loadBinaryDataWrapper } from '../data/utils'; +import { getUrls } from '../app/utils'; + +interface TabPanelProps { + children?: React.ReactNode; + index: number; + value: number; +} + +function TabPanel(props: TabPanelProps) { + const { children, value, index, ...other } = props; + return ( + + ); +} + +export interface RefreshDataDialogProps { + open: boolean; + onClose: () => void; + table: DictTable; + onRefreshComplete: (newRows: any[]) => void; +} + +export const RefreshDataDialog: React.FC = ({ + open, + onClose, + table, + onRefreshComplete, +}) => { + const dispatch = useDispatch(); + const [tabValue, setTabValue] = useState(0); + const [pasteContent, setPasteContent] = useState(''); + const [urlContent, setUrlContent] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const fileInputRef = useRef(null); + const serverConfig = useSelector((state: DataFormulatorState) => state.serverConfig); + + // Constants for content size limits + const MAX_DISPLAY_LINES = 20; + const LARGE_CONTENT_THRESHOLD = 50000; + const MAX_CONTENT_SIZE = 2 * 1024 * 1024; // 2MB + + const [displayContent, setDisplayContent] = useState(''); + const [isLargeContent, setIsLargeContent] = useState(false); + const [showFullContent, setShowFullContent] = useState(false); + const [isOverSizeLimit, setIsOverSizeLimit] = useState(false); + + const validateColumns = (newRows: any[]): { valid: boolean; message: string } => { + if (!newRows || newRows.length === 0) { + return { valid: false, message: 'No data found in the uploaded content.' }; + } + + const newColumns = Object.keys(newRows[0]).sort(); + const existingColumns = [...table.names].sort(); + + if (newColumns.length !== existingColumns.length) { + return { + valid: false, + message: `Column count mismatch. Expected ${existingColumns.length} columns (${existingColumns.join(', ')}), but got ${newColumns.length} columns (${newColumns.join(', ')}).`, + }; + } + + const missingColumns = existingColumns.filter(col => !newColumns.includes(col)); + const extraColumns = newColumns.filter(col => !existingColumns.includes(col)); + + if (missingColumns.length > 0 || extraColumns.length > 0) { + let message = 'Column names do not match.'; + if (missingColumns.length > 0) { + message += ` Missing: ${missingColumns.join(', ')}.`; + } + if (extraColumns.length > 0) { + message += ` Unexpected: ${extraColumns.join(', ')}.`; + } + return { valid: false, message }; + } + + return { valid: true, message: '' }; + }; + + const processAndValidateData = (newRows: any[]): boolean => { + const validation = validateColumns(newRows); + if (!validation.valid) { + setError(validation.message); + return false; + } + setError(null); + onRefreshComplete(newRows); + handleClose(); + return true; + }; + + const handleClose = () => { + setPasteContent(''); + setUrlContent(''); + setDisplayContent(''); + setError(null); + setIsLoading(false); + setIsLargeContent(false); + setShowFullContent(false); + setIsOverSizeLimit(false); + onClose(); + }; + + const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { + setTabValue(newValue); + setError(null); + }; + + // Handle paste content change with optimization for large content + const handlePasteContentChange = useCallback((event: React.ChangeEvent) => { + const newContent = event.target.value; + setPasteContent(newContent); + + const contentSizeBytes = new Blob([newContent]).size; + const isOverLimit = contentSizeBytes > MAX_CONTENT_SIZE; + setIsOverSizeLimit(isOverLimit); + + const isLarge = newContent.length > LARGE_CONTENT_THRESHOLD; + setIsLargeContent(isLarge); + + if (isLarge && !showFullContent) { + const lines = newContent.split('\n'); + const previewLines = lines.slice(0, MAX_DISPLAY_LINES); + const preview = previewLines.join('\n') + (lines.length > MAX_DISPLAY_LINES ? '\n... (truncated for performance)' : ''); + setDisplayContent(preview); + } else { + setDisplayContent(newContent); + } + }, [showFullContent]); + + const toggleFullContent = useCallback(() => { + setShowFullContent(!showFullContent); + if (!showFullContent) { + setDisplayContent(pasteContent); + } else { + const lines = pasteContent.split('\n'); + const previewLines = lines.slice(0, MAX_DISPLAY_LINES); + const preview = previewLines.join('\n') + (lines.length > MAX_DISPLAY_LINES ? '\n... (truncated for performance)' : ''); + setDisplayContent(preview); + } + }, [showFullContent, pasteContent]); + + // Handle paste submit + const handlePasteSubmit = () => { + if (!pasteContent.trim()) { + setError('Please paste some data.'); + return; + } + + setIsLoading(true); + try { + let newRows: any[] = []; + try { + const jsonContent = JSON.parse(pasteContent); + if (Array.isArray(jsonContent)) { + newRows = jsonContent; + } else { + setError('JSON content must be an array of objects.'); + setIsLoading(false); + return; + } + } catch { + // Try parsing as CSV/TSV + const tempTable = createTableFromText('temp', pasteContent); + if (tempTable) { + newRows = tempTable.rows; + } else { + setError('Could not parse the pasted content as JSON or CSV/TSV.'); + setIsLoading(false); + return; + } + } + processAndValidateData(newRows); + } catch (err) { + setError('Failed to parse the pasted content.'); + } finally { + setIsLoading(false); + } + }; + + // Handle URL submit + const handleUrlSubmit = () => { + if (!urlContent.trim()) { + setError('Please enter a URL.'); + return; + } + + const hasValidSuffix = urlContent.endsWith('.csv') || urlContent.endsWith('.tsv') || urlContent.endsWith('.json'); + if (!hasValidSuffix) { + setError('URL must point to a .csv, .tsv, or .json file.'); + return; + } + + setIsLoading(true); + fetch(urlContent) + .then(res => res.text()) + .then(content => { + let newRows: any[] = []; + try { + const jsonContent = JSON.parse(content); + if (Array.isArray(jsonContent)) { + newRows = jsonContent; + } else { + setError('JSON content must be an array of objects.'); + setIsLoading(false); + return; + } + } catch { + const tempTable = createTableFromText('temp', content); + if (tempTable) { + newRows = tempTable.rows; + } else { + setError('Could not parse the URL content as JSON or CSV/TSV.'); + setIsLoading(false); + return; + } + } + processAndValidateData(newRows); + }) + .catch(err => { + setError(`Failed to fetch data from URL: ${err.message}`); + }) + .finally(() => { + setIsLoading(false); + }); + }; + + // Handle file upload + const handleFileUpload = (event: React.ChangeEvent) => { + const files = event.target.files; + if (!files || files.length === 0) return; + + const file = files[0]; + setIsLoading(true); + + if (file.type === 'text/csv' || + file.type === 'text/tab-separated-values' || + file.type === 'application/json' || + file.name.endsWith('.csv') || + file.name.endsWith('.tsv') || + file.name.endsWith('.json')) { + + const MAX_FILE_SIZE = 5 * 1024 * 1024; + if (file.size > MAX_FILE_SIZE) { + setError(`File is too large (${(file.size / (1024 * 1024)).toFixed(2)}MB). Maximum size is 5MB.`); + setIsLoading(false); + return; + } + + file.text().then((text) => { + let newRows: any[] = []; + try { + const jsonContent = JSON.parse(text); + if (Array.isArray(jsonContent)) { + newRows = jsonContent; + } else { + setError('JSON content must be an array of objects.'); + setIsLoading(false); + return; + } + } catch { + const tempTable = loadTextDataWrapper('temp', text, file.type); + if (tempTable) { + newRows = tempTable.rows; + } else { + setError('Could not parse the file content.'); + setIsLoading(false); + return; + } + } + processAndValidateData(newRows); + }).catch(err => { + setError(`Failed to read file: ${err.message}`); + }).finally(() => { + setIsLoading(false); + }); + } else if (file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' || + file.type === 'application/vnd.ms-excel' || + file.name.endsWith('.xlsx') || + file.name.endsWith('.xls')) { + + const reader = new FileReader(); + reader.onload = async (e) => { + const arrayBuffer = e.target?.result as ArrayBuffer; + if (arrayBuffer) { + try { + const tables = await loadBinaryDataWrapper('temp', arrayBuffer); + if (tables.length > 0) { + processAndValidateData(tables[0].rows); + } else { + setError('Failed to parse Excel file.'); + } + } catch (err) { + setError('Failed to parse Excel file.'); + } + } + setIsLoading(false); + }; + reader.readAsArrayBuffer(file); + } else { + setError('Unsupported file format. Please use CSV, TSV, JSON, or Excel files.'); + setIsLoading(false); + } + + // Reset file input + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const hasValidUrlSuffix = urlContent.endsWith('.csv') || urlContent.endsWith('.tsv') || urlContent.endsWith('.json'); + + return ( + + + Refresh Data for "{table.displayId || table.id}" + + + + + + + Upload new data to replace the current table content. The new data must have the same columns: {table.names.join(', ')} + + + {error && ( + + {error} + + )} + + {isLoading && } + + + + + + + + + {isOverSizeLimit && ( + + + ⚠️ Content exceeds 2MB size limit. + + + )} + {isLargeContent && !isOverSizeLimit && ( + + + Large content detected. {showFullContent ? 'Showing full content' : 'Showing preview'} + + + + )} + + + + + + + Upload a CSV, TSV, JSON, or Excel file + + + + Install Data Formulator locally to enable file upload. + + ) : ""} + > + + + + + + + + + setUrlContent(e.target.value.trim())} + disabled={isLoading} + error={urlContent !== '' && !hasValidUrlSuffix} + helperText={urlContent !== '' && !hasValidUrlSuffix ? 'URL should link to a .csv, .tsv, or .json file' : ''} + sx={{ '& .MuiInputBase-input': { fontSize: 12 } }} + /> + + + + + {tabValue === 0 && ( + + )} + {tabValue === 2 && ( + + )} + + + ); +}; diff --git a/src/views/UnifiedDataUploadDialog.tsx b/src/views/UnifiedDataUploadDialog.tsx new file mode 100644 index 0000000..5424840 --- /dev/null +++ b/src/views/UnifiedDataUploadDialog.tsx @@ -0,0 +1,1720 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as React from 'react'; +import { useState, useCallback, useEffect, useRef } from 'react'; +import { + Box, + Button, + Dialog, + DialogContent, + DialogTitle, + DialogActions, + IconButton, + TextField, + Typography, + Tooltip, + LinearProgress, + Link, + Input, + alpha, + useTheme, +} from '@mui/material'; + +import CloseIcon from '@mui/icons-material/Close'; +import UploadFileIcon from '@mui/icons-material/UploadFile'; +import ContentPasteIcon from '@mui/icons-material/ContentPaste'; +import LinkIcon from '@mui/icons-material/Link'; +import StorageIcon from '@mui/icons-material/Storage'; +import ImageSearchIcon from '@mui/icons-material/ImageSearch'; +import ExploreIcon from '@mui/icons-material/Explore'; +import RestartAltIcon from '@mui/icons-material/RestartAlt'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import DeleteIcon from '@mui/icons-material/Delete'; +import AnalyticsIcon from '@mui/icons-material/Analytics'; +import CleaningServicesIcon from '@mui/icons-material/CleaningServices'; +import SearchIcon from '@mui/icons-material/Search'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import Paper from '@mui/material/Paper'; +import CircularProgress from '@mui/material/CircularProgress'; + +import { useDispatch, useSelector } from 'react-redux'; +import { DataFormulatorState, dfActions, fetchFieldSemanticType } from '../app/dfSlice'; +import { AppDispatch } from '../app/store'; +import { DictTable } from '../components/ComponentType'; +import { createTableFromFromObjectArray, createTableFromText, loadTextDataWrapper, loadBinaryDataWrapper } from '../data/utils'; +import { DataLoadingChat } from './DataLoadingChat'; +import { DatasetSelectionView, DatasetMetadata } from './TableSelectionView'; +import { getUrls } from '../app/utils'; +import { CustomReactTable } from './ReactTable'; +import { Type } from '../data/types'; +import { TableStatisticsView, DataLoaderForm, handleDBDownload } from './DBTableManager'; + +export type UploadTabType = 'menu' | 'upload' | 'paste' | 'url' | 'database' | 'extract' | 'explore'; + +interface TabPanelProps { + children?: React.ReactNode; + index: UploadTabType; + value: UploadTabType; +} + +function TabPanel(props: TabPanelProps) { + const { children, value, index, ...other } = props; + + return ( + + ); +} + +// Data source menu card component +interface DataSourceCardProps { + icon: React.ReactNode; + title: string; + description: string; + onClick: () => void; + disabled?: boolean; + disabledReason?: string; +} + +interface PreviewPanelProps { + title: string; + loading: boolean; + error: string | null; + table?: DictTable | null; + tables?: DictTable[] | null; + emptyLabel: string; + meta?: string; + onRemoveTable?: (index: number) => void; +} + +const PreviewPanel: React.FC = ({ + title, + loading, + error, + table, + tables, + emptyLabel, + meta, + onRemoveTable, +}) => { + const previewTables = tables ?? (table ? [table] : null); + const [activeIndex, setActiveIndex] = useState(0); + + useEffect(() => { + if (!previewTables || previewTables.length === 0) { + setActiveIndex(0); + return; + } + if (activeIndex > previewTables.length - 1) { + setActiveIndex(previewTables.length - 1); + } + }, [previewTables, activeIndex]); + + const activeTable = previewTables && previewTables.length > 0 ? previewTables[activeIndex] : null; + return ( + + + + {title} + + + + {loading && } + + {error && ( + + {error} + + )} + + {!loading && !error && (!previewTables || previewTables.length === 0) && ( + + {emptyLabel} + + )} + + {previewTables && previewTables.length > 0 && ( + + {previewTables.length > 1 && ( + + {previewTables.map((t, idx) => { + const label = t.displayId || t.id; + return ( + + + + ); + })} + + )} + + {activeTable && ( + + + + {activeTable.rows.length} rows × {activeTable.names.length} columns + {previewTables && previewTables.length === 1 + ? ` • ${activeTable.displayId || activeTable.id}` + : ''} + + {onRemoveTable && previewTables.length > 1 && ( + onRemoveTable(activeIndex)} + sx={{ ml: 'auto' }} + > + + + )} + + ({ + id: name, + label: name, + minWidth: 60, + }))} + rowsPerPageNum={-1} + compact={true} + isIncompleteTable={activeTable.rows.length > 12} + maxHeight={200} + /> + + )} + + )} + + ); +}; + +const DataSourceCard: React.FC = ({ + icon, + title, + description, + onClick, + disabled = false, + disabledReason +}) => { + const theme = useTheme(); + + const card = ( + + + {icon} + + + + {title} + + + {description} + + + + ); + + if (disabled && disabledReason) { + return ( + + {card} + + ); + } + + return card; +}; + +const getUniqueTableName = (baseName: string, existingNames: Set): string => { + let uniqueName = baseName; + let counter = 1; + while (existingNames.has(uniqueName)) { + uniqueName = `${baseName}_${counter}`; + counter++; + } + return uniqueName; +}; + +export interface UnifiedDataUploadDialogProps { + open: boolean; + onClose: () => void; + initialTab?: UploadTabType; +} + +export const UnifiedDataUploadDialog: React.FC = ({ + open, + onClose, + initialTab = 'menu', +}) => { + const theme = useTheme(); + const dispatch = useDispatch(); + const existingTables = useSelector((state: DataFormulatorState) => state.tables); + const serverConfig = useSelector((state: DataFormulatorState) => state.serverConfig); + const dataCleanBlocks = useSelector((state: DataFormulatorState) => state.dataCleanBlocks); + const existingNames = new Set(existingTables.map(t => t.id)); + + const [activeTab, setActiveTab] = useState(initialTab === 'menu' ? 'menu' : initialTab); + const fileInputRef = useRef(null); + + // Paste tab state + const [pasteContent, setPasteContent] = useState(""); + const [displayContent, setDisplayContent] = useState(""); + const [isLargeContent, setIsLargeContent] = useState(false); + const [showFullContent, setShowFullContent] = useState(false); + const [isOverSizeLimit, setIsOverSizeLimit] = useState(false); + + // URL tab state + const [tableURL, setTableURL] = useState(""); + const [urlPreviewTable, setUrlPreviewTable] = useState(null); + const [urlPreviewLoading, setUrlPreviewLoading] = useState(false); + const [urlPreviewError, setUrlPreviewError] = useState(null); + const [urlPreviewUrl, setUrlPreviewUrl] = useState(""); + + // File preview state + const [filePreviewTables, setFilePreviewTables] = useState(null); + const [filePreviewLoading, setFilePreviewLoading] = useState(false); + const [filePreviewError, setFilePreviewError] = useState(null); + const [filePreviewFiles, setFilePreviewFiles] = useState([]); + + // Sample datasets state + const [datasetPreviews, setDatasetPreviews] = useState([]); + + // Constants + const MAX_DISPLAY_LINES = 20; + const LARGE_CONTENT_THRESHOLD = 50000; + const MAX_CONTENT_SIZE = 2 * 1024 * 1024; + + // Update active tab when initialTab changes + useEffect(() => { + if (open) { + setActiveTab(initialTab === 'menu' ? 'menu' : initialTab); + } + }, [initialTab, open]); + + useEffect(() => { + if (tableURL !== urlPreviewUrl) { + setUrlPreviewTable(null); + setUrlPreviewError(null); + } + }, [tableURL, urlPreviewUrl]); + + // Load sample datasets + useEffect(() => { + if (open && activeTab === 'explore') { + fetch(`${getUrls().EXAMPLE_DATASETS}`) + .then((response) => response.json()) + .then((result) => { + let datasets: DatasetMetadata[] = result.map((info: any) => { + let tables = info["tables"].map((table: any) => { + if (table["format"] == "json") { + return { + table_name: table["name"], + url: table["url"], + format: table["format"], + sample: table["sample"], + } + } + else if (table["format"] == "csv" || table["format"] == "tsv") { + const delimiter = table["format"] === "csv" ? "," : "\t"; + const rows = table["sample"] + .split("\n") + .map((row: string) => row.split(delimiter)); + + if (rows.length > 0) { + const headers = rows[0]; + const dataRows = rows.slice(1); + const sampleData = dataRows.map((row: string[]) => { + const obj: any = {}; + headers.forEach((header: string, index: number) => { + obj[header] = row[index] || ''; + }); + return obj; + }); + + return { + table_name: table["name"], + url: table["url"], + format: table["format"], + sample: sampleData, + }; + } + + return { + table_name: table["name"], + url: table["url"], + format: table["format"], + sample: [], + }; + } + }) + return { tables: tables, name: info["name"], description: info["description"], source: info["source"] } + }).filter((t: DatasetMetadata | undefined) => t != undefined); + setDatasetPreviews(datasets); + }); + } + }, [open, activeTab]); + + const handleClose = useCallback(() => { + // Reset state when closing + setPasteContent(""); + setDisplayContent(""); + setIsLargeContent(false); + setIsOverSizeLimit(false); + setShowFullContent(false); + setTableURL(""); + setUrlPreviewTable(null); + setUrlPreviewLoading(false); + setUrlPreviewError(null); + setUrlPreviewUrl(""); + setFilePreviewTables(null); + setFilePreviewLoading(false); + setFilePreviewError(null); + setFilePreviewFiles([]); + onClose(); + }, [onClose]); + + // File upload handler + const handleFileUpload = (event: React.ChangeEvent): void => { + const files = event.target.files; + + if (files && files.length > 0) { + const selectedFiles = Array.from(files); + setFilePreviewFiles(selectedFiles); + setFilePreviewError(null); + setFilePreviewTables(null); + setFilePreviewLoading(true); + + const MAX_FILE_SIZE = 5 * 1024 * 1024; + const previewTables: DictTable[] = []; + const errors: string[] = []; + + const processFiles = async () => { + for (const file of selectedFiles) { + const uniqueName = getUniqueTableName(file.name, existingNames); + const isTextFile = file.type === 'text/csv' || + file.type === 'text/tab-separated-values' || + file.type === 'application/json' || + file.name.endsWith('.csv') || + file.name.endsWith('.tsv') || + file.name.endsWith('.json'); + const isExcelFile = file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' || + file.type === 'application/vnd.ms-excel' || + file.name.endsWith('.xlsx') || + file.name.endsWith('.xls'); + + if (file.size > MAX_FILE_SIZE && isTextFile) { + errors.push(`File ${file.name} is too large (${(file.size / (1024 * 1024)).toFixed(2)}MB). Use Database for large files.`); + continue; + } + + if (isTextFile) { + try { + const text = await file.text(); + const table = loadTextDataWrapper(uniqueName, text, file.type); + if (table) { + previewTables.push(table); + } else { + errors.push(`Failed to parse ${file.name}.`); + } + } catch { + errors.push(`Failed to read ${file.name}.`); + } + continue; + } + + if (isExcelFile) { + try { + const arrayBuffer = await file.arrayBuffer(); + const tables = await loadBinaryDataWrapper(uniqueName, arrayBuffer); + if (tables.length > 0) { + previewTables.push(...tables); + } else { + errors.push(`Failed to parse Excel file ${file.name}.`); + } + } catch { + errors.push(`Failed to parse Excel file ${file.name}.`); + } + continue; + } + + errors.push(`Unsupported file format: ${file.name}.`); + } + + setFilePreviewTables(previewTables.length > 0 ? previewTables : null); + setFilePreviewError(errors.length > 0 ? errors.join(' ') : null); + setFilePreviewLoading(false); + }; + + processFiles(); + } + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const handleFileLoadSubmit = (): void => { + if (!filePreviewTables || filePreviewTables.length === 0) { + return; + } + for (let table of filePreviewTables) { + dispatch(dfActions.loadTable(table)); + dispatch(fetchFieldSemanticType(table)); + } + handleClose(); + }; + + const handleRemoveFilePreviewTable = (index: number): void => { + setFilePreviewTables((prev) => { + if (!prev) return prev; + const next = prev.filter((_, i) => i !== index); + return next.length > 0 ? next : null; + }); + }; + + // Paste content handler + const handleContentChange = useCallback((event: React.ChangeEvent) => { + const newContent = event.target.value; + setPasteContent(newContent); + + const contentSizeBytes = new Blob([newContent]).size; + const isOverLimit = contentSizeBytes > MAX_CONTENT_SIZE; + setIsOverSizeLimit(isOverLimit); + + const isLarge = newContent.length > LARGE_CONTENT_THRESHOLD; + setIsLargeContent(isLarge); + + if (isLarge && !showFullContent) { + const lines = newContent.split('\n'); + const previewLines = lines.slice(0, MAX_DISPLAY_LINES); + const preview = previewLines.join('\n') + (lines.length > MAX_DISPLAY_LINES ? '\n... (truncated for performance)' : ''); + setDisplayContent(preview); + } else { + setDisplayContent(newContent); + } + }, [showFullContent, MAX_CONTENT_SIZE]); + + const toggleFullContent = useCallback(() => { + setShowFullContent(!showFullContent); + if (!showFullContent) { + setDisplayContent(pasteContent); + } else { + const lines = pasteContent.split('\n'); + const previewLines = lines.slice(0, MAX_DISPLAY_LINES); + const preview = previewLines.join('\n') + (lines.length > MAX_DISPLAY_LINES ? '\n... (truncated for performance)' : ''); + setDisplayContent(preview); + } + }, [showFullContent, pasteContent]); + + const handlePasteSubmit = (): void => { + let table: undefined | DictTable = undefined; + + const defaultName = (() => { + const hashStr = pasteContent.substring(0, 100) + Date.now(); + const hashCode = hashStr.split('').reduce((acc, char) => { + return ((acc << 5) - acc) + char.charCodeAt(0) | 0; + }, 0); + const shortHash = Math.abs(hashCode).toString(36).substring(0, 4); + return `data-${shortHash}`; + })(); + + const uniqueName = getUniqueTableName(defaultName, existingNames); + + try { + let content = JSON.parse(pasteContent); + table = createTableFromFromObjectArray(uniqueName, content, true); + } catch (error) { + table = createTableFromText(uniqueName, pasteContent); + } + if (table) { + dispatch(dfActions.loadTable(table)); + dispatch(fetchFieldSemanticType(table)); + handleClose(); + } + }; + + // URL submit handler + const handleURLSubmit = (): void => { + if (urlPreviewTable && urlPreviewUrl === tableURL) { + dispatch(dfActions.loadTable(urlPreviewTable)); + dispatch(fetchFieldSemanticType(urlPreviewTable)); + handleClose(); + } + }; + + const handleURLPreview = (): void => { + setUrlPreviewLoading(true); + setUrlPreviewError(null); + setUrlPreviewTable(null); + + let parts = tableURL.split('/'); + const baseName = parts[parts.length - 1] || 'dataset'; + const tableName = getUniqueTableName(baseName, existingNames); + + fetch(tableURL) + .then(res => res.text()) + .then(content => { + let table: undefined | DictTable = undefined; + try { + let jsonContent = JSON.parse(content); + if (!Array.isArray(jsonContent)) { + throw new Error('JSON content must be an array of objects.'); + } + table = createTableFromFromObjectArray(tableName, jsonContent, true); + } catch (error) { + table = createTableFromText(tableName, content); + } + + if (table) { + setUrlPreviewTable(table); + setUrlPreviewUrl(tableURL); + } else { + setUrlPreviewError('Unable to parse data from the provided URL.'); + } + }) + .catch(() => { + setUrlPreviewError('Failed to fetch data from the URL.'); + }) + .finally(() => { + setUrlPreviewLoading(false); + }); + }; + + const hasValidUrlSuffix = tableURL.endsWith('.csv') || tableURL.endsWith('.tsv') || tableURL.endsWith(".json"); + const hasMultipleFileTables = (filePreviewTables?.length || 0) > 1; + const showFilePreview = filePreviewLoading || !!filePreviewError || (filePreviewTables && filePreviewTables.length > 0); + const showUrlPreview = urlPreviewLoading || !!urlPreviewError || (urlPreviewTable && urlPreviewUrl === tableURL); + const hasPasteContent = pasteContent.trim() !== ''; + + // Data source configurations for the menu + const dataSourceConfig = [ + { + value: 'explore' as UploadTabType, + title: 'Sample Datasets', + description: 'Explore and load curated example datasets', + icon: , + disabled: false + }, + { + value: 'upload' as UploadTabType, + title: 'Upload File', + description: 'Upload CSV, TSV, JSON, or Excel files from your computer', + icon: , + disabled: serverConfig.DISABLE_FILE_UPLOAD, + disabledReason: 'File upload is disabled in this environment' + }, + { + value: 'paste' as UploadTabType, + title: 'Paste Data', + description: 'Paste tabular data directly from clipboard', + icon: , + disabled: false + }, + { + value: 'url' as UploadTabType, + title: 'From URL', + description: 'Load data from a web URL (CSV, TSV, or JSON)', + icon: , + disabled: false + }, + { + value: 'database' as UploadTabType, + title: 'Database', + description: 'Connect to databases or data services', + icon: , + disabled: serverConfig.DISABLE_DATABASE, + disabledReason: 'Database connection is disabled in this environment' + }, + { + value: 'extract' as UploadTabType, + title: 'Extract from Documents', + description: 'Extract tables from images, PDFs, or documents using AI', + icon: , + disabled: false + }, + ]; + + // Get current tab title for header + const getCurrentTabTitle = () => { + const tab = dataSourceConfig.find(t => t.value === activeTab); + return tab?.title || 'Add Data'; + }; + + return ( + + + {activeTab !== 'menu' && ( + setActiveTab('menu')} + sx={{ mr: 0.5 }} + > + + + )} + + {activeTab === 'menu' ? 'Add Data' : getCurrentTabTitle()} + + {activeTab === 'extract' && dataCleanBlocks.length > 0 && ( + + dispatch(dfActions.resetDataCleanBlocks())} + > + + + + )} + + + + + + + {/* Main Menu */} + + + + {dataSourceConfig.map((source) => ( + setActiveTab(source.value)} + disabled={source.disabled} + disabledReason={source.disabledReason} + /> + ))} + + + + + {/* Upload File Tab */} + + + + + {serverConfig.DISABLE_FILE_UPLOAD ? ( + + + File upload is disabled in this environment. + + + Install Data Formulator locally to enable file upload.
    + + https://github.com/microsoft/data-formulator + +
    +
    + ) : ( + fileInputRef.current?.click()} + > + + + Click to upload or drag and drop + + + Supported formats: CSV, TSV, JSON, Excel (xlsx, xls) + + + Maximum file size: 5MB (use Database tab for larger files) + + + )} + + {showFilePreview && ( + 0 ? `${filePreviewTables.length} table${filePreviewTables.length > 1 ? 's' : ''} previewed${hasMultipleFileTables ? ' • Multiple sheets detected' : ''}` : undefined} + onRemoveTable={handleRemoveFilePreviewTable} + /> + )} + + {filePreviewTables && filePreviewTables.length > 0 && ( + + + + )} +
    +
    +
    + + {/* URL Tab */} + + + + + setTableURL(e.target.value.trim())} + error={tableURL !== "" && !hasValidUrlSuffix} + size="small" + /> + + + + {tableURL !== "" && !hasValidUrlSuffix && ( + + URL should link to a .csv, .tsv, or .json file. + + )} + + + Supported URLs: direct links to .csv, .tsv, or .json files. + + + {showUrlPreview && ( + + )} + + {urlPreviewTable && urlPreviewUrl === tableURL && ( + + + + )} + + + + + {/* Paste Data Tab */} + + + {isOverSizeLimit && ( + + + ⚠️ Content exceeds {(MAX_CONTENT_SIZE / (1024 * 1024)).toFixed(0)}MB size limit. + Current size: {(new Blob([pasteContent]).size / (1024 * 1024)).toFixed(2)}MB. + Please use the DATABASE tab for large datasets. + + + )} + + {isLargeContent && !isOverSizeLimit && ( + + + Large content detected ({Math.round(pasteContent.length / 1000)}KB). + {showFullContent ? 'Showing full content (may be slow)' : 'Showing preview for performance'} + + + + )} + + + + + + + + + + + + {/* Database Tab */} + + {serverConfig.DISABLE_DATABASE ? ( + + + Database connection is disabled in this environment. + + + Install Data Formulator locally to use database features.
    + + https://github.com/microsoft/data-formulator + +
    +
    + ) : ( + + )} +
    + + {/* Extract Data Tab */} + + + + + {/* Explore Sample Datasets Tab */} + + + { + for (let table of dataset.tables) { + fetch(table.url) + .then(res => res.text()) + .then(textData => { + let tableName = table.url.split("/").pop()?.split(".")[0] || 'table-' + Date.now().toString().substring(0, 8); + let dictTable; + if (table.format == "csv") { + dictTable = createTableFromText(tableName, textData); + } else if (table.format == "json") { + dictTable = createTableFromFromObjectArray(tableName, JSON.parse(textData), true); + } + if (dictTable) { + dispatch(dfActions.loadTable(dictTable)); + dispatch(fetchFieldSemanticType(dictTable)); + } + }); + } + handleClose(); + }} + /> + + +
    +
    + ); +}; + +interface DBTable { + name: string; + columns: { name: string; type: string; }[]; + row_count: number; + sample_rows: any[]; + view_source: string | null; +} + +interface ColumnStatistics { + column: string; + type: string; + statistics: { + count: number; + unique_count: number; + null_count: number; + min?: number; + max?: number; + avg?: number; + }; +} + +// Separate component for Database tab content +const DatabaseTabContent: React.FC<{ onClose: () => void }> = ({ onClose }) => { + const theme = useTheme(); + const dispatch = useDispatch(); + const sessionId = useSelector((state: DataFormulatorState) => state.sessionId); + const tables = useSelector((state: DataFormulatorState) => state.tables); + const serverConfig = useSelector((state: DataFormulatorState) => state.serverConfig); + + const [dbTables, setDbTables] = React.useState([]); + const [selectedTabKey, setSelectedTabKey] = React.useState(""); + const [isUploading, setIsUploading] = React.useState(false); + const [tableAnalysisMap, setTableAnalysisMap] = React.useState>({}); + const [dataLoaderMetadata, setDataLoaderMetadata] = React.useState>({}); + + const setSystemMessage = (content: string, severity: "error" | "warning" | "info" | "success") => { + dispatch(dfActions.addMessages({ + "timestamp": Date.now(), + "component": "DB manager", + "type": severity, + "value": content + })); + }; + + React.useEffect(() => { + fetchTables(); + fetchDataLoaders(); + }, []); + + React.useEffect(() => { + if (!selectedTabKey.startsWith("dataLoader:") && dbTables.length == 0) { + setSelectedTabKey(""); + } else if (!selectedTabKey.startsWith("dataLoader:") && dbTables.find(t => t.name === selectedTabKey) == undefined) { + if (dbTables.length > 0) { + setSelectedTabKey(dbTables[0].name); + } + } + }, [dbTables]); + + const fetchTables = async () => { + if (serverConfig.DISABLE_DATABASE) return; + try { + const response = await fetch(getUrls().LIST_TABLES); + const data = await response.json(); + if (data.status === 'success') { + setDbTables(data.tables); + } + } catch (error) { + setSystemMessage('Failed to fetch tables, please check if the server is running', "error"); + } + }; + + const fetchDataLoaders = async () => { + fetch(getUrls().DATA_LOADER_LIST_DATA_LOADERS, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }) + .then(response => response.json()) + .then(data => { + if (data.status === "success") { + setDataLoaderMetadata(data.data_loaders); + } + }) + .catch(error => { + console.error('Failed to fetch data loader params:', error); + }); + }; + + const handleDBFileUpload = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + const formData = new FormData(); + formData.append('file', file); + formData.append('table_name', file.name.split('.')[0]); + + try { + setIsUploading(true); + const response = await fetch(getUrls().CREATE_TABLE, { + method: 'POST', + body: formData + }); + const data = await response.json(); + if (data.status === 'success') { + if (data.is_renamed) { + setSystemMessage(`Table ${data.original_name} already exists. Renamed to ${data.table_name}`, "warning"); + } + fetchTables(); + } else { + setSystemMessage(data.error || 'Failed to upload table', "error"); + } + } catch (error) { + setSystemMessage('Failed to upload table, please check if the server is running', "error"); + } finally { + setIsUploading(false); + if (event.target) { + event.target.value = ''; + } + } + }; + + const handleDropTable = async (tableName: string) => { + if (tables.some(t => t.id === tableName)) { + if (!confirm(`Are you sure you want to delete ${tableName}? \n ${tableName} is currently loaded and will be removed from the database.`)) return; + } + + try { + const response = await fetch(getUrls().DELETE_TABLE, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ table_name: tableName }) + }); + const data = await response.json(); + if (data.status === 'success') { + fetchTables(); + setSelectedTabKey(dbTables.length > 0 ? dbTables[0].name : ""); + } else { + setSystemMessage(data.error || 'Failed to delete table', "error"); + } + } catch (error) { + setSystemMessage('Failed to delete table, please check if the server is running', "error"); + } + }; + + const handleAnalyzeData = async (tableName: string) => { + if (!tableName) return; + if (tableAnalysisMap[tableName]) return; + + try { + const response = await fetch(getUrls().GET_COLUMN_STATS, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ table_name: tableName }) + }); + const data = await response.json(); + if (data.status === 'success') { + setTableAnalysisMap(prevMap => ({ + ...prevMap, + [tableName]: data.statistics + })); + } + } catch (error) { + setSystemMessage('Failed to analyze table data', "error"); + } + }; + + const toggleAnalysisView = (tableName: string) => { + if (tableAnalysisMap[tableName]) { + setTableAnalysisMap(prevMap => { + const newMap = { ...prevMap }; + delete newMap[tableName]; + return newMap; + }); + } else { + handleAnalyzeData(tableName); + } + }; + + const handleAddTableToDF = (dbTable: DBTable) => { + const convertSqlTypeToAppType = (sqlType: string): Type => { + sqlType = sqlType.toUpperCase(); + if (sqlType.includes('INT') || sqlType === 'BIGINT' || sqlType === 'SMALLINT' || sqlType === 'TINYINT') { + return Type.Integer; + } else if (sqlType.includes('FLOAT') || sqlType.includes('DOUBLE') || sqlType.includes('DECIMAL') || sqlType.includes('NUMERIC') || sqlType.includes('REAL')) { + return Type.Number; + } else if (sqlType.includes('BOOL')) { + return Type.Boolean; + } else if (sqlType.includes('DATE') || sqlType.includes('TIME') || sqlType.includes('TIMESTAMP')) { + return Type.Date; + } else { + return Type.String; + } + }; + + let table: DictTable = { + id: dbTable.name, + displayId: dbTable.name, + names: dbTable.columns.map((col: any) => col.name), + metadata: dbTable.columns.reduce((acc: Record, col: any) => ({ + ...acc, + [col.name]: { + type: convertSqlTypeToAppType(col.type), + semanticType: "", + levels: [] + } + }), {}), + rows: dbTable.sample_rows, + virtual: { + tableId: dbTable.name, + rowCount: dbTable.row_count, + }, + anchored: true, + createdBy: 'user', + attachedMetadata: '' + } + dispatch(dfActions.loadTable(table)); + dispatch(fetchFieldSemanticType(table)); + onClose(); + }; + + const handleCleanDerivedViews = async () => { + let unreferencedViews = dbTables.filter(t => t.view_source !== null && t.view_source !== undefined && !tables.some(t2 => t2.id === t.name)); + + if (unreferencedViews.length > 0) { + if (confirm(`Are you sure you want to delete the following unreferenced derived views? \n${unreferencedViews.map(v => `- ${v.name}`).join("\n")}`)) { + let deletedViews = []; + for (let view of unreferencedViews) { + try { + const response = await fetch(getUrls().DELETE_TABLE, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ table_name: view.name }) + }); + const data = await response.json(); + if (data.status === 'success') { + deletedViews.push(view.name); + } + } catch (error) { + setSystemMessage('Failed to delete table', "error"); + } + } + if (deletedViews.length > 0) { + setSystemMessage(`Deleted ${deletedViews.length} unreferenced derived views`, "success"); + } + fetchTables(); + } + } + }; + + const handleDBReset = async () => { + try { + const response = await fetch(getUrls().RESET_DB_FILE, { method: 'POST' }); + const data = await response.json(); + if (data.status === 'success') { + fetchTables(); + } else { + setSystemMessage(data.error || 'Failed to reset database', "error"); + } + } catch (error) { + setSystemMessage('Failed to reset database', "error"); + } + }; + + const handleDBUpload = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + const formData = new FormData(); + formData.append('file', file); + + try { + setIsUploading(true); + const response = await fetch(getUrls().UPLOAD_DB_FILE, { + method: 'POST', + body: formData + }); + const data = await response.json(); + if (data.status === 'success') { + fetchTables(); + } else { + setSystemMessage(data.error || 'Failed to upload database', "error"); + } + } catch (error) { + setSystemMessage('Failed to upload database', "error"); + } finally { + setIsUploading(false); + } + }; + + const hasDerivedViews = dbTables.filter(t => t.view_source !== null).length > 0; + + const dataLoaderPanel = ( + + + + Data Connectors + + + + {["file upload", ...Object.keys(dataLoaderMetadata ?? {})].map((dataLoaderType) => ( + + ))} + + ); + + const tableSelectionPanel = ( + + + + Data Tables + + + + + + + + + {dbTables.length == 0 && + + no tables available + + } + + {dbTables.filter(t => t.view_source === null).map((t) => ( + + ))} + + {hasDerivedViews && ( + + + + Derived Views + + + t.view_source !== null).length === 0} onClick={handleCleanDerivedViews}> + + + + + + {dbTables.filter(t => t.view_source !== null).map((t) => ( + + ))} + + )} + + ); + + const tableView = ( + + {selectedTabKey === '' && ( + + The database is empty, refresh the table list or import some data to get started. + + )} + + {selectedTabKey === 'dataLoader:file upload' && ( + + + + + + Files uploaded here are stored in the database and can handle larger datasets + + + )} + + {dataLoaderMetadata && Object.entries(dataLoaderMetadata).map(([dataLoaderType, metadata]) => ( + selectedTabKey === 'dataLoader:' + dataLoaderType && ( + + setIsUploading(true)} + onFinish={(status, message, importedTables) => { + setIsUploading(false); + fetchTables().then(() => { + if (status === "success" && importedTables && importedTables.length > 0) { + setSelectedTabKey(importedTables[0]); + } + }); + if (status === "error") { + setSystemMessage(message, "error"); + } + }} + /> + + ) + ))} + + {dbTables.map((t) => { + if (selectedTabKey !== t.name) return null; + + const showingAnalysis = tableAnalysisMap[t.name] !== undefined; + return ( + + + + + {showingAnalysis ? "column stats for " : "sample data from "} + {t.name} + + ({t.columns.length} columns × {t.row_count} rows) + + + + + handleDropTable(t.name)} title="Drop Table"> + + + + + {showingAnalysis ? ( + + ) : ( + Object.fromEntries(Object.entries(row).map(([key, value]: [string, any]) => [key, String(value)]))).slice(0, 9)} + columnDefs={t.columns.map(col => ({ id: col.name, label: col.name, minWidth: 60 }))} + rowsPerPageNum={-1} + compact={false} + isIncompleteTable={t.row_count > 10} + /> + )} + + + + ); + })} + + ); + + return ( + + {isUploading && ( + + + + )} + + + {dataLoaderPanel} + {tableSelectionPanel} + + + + + + , + + + + or + + + + {tableView} + + ); +}; + +export default UnifiedDataUploadDialog; From ad342b9dae1ab942e9809370c23f67182fbe04a3 Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Fri, 16 Jan 2026 17:58:22 -0800 Subject: [PATCH 05/21] keep this for now.. --- src/views/UnifiedDataUploadDialog.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/views/UnifiedDataUploadDialog.tsx b/src/views/UnifiedDataUploadDialog.tsx index 5424840..ab9e55d 100644 --- a/src/views/UnifiedDataUploadDialog.tsx +++ b/src/views/UnifiedDataUploadDialog.tsx @@ -767,9 +767,9 @@ export const UnifiedDataUploadDialog: React.FC = ( maxWidth={false} sx={{ '& .MuiDialog-paper': { - width: activeTab === 'menu' ? 720 : 1100, + width: 1100, maxWidth: '95vw', - height: activeTab === 'menu' ? 'auto' : 700, + height: 700, maxHeight: '90vh', display: 'flex', flexDirection: 'column', @@ -820,11 +820,13 @@ export const UnifiedDataUploadDialog: React.FC = ( {/* Main Menu */} - + {dataSourceConfig.map((source) => ( Date: Sat, 17 Jan 2026 09:42:55 -0800 Subject: [PATCH 06/21] ok --- src/app/App.tsx | 5 - src/views/DBTableManager.tsx | 1539 ++++++++++++++----------- src/views/UnifiedDataUploadDialog.tsx | 907 +++------------ 3 files changed, 1042 insertions(+), 1409 deletions(-) diff --git a/src/app/App.tsx b/src/app/App.tsx index 0bcfea5..1a8707f 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -269,11 +269,6 @@ const TableMenu: React.FC = () => { paste data (csv/tsv/json) - handleOpenDialog('url')}> - - from URL - - handleOpenDialog('database')}> diff --git a/src/views/DBTableManager.tsx b/src/views/DBTableManager.tsx index b883aa1..29b4a89 100644 --- a/src/views/DBTableManager.tsx +++ b/src/views/DBTableManager.tsx @@ -1,5 +1,5 @@ // TableManager.tsx -import React, { useState, useEffect, FC } from 'react'; +import React, { useState, useEffect, useCallback, FC, useRef } from 'react'; import { Card, CardContent, @@ -9,13 +9,7 @@ import { Box, IconButton, Paper, - Tabs, - Tab, TextField, - Dialog, - DialogActions, - DialogContent, - DialogTitle, Divider, SxProps, Table, @@ -26,24 +20,26 @@ import { TableRow, CircularProgress, ButtonGroup, + ToggleButton, + ToggleButtonGroup, Tooltip, MenuItem, + Menu, Chip, Collapse, styled, useTheme, Link, - Checkbox + Checkbox, + Popover } from '@mui/material'; import DeleteIcon from '@mui/icons-material/Delete'; import CloseIcon from '@mui/icons-material/Close'; -import AnalyticsIcon from '@mui/icons-material/Analytics'; import ExpandLessIcon from '@mui/icons-material/ExpandLess'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import TableRowsIcon from '@mui/icons-material/TableRows'; import RefreshIcon from '@mui/icons-material/Refresh'; -import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; import SearchIcon from '@mui/icons-material/Search'; import { getUrls } from '../app/utils'; @@ -61,6 +57,49 @@ import Markdown from 'markdown-to-jsx'; import CheckIcon from '@mui/icons-material/Check'; import MuiMarkdown from 'mui-markdown'; import CleaningServicesIcon from '@mui/icons-material/CleaningServices'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; +import UploadFileIcon from '@mui/icons-material/UploadFile'; +import DownloadIcon from '@mui/icons-material/Download'; +import RestartAltIcon from '@mui/icons-material/RestartAlt'; +import StorageIcon from '@mui/icons-material/Storage'; +import CloudUploadIcon from '@mui/icons-material/CloudUpload'; +import SettingsIcon from '@mui/icons-material/Settings'; + +// Industry-standard database icons +const TableIcon: React.FC<{ sx?: SxProps }> = ({ sx }) => ( + + {/* Single rectangle with grid lines - standard table icon */} + + + + + + +); + +const ViewIcon: React.FC<{ sx?: SxProps }> = ({ sx }) => ( + + {/* Two overlapping rectangles - standard view icon */} + + + +); export const handleDBDownload = async (sessionId: string) => { try { @@ -119,116 +158,9 @@ interface ColumnStatistics { }; } -interface TableStatisticsViewProps { - tableName: string; - columnStats: ColumnStatistics[]; -} - -export class TableStatisticsView extends React.Component { - render() { - const { tableName, columnStats } = this.props; - - // Common styles for header cells - const headerCellStyle = { - backgroundColor: '#fff', - fontSize: 10, - color: "#333", - borderBottomColor: (theme: any) => theme.palette.primary.main, - borderBottomWidth: '1px', - borderBottomStyle: 'solid', - padding: '6px' - }; - - // Common styles for body cells - const bodyCellStyle = { - fontSize: 10, - padding: '6px' - }; - - return ( - - - - - - Column - Type - Count - Unique - Null - Min - Max - Avg - - - - {columnStats.map((stat, idx) => ( - - - {stat.column} - - - {stat.type} - - - {stat.statistics.count} - - - {stat.statistics.unique_count} - - - {stat.statistics.null_count} - - - {stat.statistics.min !== undefined ? stat.statistics.min : '-'} - - - {stat.statistics.max !== undefined ? stat.statistics.max : '-'} - - - {stat.statistics.avg !== undefined ? - Number(stat.statistics.avg).toFixed(2) : '-'} - - - ))} - -
    -
    -
    - ); - } -} -export const DBTableSelectionDialog: React.FC<{ - buttonElement?: any, - sx?: SxProps, - onOpen?: () => void, - // Controlled mode props - open?: boolean, - onClose?: () => void -}> = function DBTableSelectionDialog({ - buttonElement, - sx, - onOpen, - open: controlledOpen, - onClose, -}) { +export const DBManagerPane: React.FC<{ +}> = function DBManagerPane({ }) { const theme = useTheme(); @@ -236,15 +168,10 @@ export const DBTableSelectionDialog: React.FC<{ const sessionId = useSelector((state: DataFormulatorState) => state.sessionId); const tables = useSelector((state: DataFormulatorState) => state.tables); const serverConfig = useSelector((state: DataFormulatorState) => state.serverConfig); + const dataLoaderConnectParams = useSelector((state: DataFormulatorState) => state.dataLoaderConnectParams); - const [internalOpen, setInternalOpen] = useState(false); // Support both controlled and uncontrolled modes - const isControlled = controlledOpen !== undefined; - const tableDialogOpen = isControlled ? controlledOpen : internalOpen; - const setTableDialogOpen = isControlled - ? (open: boolean) => { if (!open && onClose) onClose(); } - : setInternalOpen; const [tableAnalysisMap, setTableAnalysisMap] = useState>({}); // maps data loader type to list of param defs @@ -254,8 +181,15 @@ export const DBTableSelectionDialog: React.FC<{ const [dbTables, setDbTables] = useState([]); const [selectedTabKey, setSelectedTabKey] = useState(""); + const [selectedDataLoader, setSelectedDataLoader] = useState(""); + const [connectorMenuAnchorEl, setConnectorMenuAnchorEl] = useState(null); const [isUploading, setIsUploading] = useState(false); + const [resetAnchorEl, setResetAnchorEl] = useState(null); + const [tableMenuAnchorEl, setTableMenuAnchorEl] = useState(null); + const [showViews, setShowViews] = useState(false); + const dbFileInputRef = useRef(null); + const menuButtonRef = useRef(null); let setSystemMessage = (content: string, severity: "error" | "warning" | "info" | "success") => { dispatch(dfActions.addMessages({ @@ -271,12 +205,14 @@ export const DBTableSelectionDialog: React.FC<{ }, []); useEffect(() => { - if (!selectedTabKey.startsWith("dataLoader:") && dbTables.length == 0) { - setSelectedTabKey(""); - } else if (!selectedTabKey.startsWith("dataLoader:") && dbTables.find(t => t.name === selectedTabKey) == undefined) { - setSelectedTabKey(dbTables[0].name); + if (selectedDataLoader === "") { + if (dbTables.length == 0) { + setSelectedTabKey(""); + } else if (dbTables.find(t => t.name === selectedTabKey) == undefined) { + setSelectedTabKey(dbTables[0]?.name || ""); + } } - }, [dbTables]); + }, [dbTables, selectedDataLoader]); // Fetch list of tables const fetchTables = async () => { @@ -450,13 +386,11 @@ export const DBTableSelectionDialog: React.FC<{ } }; - // Handle data analysis - const handleAnalyzeData = async (tableName: string) => { + // Handle data analysis - auto-fetch when table is selected + const handleAnalyzeData = useCallback(async (tableName: string) => { if (!tableName) return; if (tableAnalysisMap[tableName]) return; - console.log('Analyzing table:', tableName); - try { const response = await fetch(getUrls().GET_COLUMN_STATS, { method: 'POST', @@ -467,7 +401,6 @@ export const DBTableSelectionDialog: React.FC<{ }); const data = await response.json(); if (data.status === 'success') { - console.log('Analysis results:', data); // Update the analysis map with the new results setTableAnalysisMap(prevMap => ({ ...prevMap, @@ -476,24 +409,16 @@ export const DBTableSelectionDialog: React.FC<{ } } catch (error) { console.error('Failed to analyze table data:', error); - setSystemMessage('Failed to analyze table data, please check if the server is running', "error"); + // Don't show error message for auto-fetch, just fail silently } - }; + }, [tableAnalysisMap]); - // Toggle analysis view - const toggleAnalysisView = (tableName: string) => { - if (tableAnalysisMap[tableName]) { - // If we already have analysis, remove it to show table data again - setTableAnalysisMap(prevMap => { - const newMap = { ...prevMap }; - delete newMap[tableName]; - return newMap; - }); - } else { - // If no analysis yet, fetch it - handleAnalyzeData(tableName); + // Auto-fetch stats when a table is selected + useEffect(() => { + if (selectedTabKey && selectedDataLoader === "") { + handleAnalyzeData(selectedTabKey); } - }; + }, [selectedTabKey, selectedDataLoader, handleAnalyzeData]); const handleAddTableToDF = (dbTable: DBTable) => { const convertSqlTypeToAppType = (sqlType: string): Type => { @@ -535,44 +460,12 @@ export const DBTableSelectionDialog: React.FC<{ } dispatch(dfActions.loadTable(table)); dispatch(fetchFieldSemanticType(table)); - setTableDialogOpen(false); } - const handleTabChange = (event: React.SyntheticEvent, newValue: string) => { - setSelectedTabKey(newValue); - }; useEffect(() => { - if (tableDialogOpen) { - fetchTables(); - } - }, [tableDialogOpen]); - - let importButton = (buttonElement: React.ReactNode) => { - return - - - - - } - - let exportButton = - - - - - + fetchTables(); + }, []); function uploadFileButton(element: React.ReactNode, buttonSx?: SxProps) { return ( @@ -598,81 +491,192 @@ export const DBTableSelectionDialog: React.FC<{ ); } - let hasDerivedViews = dbTables.filter(t => t.view_source !== null).length > 0; - - let dataLoaderPanel = - + let tableSelectionPanel = + {/* Recent Data Loaders */} + + + External Data Loaders + + + setSelectedDataLoader("file upload")} + sx={{ + fontSize: '0.7rem', + height: 20, + maxWidth: '100%', + backgroundColor: selectedDataLoader === "file upload" ? alpha(theme.palette.secondary.main, 0.2) : 'transparent', + '& .MuiChip-label': { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap' + }, + '&:hover': { + backgroundColor: alpha(theme.palette.secondary.main, 0.12), + cursor: 'pointer' + } + }} + /> + {Object.keys(dataLoaderMetadata ?? {}) + .map((dataLoaderType) => ( + setSelectedDataLoader(dataLoaderType)} + sx={{ + fontSize: '0.7rem', + height: 20, + maxWidth: '100%', + backgroundColor: selectedDataLoader === dataLoaderType ? alpha(theme.palette.secondary.main, 0.2) : 'transparent', + '& .MuiChip-label': { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap' + }, + '&:hover': { + backgroundColor: alpha(theme.palette.secondary.main, 0.12), + cursor: 'pointer' + } + }} + /> + ))} + + + + - Data Connectors + Local DuckDB - - - {["file upload", ...Object.keys(dataLoaderMetadata ?? {})].map((dataLoaderType, i) => ( - - ))} - ; - - let tableSelectionPanel = - - - Data Tables - - - { - fetchTables(); - }}> - - - + { + fetchTables(); + setTableMenuAnchorEl(null); + }} + dense + > + + Refresh table list + + { + handleCleanDerivedViews(); + setTableMenuAnchorEl(null); + }} + disabled={dbTables.filter(t => t.view_source !== null).length === 0} + dense + > + + Clean up derived views + + + { + dbFileInputRef.current?.click(); + setTableMenuAnchorEl(null); + }} + disabled={isUploading} + dense + > + + Import database file + + { + if (!isUploading && dbTables.length > 0) { + handleDBDownload(sessionId ?? '') + .catch(error => { + console.error('Failed to download database:', error); + setSystemMessage('Failed to download database file', "error"); + }); + } + setTableMenuAnchorEl(null); + }} + disabled={isUploading || dbTables.length === 0} + dense + > + + Export database file + + { + setTableMenuAnchorEl(null); + if (!isUploading && menuButtonRef.current) { + // Use setTimeout to ensure menu closes before popover opens + setTimeout(() => { + setResetAnchorEl(menuButtonRef.current); + }, 100); + } + }} + disabled={isUploading} + dense + sx={{ color: 'error.main' }} + > + + Reset database + + + + {dbTables.length == 0 && @@ -681,122 +685,147 @@ export const DBTableSelectionDialog: React.FC<{ } {/* Regular Tables */} - {dbTables.filter(t => t.view_source === null).map((t, i) => ( - - ))} + textTransform: "none", + width: '100%', + maxWidth: '100%', + justifyContent: 'flex-start', + textAlign: 'left', + borderRadius: 0, + py: 0.5, + px: 2, + color: (selectedTabKey === t.name && selectedDataLoader === "") ? 'primary.main' : 'text.secondary', + borderRight: (selectedTabKey === t.name && selectedDataLoader === "") ? 2 : 0, + minWidth: 0, + }} + startIcon={} + endIcon={isLoaded ? : null} + > + + {t.name} + + + ); + })} {/* Derived Views Section */} - {hasDerivedViews && ( + {dbTables.filter(t => t.view_source !== null).length > 0 && ( - - - Derived Views - - - t.view_source !== null).length === 0} - onClick={() => { - handleCleanDerivedViews(); - }}> - - - - - - {dbTables.filter(t => t.view_source !== null).map((t, i) => ( - - ))} + }}> + Views ({dbTables.filter(t => t.view_source !== null).length}) + + + + + {dbTables.filter(t => t.view_source !== null).map((t, i) => { + return ( + + ); + })} + + )} - let tableView = - {/* Empty state */} - {selectedTabKey === '' && ( - - The database is empty, refresh the table list or import some data to get started. - - )} + let dataConnectorView = + {/* File upload */} - {selectedTabKey === 'dataLoader:file upload' && ( + {selectedDataLoader === 'file upload' && ( {uploadFileButton({isUploading ? 'uploading...' : 'upload a csv/tsv file to the local database'})} @@ -804,7 +833,7 @@ export const DBTableSelectionDialog: React.FC<{ {/* Data loader forms */} {dataLoaderMetadata && Object.entries(dataLoaderMetadata).map(([dataLoaderType, metadata]) => ( - selectedTabKey === 'dataLoader:' + dataLoaderType && ( + selectedDataLoader === dataLoaderType && ( { setIsUploading(false); fetchTables().then(() => { + // Switch back to tables view after import + setSelectedDataLoader(""); // Navigate to the first imported table after tables are fetched if (status === "success" && importedTables && importedTables.length > 0) { setSelectedTabKey(importedTables[0]); @@ -830,196 +861,252 @@ export const DBTableSelectionDialog: React.FC<{ ) ))} + ; + + let tableView = + {/* Empty state */} + {selectedTabKey === '' && ( + + The database is empty, refresh the table list or import some data to get started. + + )} {/* Table content */} {dbTables.map((t, i) => { if (selectedTabKey !== t.name) return null; const currentTable = t; - const showingAnalysis = tableAnalysisMap[currentTable.name] !== undefined; + const columnStats = tableAnalysisMap[currentTable.name] ?? []; + const statsMap = new Map(columnStats.map((stat: ColumnStatistics) => [stat.column, stat])); + return ( - - - - {showingAnalysis ? "column stats for " : "sample data from "} - - {currentTable.name} - - - ({currentTable.columns.length} columns × {currentTable.row_count} rows) - + + + {currentTable.view_source ? : } + + {currentTable.name} + + + + handleDropTable(currentTable.name)} + title="Drop Table" + > + + + + + + + + + + + {currentTable.columns.map((col) => { + const stat = statsMap.get(col.name); + return ( + + + + {col.name} + + {stat ? ( + + {stat.type} + + ) : ( + + {col.type} + + )} + + + ); + })} + + + + {currentTable.sample_rows.slice(0, 9).map((row: any, rowIdx: number) => ( + + {currentTable.columns.map((col) => { + const value = row[col.name]; + return ( + + {String(value ?? '')} + + ); + })} + + ))} + +
    +
    + {currentTable.row_count > 10 && ( + + + Showing first 9 rows of {currentTable.row_count} total rows + + + )} +
    +
    + {tables.some(t => t.id === currentTable.name) ? ( + + + + Loaded - - + )} + + ); + })} + ; + + let mainContent = + + + {/* Button navigation - similar to TableSelectionView */} + + + {/* Available Tables Section - always visible */} + {tableSelectionPanel} + + {/* Reset Confirmation Popover */} + setResetAnchorEl(null)} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'left', + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'left', + }} + > + + + Reset backend database and delete all tables? This cannot be undone. + + + - handleDropTable(currentTable.name)} - title="Drop Table" + variant="contained" + onClick={async () => { + setResetAnchorEl(null); + await handleDBReset(); + }} + sx={{ textTransform: 'none', fontSize: '12px', minWidth: 'auto', px: 0.75, py: 0.25, minHeight: '24px' }} > - - + Reset + - {showingAnalysis ? ( - - ) : ( - { - return Object.fromEntries(Object.entries(row).map(([key, value]: [string, any]) => { - return [key, String(value)]; - })); - }).slice(0, 9)} - columnDefs={currentTable.columns.map(col => ({ - id: col.name, - label: col.name, - minWidth: 60 - }))} - rowsPerPageNum={-1} - compact={false} - isIncompleteTable={currentTable.row_count > 10} - /> - )} -
    - +
    - ); - })} -
    ; - - let mainContent = - - {/* Button navigation - similar to TableSelectionView */} - - - {/* External Data Loaders Section */} - {dataLoaderPanel} - {/* Available Tables Section */} - {tableSelectionPanel} + {/* Content area - show connector view if a connector is selected, otherwise show table view */} + + {selectedDataLoader !== "" ? dataConnectorView : tableView} - - {importButton(Import)} - , - {exportButton} - or - - the backend database - - {/* Content area - using conditional rendering instead of TabPanel */} - {tableView} return ( - <> - {buttonElement && ( - - Install Data Formulator locally to use database.
    - Link: e.stopPropagation()} - > - https://github.com/microsoft/data-formulator - -
    - ) : ""} - placement="top" - > - - - - + + {mainContent} + {isUploading && ( + + + )} - {setTableDialogOpen(false)}} - open={tableDialogOpen} - sx={{ '& .MuiDialog-paper': { maxWidth: '100%', maxHeight: 800, minWidth: 800 } }} - > - - Database - setTableDialogOpen(false)} - > - - - - - {mainContent} - {isUploading && ( - - - - )} - - - + ); } @@ -1040,8 +1127,6 @@ export const DataLoaderForm: React.FC<{ let [tableFilter, setTableFilter] = useState(""); const [selectedTables, setSelectedTables] = useState>(new Set()); - const [displayAuthInstructions, setDisplayAuthInstructions] = useState(false); - let [isConnecting, setIsConnecting] = useState(false); const toggleDisplaySamples = (tableName: string) => { @@ -1049,7 +1134,7 @@ export const DataLoaderForm: React.FC<{ } let tableMetadataBox = [ - + {Object.entries(tableMetadata).map(([tableName, metadata]) => { @@ -1058,17 +1143,32 @@ export const DataLoaderForm: React.FC<{ key={tableName} sx={{ '&:last-child td, &:last-child th': { border: 0 }, - '& .MuiTableCell-root': { padding: 0.25, wordWrap: 'break-word', whiteSpace: 'normal' }, + '& .MuiTableCell-root': { + borderBottom: displaySamples[tableName] ? 'none' : '1px solid rgba(0, 0, 0, 0.1)', + padding: 0.25, wordWrap: 'break-word', whiteSpace: 'normal'}, backgroundColor: selectedTables.has(tableName) ? 'action.selected' : 'inherit', - '&:hover': { backgroundColor: selectedTables.has(tableName) ? 'action.selected' : 'action.hover' } + '&:hover': { backgroundColor: selectedTables.has(tableName) ? 'action.selected' : 'action.hover' }, + cursor: 'pointer', + }} + onClick={() => { + const newSelected = new Set(selectedTables); + if (newSelected.has(tableName)) { + newSelected.delete(tableName); + } else { + newSelected.add(tableName); + } + setSelectedTables(newSelected); }} > - - toggleDisplaySamples(tableName)}> + + { + e.stopPropagation(); + toggleDisplaySamples(tableName); + }}> {displaySamples[tableName] ? : } - + {tableName} ({metadata.row_count > 0 ? `${metadata.row_count} rows × ` : ""}{metadata.columns.length} cols) @@ -1078,19 +1178,10 @@ export const DataLoaderForm: React.FC<{ ))} - + { - const newSelected = new Set(selectedTables); - if (e.target.checked) { - newSelected.add(tableName); - } else { - newSelected.delete(tableName); - } - setSelectedTables(newSelected); - }} /> , @@ -1098,7 +1189,7 @@ export const DataLoaderForm: React.FC<{ - + { return Object.fromEntries(Object.entries(row).map(([key, value]: [string, any]) => { return [key, String(value)]; @@ -1109,7 +1200,7 @@ export const DataLoaderForm: React.FC<{ compact={false} isIncompleteTable={metadata.row_count > 10} /> - + ] @@ -1117,11 +1208,13 @@ export const DataLoaderForm: React.FC<{
    , - Object.keys(tableMetadata).length > 0 && + Object.keys(tableMetadata).length > 0 && ] + const isConnected = Object.keys(tableMetadata).length > 0; + return ( + + Import tables from {dataLoaderType} + {isConnecting && } - - Data Connector ({dataLoaderType}) - - - {paramDefs.map((paramDef) => ( - - 0} - sx={{width: "270px", - '& .MuiInputLabel-root': {fontSize: 14}, - '& .MuiInputBase-root': {fontSize: 14}, - '& .MuiInputBase-input::placeholder': {fontSize: 12, fontStyle: "italic"} - }} - variant="standard" - size="small" - required={paramDef.required} - key={paramDef.name} - label={paramDef.name} - value={params[paramDef.name] ?? ''} - placeholder={paramDef.default ? `e.g. ${paramDef.default}` : paramDef.description} - onChange={(event: any) => { - dispatch(dfActions.updateDataLoaderConnectParam({ - dataLoaderType, paramName: paramDef.name, - paramValue: event.target.value})); - }} - slotProps={{ - inputLabel: {shrink: true} - }} - /> + {isConnected ? ( + // Connected state: show connection parameters and disconnect button + + + + + {paramDefs.filter((paramDef) => params[paramDef.name]).map((paramDef, index) => ( + + + {paramDef.name}: + + + {params[paramDef.name] || '(empty)'} + + {index < paramDefs.filter((paramDef) => params[paramDef.name]).length - 1 && ( + + • + + )} + + ))} + + + + + + + + table filter + + + setTableFilter(event.target.value)} + /> + + + + + + - ))} - - - table filter - } - placeholder="load only tables containing keywords" - value={tableFilter} - onChange={(event) => setTableFilter(event.target.value)} - slotProps={{ - inputLabel: {shrink: true}, - }} - /> - {paramDefs.length > 0 && - - - } - - - - - - - { - - + .catch(error => { + onFinish("error", `Failed to fetch data loader tables, please check the server is running`); + setIsConnecting(false); + }); + }}> + connect {tableFilter.trim() ? "with filter" : ""} + + } + + + + + {authInstructions.trim()} - - - } - - {Object.keys(tableMetadata).length > 0 && tableMetadataBox } + + )} ); } \ No newline at end of file diff --git a/src/views/UnifiedDataUploadDialog.tsx b/src/views/UnifiedDataUploadDialog.tsx index ab9e55d..17b8558 100644 --- a/src/views/UnifiedDataUploadDialog.tsx +++ b/src/views/UnifiedDataUploadDialog.tsx @@ -9,7 +9,6 @@ import { Dialog, DialogContent, DialogTitle, - DialogActions, IconButton, TextField, Typography, @@ -19,6 +18,7 @@ import { Input, alpha, useTheme, + Divider, } from '@mui/material'; import CloseIcon from '@mui/icons-material/Close'; @@ -29,14 +29,9 @@ import StorageIcon from '@mui/icons-material/Storage'; import ImageSearchIcon from '@mui/icons-material/ImageSearch'; import ExploreIcon from '@mui/icons-material/Explore'; import RestartAltIcon from '@mui/icons-material/RestartAlt'; -import RefreshIcon from '@mui/icons-material/Refresh'; import DeleteIcon from '@mui/icons-material/Delete'; -import AnalyticsIcon from '@mui/icons-material/Analytics'; -import CleaningServicesIcon from '@mui/icons-material/CleaningServices'; -import SearchIcon from '@mui/icons-material/Search'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import Paper from '@mui/material/Paper'; -import CircularProgress from '@mui/material/CircularProgress'; import { useDispatch, useSelector } from 'react-redux'; import { DataFormulatorState, dfActions, fetchFieldSemanticType } from '../app/dfSlice'; @@ -47,8 +42,7 @@ import { DataLoadingChat } from './DataLoadingChat'; import { DatasetSelectionView, DatasetMetadata } from './TableSelectionView'; import { getUrls } from '../app/utils'; import { CustomReactTable } from './ReactTable'; -import { Type } from '../data/types'; -import { TableStatisticsView, DataLoaderForm, handleDBDownload } from './DBTableManager'; +import { DBManagerPane } from './DBTableManager'; export type UploadTabType = 'menu' | 'upload' | 'paste' | 'url' | 'database' | 'extract' | 'explore'; @@ -137,6 +131,17 @@ const PreviewPanel: React.FC = ({ {title} + {onRemoveTable && previewTables && previewTables.length > 0 && ( + onRemoveTable(activeIndex)} + sx={{ ml: 'auto' }} + aria-label="Remove table" + > + + + )} {loading && } @@ -231,16 +236,6 @@ const PreviewPanel: React.FC = ({ ? ` • ${activeTable.displayId || activeTable.id}` : ''} - {onRemoveTable && previewTables.length > 1 && ( - onRemoveTable(activeIndex)} - sx={{ ml: 'auto' }} - > - - - )}
    = ( const [showFullContent, setShowFullContent] = useState(false); const [isOverSizeLimit, setIsOverSizeLimit] = useState(false); - // URL tab state + // URL input state (merged into file upload) const [tableURL, setTableURL] = useState(""); - const [urlPreviewTable, setUrlPreviewTable] = useState(null); - const [urlPreviewLoading, setUrlPreviewLoading] = useState(false); - const [urlPreviewError, setUrlPreviewError] = useState(null); - const [urlPreviewUrl, setUrlPreviewUrl] = useState(""); - // File preview state + // File preview state (shared with URL) const [filePreviewTables, setFilePreviewTables] = useState(null); const [filePreviewLoading, setFilePreviewLoading] = useState(false); const [filePreviewError, setFilePreviewError] = useState(null); @@ -412,12 +403,6 @@ export const UnifiedDataUploadDialog: React.FC = ( } }, [initialTab, open]); - useEffect(() => { - if (tableURL !== urlPreviewUrl) { - setUrlPreviewTable(null); - setUrlPreviewError(null); - } - }, [tableURL, urlPreviewUrl]); // Load sample datasets useEffect(() => { @@ -483,10 +468,6 @@ export const UnifiedDataUploadDialog: React.FC = ( setIsOverSizeLimit(false); setShowFullContent(false); setTableURL(""); - setUrlPreviewTable(null); - setUrlPreviewLoading(false); - setUrlPreviewError(null); - setUrlPreviewUrl(""); setFilePreviewTables(null); setFilePreviewLoading(false); setFilePreviewError(null); @@ -504,6 +485,8 @@ export const UnifiedDataUploadDialog: React.FC = ( setFilePreviewError(null); setFilePreviewTables(null); setFilePreviewLoading(true); + // Clear URL input when file is uploaded + setTableURL(""); const MAX_FILE_SIZE = 5 * 1024 * 1024; const previewTables: DictTable[] = []; @@ -653,19 +636,13 @@ export const UnifiedDataUploadDialog: React.FC = ( } }; - // URL submit handler - const handleURLSubmit = (): void => { - if (urlPreviewTable && urlPreviewUrl === tableURL) { - dispatch(dfActions.loadTable(urlPreviewTable)); - dispatch(fetchFieldSemanticType(urlPreviewTable)); - handleClose(); - } - }; const handleURLPreview = (): void => { - setUrlPreviewLoading(true); - setUrlPreviewError(null); - setUrlPreviewTable(null); + setFilePreviewLoading(true); + setFilePreviewError(null); + setFilePreviewTables(null); + // Clear file preview when URL is loaded + setFilePreviewFiles([]); let parts = tableURL.split('/'); const baseName = parts[parts.length - 1] || 'dataset'; @@ -686,57 +663,61 @@ export const UnifiedDataUploadDialog: React.FC = ( } if (table) { - setUrlPreviewTable(table); - setUrlPreviewUrl(tableURL); + setFilePreviewTables([table]); } else { - setUrlPreviewError('Unable to parse data from the provided URL.'); + setFilePreviewError('Unable to parse data from the provided URL.'); } }) .catch(() => { - setUrlPreviewError('Failed to fetch data from the URL.'); + setFilePreviewError('Failed to fetch data from the URL.'); }) .finally(() => { - setUrlPreviewLoading(false); + setFilePreviewLoading(false); }); }; const hasValidUrlSuffix = tableURL.endsWith('.csv') || tableURL.endsWith('.tsv') || tableURL.endsWith(".json"); const hasMultipleFileTables = (filePreviewTables?.length || 0) > 1; const showFilePreview = filePreviewLoading || !!filePreviewError || (filePreviewTables && filePreviewTables.length > 0); - const showUrlPreview = urlPreviewLoading || !!urlPreviewError || (urlPreviewTable && urlPreviewUrl === tableURL); const hasPasteContent = pasteContent.trim() !== ''; // Data source configurations for the menu - const dataSourceConfig = [ + const regularDataSources = [ { value: 'explore' as UploadTabType, title: 'Sample Datasets', description: 'Explore and load curated example datasets', icon: , - disabled: false + disabled: false, + disabledReason: undefined }, { value: 'upload' as UploadTabType, title: 'Upload File', - description: 'Upload CSV, TSV, JSON, or Excel files from your computer', + description: 'Upload structured data (CSV, TSV, JSON, Excel) from files or URLs', icon: , - disabled: serverConfig.DISABLE_FILE_UPLOAD, - disabledReason: 'File upload is disabled in this environment' + disabled: false, + disabledReason: undefined }, { value: 'paste' as UploadTabType, title: 'Paste Data', description: 'Paste tabular data directly from clipboard', icon: , - disabled: false + disabled: false, + disabledReason: undefined }, { - value: 'url' as UploadTabType, - title: 'From URL', - description: 'Load data from a web URL (CSV, TSV, or JSON)', - icon: , - disabled: false + value: 'extract' as UploadTabType, + title: 'Extract from Documents', + description: 'Extract tables from images, PDFs, or documents using AI', + icon: , + disabled: false, + disabledReason: undefined }, + ]; + + const databaseDataSources = [ { value: 'database' as UploadTabType, title: 'Database', @@ -745,15 +726,11 @@ export const UnifiedDataUploadDialog: React.FC = ( disabled: serverConfig.DISABLE_DATABASE, disabledReason: 'Database connection is disabled in this environment' }, - { - value: 'extract' as UploadTabType, - title: 'Extract from Documents', - description: 'Extract tables from images, PDFs, or documents using AI', - icon: , - disabled: false - }, ]; + // Combined config for finding tab titles + const dataSourceConfig = [...regularDataSources, ...databaseDataSources]; + // Get current tab title for header const getCurrentTabTitle = () => { const tab = dataSourceConfig.find(t => t.value === activeTab); @@ -769,7 +746,7 @@ export const UnifiedDataUploadDialog: React.FC = ( '& .MuiDialog-paper': { width: 1100, maxWidth: '95vw', - height: 700, + height: 600, maxHeight: '90vh', display: 'flex', flexDirection: 'column', @@ -824,21 +801,50 @@ export const UnifiedDataUploadDialog: React.FC = ( - {dataSourceConfig.map((source) => ( - setActiveTab(source.value)} - disabled={source.disabled} - disabledReason={source.disabledReason} - /> - ))} + {/* Regular Data Sources Group */} + + {regularDataSources.map((source) => ( + setActiveTab(source.value)} + disabled={source.disabled} + disabledReason={source.disabledReason} + /> + ))} + + + {/* Divider */} + + + {/* Database Data Sources Group */} + + {databaseDataSources.map((source) => ( + setActiveTab(source.value)} + disabled={source.disabled} + disabledReason={source.disabledReason} + /> + ))} +
    @@ -852,9 +858,9 @@ export const UnifiedDataUploadDialog: React.FC = ( boxSizing: 'border-box', gap: 2, p: 2, - justifyContent: 'center', + justifyContent: showFilePreview ? 'flex-start' : 'center', }}> - + = ( ) : ( - fileInputRef.current?.click()} - > - - - Click to upload or drag and drop - - - Supported formats: CSV, TSV, JSON, Excel (xlsx, xls) - - - Maximum file size: 5MB (use Database tab for larger files) - + + fileInputRef.current?.click()} + > + + + Drag & drop file here + + + or Browse + + {!showFilePreview && ( + + Supported: CSV, TSV, JSON, Excel (xlsx, xls) + + )} + + + {/* URL Input Section */} + + setTableURL(e.target.value.trim())} + error={tableURL !== "" && !hasValidUrlSuffix} + size="small" + sx={{ + flex: 1, + '& .MuiInputBase-input': { + fontSize: '0.75rem', + }, + '& .MuiInputBase-input::placeholder': { + fontSize: '0.75rem', + }, + }} + /> + + )} @@ -933,7 +986,7 @@ export const UnifiedDataUploadDialog: React.FC = ( disabled={serverConfig.DISABLE_FILE_UPLOAD || filePreviewLoading} sx={{ textTransform: 'none' }} > - {hasMultipleFileTables ? 'Load Files' : 'Load File'} + {hasMultipleFileTables ? 'Load Tables' : 'Load Table'} )} @@ -941,77 +994,6 @@ export const UnifiedDataUploadDialog: React.FC = ( - {/* URL Tab */} - - - - - setTableURL(e.target.value.trim())} - error={tableURL !== "" && !hasValidUrlSuffix} - size="small" - /> - - - - {tableURL !== "" && !hasValidUrlSuffix && ( - - URL should link to a .csv, .tsv, or .json file. - - )} - - - Supported URLs: direct links to .csv, .tsv, or .json files. - - - {showUrlPreview && ( - - )} - - {urlPreviewTable && urlPreviewUrl === tableURL && ( - - - - )} - - - - {/* Paste Data Tab */} = ( ) : ( - + )} @@ -1164,559 +1146,4 @@ export const UnifiedDataUploadDialog: React.FC = ( ); }; -interface DBTable { - name: string; - columns: { name: string; type: string; }[]; - row_count: number; - sample_rows: any[]; - view_source: string | null; -} - -interface ColumnStatistics { - column: string; - type: string; - statistics: { - count: number; - unique_count: number; - null_count: number; - min?: number; - max?: number; - avg?: number; - }; -} - -// Separate component for Database tab content -const DatabaseTabContent: React.FC<{ onClose: () => void }> = ({ onClose }) => { - const theme = useTheme(); - const dispatch = useDispatch(); - const sessionId = useSelector((state: DataFormulatorState) => state.sessionId); - const tables = useSelector((state: DataFormulatorState) => state.tables); - const serverConfig = useSelector((state: DataFormulatorState) => state.serverConfig); - - const [dbTables, setDbTables] = React.useState([]); - const [selectedTabKey, setSelectedTabKey] = React.useState(""); - const [isUploading, setIsUploading] = React.useState(false); - const [tableAnalysisMap, setTableAnalysisMap] = React.useState>({}); - const [dataLoaderMetadata, setDataLoaderMetadata] = React.useState>({}); - - const setSystemMessage = (content: string, severity: "error" | "warning" | "info" | "success") => { - dispatch(dfActions.addMessages({ - "timestamp": Date.now(), - "component": "DB manager", - "type": severity, - "value": content - })); - }; - - React.useEffect(() => { - fetchTables(); - fetchDataLoaders(); - }, []); - - React.useEffect(() => { - if (!selectedTabKey.startsWith("dataLoader:") && dbTables.length == 0) { - setSelectedTabKey(""); - } else if (!selectedTabKey.startsWith("dataLoader:") && dbTables.find(t => t.name === selectedTabKey) == undefined) { - if (dbTables.length > 0) { - setSelectedTabKey(dbTables[0].name); - } - } - }, [dbTables]); - - const fetchTables = async () => { - if (serverConfig.DISABLE_DATABASE) return; - try { - const response = await fetch(getUrls().LIST_TABLES); - const data = await response.json(); - if (data.status === 'success') { - setDbTables(data.tables); - } - } catch (error) { - setSystemMessage('Failed to fetch tables, please check if the server is running', "error"); - } - }; - - const fetchDataLoaders = async () => { - fetch(getUrls().DATA_LOADER_LIST_DATA_LOADERS, { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - }) - .then(response => response.json()) - .then(data => { - if (data.status === "success") { - setDataLoaderMetadata(data.data_loaders); - } - }) - .catch(error => { - console.error('Failed to fetch data loader params:', error); - }); - }; - - const handleDBFileUpload = async (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (!file) return; - - const formData = new FormData(); - formData.append('file', file); - formData.append('table_name', file.name.split('.')[0]); - - try { - setIsUploading(true); - const response = await fetch(getUrls().CREATE_TABLE, { - method: 'POST', - body: formData - }); - const data = await response.json(); - if (data.status === 'success') { - if (data.is_renamed) { - setSystemMessage(`Table ${data.original_name} already exists. Renamed to ${data.table_name}`, "warning"); - } - fetchTables(); - } else { - setSystemMessage(data.error || 'Failed to upload table', "error"); - } - } catch (error) { - setSystemMessage('Failed to upload table, please check if the server is running', "error"); - } finally { - setIsUploading(false); - if (event.target) { - event.target.value = ''; - } - } - }; - - const handleDropTable = async (tableName: string) => { - if (tables.some(t => t.id === tableName)) { - if (!confirm(`Are you sure you want to delete ${tableName}? \n ${tableName} is currently loaded and will be removed from the database.`)) return; - } - - try { - const response = await fetch(getUrls().DELETE_TABLE, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ table_name: tableName }) - }); - const data = await response.json(); - if (data.status === 'success') { - fetchTables(); - setSelectedTabKey(dbTables.length > 0 ? dbTables[0].name : ""); - } else { - setSystemMessage(data.error || 'Failed to delete table', "error"); - } - } catch (error) { - setSystemMessage('Failed to delete table, please check if the server is running', "error"); - } - }; - - const handleAnalyzeData = async (tableName: string) => { - if (!tableName) return; - if (tableAnalysisMap[tableName]) return; - - try { - const response = await fetch(getUrls().GET_COLUMN_STATS, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ table_name: tableName }) - }); - const data = await response.json(); - if (data.status === 'success') { - setTableAnalysisMap(prevMap => ({ - ...prevMap, - [tableName]: data.statistics - })); - } - } catch (error) { - setSystemMessage('Failed to analyze table data', "error"); - } - }; - - const toggleAnalysisView = (tableName: string) => { - if (tableAnalysisMap[tableName]) { - setTableAnalysisMap(prevMap => { - const newMap = { ...prevMap }; - delete newMap[tableName]; - return newMap; - }); - } else { - handleAnalyzeData(tableName); - } - }; - - const handleAddTableToDF = (dbTable: DBTable) => { - const convertSqlTypeToAppType = (sqlType: string): Type => { - sqlType = sqlType.toUpperCase(); - if (sqlType.includes('INT') || sqlType === 'BIGINT' || sqlType === 'SMALLINT' || sqlType === 'TINYINT') { - return Type.Integer; - } else if (sqlType.includes('FLOAT') || sqlType.includes('DOUBLE') || sqlType.includes('DECIMAL') || sqlType.includes('NUMERIC') || sqlType.includes('REAL')) { - return Type.Number; - } else if (sqlType.includes('BOOL')) { - return Type.Boolean; - } else if (sqlType.includes('DATE') || sqlType.includes('TIME') || sqlType.includes('TIMESTAMP')) { - return Type.Date; - } else { - return Type.String; - } - }; - - let table: DictTable = { - id: dbTable.name, - displayId: dbTable.name, - names: dbTable.columns.map((col: any) => col.name), - metadata: dbTable.columns.reduce((acc: Record, col: any) => ({ - ...acc, - [col.name]: { - type: convertSqlTypeToAppType(col.type), - semanticType: "", - levels: [] - } - }), {}), - rows: dbTable.sample_rows, - virtual: { - tableId: dbTable.name, - rowCount: dbTable.row_count, - }, - anchored: true, - createdBy: 'user', - attachedMetadata: '' - } - dispatch(dfActions.loadTable(table)); - dispatch(fetchFieldSemanticType(table)); - onClose(); - }; - - const handleCleanDerivedViews = async () => { - let unreferencedViews = dbTables.filter(t => t.view_source !== null && t.view_source !== undefined && !tables.some(t2 => t2.id === t.name)); - - if (unreferencedViews.length > 0) { - if (confirm(`Are you sure you want to delete the following unreferenced derived views? \n${unreferencedViews.map(v => `- ${v.name}`).join("\n")}`)) { - let deletedViews = []; - for (let view of unreferencedViews) { - try { - const response = await fetch(getUrls().DELETE_TABLE, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ table_name: view.name }) - }); - const data = await response.json(); - if (data.status === 'success') { - deletedViews.push(view.name); - } - } catch (error) { - setSystemMessage('Failed to delete table', "error"); - } - } - if (deletedViews.length > 0) { - setSystemMessage(`Deleted ${deletedViews.length} unreferenced derived views`, "success"); - } - fetchTables(); - } - } - }; - - const handleDBReset = async () => { - try { - const response = await fetch(getUrls().RESET_DB_FILE, { method: 'POST' }); - const data = await response.json(); - if (data.status === 'success') { - fetchTables(); - } else { - setSystemMessage(data.error || 'Failed to reset database', "error"); - } - } catch (error) { - setSystemMessage('Failed to reset database', "error"); - } - }; - - const handleDBUpload = async (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (!file) return; - - const formData = new FormData(); - formData.append('file', file); - - try { - setIsUploading(true); - const response = await fetch(getUrls().UPLOAD_DB_FILE, { - method: 'POST', - body: formData - }); - const data = await response.json(); - if (data.status === 'success') { - fetchTables(); - } else { - setSystemMessage(data.error || 'Failed to upload database', "error"); - } - } catch (error) { - setSystemMessage('Failed to upload database', "error"); - } finally { - setIsUploading(false); - } - }; - - const hasDerivedViews = dbTables.filter(t => t.view_source !== null).length > 0; - - const dataLoaderPanel = ( - - - - Data Connectors - - - - {["file upload", ...Object.keys(dataLoaderMetadata ?? {})].map((dataLoaderType) => ( - - ))} - - ); - - const tableSelectionPanel = ( - - - - Data Tables - - - - - - - - - {dbTables.length == 0 && - - no tables available - - } - - {dbTables.filter(t => t.view_source === null).map((t) => ( - - ))} - - {hasDerivedViews && ( - - - - Derived Views - - - t.view_source !== null).length === 0} onClick={handleCleanDerivedViews}> - - - - - - {dbTables.filter(t => t.view_source !== null).map((t) => ( - - ))} - - )} - - ); - - const tableView = ( - - {selectedTabKey === '' && ( - - The database is empty, refresh the table list or import some data to get started. - - )} - - {selectedTabKey === 'dataLoader:file upload' && ( - - - - - - Files uploaded here are stored in the database and can handle larger datasets - - - )} - - {dataLoaderMetadata && Object.entries(dataLoaderMetadata).map(([dataLoaderType, metadata]) => ( - selectedTabKey === 'dataLoader:' + dataLoaderType && ( - - setIsUploading(true)} - onFinish={(status, message, importedTables) => { - setIsUploading(false); - fetchTables().then(() => { - if (status === "success" && importedTables && importedTables.length > 0) { - setSelectedTabKey(importedTables[0]); - } - }); - if (status === "error") { - setSystemMessage(message, "error"); - } - }} - /> - - ) - ))} - - {dbTables.map((t) => { - if (selectedTabKey !== t.name) return null; - - const showingAnalysis = tableAnalysisMap[t.name] !== undefined; - return ( - - - - - {showingAnalysis ? "column stats for " : "sample data from "} - {t.name} - - ({t.columns.length} columns × {t.row_count} rows) - - - - - handleDropTable(t.name)} title="Drop Table"> - - - - - {showingAnalysis ? ( - - ) : ( - Object.fromEntries(Object.entries(row).map(([key, value]: [string, any]) => [key, String(value)]))).slice(0, 9)} - columnDefs={t.columns.map(col => ({ id: col.name, label: col.name, minWidth: 60 }))} - rowsPerPageNum={-1} - compact={false} - isIncompleteTable={t.row_count > 10} - /> - )} - - - - ); - })} - - ); - - return ( - - {isUploading && ( - - - - )} - - - {dataLoaderPanel} - {tableSelectionPanel} - - - - - - , - - - - or - - - - {tableView} - - ); -}; - export default UnifiedDataUploadDialog; From e2bb8cc9e3d7d9cbc804233312ec16fdad96d063 Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Sat, 17 Jan 2026 11:14:57 -0800 Subject: [PATCH 07/21] fixes and improvements --- src/app/App.tsx | 66 +---- src/app/store.ts | 3 + src/app/utils.tsx | 113 +++++++++ src/views/DBTableManager.tsx | 176 +++++++------ src/views/DataFormulator.tsx | 11 +- src/views/DataLoadingChat.tsx | 334 ++++++++++++++++--------- src/views/TableSelectionView.tsx | 167 +++++++------ src/views/UnifiedDataUploadDialog.tsx | 346 ++++++++++++++++---------- 8 files changed, 745 insertions(+), 471 deletions(-) diff --git a/src/app/App.tsx b/src/app/App.tsx index 1a8707f..209f3ac 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -65,16 +65,11 @@ import { AppDispatch } from './store'; import dfLogo from '../assets/df-logo.png'; import { ModelSelectionButton } from '../views/ModelSelectionDialog'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; -import ContentPasteIcon from '@mui/icons-material/ContentPaste'; import UploadFileIcon from '@mui/icons-material/UploadFile'; import DownloadIcon from '@mui/icons-material/Download'; import { handleDBDownload } from '../views/DBTableManager'; -import CloudQueueIcon from '@mui/icons-material/CloudQueue'; import { getUrls } from './utils'; -import { UnifiedDataUploadDialog, UploadTabType } from '../views/UnifiedDataUploadDialog'; -import LinkIcon from '@mui/icons-material/Link'; -import ImageSearchIcon from '@mui/icons-material/ImageSearch'; -import ExploreIcon from '@mui/icons-material/Explore'; +import { UnifiedDataUploadDialog } from '../views/UnifiedDataUploadDialog'; import ChatIcon from '@mui/icons-material/Chat'; import { AgentRulesDialog } from '../views/AgentRulesDialog'; import ArticleIcon from '@mui/icons-material/Article'; @@ -221,78 +216,23 @@ export interface AppFCProps { // Extract menu components into separate components to prevent full app re-renders const TableMenu: React.FC = () => { - const [anchorEl, setAnchorEl] = useState(null); const [dialogOpen, setDialogOpen] = useState(false); - const [initialTab, setInitialTab] = useState('menu'); - const open = Boolean(anchorEl); - - const handleOpenDialog = (tab: UploadTabType) => { - setAnchorEl(null); - setInitialTab(tab); - setDialogOpen(true); - }; return ( <> - setAnchorEl(null)} - slotProps={{ - paper: { sx: { py: '4px', px: '8px' } } - }} - aria-labelledby="add-table-button" - sx={{ - '& .MuiMenuItem-root': { padding: '4px 8px' }, - '& .MuiTypography-root': { fontSize: 14, display: 'flex', alignItems: 'center', textTransform: 'none', gap: 1 } - }} - > - handleOpenDialog('upload')}> - - upload file (csv/json/xlsx) - - - handleOpenDialog('paste')}> - - paste data (csv/tsv/json) - - - - handleOpenDialog('database')}> - - database - - - handleOpenDialog('extract')}> - - extract data (image/text) - - - - handleOpenDialog('explore')}> - - explore samples - - - {/* Unified Data Upload Dialog */} setDialogOpen(false)} - initialTab={initialTab} + initialTab="menu" /> ); diff --git a/src/app/store.ts b/src/app/store.ts index 027f4ac..edc7c72 100644 --- a/src/app/store.ts +++ b/src/app/store.ts @@ -26,3 +26,6 @@ let store = configureStore({ }) export default store; + +// Export store instance for use in utilities +export { store }; diff --git a/src/app/utils.tsx b/src/app/utils.tsx index aeee817..8ed501c 100644 --- a/src/app/utils.tsx +++ b/src/app/utils.tsx @@ -62,6 +62,119 @@ export function getUrls() { }; } +/** + * List of API endpoints that require a session ID + * These endpoints will automatically fetch session ID if missing + */ +const SESSION_REQUIRED_ENDPOINTS = [ + '/api/tables/upload-db-file', + '/api/tables/download-db-file', + '/api/tables/reset-db-file', + '/api/tables/list-tables', + '/api/tables/get-table', + '/api/tables/create-table', + '/api/tables/delete-table', + '/api/tables/analyze', + '/api/tables/sample-table', + '/api/tables/data-loader/list-tables', + '/api/tables/data-loader/ingest-data', + '/api/tables/data-loader/view-query-sample', + '/api/tables/data-loader/ingest-data-from-query', + '/api/tables/refresh-derived-data', +]; + +/** + * Enhanced fetch wrapper that automatically handles session ID at the app level + * If a request fails with "No session ID found" or similar errors, + * it will automatically fetch the session ID and retry the request + * + * This function can be used as a drop-in replacement for fetch() in components + * that have access to Redux dispatch + * + * @param url - The URL to fetch + * @param options - Fetch options (same as native fetch) + * @param dispatch - Redux dispatch function (optional, will try to get from store if not provided) + * @returns Promise + */ +export async function fetchWithSession( + url: string | URL, + options: RequestInit = {}, + dispatch?: any +): Promise { + const urlString = typeof url === 'string' ? url : url.toString(); + + // Check if this endpoint requires session ID + const requiresSession = SESSION_REQUIRED_ENDPOINTS.some(endpoint => + urlString.includes(endpoint) + ); + + // Make the initial request + let response = await fetch(url, options); + + // If the response indicates a session ID issue, try to fetch it and retry + if (requiresSession && !response.ok) { + try { + const errorData = await response.clone().json().catch(() => null); + const errorMessage = errorData?.message || errorData?.error || ''; + + // Check if error is related to missing session ID + if (errorMessage.toLowerCase().includes('session') && + (errorMessage.toLowerCase().includes('not found') || + errorMessage.toLowerCase().includes('no session'))) { + + // Try to get dispatch from store if not provided + let dispatchFn = dispatch; + if (!dispatchFn) { + try { + const { store } = await import('./store'); + dispatchFn = store.dispatch; + } catch (storeError) { + console.error('Failed to access store:', storeError); + // Return the original error response + return response; + } + } + + // Fetch session ID and retry + if (dispatchFn) { + try { + const { getSessionId } = await import('./dfSlice'); + // Dispatch the thunk and unwrap the result + await dispatchFn(getSessionId()).unwrap(); + + // Retry the original request + response = await fetch(url, options); + } catch (sessionError) { + console.error('Failed to fetch session ID:', sessionError); + // Return the original error response + } + } + } + } catch (parseError) { + // If we can't parse the error, just return the original response + } + } + + return response; +} + +/** + * React hook that provides a fetch function with automatic session ID handling + * Use this in components instead of native fetch for API calls that require session ID + * + * @example + * const fetchWithSession = useFetchWithSession(); + * const response = await fetchWithSession('/api/tables/list-tables'); + */ +export function useFetchWithSession() { + // Dynamic import to avoid circular dependency + const { useDispatch } = require('react-redux'); + const dispatch = useDispatch(); + + return (url: string | URL, options: RequestInit = {}) => + fetchWithSession(url, options, dispatch); +} + import * as vm from 'vm-browserify'; import { generateFreshChart } from "./dfSlice"; diff --git a/src/views/DBTableManager.tsx b/src/views/DBTableManager.tsx index 29b4a89..8d09016 100644 --- a/src/views/DBTableManager.tsx +++ b/src/views/DBTableManager.tsx @@ -42,7 +42,7 @@ import TableRowsIcon from '@mui/icons-material/TableRows'; import RefreshIcon from '@mui/icons-material/Refresh'; import SearchIcon from '@mui/icons-material/Search'; -import { getUrls } from '../app/utils'; +import { getUrls, fetchWithSession } from '../app/utils'; import { CustomReactTable } from './ReactTable'; import { DictTable } from '../components/ComponentType'; import { Type } from '../data/types'; @@ -101,16 +101,19 @@ const ViewIcon: React.FC<{ sx?: SxProps }> = ({ sx }) => (
    ); -export const handleDBDownload = async (sessionId: string) => { +export const handleDBDownload = async (sessionId: string, dispatch?: any) => { try { - const response = await fetch(getUrls().DOWNLOAD_DB_FILE, { - method: 'GET', - }); + // Use fetchWithSession which automatically handles session ID if missing + const response = await fetchWithSession( + getUrls().DOWNLOAD_DB_FILE, + { method: 'GET' }, + dispatch + ); // Check if the response is ok if (!response.ok) { const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to download database file'); + throw new Error(errorData.error || errorData.message || 'Failed to download database file'); } // Get the blob directly from response @@ -120,7 +123,7 @@ export const handleDBDownload = async (sessionId: string) => { // Create a temporary link element const link = document.createElement('a'); link.href = url; - link.download = `df_${sessionId?.slice(0, 4)}.db`; + link.download = `df_${sessionId?.slice(0, 4) || 'db'}.db`; document.body.appendChild(link); // Trigger download @@ -218,7 +221,7 @@ export const DBManagerPane: React.FC<{ const fetchTables = async () => { if (serverConfig.DISABLE_DATABASE) return; try { - const response = await fetch(getUrls().LIST_TABLES); + const response = await fetchWithSession(getUrls().LIST_TABLES, { method: 'GET' }, dispatch); const data = await response.json(); if (data.status === 'success') { setDbTables(data.tables); @@ -258,10 +261,10 @@ export const DBManagerPane: React.FC<{ try { setIsUploading(true); - const response = await fetch(getUrls().UPLOAD_DB_FILE, { + const response = await fetchWithSession(getUrls().UPLOAD_DB_FILE, { method: 'POST', body: formData - }); + }, dispatch); const data = await response.json(); if (data.status === 'success') { fetchTables(); // Refresh table list @@ -286,10 +289,10 @@ export const DBManagerPane: React.FC<{ try { setIsUploading(true); - const response = await fetch(getUrls().CREATE_TABLE, { + const response = await fetchWithSession(getUrls().CREATE_TABLE, { method: 'POST', body: formData - }); + }, dispatch); const data = await response.json(); if (data.status === 'success') { if (data.is_renamed) { @@ -312,9 +315,9 @@ export const DBManagerPane: React.FC<{ const handleDBReset = async () => { try { - const response = await fetch(getUrls().RESET_DB_FILE, { + const response = await fetchWithSession(getUrls().RESET_DB_FILE, { method: 'POST', - }); + }, dispatch); const data = await response.json(); if (data.status === 'success') { fetchTables(); @@ -334,13 +337,13 @@ export const DBManagerPane: React.FC<{ let deletedViews = []; for (let view of unreferencedViews) { try { - const response = await fetch(getUrls().DELETE_TABLE, { + const response = await fetchWithSession(getUrls().DELETE_TABLE, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ table_name: view.name }) - }); + }, dispatch); const data = await response.json(); if (data.status === 'success') { deletedViews.push(view.name); @@ -367,13 +370,13 @@ export const DBManagerPane: React.FC<{ } try { - const response = await fetch(getUrls().DELETE_TABLE, { + const response = await fetchWithSession(getUrls().DELETE_TABLE, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ table_name: tableName }) - }); + }, dispatch); const data = await response.json(); if (data.status === 'success') { fetchTables(); @@ -392,13 +395,13 @@ export const DBManagerPane: React.FC<{ if (tableAnalysisMap[tableName]) return; try { - const response = await fetch(getUrls().GET_COLUMN_STATS, { + const response = await fetchWithSession(getUrls().GET_COLUMN_STATS, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ table_name: tableName }) - }); + }, dispatch); const data = await response.json(); if (data.status === 'success') { // Update the analysis map with the new results @@ -495,7 +498,6 @@ export const DBManagerPane: React.FC<{ px: 0.5, pt: 1, display: 'flex', flexDirection: 'column', - backgroundColor: alpha(theme.palette.primary.main, 0.02), width: '100%' }}> {/* Recent Data Loaders */} @@ -503,7 +505,7 @@ export const DBManagerPane: React.FC<{ External Data Loaders @@ -520,21 +522,20 @@ export const DBManagerPane: React.FC<{ key="file upload" label="file" size="small" + variant="outlined" onClick={() => setSelectedDataLoader("file upload")} sx={{ fontSize: '0.7rem', height: 20, maxWidth: '100%', - backgroundColor: selectedDataLoader === "file upload" ? alpha(theme.palette.secondary.main, 0.2) : 'transparent', + borderColor: selectedDataLoader === "file upload" + ? theme.palette.secondary.main + : theme.palette.divider, '& .MuiChip-label': { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, - '&:hover': { - backgroundColor: alpha(theme.palette.secondary.main, 0.12), - cursor: 'pointer' - } }} /> {Object.keys(dataLoaderMetadata ?? {}) @@ -543,28 +544,30 @@ export const DBManagerPane: React.FC<{ key={dataLoaderType} label={dataLoaderType} size="small" + variant="outlined" onClick={() => setSelectedDataLoader(dataLoaderType)} sx={{ fontSize: '0.7rem', height: 20, maxWidth: '100%', - backgroundColor: selectedDataLoader === dataLoaderType ? alpha(theme.palette.secondary.main, 0.2) : 'transparent', + backgroundColor: selectedDataLoader === dataLoaderType + ? alpha(theme.palette.secondary.main, 0.2) + : 'transparent', + borderColor: selectedDataLoader === dataLoaderType + ? theme.palette.secondary.main + : theme.palette.divider, '& .MuiChip-label': { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, - '&:hover': { - backgroundColor: alpha(theme.palette.secondary.main, 0.12), - cursor: 'pointer' - } }} /> ))}
    - + Refresh table list - { - handleCleanDerivedViews(); - setTableMenuAnchorEl(null); - }} - disabled={dbTables.filter(t => t.view_source !== null).length === 0} - dense - > - - Clean up derived views - { @@ -635,7 +627,8 @@ export const DBManagerPane: React.FC<{ { if (!isUploading && dbTables.length > 0) { - handleDBDownload(sessionId ?? '') + // fetchWithSession will automatically handle session ID if missing + handleDBDownload(sessionId ?? '', dispatch) .catch(error => { console.error('Failed to download database:', error); setSystemMessage('Failed to download database file', "error"); @@ -735,36 +728,54 @@ export const DBManagerPane: React.FC<{ {/* Derived Views Section */} {dbTables.filter(t => t.view_source !== null).length > 0 && ( - + borderRadius: 0, + py: 0.5, + px: 2, + color: 'text.secondary', + minWidth: 0, + '&:hover': { + backgroundColor: alpha(theme.palette.primary.main, 0.08) + } + }} + startIcon={showViews ? : } + > + + Views ({dbTables.filter(t => t.view_source !== null).length}) + + + + t.view_source !== null && t.view_source !== undefined && !tables.some(t2 => t2.id === t.name)).length === 0} + sx={{ + padding: 0.5, + mr: 0.5, + '&:hover': { + backgroundColor: alpha(theme.palette.primary.main, 0.08), + } + }} + > + + + + {dbTables.filter(t => t.view_source !== null).map((t, i) => { @@ -1033,6 +1044,7 @@ export const DBManagerPane: React.FC<{ minHeight: 0, height: '100%', position: 'relative', + borderRight: `1px solid ${theme.palette.divider}`, overscrollBehavior: 'contain' }}> {/* Available Tables Section - always visible */} @@ -1221,7 +1233,7 @@ export const DataLoaderForm: React.FC<{ // Import all selected tables sequentially const importPromises = tablesToImport.map(tableName => - fetch(getUrls().DATA_LOADER_INGEST_DATA, { + fetchWithSession(getUrls().DATA_LOADER_INGEST_DATA, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1231,7 +1243,7 @@ export const DataLoaderForm: React.FC<{ data_loader_params: params, table_name: tableName }) - }).then(response => response.json()) + }, dispatch).then((response: Response) => response.json()) ); Promise.all(importPromises) @@ -1339,7 +1351,7 @@ export const DataLoaderForm: React.FC<{ sx={{textTransform: "none"}} onClick={() => { setIsConnecting(true); - fetch(getUrls().DATA_LOADER_LIST_TABLES, { + fetchWithSession(getUrls().DATA_LOADER_LIST_TABLES, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1349,8 +1361,8 @@ export const DataLoaderForm: React.FC<{ data_loader_params: params, table_filter: tableFilter.trim() || null }) - }).then(response => response.json()) - .then(data => { + }, dispatch).then((response: Response) => response.json()) + .then((data: any) => { if (data.status === "success") { console.log(data.tables); setTableMetadata(Object.fromEntries(data.tables.map((table: any) => { @@ -1362,7 +1374,7 @@ export const DataLoaderForm: React.FC<{ } setIsConnecting(false); }) - .catch(error => { + .catch((error: any) => { onFinish("error", `Failed to fetch data loader tables, please check the server is running`); setIsConnecting(false); }); @@ -1464,7 +1476,7 @@ export const DataLoaderForm: React.FC<{ sx={{textTransform: "none"}} onClick={() => { setIsConnecting(true); - fetch(getUrls().DATA_LOADER_LIST_TABLES, { + fetchWithSession(getUrls().DATA_LOADER_LIST_TABLES, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1474,8 +1486,8 @@ export const DataLoaderForm: React.FC<{ data_loader_params: params, table_filter: tableFilter.trim() || null }) - }).then(response => response.json()) - .then(data => { + }, dispatch).then((response: Response) => response.json()) + .then((data: any) => { if (data.status === "success") { console.log(data.tables); setTableMetadata(Object.fromEntries(data.tables.map((table: any) => { @@ -1487,7 +1499,7 @@ export const DataLoaderForm: React.FC<{ } setIsConnecting(false); }) - .catch(error => { + .catch((error: any) => { onFinish("error", `Failed to fetch data loader tables, please check the server is running`); setIsConnecting(false); }); diff --git a/src/views/DataFormulator.tsx b/src/views/DataFormulator.tsx index 20aa40e..c71aaea 100644 --- a/src/views/DataFormulator.tsx +++ b/src/views/DataFormulator.tsx @@ -281,7 +281,16 @@ export const DataFormulatorFC = ({ }) => { + lineHeight: 2, + '& span': { + textTransform: 'uppercase', + letterSpacing: '0.02em', + textDecoration: 'underline', textUnderlineOffset: '0.2em', + cursor: 'pointer', color: theme.palette.primary.main, + '&:hover': { + color: theme.palette.primary.dark, + } + }}}> To begin, {' '} openUploadDialog('extract')}>extract{' '} data from images or text documents, load {' '} diff --git a/src/views/DataLoadingChat.tsx b/src/views/DataLoadingChat.tsx index 1a014ba..6602149 100644 --- a/src/views/DataLoadingChat.tsx +++ b/src/views/DataLoadingChat.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import { useEffect, useRef, useState } from 'react'; -import { Box, Button, Divider, IconButton, Typography, Tooltip, CircularProgress } from '@mui/material'; +import { Box, Button, Divider, IconButton, Typography, Tooltip, CircularProgress, alpha, useTheme } from '@mui/material'; import { useDispatch, useSelector } from 'react-redux'; @@ -30,7 +30,7 @@ const getUniqueTableName = (baseName: string, existingNames: Set): strin }; export const DataLoadingChat: React.FC = () => { - + const theme = useTheme(); const dispatch = useDispatch(); const inputBoxRef = useRef<(() => void) | null>(null); const abortControllerRef = useRef(null); @@ -87,12 +87,6 @@ export const DataLoadingChat: React.FC = () => { } }; - if (!existOutputBlocks && !streamingContent) { - return - - - } - const thinkingBanner = ( { ); - - let chatCard = ( - - - {/* Left: Chat panel */} - + - - {threadsComponent} + gap: 2, + }}> + - + ); + } - - - {streamingContent && ( - - {thinkingBanner} - - {streamingContent.trim()} - - - )} - - {/* Right: Data preview panel */} - {(existOutputBlocks && !streamingContent) && ( - + + {/* Left sidebar - Thread list (similar to DBTablePane) */} + + - - - {selectedTable && ( - - {selectedTable?.name} - - )} - - - - {selectedTable ? ( - + overflowY: 'auto', + overflowX: 'hidden', + flex: 1, + minHeight: 0, + height: '100%', + position: 'relative', + overscrollBehavior: 'contain', + px: 0.5, + pt: 1 + }}> + {threadsComponent.length > 0 ? ( + threadsComponent ) : ( - - No data available + + No extraction threads yet )} + + + + + - {/* Bottom submit bar */} - - - + {selectedTable ? ( + + + + ) : ( + + + Select a table from the left to preview + + + )} + + {/* Bottom submit bar */} + {selectedTable && ( + + + + + )} + - + ) : null} - )} + ); - - return chatCard; }; diff --git a/src/views/TableSelectionView.tsx b/src/views/TableSelectionView.tsx index 79a127f..ba3cbba 100644 --- a/src/views/TableSelectionView.tsx +++ b/src/views/TableSelectionView.tsx @@ -6,7 +6,7 @@ import { useEffect } from 'react'; import Typography from '@mui/material/Typography'; import Box from '@mui/material/Box'; -import { Button, Paper } from '@mui/material'; +import { Button, Card, Paper } from '@mui/material'; import { CustomReactTable } from './ReactTable'; import { createTableFromFromObjectArray } from '../data/utils'; @@ -56,93 +56,112 @@ export const DatasetSelectionView: React.FC = functio } return ( - + {/* Button navigation */} - {datasetTitles.map((title, i) => ( - - ))} + + {datasetTitles.map((title, i) => ( + + ))} + {/* Content area */} - - {datasets.map((dataset, i) => { - if (dataset.name !== selectedDatasetName) return null; + + + {datasets.map((dataset, i) => { + if (dataset.name !== selectedDatasetName) return null; - let tableComponents = dataset.tables.map((table, j) => { - let t = createTableFromFromObjectArray(table.table_name, table.sample, true); - let maxDisplayRows = dataset.tables.length > 1 ? 5 : 9; - if (t.rows.length < maxDisplayRows) { - maxDisplayRows = t.rows.length - 1; - } - let sampleRows = [ - ...t.rows.slice(0,maxDisplayRows), - Object.fromEntries(t.names.map(n => [n, "..."])) - ]; - let colDefs = t.names.map(name => { return { - id: name, label: name, minWidth: 60, align: undefined, format: (v: any) => v, - }}) - - let content = - - + let tableComponents = dataset.tables.map((table, j) => { + let t = createTableFromFromObjectArray(table.table_name, table.sample, true); + let maxDisplayRows = dataset.tables.length > 1 ? 5 : 9; + if (t.rows.length < maxDisplayRows) { + maxDisplayRows = t.rows.length - 1; + } + let sampleRows = [ + ...t.rows.slice(0,maxDisplayRows), + Object.fromEntries(t.names.map(n => [n, "..."])) + ]; + let colDefs = t.names.map(name => { return { + id: name, label: name, minWidth: 60, align: undefined, format: (v: any) => v, + }}) + return ( + + + {table.url.split("/").pop()?.split(".")[0]} ({Object.keys(t.rows[0]).length} columns{hideRowNum ? "" : ` ⨉ ${t.rows.length} rows`}) + + + + + + + + ) + }); + return ( - - - {table.url.split("/").pop()?.split(".")[0]} ({Object.keys(t.rows[0]).length} columns{hideRowNum ? "" : ` ⨉ ${t.rows.length} rows`}) - - {content} - - ) - }); - - return ( - - - - {dataset.description} [from {dataset.source}] - - - + + + + {dataset.description} [from {dataset.source}] + + + + + {tableComponents} - {tableComponents} - - ); - })} + ); + })} + ); diff --git a/src/views/UnifiedDataUploadDialog.tsx b/src/views/UnifiedDataUploadDialog.tsx index 17b8558..dfe5adf 100644 --- a/src/views/UnifiedDataUploadDialog.tsx +++ b/src/views/UnifiedDataUploadDialog.tsx @@ -6,6 +6,7 @@ import { useState, useCallback, useEffect, useRef } from 'react'; import { Box, Button, + Chip, Dialog, DialogContent, DialogTitle, @@ -18,7 +19,7 @@ import { Input, alpha, useTheme, - Divider, + Card, } from '@mui/material'; import CloseIcon from '@mui/icons-material/Close'; @@ -88,6 +89,8 @@ interface PreviewPanelProps { emptyLabel: string; meta?: string; onRemoveTable?: (index: number) => void; + activeIndex?: number; + onActiveIndexChange?: (index: number) => void; } const PreviewPanel: React.FC = ({ @@ -99,27 +102,37 @@ const PreviewPanel: React.FC = ({ emptyLabel, meta, onRemoveTable, + activeIndex: controlledActiveIndex, + onActiveIndexChange, }) => { const previewTables = tables ?? (table ? [table] : null); - const [activeIndex, setActiveIndex] = useState(0); + const [internalActiveIndex, setInternalActiveIndex] = useState(0); + const activeIndex = controlledActiveIndex !== undefined ? controlledActiveIndex : internalActiveIndex; + const setActiveIndex = onActiveIndexChange || setInternalActiveIndex; useEffect(() => { if (!previewTables || previewTables.length === 0) { - setActiveIndex(0); + if (onActiveIndexChange) { + onActiveIndexChange(0); + } else { + setInternalActiveIndex(0); + } return; } if (activeIndex > previewTables.length - 1) { - setActiveIndex(previewTables.length - 1); + const newIndex = previewTables.length - 1; + if (onActiveIndexChange) { + onActiveIndexChange(newIndex); + } else { + setInternalActiveIndex(newIndex); + } } - }, [previewTables, activeIndex]); + }, [previewTables, activeIndex, onActiveIndexChange]); const activeTable = previewTables && previewTables.length > 0 ? previewTables[activeIndex] : null; return ( = ({ minHeight: 120, }} > - - - {title} - - {onRemoveTable && previewTables && previewTables.length > 0 && ( - onRemoveTable(activeIndex)} - sx={{ ml: 'auto' }} - aria-label="Remove table" - > - - - )} - - {loading && } {error && ( @@ -160,96 +156,107 @@ const PreviewPanel: React.FC = ({ {previewTables && previewTables.length > 0 && ( - {previewTables.length > 1 && ( - - {previewTables.map((t, idx) => { - const label = t.displayId || t.id; - return ( - - - - ); - })} - - )} + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + maxWidth: 140, + }, + '&:hover': { + backgroundColor: (theme) => + isSelected + ? alpha(theme.palette.primary.main, 0.16) + : alpha(theme.palette.primary.main, 0.08), + }, + }} + /> + + ); + })} + {onRemoveTable && ( + onRemoveTable(activeIndex)} + sx={{ ml: 'auto', flexShrink: 0 }} + aria-label="Remove table" + > + + + )} + {activeTable && ( - - - - {activeTable.rows.length} rows × {activeTable.names.length} columns - {previewTables && previewTables.length === 1 - ? ` • ${activeTable.displayId || activeTable.id}` - : ''} - - - ({ - id: name, - label: name, - minWidth: 60, - }))} - rowsPerPageNum={-1} - compact={true} - isIncompleteTable={activeTable.rows.length > 12} - maxHeight={200} - /> - + + + ({ + id: name, + label: name, + minWidth: 60, + }))} + rowsPerPageNum={-1} + compact={true} + isIncompleteTable={activeTable.rows.length > 12} + maxHeight={200} + /> + + + + {activeTable.rows.length} rows × {activeTable.names.length} columns + + )} )} @@ -387,6 +394,7 @@ export const UnifiedDataUploadDialog: React.FC = ( const [filePreviewLoading, setFilePreviewLoading] = useState(false); const [filePreviewError, setFilePreviewError] = useState(null); const [filePreviewFiles, setFilePreviewFiles] = useState([]); + const [filePreviewActiveIndex, setFilePreviewActiveIndex] = useState(0); // Sample datasets state const [datasetPreviews, setDatasetPreviews] = useState([]); @@ -556,7 +564,30 @@ export const UnifiedDataUploadDialog: React.FC = ( } }; - const handleFileLoadSubmit = (): void => { + // Reset activeIndex when tables change + useEffect(() => { + if (filePreviewTables && filePreviewTables.length > 0) { + if (filePreviewActiveIndex >= filePreviewTables.length) { + setFilePreviewActiveIndex(filePreviewTables.length - 1); + } + } else { + setFilePreviewActiveIndex(0); + } + }, [filePreviewTables, filePreviewActiveIndex]); + + const handleFileLoadSingleTable = (): void => { + if (!filePreviewTables || filePreviewTables.length === 0) { + return; + } + const table = filePreviewTables[filePreviewActiveIndex]; + if (table) { + dispatch(dfActions.loadTable(table)); + dispatch(fetchFieldSemanticType(table)); + handleClose(); + } + }; + + const handleFileLoadAllTables = (): void => { if (!filePreviewTables || filePreviewTables.length === 0) { return; } @@ -694,7 +725,7 @@ export const UnifiedDataUploadDialog: React.FC = ( { value: 'upload' as UploadTabType, title: 'Upload File', - description: 'Upload structured data (CSV, TSV, JSON, Excel) from files or URLs', + description: 'Structured data (CSV, TSV, JSON, Excel) from files or URLs', icon: , disabled: false, disabledReason: undefined @@ -805,6 +836,21 @@ export const UnifiedDataUploadDialog: React.FC = ( flexDirection: 'column', gap: 2, }}> + {/* Section hint */} + + Local data + + {/* Regular Data Sources Group */} = ( ))} - {/* Divider */} - + {/* Section hint */} + + Or connect to a database + {/* Database Data Sources Group */} = ( p: 2, justifyContent: showFilePreview ? 'flex-start' : 'center', }}> - + = ( ) : ( = ( textAlign: 'center', cursor: 'pointer', transition: 'all 0.2s', - flex: showFilePreview ? '0 0 45%' : '1', + flex: '1', minWidth: showFilePreview ? 0 : 'auto', '&:hover': { borderColor: 'primary.main', @@ -932,8 +990,7 @@ export const UnifiedDataUploadDialog: React.FC = ( display: 'flex', alignItems: 'center', gap: 1, - flex: showFilePreview ? '1' : '0 0 auto', - minWidth: showFilePreview ? 0 : 'auto', + flex: '0 0 auto', }}> = ( )} + {showFilePreview && ( - 0 ? `${filePreviewTables.length} table${filePreviewTables.length > 1 ? 's' : ''} previewed${hasMultipleFileTables ? ' • Multiple sheets detected' : ''}` : undefined} - onRemoveTable={handleRemoveFilePreviewTable} - /> + + 0 ? `${filePreviewTables.length} table${filePreviewTables.length > 1 ? 's' : ''} previewed${hasMultipleFileTables ? ' • Multiple sheets detected' : ''}` : undefined} + onRemoveTable={handleRemoveFilePreviewTable} + activeIndex={filePreviewActiveIndex} + onActiveIndexChange={setFilePreviewActiveIndex} + /> + )} {filePreviewTables && filePreviewTables.length > 0 && ( + {hasMultipleFileTables && ( + + )} )} - @@ -1114,7 +1186,7 @@ export const UnifiedDataUploadDialog: React.FC = ( {/* Explore Sample Datasets Tab */} - + = ( } handleClose(); }} - /> + /> From cb925eef6ea882519460790fd7133c2cc7d9dc45 Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Sat, 17 Jan 2026 12:17:26 -0800 Subject: [PATCH 08/21] ok --- src/views/DataThread.tsx | 288 ++++++++++++++++++++++++++++---- src/views/RefreshDataDialog.tsx | 269 ++++++++++++++++++++--------- 2 files changed, 444 insertions(+), 113 deletions(-) diff --git a/src/views/DataThread.tsx b/src/views/DataThread.tsx index df772a5..d210fee 100644 --- a/src/views/DataThread.tsx +++ b/src/views/DataThread.tsx @@ -465,12 +465,62 @@ const EditableTableName: FC<{ ); }; +// Compact view for thread0 - displays table cards with charts in a simple grid +// Reuses SingleThreadGroupView with compact mode +let CompactThread0View: FC<{ + scrollRef: any, + leafTables: DictTable[]; + chartElements: { tableId: string, chartId: string, element: any }[]; + sx?: SxProps +}> = function ({ + scrollRef, + leafTables, + chartElements, + sx +}) { + const theme = useTheme(); + + return ( + + + + + workspace + + + + + + + + ); +} + let SingleThreadGroupView: FC<{ scrollRef: any, threadIdx: number, leafTables: DictTable[]; chartElements: { tableId: string, chartId: string, element: any }[]; usedIntermediateTableIds: string[], + compact?: boolean, // When true, only show table cards in a simple column (for thread0) sx?: SxProps }> = function ({ scrollRef, @@ -478,6 +528,7 @@ let SingleThreadGroupView: FC<{ leafTables, chartElements, usedIntermediateTableIds, // tables that have been used + compact = false, sx }) { @@ -506,8 +557,6 @@ let SingleThreadGroupView: FC<{ const [selectedTableForRefresh, setSelectedTableForRefresh] = useState(null); const [isRefreshing, setIsRefreshing] = useState(false); - const activeModel = useSelector(dfSelectors.getActiveModel); - let handleUpdateTableDisplayId = (tableId: string, displayId: string) => { dispatch(dfActions.updateTableDisplayId({ tableId: tableId, @@ -681,7 +730,7 @@ let SingleThreadGroupView: FC<{ ; } - let buildTableCard = (tableId: string) => { + let buildTableCard = (tableId: string, compact = false) => { if (parentTable && tableId == parentTable.id && parentTable.anchored && tableIdList.length > 1) { let table = tables.find(t => t.id == tableId); @@ -901,7 +950,7 @@ let SingleThreadGroupView: FC<{ backgroundSize: '1px 6px, 3px 100%' }}> } - + {releventChartElements} {agentActionBox} @@ -974,6 +1023,94 @@ let SingleThreadGroupView: FC<{ ; }); + // Compact mode: just show leaf table cards in a simple column + if (compact) { + // For compact mode, ensure highlightedTableIds includes focused table if it's a leaf + if (focusedTableId && leafTableIds.includes(focusedTableId)) { + highlightedTableIds = [focusedTableId]; + } + + return ( + + {leafTables.map((table) => { + const tableCardResult = buildTableCard(table.id, compact); + // buildTableCard returns an array [regularTableBox, chartBox] + // In compact mode, we want to show them stacked + return ( + + {tableCardResult} + + ); + })} + + e.stopPropagation()} + > + { + e.stopPropagation(); + if (selectedTableForMenu) { + handleOpenMetadataPopup(selectedTableForMenu, tableMenuAnchorEl!); + } + handleCloseTableMenu(); + }} + sx={{ fontSize: '12px', display: 'flex', alignItems: 'center', gap: 1 }} + > + + {selectedTableForMenu?.attachedMetadata ? "Edit metadata" : "Attach metadata"} + + { + e.stopPropagation(); + if (selectedTableForMenu) { + handleOpenRefreshDialog(selectedTableForMenu); + } + }} + sx={{ fontSize: '12px', display: 'flex', alignItems: 'center', gap: 1 }} + > + + Refresh data + + { + e.stopPropagation(); + if (selectedTableForMenu) { + dispatch(dfActions.deleteTable(selectedTableForMenu.id)); + } + handleCloseTableMenu(); + }} + disabled={selectedTableForMenu ? tables.some(t => t.derive?.trigger.tableId === selectedTableForMenu.id) : true} + sx={{ fontSize: '12px', display: 'flex', alignItems: 'center', gap: 1, color: 'warning.main' }} + > + + Delete table + + + {selectedTableForRefresh && ( + + )} + + ); + } + return - {`thread - ${threadIdx + 1}`} + {threadIdx === -1 ? 'thread0' : `thread - ${threadIdx + 1}`} @@ -1385,7 +1522,22 @@ export const DataThread: FC<{sx?: SxProps}> = function ({ sx }) { return aOrders.length - bOrders.length; }); - let leafTableGroups = leafTables.reduce((groups: { [groupId: string]: DictTable[] }, leafTable) => { + // Identify hanging tables (tables with no descendants or parents) + let isHangingTable = (table: DictTable) => { + // A table is hanging if: + // 1. It has no derive.source (no parent) + // 2. No other table derives from it (no descendants) + const hasNoParent = table.derive == undefined; + const hasNoDescendants = !tables.some(t => t.derive?.trigger.tableId == table.id); + return hasNoParent && hasNoDescendants; + }; + + // Separate hanging tables from regular leaf tables + let hangingTables = leafTables.filter(t => isHangingTable(t)); + let regularLeafTables = leafTables.filter(t => !isHangingTable(t)); + + // Build groups for regular leaf tables (excluding hanging tables) + let leafTableGroups = regularLeafTables.reduce((groups: { [groupId: string]: DictTable[] }, leafTable) => { // Get the immediate parent table ID (first trigger in the chain) const triggers = getTriggers(leafTable, tables); const immediateParentTableId = triggers.length > 0 ? triggers[triggers.length - 1].tableId : 'root'; @@ -1409,8 +1561,38 @@ export const DataThread: FC<{sx?: SxProps}> = function ({ sx }) { return groups; }, {}); - let drawerOpen = threadDrawerOpen && leafTables.length > 1; - let collaposedViewWidth = Math.max(...Object.values(leafTableGroups).map(x => x.length)) > 1 ? 248 : 232 + // Filter threads to only include those with length > 1 + let filteredLeafTableGroups: { [groupId: string]: DictTable[] } = {}; + Object.entries(leafTableGroups).forEach(([groupId, groupTables]) => { + // Calculate thread length: count all tables in the thread chain + const threadLength = groupTables.reduce((maxLength, leafTable) => { + const triggers = getTriggers(leafTable, tables); + // Thread length = number of triggers + 1 (the leaf table itself) + return Math.max(maxLength, triggers.length + 1); + }, 0); + + // Only include threads with length > 1 + if (threadLength > 1) { + filteredLeafTableGroups[groupId] = groupTables; + } else { + // Add single-table threads to hanging tables (they go to thread0) + groupTables.forEach(table => { + if (!hangingTables.includes(table)) { + hangingTables.push(table); + } + }); + } + }); + + // Create thread0 group for hanging tables + let thread0Group: { [groupId: string]: DictTable[] } = {}; + if (hangingTables.length > 0) { + thread0Group['thread0'] = hangingTables; + } + + let drawerOpen = threadDrawerOpen && (Object.keys(filteredLeafTableGroups).length > 0 || hangingTables.length > 0); + let allGroupsForWidth = { ...filteredLeafTableGroups, ...thread0Group }; + let collaposedViewWidth = Math.max(...Object.values(allGroupsForWidth).map(x => x.length)) > 1 ? 248 : 232 let view = = function ({ sx }) { p: 1, transition: 'max-width 0.1s linear', // Smooth width transition }}> - {Object.entries(leafTableGroups).map(([groupId, leafTables], i) => { - - let usedIntermediateTableIds = Object.values(leafTableGroups).slice(0, i).flat() + {/* Render thread0 (hanging tables) first if it exists - using compact view */} + {Object.entries(thread0Group).map(([groupId, leafTables], i) => { + return 1 ? '216px' : '200px', + transition: 'all 0.3s ease', + }} /> + })} + {/* Render regular threads (length > 1) */} + {Object.entries(filteredLeafTableGroups).map(([groupId, leafTables], i) => { + // Calculate used tables from thread0 and previous threads + let usedIntermediateTableIds = Object.values(thread0Group).flat() .map(x => [ ...getTriggers(x, tables).map(y => y.tableId) || []]).flat(); - let usedLeafTableIds = Object.values(leafTableGroups).slice(0, i).flat().map(x => x.id); + let usedLeafTableIds = Object.values(thread0Group).flat().map(x => x.id); + + // Add tables from previous regular threads + const previousThreadGroups = Object.values(filteredLeafTableGroups).slice(0, i); + usedIntermediateTableIds = [...usedIntermediateTableIds, ...previousThreadGroups.flat() + .map(x => [ ...getTriggers(x, tables).map(y => y.tableId) || []]).flat()]; + usedLeafTableIds = [...usedLeafTableIds, ...previousThreadGroups.flat().map(x => x.id)]; return = function ({ sx }) { })} + // Calculate total thread count (thread0 + regular threads) + let totalThreadCount = Object.keys(filteredLeafTableGroups).length + (Object.keys(thread0Group).length > 0 ? 1 : 0); + let threadIndices: number[] = []; + if (Object.keys(thread0Group).length > 0) { + threadIndices.push(-1); // thread0 + } + threadIndices.push(...Array.from({length: Object.keys(filteredLeafTableGroups).length}, (_, i) => i)); + let jumpButtonsDrawerOpen = - {_.chunk(Array.from({length: Object.keys(leafTableGroups).length}, (_, i) => i), 3).map((group, groupIdx) => { - const startNum = group[0] + 1; - const endNum = group[group.length - 1] + 1; - const label = startNum === endNum ? `${startNum}` : `${startNum}-${endNum}`; + {_.chunk(threadIndices, 3).map((group, groupIdx) => { + const getLabel = (idx: number) => idx === -1 ? '0' : String(idx + 1); + const startNum = getLabel(group[0]); + const endNum = getLabel(group[group.length - 1]); + const label = startNum === endNum ? startNum : `${startNum}-${endNum}`; return ( @@ -1471,8 +1689,9 @@ export const DataThread: FC<{sx?: SxProps}> = function ({ sx }) { const currentIndex = Array.from(document.querySelectorAll('[data-thread-index]')).reduce((closest, element) => { const rect = element.getBoundingClientRect(); const distance = Math.abs(rect.left + rect.width/2 - viewportCenter); + const idx = parseInt(element.getAttribute('data-thread-index') || '0'); if (!closest || distance < closest.distance) { - return { index: parseInt(element.getAttribute('data-thread-index') || '0'), distance }; + return { index: idx, distance }; } return closest; }, null as { index: number, distance: number } | null)?.index || 0; @@ -1500,21 +1719,24 @@ export const DataThread: FC<{sx?: SxProps}> = function ({ sx }) { let jumpButtonDrawerClosed = - {Object.keys(leafTableGroups).map((groupId, idx) => ( - - { - const threadElement = document.querySelector(`[data-thread-index="${idx}"]`); - threadElement?.scrollIntoView({ behavior: 'smooth' }); - }} - > - {idx + 1} - - - ))} + {threadIndices.map((threadIdx) => { + const label = threadIdx === -1 ? '0' : String(threadIdx + 1); + return ( + + { + const threadElement = document.querySelector(`[data-thread-index="${threadIdx}"]`); + threadElement?.scrollIntoView({ behavior: 'smooth' }); + }} + > + {label} + + + ); + })} let jumpButtons = drawerOpen ? jumpButtonsDrawerOpen : jumpButtonDrawerClosed; @@ -1544,7 +1766,7 @@ export const DataThread: FC<{sx?: SxProps}> = function ({ sx }) { { + disabled={totalThreadCount <= 1} onClick={() => { setThreadDrawerOpen(true); }}> diff --git a/src/views/RefreshDataDialog.tsx b/src/views/RefreshDataDialog.tsx index 92fb32b..a650cc8 100644 --- a/src/views/RefreshDataDialog.tsx +++ b/src/views/RefreshDataDialog.tsx @@ -19,8 +19,11 @@ import { Alert, Tooltip, Link, + alpha, + useTheme, } from '@mui/material'; import CloseIcon from '@mui/icons-material/Close'; +import UploadFileIcon from '@mui/icons-material/UploadFile'; import { useDispatch, useSelector } from 'react-redux'; import { AppDispatch } from '../app/store'; import { DataFormulatorState, dfActions, dfSelectors, fetchFieldSemanticType } from '../app/dfSlice'; @@ -44,7 +47,7 @@ function TabPanel(props: TabPanelProps) { aria-labelledby={`refresh-tab-${index}`} {...other} > - {value === index && {children}} + {value === index && {children}} ); } @@ -62,6 +65,7 @@ export const RefreshDataDialog: React.FC = ({ table, onRefreshComplete, }) => { + const theme = useTheme(); const dispatch = useDispatch(); const [tabValue, setTabValue] = useState(0); const [pasteContent, setPasteContent] = useState(''); @@ -352,115 +356,218 @@ export const RefreshDataDialog: React.FC = ({ onClose={handleClose} maxWidth="md" fullWidth - sx={{ '& .MuiDialog-paper': { minHeight: 400 } }} + sx={{ '& .MuiDialog-paper': { minHeight: 500 } }} > - - Refresh Data for "{table.displayId || table.id}" + + + Refresh Data for "{table.displayId || table.id}" + - + - - - Upload new data to replace the current table content. The new data must have the same columns: {table.names.join(', ')} - - - {error && ( - - {error} - - )} + + + + Upload new data to replace the current table content. Required columns: {table.names.join(', ')} + + + {error && ( + + + {error} + + + )} - {isLoading && } + {isLoading && } + - - - - + + + + - {isOverSizeLimit && ( - - - ⚠️ Content exceeds 2MB size limit. - - - )} - {isLargeContent && !isOverSizeLimit && ( - - - Large content detected. {showFullContent ? 'Showing full content' : 'Showing preview'} - - - - )} - - - - - - - Upload a CSV, TSV, JSON, or Excel file - - - - Install Data Formulator locally to enable file upload. + + {isOverSizeLimit && ( + + + Content exceeds {(MAX_CONTENT_SIZE / (1024 * 1024)).toFixed(0)}MB limit ({(new Blob([pasteContent]).size / (1024 * 1024)).toFixed(2)}MB) - ) : ""} - > - - - - + + )} + + + {serverConfig.DISABLE_FILE_UPLOAD ? ( + + + File upload is disabled in this environment. + + + Install Data Formulator locally to enable file upload.
    + + https://github.com/microsoft/data-formulator + +
    +
    + ) : ( + + + fileInputRef.current?.click()} + > + + + Drag & drop file here + + + or Browse + + + Supported: CSV, TSV, JSON, Excel (xlsx, xls) + + + + )} +
    + setUrlContent(e.target.value.trim())} disabled={isLoading} error={urlContent !== '' && !hasValidUrlSuffix} helperText={urlContent !== '' && !hasValidUrlSuffix ? 'URL should link to a .csv, .tsv, or .json file' : ''} - sx={{ '& .MuiInputBase-input': { fontSize: 12 } }} + size="small" + sx={{ + '& .MuiInputBase-input': { + fontSize: '0.875rem', + }, + '& .MuiInputBase-input::placeholder': { + fontSize: '0.875rem', + }, + '& .MuiFormHelperText-root': { + fontSize: '0.75rem', + }, + }} /> - - {tabValue === 0 && ( @@ -468,6 +575,7 @@ export const RefreshDataDialog: React.FC = ({ variant="contained" onClick={handlePasteSubmit} disabled={isLoading || !pasteContent.trim() || isOverSizeLimit} + sx={{ textTransform: 'none' }} > Refresh Data @@ -477,6 +585,7 @@ export const RefreshDataDialog: React.FC = ({ variant="contained" onClick={handleUrlSubmit} disabled={isLoading || !urlContent.trim() || !hasValidUrlSuffix} + sx={{ textTransform: 'none' }} > Refresh Data From 6932f787a344353896afd329dc9db9c975a99107 Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Sat, 17 Jan 2026 12:40:30 -0800 Subject: [PATCH 09/21] TODO add agentic data selection --- src/views/ChartRecBox.tsx | 130 +------------------------------- src/views/EncodingShelfCard.tsx | 125 +----------------------------- 2 files changed, 7 insertions(+), 248 deletions(-) diff --git a/src/views/ChartRecBox.tsx b/src/views/ChartRecBox.tsx index 943f833..4e403f6 100644 --- a/src/views/ChartRecBox.tsx +++ b/src/views/ChartRecBox.tsx @@ -69,111 +69,6 @@ export interface ChartRecBoxProps { sx?: SxProps; } -// Table selector component for ChartRecBox -const NLTableSelector: FC<{ - selectedTableIds: string[], - tables: DictTable[], - updateSelectedTableIds: (tableIds: string[]) => void, - requiredTableIds?: string[] -}> = ({ selectedTableIds, tables, updateSelectedTableIds, requiredTableIds = [] }) => { - const [anchorEl, setAnchorEl] = useState(null); - const open = Boolean(anchorEl); - - const handleClick = (event: React.MouseEvent) => { - setAnchorEl(event.currentTarget); - }; - - const handleClose = () => { - setAnchorEl(null); - }; - - const handleTableSelect = (table: DictTable) => { - if (!selectedTableIds.includes(table.id)) { - updateSelectedTableIds([...selectedTableIds, table.id]); - } - handleClose(); - }; - - return ( - - {selectedTableIds.map((tableId) => { - const isRequired = requiredTableIds.includes(tableId); - return ( - t.id == tableId)?.displayId} - size="small" - sx={{ - height: 16, - fontSize: '10px', - borderRadius: '2px', - bgcolor: isRequired ? 'rgba(25, 118, 210, 0.2)' : 'rgba(25, 118, 210, 0.1)', - color: 'rgba(0, 0, 0, 0.7)', - '& .MuiChip-label': { - pl: '4px', - pr: '6px' - } - }} - deleteIcon={isRequired ? undefined : } - onDelete={isRequired ? undefined : () => updateSelectedTableIds(selectedTableIds.filter(id => id !== tableId))} - /> - ); - })} - - - - - - - {tables - .filter(t => t.derive === undefined || t.anchored) - .map((table) => { - const isSelected = selectedTableIds.includes(table.id); - const isRequired = requiredTableIds.includes(table.id); - return ( - handleTableSelect(table)} - sx={{ - fontSize: '12px', - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center' - }} - > - {table.displayId} - {isRequired && (required)} - - ); - }) - } - - - ); -}; - - - export const IdeaChip: FC<{ mini?: boolean, idea: {text?: string, questions?: string[], goal: string, difficulty: 'easy' | 'medium' | 'hard', type?: 'branch' | 'deep_dive'} @@ -357,17 +252,11 @@ export const ChartRecBox: FC = function ({ tableId, placeHolde const currentTable = tables.find(t => t.id === tableId); const availableTables = tables.filter(t => t.derive === undefined || t.anchored); - const [additionalTableIds, setAdditionalTableIds] = useState([]); - + // Combine the main tableId with additional selected tables - const selectedTableIds = currentTable?.derive ? [...currentTable.derive.source, ...additionalTableIds] : [tableId, ...additionalTableIds]; - - const handleTableSelectionChange = (newTableIds: string[]) => { - // Filter out the main tableId since it's always included - const additionalIds = newTableIds.filter(id => id !== tableId); - setAdditionalTableIds(additionalIds); - }; - + let selectedTableIds = currentTable?.derive ? [...currentTable.derive.source] : [tableId]; + selectedTableIds = [...selectedTableIds, ...availableTables.map(t => t.id).filter(id => !selectedTableIds.includes(id))]; + // Function to get a question from the list with cycling const getQuestion = (): string => { return mode === "agent" ? "let's explore something interesting about the data" : "show something interesting about the data"; @@ -1238,17 +1127,6 @@ export const ChartRecBox: FC = function ({ tableId, placeHolde }} /> )} - {showTableSelector && ( - - - - )} - } -// Add this component before EncodingShelfCard -const UserActionTableSelector: FC<{ - requiredActionTableIds: string[], - userSelectedActionTableIds: string[], - tables: DictTable[], - updateUserSelectedActionTableIds: (tableIds: string[]) => void, - requiredTableIds?: string[] -}> = ({ requiredActionTableIds, userSelectedActionTableIds, tables, updateUserSelectedActionTableIds, requiredTableIds = [] }) => { - const [anchorEl, setAnchorEl] = useState(null); - const open = Boolean(anchorEl); - - let actionTableIds = [...requiredActionTableIds, ...userSelectedActionTableIds.filter(id => !requiredActionTableIds.includes(id))]; - - const handleClick = (event: React.MouseEvent) => { - setAnchorEl(event.currentTarget); - }; - - const handleClose = () => { - setAnchorEl(null); - }; - - const handleTableSelect = (table: DictTable) => { - if (!actionTableIds.includes(table.id)) { - updateUserSelectedActionTableIds([...userSelectedActionTableIds, table.id]); - } - handleClose(); - }; - - return ( - - {actionTableIds.map((tableId) => { - const isRequired = requiredTableIds.includes(tableId); - return ( - t.id == tableId)?.displayId} - size="small" - sx={{ - height: 16, - fontSize: '10px', - borderRadius: '0px', - bgcolor: isRequired ? 'rgba(25, 118, 210, 0.2)' : 'rgba(25, 118, 210, 0.1)', // darker blue for required - color: 'rgba(0, 0, 0, 0.7)', - '& .MuiChip-label': { - pl: '4px', - pr: '6px' - } - }} - deleteIcon={} - onDelete={isRequired ? undefined : () => updateUserSelectedActionTableIds(actionTableIds.filter(id => id !== tableId))} - /> - ); - })} - - - - - - - - - {tables - .map((table) => { - const isSelected = !!actionTableIds.find(t => t === table.id); - return ( - handleTableSelect(table)} - sx={{ - fontSize: '12px', - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center' - }} - > - {table.displayId} - - ); - }) - } - - - ); -}; - export const EncodingShelfCard: FC = function ({ chartId }) { const theme = useTheme(); @@ -363,7 +258,6 @@ export const EncodingShelfCard: FC = function ({ chartId const tables = useSelector((state: DataFormulatorState) => state.tables); const config = useSelector((state: DataFormulatorState) => state.config); const agentRules = useSelector((state: DataFormulatorState) => state.agentRules); - let existMultiplePossibleBaseTables = tables.filter(t => t.derive == undefined || t.anchored).length > 1; let activeModel = useSelector(dfSelectors.getActiveModel); let allCharts = useSelector(dfSelectors.getAllCharts); @@ -401,9 +295,7 @@ export const EncodingShelfCard: FC = function ({ chartId // Check if chart is available let isChartAvailable = checkChartAvailability(chart, conceptShelfItems, currentTable.rows); - // Add this state - const [userSelectedActionTableIds, setUserSelectedActionTableIds] = useState([]); - + // Consolidated chart state - maps chartId to its ideas, thinkingBuffer, and loading state const [chartState, setChartState] = useState = function ({ chartId // Add state for developer message dialog const [devMessageOpen, setDevMessageOpen] = useState(false); - - // Update the handler to use state - const handleUserSelectedActionTableChange = (newTableIds: string[]) => { - setUserSelectedActionTableIds(newTableIds); - }; + let encodingBoxGroups = Object.entries(ChannelGroups) .filter(([group, channelList]) => channelList.some(ch => Object.keys(encodingMap).includes(ch))) @@ -479,7 +367,7 @@ export const EncodingShelfCard: FC = function ({ chartId let requiredActionTables = selectBaseTables(activeFields, currentTable, tables); let actionTableIds = [ ...requiredActionTables.map(t => t.id), - ...userSelectedActionTableIds.filter(id => !requiredActionTables.map(t => t.id).includes(id)) + ...tables.filter(t => t.derive === undefined || t.anchored).map(t => t.id).filter(id => !requiredActionTables.map(t => t.id).includes(id)) ]; let getIdeasForVisualization = async () => { @@ -1155,13 +1043,6 @@ export const EncodingShelfCard: FC = function ({ chartId let channelComponent = ( - {existMultiplePossibleBaseTables && t.id)} - userSelectedActionTableIds={userSelectedActionTableIds} - tables={tables.filter(t => t.derive === undefined || t.anchored)} - updateUserSelectedActionTableIds={handleUserSelectedActionTableChange} - requiredTableIds={requiredActionTables.map(t => t.id)} - />}