From 84d9d83891082f7b4733c43daa1a0489eddd29d3 Mon Sep 17 00:00:00 2001 From: Taylor Bantle Date: Mon, 23 Oct 2023 14:47:13 -0700 Subject: [PATCH 01/10] graphql: Add mysql connection and load data query --- packages/graphql-server/package.json | 1 + packages/graphql-server/schema.gql | 18 ++++++ .../src/dataSources/dataSource.module.ts | 2 +- .../src/dataSources/dataSource.service.ts | 26 +++++++- packages/graphql-server/src/main.ts | 5 ++ packages/graphql-server/src/resolvers.ts | 2 + .../graphql-server/src/tables/table.enum.ts | 28 ++++++++ .../src/tables/table.queries.ts | 31 +++++++++ .../src/tables/upload.resolver.ts | 64 +++++++++++++++++++ 9 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 packages/graphql-server/src/tables/table.enum.ts create mode 100644 packages/graphql-server/src/tables/upload.resolver.ts diff --git a/packages/graphql-server/package.json b/packages/graphql-server/package.json index ac586847..8724eef3 100644 --- a/packages/graphql-server/package.json +++ b/packages/graphql-server/package.json @@ -37,6 +37,7 @@ "cookie-parser": "^1.4.6", "cors": "^2.8.5", "graphql": "^16.7.1", + "graphql-upload": "13", "mysql2": "^3.1.0", "reflect-metadata": "^0.1.13", "rxjs": "^7.5.4", diff --git a/packages/graphql-server/schema.gql b/packages/graphql-server/schema.gql index a9a63cc3..fce9b63f 100644 --- a/packages/graphql-server/schema.gql +++ b/packages/graphql-server/schema.gql @@ -201,6 +201,24 @@ type Mutation { deleteBranch(databaseName: String!, branchName: String!): Boolean! addDatabaseConnection(url: String, useEnv: Boolean): String! createDatabase(databaseName: String!): Boolean! + loadDataFile(tableName: String!, refName: String!, databaseName: String!, importOp: ImportOperation!, fileType: FileType!, file: Upload!, modifier: LoadDataModifier): Boolean! createTag(tagName: String!, databaseName: String!, message: String, fromRefName: String!): Tag! deleteTag(databaseName: String!, tagName: String!): Boolean! +} + +enum ImportOperation { + Update +} + +enum FileType { + Csv + Psv +} + +"""The `Upload` scalar type represents a file upload.""" +scalar Upload + +enum LoadDataModifier { + Ignore + Replace } \ No newline at end of file diff --git a/packages/graphql-server/src/dataSources/dataSource.module.ts b/packages/graphql-server/src/dataSources/dataSource.module.ts index a0c759c5..48f8c425 100644 --- a/packages/graphql-server/src/dataSources/dataSource.module.ts +++ b/packages/graphql-server/src/dataSources/dataSource.module.ts @@ -5,7 +5,7 @@ import { DataSourceService } from "./dataSource.service"; providers: [ { provide: DataSourceService, - useValue: new DataSourceService(undefined), + useValue: new DataSourceService(undefined, undefined), }, ], exports: [DataSourceService], diff --git a/packages/graphql-server/src/dataSources/dataSource.service.ts b/packages/graphql-server/src/dataSources/dataSource.service.ts index 5a5345a3..75b8f423 100644 --- a/packages/graphql-server/src/dataSources/dataSource.service.ts +++ b/packages/graphql-server/src/dataSources/dataSource.service.ts @@ -1,4 +1,5 @@ import { Injectable } from "@nestjs/common"; +import * as mysql from "mysql2/promise"; import { DataSource, QueryRunner } from "typeorm"; import { RawRows } from "../utils/commonTypes"; @@ -7,7 +8,10 @@ export type ParQuery = (q: string, p?: any[] | undefined) => Promise; @Injectable() export class DataSourceService { - constructor(private ds: DataSource | undefined) {} + constructor( + private ds: DataSource | undefined, + private mysqlConfig: mysql.ConnectionOptions | undefined, // Used for file upload + ) {} getDS(): DataSource { const { ds } = this; @@ -15,6 +19,12 @@ export class DataSourceService { return ds; } + getMySQLConfig(): mysql.ConnectionOptions { + const { mysqlConfig } = this; + if (!mysqlConfig) throw new Error("MySQL config not found"); + return mysqlConfig; + } + getQR(): QueryRunner { return this.getDS().createQueryRunner(); } @@ -94,13 +104,25 @@ export class DataSourceService { }, }); + this.mysqlConfig = { + uri: connUrl, + ssl: { + rejectUnauthorized: false, + }, + connectionLimit: 1, + dateStrings: ["DATE"], + + // Allows file upload via LOAD DATA + flags: ["+LOCAL_FILES"], + }; + await this.ds.initialize(); } } // Cannot use params here for the database revision. It will incorrectly // escape refs with dots -function useDBStatement( +export function useDBStatement( dbName?: string, refName?: string, isDolt = true, diff --git a/packages/graphql-server/src/main.ts b/packages/graphql-server/src/main.ts index 4bfa5fe8..aa715144 100644 --- a/packages/graphql-server/src/main.ts +++ b/packages/graphql-server/src/main.ts @@ -1,11 +1,16 @@ import { NestFactory } from "@nestjs/core"; import * as cookieParser from "cookie-parser"; import * as cors from "cors"; +import { graphqlUploadExpress } from "graphql-upload"; import { AppModule } from "./app.module"; +const oneMB = 1024 * 1024; +const maxFileSize = 400 * oneMB; + async function bootstrap() { const app = await NestFactory.create(AppModule); app.use(cookieParser()); + app.use(graphqlUploadExpress({ maxFileSize, maxFiles: 1 })); if (process.env.NODE_ENV === "development") { app.use( "/graphql", diff --git a/packages/graphql-server/src/resolvers.ts b/packages/graphql-server/src/resolvers.ts index b5489c68..0520b1f8 100644 --- a/packages/graphql-server/src/resolvers.ts +++ b/packages/graphql-server/src/resolvers.ts @@ -6,6 +6,7 @@ import { RowResolver } from "./rows/row.resolver"; import { SqlSelectResolver } from "./sqlSelects/sqlSelect.resolver"; import { StatusResolver } from "./status/status.resolver"; import { TableResolver } from "./tables/table.resolver"; +import { FileUploadResolver } from "./tables/upload.resolver"; import { TagResolver } from "./tags/tag.resolver"; const resolvers = [ @@ -13,6 +14,7 @@ const resolvers = [ CommitResolver, DatabaseResolver, DocsResolver, + FileUploadResolver, RowResolver, SqlSelectResolver, StatusResolver, diff --git a/packages/graphql-server/src/tables/table.enum.ts b/packages/graphql-server/src/tables/table.enum.ts new file mode 100644 index 00000000..4e9d7d9a --- /dev/null +++ b/packages/graphql-server/src/tables/table.enum.ts @@ -0,0 +1,28 @@ +import { registerEnumType } from "@nestjs/graphql"; + +export enum ImportOperation { + // Create, + // Overwrite, + Update, + // Replace, +} + +registerEnumType(ImportOperation, { name: "ImportOperation" }); + +export enum FileType { + Csv, + Psv, + // Xlsx, + // Json, + // Sql, + // Any, +} + +registerEnumType(FileType, { name: "FileType" }); + +export enum LoadDataModifier { + Ignore, + Replace, +} + +registerEnumType(LoadDataModifier, { name: "LoadDataModifier" }); diff --git a/packages/graphql-server/src/tables/table.queries.ts b/packages/graphql-server/src/tables/table.queries.ts index 5d239e33..4f0d03db 100644 --- a/packages/graphql-server/src/tables/table.queries.ts +++ b/packages/graphql-server/src/tables/table.queries.ts @@ -1,3 +1,5 @@ +import { FileType, LoadDataModifier } from "./table.enum"; + export const indexQuery = `SELECT table_name, index_name, @@ -13,3 +15,32 @@ export const foreignKeysQuery = `SELECT * FROM INFORMATION_SCHEMA.KEY_COLUMN_USA export const columnsQuery = `DESCRIBE ??`; export const listTablesQuery = `SHOW FULL TABLES WHERE table_type = 'BASE TABLE'`; + +export const getLoadDataQuery = ( + filename: string, + tableName: string, + fileType: FileType, + modifier?: LoadDataModifier, +): string => `LOAD DATA LOCAL INFILE '${filename}' +${getModifier(modifier)}INTO TABLE \`${tableName}\` +FIELDS TERMINATED BY '${getDelim(fileType)}' ENCLOSED BY '' +LINES TERMINATED BY '\n' +IGNORE 1 ROWS;`; + +function getModifier(m?: LoadDataModifier): string { + switch (m) { + case LoadDataModifier.Ignore: + return "IGNORE "; + case LoadDataModifier.Replace: + return "REPLACE "; + default: + return ""; + } +} + +function getDelim(ft: FileType): string { + if (ft === FileType.Psv) { + return "|"; + } + return ","; +} diff --git a/packages/graphql-server/src/tables/upload.resolver.ts b/packages/graphql-server/src/tables/upload.resolver.ts new file mode 100644 index 00000000..df16c581 --- /dev/null +++ b/packages/graphql-server/src/tables/upload.resolver.ts @@ -0,0 +1,64 @@ +import { Args, ArgsType, Field, Mutation, Resolver } from "@nestjs/graphql"; +import { ReadStream } from "fs"; +import { GraphQLUpload } from "graphql-upload"; +import * as mysql from "mysql2/promise"; +import { + DataSourceService, + useDBStatement, +} from "../dataSources/dataSource.service"; +import { TableArgs } from "../utils/commonTypes"; +import { FileType, ImportOperation, LoadDataModifier } from "./table.enum"; +import { Table } from "./table.model"; +import { getLoadDataQuery } from "./table.queries"; + +export interface FileUpload { + filename: string; + mimetype: string; + encoding: string; + createReadStream: () => ReadStream; +} + +@ArgsType() +class TableImportArgs extends TableArgs { + @Field(_type => ImportOperation) + importOp: ImportOperation; + + @Field(_type => FileType) + fileType: FileType; + + @Field(() => GraphQLUpload) + file: Promise; + + @Field(_type => LoadDataModifier, { nullable: true }) + modifier?: LoadDataModifier; +} + +@Resolver(_of => Table) +export class FileUploadResolver { + constructor(private readonly dss: DataSourceService) {} + + @Mutation(_returns => Boolean) + async loadDataFile(@Args() args: TableImportArgs): Promise { + const conn = await mysql.createConnection(this.dss.getMySQLConfig()); + + const isDolt = conn.query("SELECT dolt_version()"); + await conn.query(useDBStatement(args.databaseName, args.refName, !!isDolt)); + await conn.query("SET GLOBAL local_infile=ON;"); + + const { createReadStream, filename } = await args.file; + + await conn.query({ + sql: getLoadDataQuery( + filename, + args.tableName, + args.fileType, + args.modifier, + ), + infileStreamFactory: createReadStream, + }); + + conn.destroy(); + + return true; + } +} From 258991ede329336ad8e8bde7cb5defdc13d0f686 Mon Sep 17 00:00:00 2001 From: Taylor Bantle Date: Mon, 23 Oct 2023 14:47:29 -0700 Subject: [PATCH 02/10] web: Add file upload page --- .../components/CustomRadio/index.module.css | 44 +++ .../web/components/CustomRadio/index.test.tsx | 50 +++ packages/web/components/CustomRadio/index.tsx | 34 ++ .../web/components/HelpPopup/index.module.css | 15 + packages/web/components/HelpPopup/index.tsx | 35 ++ .../web/components/TableSelector/index.tsx | 51 +++ .../web/components/links/DatabaseLink.tsx | 17 + .../FileUploadPage/FileInfo/index.module.css | 23 ++ .../FileUploadPage/FileInfo/index.tsx | 61 ++++ .../FileUploadPage/Layout/index.module.css | 56 +++ .../FileUploadPage/Layout/index.tsx | 42 +++ .../FileUploadPage/LoadDataInfo.tsx | 85 +++++ .../Navigation/index.module.css | 40 +++ .../FileUploadPage/Navigation/index.tsx | 104 ++++++ .../FileUploadPage/PageWrapper.tsx | 64 ++++ .../StepLayout/WrongStageModal.tsx | 28 ++ .../StepLayout/index.module.css | 35 ++ .../FileUploadPage/StepLayout/index.tsx | 64 ++++ .../Steps/Branch/index.module.css | 3 + .../FileUploadPage/Steps/Branch/index.tsx | 53 +++ .../Steps/Table/TableOption.tsx | 27 ++ .../Steps/Table/index.module.css | 27 ++ .../FileUploadPage/Steps/Table/index.tsx | 60 ++++ .../FileUploadPage/Steps/Table/useTable.ts | 53 +++ .../FileUploadPage/Steps/Upload/DropZone.tsx | 89 +++++ .../EditableTable/TableEditorOverlay.tsx | 57 +++ .../TableGrid/Buttons/ExportButton.tsx | 28 ++ .../TableGrid/Buttons/index.module.css | 31 ++ .../EditableTable/TableGrid/Buttons/index.tsx | 113 ++++++ .../EditableTable/TableGrid/IndexColumn.tsx | 26 ++ .../EditableTable/TableGrid/index.module.css | 67 ++++ .../Upload/EditableTable/TableGrid/index.tsx | 119 +++++++ .../Upload/EditableTable/TableGrid/types.ts | 48 +++ .../Upload/EditableTable/TableGrid/useGrid.ts | 280 +++++++++++++++ .../EditableTable/TableGrid/utils.test.ts | 111 ++++++ .../Upload/EditableTable/TableGrid/utils.ts | 325 ++++++++++++++++++ .../EditableTable/TableGrid/validate.ts | 64 ++++ .../Upload/EditableTable/index.module.css | 42 +++ .../Steps/Upload/EditableTable/index.tsx | 45 +++ .../Steps/Upload/OpenSpreadsheetZone.tsx | 76 ++++ .../Steps/Upload/contexts/queries.ts | 23 ++ .../Steps/Upload/contexts/upload.tsx | 120 +++++++ .../Steps/Upload/index.module.css | 86 +++++ .../FileUploadPage/Steps/Upload/index.tsx | 60 ++++ .../Steps/Upload/useDropZone.ts | 125 +++++++ .../pageComponents/FileUploadPage/Summary.tsx | 31 ++ .../contexts/fileUploadLocalForage/index.tsx | 122 +++++++ .../contexts/fileUploadLocalForage/state.ts | 43 +++ .../pageComponents/FileUploadPage/enums.ts | 18 + .../FileUploadPage/index.module.css | 37 ++ .../pageComponents/FileUploadPage/index.tsx | 59 ++++ packages/web/gen/graphql-types.tsx | 85 +++++ packages/web/hooks/useEffectAsync.ts | 17 + packages/web/hooks/useOnRouteChange.ts | 17 + packages/web/hooks/useRole.ts | 2 + packages/web/lib/errors/helpers.ts | 10 +- packages/web/lib/params.ts | 4 + packages/web/lib/refetchQueries.ts | 43 ++- packages/web/lib/urls.ts | 24 ++ packages/web/package.json | 3 + .../upload/[uploadId]/[stage].tsx | 47 +++ .../upload/[uploadId]/index.tsx | 40 +++ .../database/[databaseName]/upload/index.tsx | 41 +++ yarn.lock | 137 ++++++++ 64 files changed, 3784 insertions(+), 2 deletions(-) create mode 100644 packages/web/components/CustomRadio/index.module.css create mode 100644 packages/web/components/CustomRadio/index.test.tsx create mode 100644 packages/web/components/CustomRadio/index.tsx create mode 100644 packages/web/components/HelpPopup/index.module.css create mode 100644 packages/web/components/HelpPopup/index.tsx create mode 100644 packages/web/components/TableSelector/index.tsx create mode 100644 packages/web/components/links/DatabaseLink.tsx create mode 100644 packages/web/components/pageComponents/FileUploadPage/FileInfo/index.module.css create mode 100644 packages/web/components/pageComponents/FileUploadPage/FileInfo/index.tsx create mode 100644 packages/web/components/pageComponents/FileUploadPage/Layout/index.module.css create mode 100644 packages/web/components/pageComponents/FileUploadPage/Layout/index.tsx create mode 100644 packages/web/components/pageComponents/FileUploadPage/LoadDataInfo.tsx create mode 100644 packages/web/components/pageComponents/FileUploadPage/Navigation/index.module.css create mode 100644 packages/web/components/pageComponents/FileUploadPage/Navigation/index.tsx create mode 100644 packages/web/components/pageComponents/FileUploadPage/PageWrapper.tsx create mode 100644 packages/web/components/pageComponents/FileUploadPage/StepLayout/WrongStageModal.tsx create mode 100644 packages/web/components/pageComponents/FileUploadPage/StepLayout/index.module.css create mode 100644 packages/web/components/pageComponents/FileUploadPage/StepLayout/index.tsx create mode 100644 packages/web/components/pageComponents/FileUploadPage/Steps/Branch/index.module.css create mode 100644 packages/web/components/pageComponents/FileUploadPage/Steps/Branch/index.tsx create mode 100644 packages/web/components/pageComponents/FileUploadPage/Steps/Table/TableOption.tsx create mode 100644 packages/web/components/pageComponents/FileUploadPage/Steps/Table/index.module.css create mode 100644 packages/web/components/pageComponents/FileUploadPage/Steps/Table/index.tsx create mode 100644 packages/web/components/pageComponents/FileUploadPage/Steps/Table/useTable.ts create mode 100644 packages/web/components/pageComponents/FileUploadPage/Steps/Upload/DropZone.tsx create mode 100644 packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableEditorOverlay.tsx create mode 100644 packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/Buttons/ExportButton.tsx create mode 100644 packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/Buttons/index.module.css create mode 100644 packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/Buttons/index.tsx create mode 100644 packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/IndexColumn.tsx create mode 100644 packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/index.module.css create mode 100644 packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/index.tsx create mode 100644 packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/types.ts create mode 100644 packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/useGrid.ts create mode 100644 packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/utils.test.ts create mode 100644 packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/utils.ts create mode 100644 packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/validate.ts create mode 100644 packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/index.module.css create mode 100644 packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/index.tsx create mode 100644 packages/web/components/pageComponents/FileUploadPage/Steps/Upload/OpenSpreadsheetZone.tsx create mode 100644 packages/web/components/pageComponents/FileUploadPage/Steps/Upload/contexts/queries.ts create mode 100644 packages/web/components/pageComponents/FileUploadPage/Steps/Upload/contexts/upload.tsx create mode 100644 packages/web/components/pageComponents/FileUploadPage/Steps/Upload/index.module.css create mode 100644 packages/web/components/pageComponents/FileUploadPage/Steps/Upload/index.tsx create mode 100644 packages/web/components/pageComponents/FileUploadPage/Steps/Upload/useDropZone.ts create mode 100644 packages/web/components/pageComponents/FileUploadPage/Summary.tsx create mode 100644 packages/web/components/pageComponents/FileUploadPage/contexts/fileUploadLocalForage/index.tsx create mode 100644 packages/web/components/pageComponents/FileUploadPage/contexts/fileUploadLocalForage/state.ts create mode 100644 packages/web/components/pageComponents/FileUploadPage/enums.ts create mode 100644 packages/web/components/pageComponents/FileUploadPage/index.module.css create mode 100644 packages/web/components/pageComponents/FileUploadPage/index.tsx create mode 100644 packages/web/hooks/useEffectAsync.ts create mode 100644 packages/web/hooks/useOnRouteChange.ts create mode 100644 packages/web/pages/database/[databaseName]/upload/[uploadId]/[stage].tsx create mode 100644 packages/web/pages/database/[databaseName]/upload/[uploadId]/index.tsx create mode 100644 packages/web/pages/database/[databaseName]/upload/index.tsx diff --git a/packages/web/components/CustomRadio/index.module.css b/packages/web/components/CustomRadio/index.module.css new file mode 100644 index 00000000..47e15a57 --- /dev/null +++ b/packages/web/components/CustomRadio/index.module.css @@ -0,0 +1,44 @@ +.container { + @apply block relative pl-8 mb-2 text-primary font-semibold cursor-pointer select-none; + + input { + @apply absolute opacity-0; + } +} + +.disabled { + @apply text-ld-darkgrey cursor-default; +} + +.checkmark { + @apply absolute top-0 left-0 bg-white rounded-full mt-1 border border-primary w-4 h-4; +} + +.container:hover input ~ .checkmark { + @apply border-acc-linkblue; +} + +.container input:checked ~ .checkmark { + @apply bg-white; +} +.container input:focus ~ .checkmark { + @apply widget-shadow-lightblue; +} + +.container input:disabled ~ .checkmark, +.container:hover input:disabled ~ .checkmark { + @apply border-ld-darkgrey; +} + +.checkmark:after { + @apply absolute hidden; + content: ""; +} + +.container .checkmark:after { + @apply rounded-full bg-white top-[3px] left-[3px] w-2 h-2; +} + +.container input:checked ~ .checkmark:after { + @apply block bg-primary; +} diff --git a/packages/web/components/CustomRadio/index.test.tsx b/packages/web/components/CustomRadio/index.test.tsx new file mode 100644 index 00000000..b699003b --- /dev/null +++ b/packages/web/components/CustomRadio/index.test.tsx @@ -0,0 +1,50 @@ +import { setup } from "@lib/testUtils.test"; +import { screen } from "@testing-library/react"; +import CustomRadio from "."; + +describe("test CustomCheckbox", () => { + const mocks = [ + { name: "one", label: "one-label" }, + { name: "two", label: "two-label" }, + { name: "three", label: "three-label" }, + ]; + + mocks.forEach((mock, ind) => { + it(`renders CustomRadio for of label ${mock.label}`, async () => { + const checked = ind % 2 === 0; + const disabled = ind === 2; + const onChangeValue = jest.fn(); + + const { user } = setup( + + {mock.label} + , + ); + const content = screen.getByLabelText(mock.label); + expect(content).toBeVisible(); + if (!disabled) { + const input = screen.getByRole("radio"); + if (checked) { + expect(input).toBeChecked(); + } else { + expect(input).not.toBeChecked(); + } + + await user.click(screen.getByLabelText(mock.label)); + if (checked) { + expect(onChangeValue).not.toHaveBeenCalled(); + } else { + expect(onChangeValue).toHaveBeenCalled(); + } + } else { + expect(content).toBeDisabled(); + } + }); + }); +}); diff --git a/packages/web/components/CustomRadio/index.tsx b/packages/web/components/CustomRadio/index.tsx new file mode 100644 index 00000000..78685f48 --- /dev/null +++ b/packages/web/components/CustomRadio/index.tsx @@ -0,0 +1,34 @@ +import cx from "classnames"; +import { ReactNode } from "react"; +import css from "./index.module.css"; + +type Props = { + name: string; + checked: boolean; + onChange: () => void; + children: ReactNode; + disabled?: boolean; + className?: string; +}; + +export default function CustomRadio({ disabled = false, ...props }: Props) { + return ( + + ); +} diff --git a/packages/web/components/HelpPopup/index.module.css b/packages/web/components/HelpPopup/index.module.css new file mode 100644 index 00000000..860470c8 --- /dev/null +++ b/packages/web/components/HelpPopup/index.module.css @@ -0,0 +1,15 @@ +.question { + @apply mt-1 opacity-30; +} + +.popup { + @apply p-3 text-sm; +} + +.blue { + @apply opacity-100 text-[#CEE3FB] bg-ld-mediumblue rounded-full; + + svg { + @apply text-lg; + } +} diff --git a/packages/web/components/HelpPopup/index.tsx b/packages/web/components/HelpPopup/index.tsx new file mode 100644 index 00000000..ae463fa7 --- /dev/null +++ b/packages/web/components/HelpPopup/index.tsx @@ -0,0 +1,35 @@ +import Popup from "@components/Popup"; +import { BsFillQuestionCircleFill } from "@react-icons/all-files/bs/BsFillQuestionCircleFill"; +import cx from "classnames"; +import { ReactNode } from "react"; +import { PopupProps } from "reactjs-popup/dist/types"; +import css from "./index.module.css"; + +type Props = { + popupProps?: Partial; + className?: string; + children: ReactNode; + icon?: ReactNode; + blue?: boolean; +}; + +export default function HelpPopup(props: Props) { + return ( +
+ {props.icon ?? }
} + contentStyle={props.icon ? { width: "30rem" } : {}} + {...props.popupProps} + > +
{props.children}
+ + + ); +} diff --git a/packages/web/components/TableSelector/index.tsx b/packages/web/components/TableSelector/index.tsx new file mode 100644 index 00000000..90bb6150 --- /dev/null +++ b/packages/web/components/TableSelector/index.tsx @@ -0,0 +1,51 @@ +import FormSelect from "@components/FormSelect"; +import QueryHandler from "@components/util/QueryHandler"; +import { useTableNamesQuery } from "@gen/graphql-types"; +import { RefParams } from "@lib/params"; + +type Props = { + params: RefParams; + onChangeTable: (t: string) => void; + selectedTable: string; + light?: boolean; +}; + +type InnerProps = Props & { + tables: string[]; +}; + +function Inner(props: InnerProps) { + return ( +
+ {props.tables.length ? ( + { + return { + value: t, + label: t, + }; + })} + hideSelectedOptions + isClearable + /> + ) : ( +

No tables found

+ )} +
+ ); +} + +export default function TableSelector(props: Props) { + const res = useTableNamesQuery({ + variables: { ...props.params, filterSystemTables: true }, + }); + return ( + } + /> + ); +} diff --git a/packages/web/components/links/DatabaseLink.tsx b/packages/web/components/links/DatabaseLink.tsx new file mode 100644 index 00000000..94a74389 --- /dev/null +++ b/packages/web/components/links/DatabaseLink.tsx @@ -0,0 +1,17 @@ +import { DatabaseParams } from "@lib/params"; +import { database } from "@lib/urls"; +import { ReactNode } from "react"; +import Link, { LinkProps } from "./Link"; + +type Props = LinkProps & { + children: ReactNode; + params: DatabaseParams; +}; + +export default function DatabaseLink({ children, ...props }: Props) { + return ( + + {children} + + ); +} diff --git a/packages/web/components/pageComponents/FileUploadPage/FileInfo/index.module.css b/packages/web/components/pageComponents/FileUploadPage/FileInfo/index.module.css new file mode 100644 index 00000000..5a3990c9 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/FileInfo/index.module.css @@ -0,0 +1,23 @@ +.fileInfoContainer { + @apply flex justify-center my-5 font-semibold flex-wrap; +} + +.between { + @apply justify-between; +} + +.fileInfo { + @apply flex justify-center flex-wrap; +} + +.fileIcon { + @apply pr-2 w-6 h-6; +} + +.fileSize { + @apply mx-3 text-ld-darkgrey; +} + +.buttons { + @apply w-full mt-3; +} diff --git a/packages/web/components/pageComponents/FileUploadPage/FileInfo/index.tsx b/packages/web/components/pageComponents/FileUploadPage/FileInfo/index.tsx new file mode 100644 index 00000000..9d4dd4ed --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/FileInfo/index.tsx @@ -0,0 +1,61 @@ +import Button from "@components/Button"; +import { FiFile } from "@react-icons/all-files/fi/FiFile"; +import cx from "classnames"; +import { ReactNode } from "react"; +import { useFileUploadContext } from "../contexts/fileUploadLocalForage"; +import css from "./index.module.css"; + +type Props = { + onRemove?: () => void; + editButton?: ReactNode; + upload?: boolean; +}; + +export default function FileInfo(props: Props) { + const { setState, state } = useFileUploadContext(); + + const removeFile = () => { + setState({ + selectedFile: undefined, + spreadsheetRows: undefined, + colNames: "", + }); + if (props.onRemove) props.onRemove(); + }; + + if (!state.selectedFile && !state.spreadsheetRows) return null; + + return ( +
+
+ + + + {state.selectedFile?.name ?? "editor.csv"} + + {state.selectedFile && ( + + {fileSize(state.selectedFile.size)} + + )} + +
+ {props.editButton} + + remove + +
+
+
+ ); +} + +function fileSize(size: number): string { + if (size === 0) return "0 Bytes"; + const k = 1024; + const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(size) / Math.log(k)); + return `${parseFloat((size / k ** i).toFixed(2))} ${sizes[i]}`; +} diff --git a/packages/web/components/pageComponents/FileUploadPage/Layout/index.module.css b/packages/web/components/pageComponents/FileUploadPage/Layout/index.module.css new file mode 100644 index 00000000..66c057b3 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Layout/index.module.css @@ -0,0 +1,56 @@ +.header { + @apply w-full bg-ld-mediumblue text-center p-4 justify-between hidden lg:flex; + + h1 { + @apply text-white text-2xl font-normal; + } + + > * { + @apply lg:w-[100rem]; + } +} + +.left { + @apply flex justify-start items-center ml-3 mt-0.5; + + svg { + @apply text-white hover:text-ld-blue; + } +} + +.chevron { + @apply mr-3 text-xl; +} + +.breadcrumbs { + span { + @apply text-white; + } + a { + @apply text-blue-300; + + &:hover { + @apply text-blue-200; + } + } +} + +.right { + @apply flex justify-end mr-3; + + button { + @apply text-white bg-transparent border border-white text-sm; + + &:hover { + @apply border-ld-blue text-ld-blue bg-transparent; + } + } +} + +.outer { + @apply absolute bottom-0 left-0 right-0 top-28 hidden lg:flex; +} + +.main { + @apply outline-none max-w-7xl mx-auto w-full bg-white h-full overflow-auto; +} diff --git a/packages/web/components/pageComponents/FileUploadPage/Layout/index.tsx b/packages/web/components/pageComponents/FileUploadPage/Layout/index.tsx new file mode 100644 index 00000000..955b9efc --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Layout/index.tsx @@ -0,0 +1,42 @@ +import Button from "@components/Button"; +import DatabaseBreadcrumbs from "@components/breadcrumbs/DatabaseBreadcrumbs"; +import DatabaseLayoutWrapper from "@components/layouts/DatabaseLayout/Wrapper"; +import DatabaseLink from "@components/links/DatabaseLink"; +import KeyNav from "@components/util/KeyNav"; +import { DatabaseParams } from "@lib/params"; +import { GoChevronLeft } from "@react-icons/all-files/go/GoChevronLeft"; +import { ReactNode } from "react"; +import css from "./index.module.css"; + +type Props = { + params: DatabaseParams; + children: ReactNode; +}; + +export default function Layout(props: Props) { + return ( + + {/* */} +
+
+ + + + +
+

File Importer

+
+ + + +
+
+
+ {props.children} +
+
+ ); +} diff --git a/packages/web/components/pageComponents/FileUploadPage/LoadDataInfo.tsx b/packages/web/components/pageComponents/FileUploadPage/LoadDataInfo.tsx new file mode 100644 index 00000000..35f3e27f --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/LoadDataInfo.tsx @@ -0,0 +1,85 @@ +import CustomRadio from "@components/CustomRadio"; +import HelpPopup from "@components/HelpPopup"; +import ExternalLink from "@components/links/ExternalLink"; +import { LoadDataModifier } from "@gen/graphql-types"; +import { useFileUploadContext } from "./contexts/fileUploadLocalForage"; +import css from "./index.module.css"; + +type Props = { + forSpreadsheet?: boolean; +}; + +export default function LoadDataInfo(props: Props) { + return ( +
+
+

+ Uses{" "} + + LOAD DATA + {" "} + to{" "} + {props.forSpreadsheet + ? "insert spreadsheet rows" + : "read rows from a file"}{" "} + into the selected table. +

{" "} + +
+ Current requirements: +
    +
  • Must have header row
  • +
  • Column count and type must match table
  • +
+
+
+
+ +
+ ); +} + +function ModifierOptions() { + const { state, setState } = useFileUploadContext(); + return ( +
+

+ How would you like to handle{" "} + + duplicate keys + + ? +

+
+ setState({ modifier: undefined })} + > + IGNORE + + + With IGNORE, new rows that duplicate an existing row on a unique key + value are discarded. This is the default behavior.{" "} + + See more information. + + + setState({ modifier: LoadDataModifier.Replace })} + > + REPLACE + + + With REPLACE, new rows that have the same value as a unique key value + in an existing row replace the existing row.{" "} + + See more information. + + +
+
+ ); +} diff --git a/packages/web/components/pageComponents/FileUploadPage/Navigation/index.module.css b/packages/web/components/pageComponents/FileUploadPage/Navigation/index.module.css new file mode 100644 index 00000000..e87ce32f --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Navigation/index.module.css @@ -0,0 +1,40 @@ +.nav { + @apply relative bg-ld-lightpurple w-full mx-auto text-center flex pt-6 border-b border-ld-lightgrey; + + ol { + @apply flex mx-auto; + } +} + +.navItem { + @apply relative px-6 mx-10 pb-2; + + a { + @apply text-ld-darkgrey; + } + + button { + @apply font-semibold bg-transparent; + + &:focus { + @apply outline-none widget-shadow-lightblue; + } + + &:disabled { + @apply cursor-default text-ld-darkgrey; + } + } +} + +.active { + @apply border-b-4 border-acc-hoverlinkblue; + + button:disabled, + a { + @apply text-acc-hoverlinkblue; + } +} + +.complete a { + @apply text-acc-green; +} diff --git a/packages/web/components/pageComponents/FileUploadPage/Navigation/index.tsx b/packages/web/components/pageComponents/FileUploadPage/Navigation/index.tsx new file mode 100644 index 00000000..efc171df --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Navigation/index.tsx @@ -0,0 +1,104 @@ +import Link from "@components/links/Link"; +import cx from "classnames"; +import { useFileUploadContext } from "../contexts/fileUploadLocalForage"; +import { FileUploadState } from "../contexts/fileUploadLocalForage/state"; +import { getUploadStage, UploadStage } from "../enums"; +import css from "./index.module.css"; + +type Props = { + activeStage: UploadStage; +}; + +const stages = ["Branch", "Table", "Upload"]; + +export default function Navigation(props: Props) { + return ( + + ); +} + +type ItemProps = { + name: string; + activeStage: UploadStage; + num: number; +}; + +function NavItem(props: ItemProps) { + const { state, getUploadUrl } = useFileUploadContext(); + const lowerName = props.name.toLowerCase(); + const stage = getUploadStage(lowerName); + const active = stage === props.activeStage; + const complete = getComplete(stage, props.activeStage, state); + const disabled = getDisabled(complete, stage, state); + + return ( +
  • + + + +
  • + ); +} + +// stage is complete if active stage is greater, if the stage is not active +// (except for Confirm), and if corresponding state has been set +function getComplete( + stage: UploadStage, + activeStage: UploadStage, + state: FileUploadState, +): boolean { + if (activeStage > stage) { + return true; + } + if (activeStage === stage) { + return false; + } + switch (stage) { + case UploadStage.Branch: + return !!state.branchName; + case UploadStage.Table: + return !!state.tableName; + case UploadStage.Upload: + return !!(state.selectedFile ?? state.spreadsheetRows); + default: + return false; + } +} + +// stage is disabled if all steps have been completed (i.e. new branch has been +// created) or if the stage before has set its corresponding state +function getDisabled( + complete: boolean, + stage: UploadStage, + state: FileUploadState, +): boolean { + if (complete) { + return false; + } + switch (stage) { + case UploadStage.Branch: + return false; + case UploadStage.Table: + return !state.branchName; + case UploadStage.Upload: + return !state.branchName || !state.tableName; + default: + return true; + } +} diff --git a/packages/web/components/pageComponents/FileUploadPage/PageWrapper.tsx b/packages/web/components/pageComponents/FileUploadPage/PageWrapper.tsx new file mode 100644 index 00000000..6d01364a --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/PageWrapper.tsx @@ -0,0 +1,64 @@ +import { useDefaultBranchPageQuery } from "@gen/graphql-types"; +import useRole from "@hooks/useRole"; +import { DatabaseParams, UploadParams } from "@lib/params"; +import { ReactNode } from "react"; +import Loader from "../../Loader"; +import Page404 from "../../Page404"; +import QueryHandler from "../../util/QueryHandler"; +import Layout from "./Layout"; +import { FileUploadLocalForageProvider } from "./contexts/fileUploadLocalForage"; +import css from "./index.module.css"; + +type Props = { + params: UploadParams & { + branchName?: string; + tableName?: string; + }; + children: ReactNode; +}; + +export default function PageWrapper(props: Props) { + const { userHasWritePerms, loading } = useRole(); + const res = useDefaultBranchPageQuery({ + variables: { ...props.params, filterSystemTables: true }, + }); + const dbParams = { + databaseName: props.params.databaseName, + }; + + if (loading) return ; + if (!userHasWritePerms) return ; + + return ( + + ( + + {props.children} + + )} + /> + + ); +} + +function PermsError(props: { params: DatabaseParams }) { + return ( + +
    + +
    +
    + You must have write permissions to create a new table or upload a + file +
    +
    +
    +
    +
    + ); +} diff --git a/packages/web/components/pageComponents/FileUploadPage/StepLayout/WrongStageModal.tsx b/packages/web/components/pageComponents/FileUploadPage/StepLayout/WrongStageModal.tsx new file mode 100644 index 00000000..4918df41 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/StepLayout/WrongStageModal.tsx @@ -0,0 +1,28 @@ +import Button from "@components/Button"; +import Modal from "@components/Modal"; +import Link from "@components/links/Link"; +import { ModalProps } from "@lib/modalProps"; +import { upload } from "@lib/urls"; +import { useFileUploadContext } from "../contexts/fileUploadLocalForage"; +import css from "./index.module.css"; + +export default function WrongStageModal(props: ModalProps) { + const { dbParams } = useFileUploadContext(); + return ( + props.setIsOpen(false)} + > +
    +

    + We did not find the expected information on this page. Please start + the file upload process from the beginning. +

    + + + +
    +
    + ); +} diff --git a/packages/web/components/pageComponents/FileUploadPage/StepLayout/index.module.css b/packages/web/components/pageComponents/FileUploadPage/StepLayout/index.module.css new file mode 100644 index 00000000..bd7b1e16 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/StepLayout/index.module.css @@ -0,0 +1,35 @@ +.container { + @apply w-full mx-auto px-24 text-center mt-12 text-primary max-w-3xl relative; + + h2 { + @apply mb-10; + } + + p { + @apply text-base mb-3; + } +} + +.buttons { + @apply my-12 text-center; +} + +.next { + @apply w-40; +} + +.back { + @apply absolute -left-48 top-1; + + button { + @apply flex font-semibold; + + svg { + @apply mr-2 mt-1; + } + } +} + +.modal { + @apply max-w-md; +} diff --git a/packages/web/components/pageComponents/FileUploadPage/StepLayout/index.tsx b/packages/web/components/pageComponents/FileUploadPage/StepLayout/index.tsx new file mode 100644 index 00000000..7bb0ea10 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/StepLayout/index.tsx @@ -0,0 +1,64 @@ +import Button from "@components/Button"; +import Link from "@components/links/Link"; +import { Route } from "@lib/urlUtils"; +import { BsChevronLeft } from "@react-icons/all-files/bs/BsChevronLeft"; +import { ReactNode, useState } from "react"; +import { UploadStage } from "../enums"; +import WrongStageModal from "./WrongStageModal"; +import css from "./index.module.css"; + +type Props = { + title: string | ReactNode; + children: ReactNode; + stage: UploadStage; + disabled: boolean; + onNext?: () => void; + nextUrl?: Route; + backUrl?: Route; + onWrongStage?: boolean; + dataCy?: string; +}; + +export default function StepLayout(props: Props) { + const [modalIsOpen, setModalIsOpen] = useState(!!props.onWrongStage); + + return ( +
    + {props.stage !== UploadStage.Branch && props.backUrl && ( +
    + + + + back + + +
    + )} +

    {props.title}

    +
    {props.children}
    +
    + +
    + +
    + ); +} + +function NextButton(props: Props) { + const nextButton = (onClick?: () => void) => ( + + ); + + return props.nextUrl ? ( + {nextButton()} + ) : ( + nextButton(props.onNext) + ); +} diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Branch/index.module.css b/packages/web/components/pageComponents/FileUploadPage/Steps/Branch/index.module.css new file mode 100644 index 00000000..dfb9d23c --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Branch/index.module.css @@ -0,0 +1,3 @@ +.selector { + @apply text-left mx-auto w-72 my-8; +} diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Branch/index.tsx b/packages/web/components/pageComponents/FileUploadPage/Steps/Branch/index.tsx new file mode 100644 index 00000000..becfb684 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Branch/index.tsx @@ -0,0 +1,53 @@ +import CustomFormSelect from "@components/CustomFormSelect"; +import ErrorMsg from "@components/ErrorMsg"; +import Loader from "@components/Loader"; +import DatabaseLink from "@components/links/DatabaseLink"; +import StepLayout from "../../StepLayout"; +import { useFileUploadContext } from "../../contexts/fileUploadLocalForage"; +import { UploadStage } from "../../enums"; +import css from "./index.module.css"; + +export default function Branch() { + const { initialLoad, error } = useFileUploadContext(); + if (initialLoad) return ; + if (error) return ; + return ; +} + +function Inner() { + const { state, error, updateLoad, setItem, dbParams, getUploadUrl } = + useFileUploadContext(); + + return ( + +
    + +
    +

    + Choose the branch on{" "} + + {dbParams.databaseName} + {" "} + to base your changes on. +

    +
    + setItem("branchName", b)} + showLabel + mono + /> +
    +
    + +
    +
    + ); +} diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Table/TableOption.tsx b/packages/web/components/pageComponents/FileUploadPage/Steps/Table/TableOption.tsx new file mode 100644 index 00000000..7361cdd2 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Table/TableOption.tsx @@ -0,0 +1,27 @@ +import HelpPopup from "@components/HelpPopup"; +import { ReactNode } from "react"; +import css from "./index.module.css"; + +type Props = { + children: ReactNode; + title: string; + helpText?: string; +}; + +export default function TableOption(props: Props) { + // get lowercased first word of title ("create" or "update") + const typeForDataCy = props.title.split(" ")[0].toLowerCase(); + return ( +
    +
    +
    {props.title}
    + {props.helpText && ( + +
    {props.helpText}
    +
    + )} +
    +
    {props.children}
    +
    + ); +} diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Table/index.module.css b/packages/web/components/pageComponents/FileUploadPage/Steps/Table/index.module.css new file mode 100644 index 00000000..48ff3939 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Table/index.module.css @@ -0,0 +1,27 @@ +.container { + @apply widget-shadow mt-10 rounded-lg text-left; +} + +.title { + @apply mx-1 px-6 py-4 border-b border-ld-lightgrey flex; +} + +.inner { + @apply bg-ld-lightblue px-16 py-8 border-b border-ld-lightgrey; +} + +.closed { + @apply hidden; +} + +/* .radio { + @apply mr-10 font-normal; +}*/ + +.bold { + @apply font-semibold; +} + +.questionIcon { + @apply mx-5; +} diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Table/index.tsx b/packages/web/components/pageComponents/FileUploadPage/Steps/Table/index.tsx new file mode 100644 index 00000000..15db7bef --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Table/index.tsx @@ -0,0 +1,60 @@ +import ErrorMsg from "@components/ErrorMsg"; +import Loader from "@components/Loader"; +import TableSelector from "@components/TableSelector"; +import LoadDataInfo from "../../LoadDataInfo"; +import StepLayout from "../../StepLayout"; +import { useFileUploadContext } from "../../contexts/fileUploadLocalForage"; +import { UploadStage } from "../../enums"; +import TableOption from "./TableOption"; +import css from "./index.module.css"; +import useTable from "./useTable"; + +export default function Table() { + const { initialLoad, error } = useFileUploadContext(); + + if (initialLoad) return ; + if (error) return ; + + return ; +} + +function Inner() { + const { + dbParams, + updateLoad, + error, + state: { branchName }, + getUploadUrl, + } = useFileUploadContext(); + const { onNext, disabled, state, setState } = useTable(); + + return ( + +
    + +
    + +
    + setState({ existingTable: t })} + light + /> + +
    +
    +
    + +
    +
    + ); +} diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Table/useTable.ts b/packages/web/components/pageComponents/FileUploadPage/Steps/Table/useTable.ts new file mode 100644 index 00000000..7c491a3f --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Table/useTable.ts @@ -0,0 +1,53 @@ +import useSetState from "@hooks/useSetState"; +import { useRouter } from "next/router"; +import { Dispatch, useEffect, useState } from "react"; +import { useFileUploadContext } from "../../contexts/fileUploadLocalForage"; + +const defaultState = { + existingTable: "", + err: "", + valErr: "", +}; + +type TableState = typeof defaultState; +export type TableDispatch = Dispatch>; + +type ReturnType = { + state: TableState; + setState: TableDispatch; + onNext: () => void; + disabled: boolean; +}; + +export default function useTable(): ReturnType { + const [state, setState] = useSetState(defaultState); + const { + state: { tableName, importOp }, + setItem, + updateLoad, + getUploadUrl, + } = useFileUploadContext(); + const router = useRouter(); + const [stateSet, setStateSet] = useState(false); + + // Set default state based on stored local forage values and only set one time + useEffect(() => { + if (updateLoad) return; + if (!tableName || stateSet) return; + setState({ + existingTable: tableName, + }); + setStateSet(true); + }, [tableName, importOp, setState, stateSet, updateLoad]); + + const disabled = !state.existingTable; + const uploadLink = getUploadUrl("upload"); + + const onNext = () => { + setItem("tableName", state.existingTable); + setItem("importOp", importOp); + router.push(uploadLink.href, uploadLink.as).catch(console.error); + }; + + return { onNext, disabled, state, setState }; +} diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/DropZone.tsx b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/DropZone.tsx new file mode 100644 index 00000000..74f0c584 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/DropZone.tsx @@ -0,0 +1,89 @@ +import Button from "@components/Button"; +import ErrorMsg from "@components/ErrorMsg"; +import { FiCheck } from "@react-icons/all-files/fi/FiCheck"; +import { FiUpload } from "@react-icons/all-files/fi/FiUpload"; +import cx from "classnames"; +import { useRef } from "react"; +import FileInfo from "../../FileInfo"; +import { useFileUploadContext } from "../../contexts/fileUploadLocalForage"; +import useUploadContext from "./contexts/upload"; +import css from "./index.module.css"; +import { useDropZone, validTypes } from "./useDropZone"; + +export default function DropZone() { + const dz = useDropZone(); + const { state } = useFileUploadContext(); + const { setState } = useUploadContext(); + const fileInputRef = useRef(null); + const fileTypes = validTypes.map(getTypesString); + + const onBrowse = () => { + if (!fileInputRef.current) return; + fileInputRef.current.click(); + }; + + return ( +
    +
    +
    + {state.selectedFile ? ( +
    + + + Upload successful + + setState({ error: undefined })} + upload + /> +
    + ) : ( +
    + +
    Drag a file here
    +
    or
    +
    + + Browse files + + + +
    +
    + )} +
    +
    + + {!state.selectedFile && !state.spreadsheetRows && ( +
    File types: {fileTypes}
    + )} +
    + ); +} + +function getTypesString(t: string, i: number): string { + const val = t === "json" ? `.${t}*` : `.${t}`; + if (i === validTypes.length - 1) { + return val; + } + return `${val}, `; +} diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableEditorOverlay.tsx b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableEditorOverlay.tsx new file mode 100644 index 00000000..af78c2a1 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableEditorOverlay.tsx @@ -0,0 +1,57 @@ +import Button from "@components/Button"; +import ErrorMsg from "@components/ErrorMsg"; +import LoadDataInfo from "@components/pageComponents/FileUploadPage/LoadDataInfo"; +import { ColumnForDataTableFragment } from "@gen/graphql-types"; +import { ErrorType } from "@lib/errors/types"; +import { IoMdClose } from "@react-icons/all-files/io/IoMdClose"; +import { useFileUploadContext } from "../../../contexts/fileUploadLocalForage"; +import { FileUploadState } from "../../../contexts/fileUploadLocalForage/state"; +import useUploadContext from "../contexts/upload"; +import TableGrid from "./TableGrid"; +import css from "./index.module.css"; + +type Props = { + columns: ColumnForDataTableFragment[]; +}; + +type InnerProps = Props & { + state: FileUploadState; + onClose: () => void; + error: ErrorType; +}; + +function Inner(props: InnerProps) { + return ( +
    +
    +
    + + + +

    Spreadsheet Editor

    + {props.state.tableName && } +
    +
    + + +
    +
    +
    +
    +
    + ); +} + +export default function TableEditorOverlay(props: Props) { + const { state } = useFileUploadContext(); + const { + state: { error }, + setState, + } = useUploadContext(); + + const onClose = () => { + setState({ spreadsheetOverlayOpen: false, error: undefined }); + }; + + return ; +} diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/Buttons/ExportButton.tsx b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/Buttons/ExportButton.tsx new file mode 100644 index 00000000..3a1e7977 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/Buttons/ExportButton.tsx @@ -0,0 +1,28 @@ +import Button from "@components/Button"; +import { useState } from "react"; +import ReactLoader from "react-loader"; +import css from "./index.module.css"; + +type Props = { + onExport: () => Promise; +}; + +export default function ExportButton({ onExport }: Props) { + const [exporting, setExporting] = useState(false); + return ( +
    + + +
    + ); +} diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/Buttons/index.module.css b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/Buttons/index.module.css new file mode 100644 index 00000000..8ee55b06 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/Buttons/index.module.css @@ -0,0 +1,31 @@ +.gridButtons { + @apply flex items-center mx-4 relative; +} + +.insertButton { + @apply flex items-center mr-4; + + svg { + @apply ml-1.5; + } +} + +.popup { + @apply absolute top-8 -left-12 bg-white rounded z-10 widget-shadow p-3 w-28; + + button { + @apply w-full text-left; + } + + button:not(:last-of-type) { + @apply mb-1.5; + } +} + +.exportButton { + @apply text-center ml-4; +} + +.rowLine { + @apply w-full border-b border-ld-lightgrey mb-1.5; +} diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/Buttons/index.tsx b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/Buttons/index.tsx new file mode 100644 index 00000000..c9ab72c7 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/Buttons/index.tsx @@ -0,0 +1,113 @@ +import Button from "@components/Button"; +import useOnClickOutside from "@hooks/useOnClickOutside"; +import { handleCaughtError } from "@lib/errors/helpers"; +import { FaChevronDown } from "@react-icons/all-files/fa/FaChevronDown"; +import { FaChevronUp } from "@react-icons/all-files/fa/FaChevronUp"; +import { useRef, useState } from "react"; +import useUploadContext, { UploadDispatch } from "../../../contexts/upload"; +import { GridDispatch, GridFunctions, GridState } from "../types"; +import ExportButton from "./ExportButton"; +import css from "./index.module.css"; + +type Props = { + gridFunctions: GridFunctions; + state: GridState; + setState: GridDispatch; + gridElement: JSX.Element; +}; + +type InnerProps = Props & { + setUcState: UploadDispatch; +}; + +function Inner(props: InnerProps) { + const { onExport, insertRow } = props.gridFunctions; + const [insertOpen, setInsertOpen] = useState(false); + const popupRef = useRef(null); + useOnClickOutside(popupRef, () => setInsertOpen(false)); + + const onPopupClick = (i: number, fn: (i: number) => void) => { + fn(i); + setInsertOpen(false); + }; + + function rowButton(position: string, insertAt: number, disabled?: boolean) { + return ( + onPopupClick(insertAt, insertRow)} + disabled={disabled} + > + Row {position} + + ); + } + + // function colButton(position: string, insertAt: number) { + // return ( + // onPopupClick(insertAt, onAddColumn)} + // > + // Column {position} + // + // ); + // } + + return ( +
    +
    + setInsertOpen(!insertOpen)} + className={css.insertButton} + > + Insert {insertOpen ? : } + + {insertOpen && ( +
    + {!props.state.selectedCell ? ( + <> + {rowButton("top", 1)} + {rowButton("bottom", props.state.rows.length)} + + ) : ( + <> + {rowButton( + "above", + props.state.selectedCell.rowIdx, + props.state.selectedCell.rowIdx === 0, + )} + {rowButton("below", props.state.selectedCell.rowIdx + 1)} + + )} + {/*
    + {!props.state.selectedCell ? ( + <> + {colButton("start", 0)} + {colButton("end", props.state.columns.length)} + + ) : ( + <> + {colButton("left", props.state.selectedCell.idx - 1)} + {colButton("right", props.state.selectedCell.idx)} + + )} */} +
    + )} +
    + { + try { + await onExport(props.gridElement); + props.setUcState({ error: undefined }); + } catch (err) { + handleCaughtError(err, e => props.setState({ error: e })); + } + }} + /> +
    + ); +} + +export default function Buttons(props: Props) { + const { setState: setUcState } = useUploadContext(); + return ; +} diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/IndexColumn.tsx b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/IndexColumn.tsx new file mode 100644 index 00000000..71015b28 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/IndexColumn.tsx @@ -0,0 +1,26 @@ +import cx from "classnames"; +import { FormatterProps } from "react-data-grid"; +import css from "./index.module.css"; +import { Column, Row } from "./types"; + +function IndexFormatter(props: FormatterProps) { + const name = props.row._idx === 0 ? "*" : props.row._idx; + return
    {name}
    ; +} + +export const indexColumn: Column = { + _idx: -1, + key: "index-column", + name: "", + width: 37, + maxWidth: 37, + resizable: false, + sortable: false, + frozen: true, + editable: false, + formatter: IndexFormatter, + cellClass: cx("index-cell", css.rowIndex), + editorOptions: { + editOnClick: false, + }, +}; diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/index.module.css b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/index.module.css new file mode 100644 index 00000000..0674dcfd --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/index.module.css @@ -0,0 +1,67 @@ +.contextMenu { + @apply bg-white text-primary border border-ld-darkgrey rounded; + + div[role="menuitem"] { + @apply py-1.5 px-3 text-sm; + + &:hover { + @apply text-acc-hoverblue cursor-auto; + } + } + div[role="menuitem"]:not(:last-child) { + @apply border-b border-ld-darkgrey; + } +} + +.dataGrid { + @apply text-primary m-4 mx-auto; + height: 75vh; + + :global(.rdg-cell-dragged-over) { + @apply bg-blue-100; + } + + :global(.index-cell > .rdg-cell-drag-handle) { + @apply hidden; + } + + :global(.index-cell[aria-selected="true"]), + :global(.rdg-cell[role="columnheader"][aria-selected="true"]) { + @apply bg-gray-300; + box-shadow: 2px 0 5px -2px rgb(136 136 136 / 30%); + } +} + +.top { + @apply flex justify-between mt-1; +} + +.msg { + @apply text-xs italic mt-1; +} + +.headerRow { + @apply border-b-4 border-ld-lightgrey; + + div[role="gridcell"] { + @apply mb-1; + } +} + +.rowIndex { + @apply font-semibold text-center; + background-color: #f9f9f9; +} + +.loading { + @apply absolute -bottom-3 right-4 text-white px-2 bg-ld-darkgrey bg-opacity-50; +} + +.cellError { + @apply bg-acc-hoverred relative; + + &:hover::before { + @apply absolute top-1.5 right-1.5 bg-acc-hoverred text-white opacity-90 text-xs; + content: attr(data-text); + } +} diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/index.tsx b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/index.tsx new file mode 100644 index 00000000..cf7ea80d --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/index.tsx @@ -0,0 +1,119 @@ +import ErrorMsg from "@components/ErrorMsg"; +import { ColumnForDataTableFragment } from "@gen/graphql-types"; +import cx from "classnames"; +import { useEffect } from "react"; +import DataGrid from "react-data-grid"; +import Buttons from "./Buttons"; +import { indexColumn } from "./IndexColumn"; +import css from "./index.module.css"; +import { GridDispatch, GridFunctions, GridState, Row } from "./types"; +import useGrid from "./useGrid"; + +type Props = { + columns: ColumnForDataTableFragment[]; +}; + +type InnerProps = Props & { + gf: GridFunctions; + state: GridState; + setState: GridDispatch; +}; + +function Inner(props: InnerProps) { + useEffect(() => { + document.addEventListener("paste", props.gf.handlePaste); + return () => document.removeEventListener("paste", props.gf.handlePaste); + }); + + const gridElement = ( + { + return { + ...col, + // headerRenderer: HeaderRenderer, + }; + }), + ]} + rows={props.state.rows} + rowKeyGetter={row => row._id} + onRowsChange={rows => props.setState({ rows })} + // onScroll={props.gf.handleScroll} + onFill={props.gf.onFill} + // rowRenderer={RowRenderer} + className={cx("rdg-light", css.dataGrid)} + style={{ resize: "both" }} + rowHeight={({ row }) => ((row as Row)._id === 0 ? 35 : 30)} + rowClass={row => (row._id === 0 ? css.headerRow : undefined)} + onSelectedCellChange={c => props.setState({ selectedCell: c })} + /> + ); + + // const rowMenu = ( + // + // Delete Row + // Insert Row Above + // Insert Row Below + // + // ); + + // const headerMenu = ( + // + // Delete Column + // + // ); + + return ( +
    +
    +
    * First row should contain column names
    + +
    + + {gridElement} + {/* {createPortal(rowMenu, document.body)} + {createPortal(headerMenu, document.body)} */} + {props.state.loading && ( +
    Loading more rows
    + )} +
    + ); +} + +export default function TableGrid(props: Props) { + const { state, setState, gridFunctions: gf } = useGrid(props.columns); + return ; +} + +// function HeaderRenderer(props: HeaderRendererProps) { +// return ( +// { +// return { column: props.column }; +// }} +// > +//
    {props.column.name}
    +//
    +// ); +// } + +// function RowRenderer(props: RowRendererProps) { +// return ( +// { +// return { rowIdx: props.rowIdx }; +// }} +// disable={props.rowIdx === 0} +// > +// +// +// ); +// } diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/types.ts b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/types.ts new file mode 100644 index 00000000..1a7fd3e9 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/types.ts @@ -0,0 +1,48 @@ +import { Dispatch, ReactElement } from "react"; +import { + DataGridProps, + FillEvent, + Column as GridColumn, +} from "react-data-grid"; + +export type Row = { + _id: number; + _idx: number; + [key: string]: any; +}; + +export type Column = GridColumn & { name: string; _idx: number }; +export type Columns = Column[]; + +export type GridState = { + rows: Row[]; + columns: Columns; + loading: boolean; + pageToken?: string; + error?: Error; + selectedCell?: { rowIdx: number; idx: number }; +}; + +export type GridDispatch = Dispatch>; + +export type RowObj = { rowIdx: number }; +export type HeadObj = { column: Column }; + +export type GridFunctions = { + onExport: (g: ReactElement>) => Promise; + onRowDelete: (e: React.MouseEvent, o: RowObj) => void; + onRowInsertAbove: (e: React.MouseEvent, o: RowObj) => void; + onRowInsertBelow: (e: React.MouseEvent, o: RowObj) => void; + onAddColumn: (i: number) => void; + onDeleteColumn: (e: React.MouseEvent, o: HeadObj) => void; + onFill: (e: FillEvent) => Row[]; + insertRow: (i: number) => void; + // handleScroll: (event: React.UIEvent) => Promise; + handlePaste: (e: ClipboardEvent) => void; +}; + +export type ReturnType = { + state: GridState; + setState: GridDispatch; + gridFunctions: GridFunctions; +}; diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/useGrid.ts b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/useGrid.ts new file mode 100644 index 00000000..5da690ad --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/useGrid.ts @@ -0,0 +1,280 @@ +import { ApolloError } from "@apollo/client"; +import { ColumnForDataTableFragment, FileType } from "@gen/graphql-types"; +import useEffectAsync from "@hooks/useEffectAsync"; +import useSetState from "@hooks/useSetState"; +import { handleCaughtApolloError } from "@lib/errors/helpers"; +import { nTimesWithIndex } from "@lib/nTimes"; +import { ReactElement, useReducer, useState } from "react"; +import { DataGridProps, FillEvent } from "react-data-grid"; +import { useFileUploadContext } from "../../../../contexts/fileUploadLocalForage"; +import useUploadContext from "../../contexts/upload"; +import { Columns, HeadObj, ReturnType, Row, RowObj } from "./types"; +import { + getColumn, + getColumnLetterFromAlphabet, + getColumnsFromPastedData, + getDefaultState, + getGridAsCsv, + getRow, + mergePastedRowsIntoExistingRows, +} from "./utils"; + +export default function useGrid( + existingCols: ColumnForDataTableFragment[], +): ReturnType { + const { onUpload, setState: setUcState } = useUploadContext(); + const { state: fState, setState: setForageState } = useFileUploadContext(); + + const [state, setState] = useSetState( + getDefaultState(fState.spreadsheetRows, existingCols), + ); + + const [nextId, setNextId] = useReducer( + (id: number) => id + 1, + state.rows[state.rows.length - 1]._id + 1, + ); + const [submitting, setSubmitting] = useState(false); + + useEffectAsync( + async ({ subscribed }) => { + if (!submitting || !fState.selectedFile?.size) { + return; + } + if (subscribed) setSubmitting(false); + try { + await onUpload(); + } catch (err) { + if (subscribed) { + handleCaughtApolloError(err, e => setUcState({ error: e })); + } + } + }, + [ + submitting, + fState.selectedFile?.size, + setSubmitting, + onUpload, + setUcState, + ], + ); + + const onExport = async ( + gridElement: ReactElement>, + ) => { + try { + const { csv, rows } = await getGridAsCsv(gridElement); + if (rows.length === 0) { + setState({ error: new Error("cannot upload empty spreadsheet") }); + return; + } + const firstRow = csv.split("\n")[0]; + + const enc = new TextEncoder(); + // encode text utf-8 + const contents = enc.encode(csv); + + const file = new File([contents], "editor.csv", { type: "text/csv" }); + setForageState({ + selectedFile: file, + spreadsheetRows: rows, + fileType: FileType.Csv, + colNames: firstRow, + }); + setSubmitting(true); + } catch (e) { + setUcState({ error: e as ApolloError }); + } + }; + + const insertRow = (insertRowIdx: number) => { + const newRow: Row = getRow(nextId, insertRowIdx, state.columns); + const mappedIdxs = state.rows.slice(insertRowIdx).map(r => { + return { ...r, _idx: r._idx + 1 }; + }); + setState({ + rows: [...state.rows.slice(0, insertRowIdx), newRow, ...mappedIdxs], + }); + setNextId(); + }; + + const onRowDelete = ( + _: React.MouseEvent, + { rowIdx }: RowObj, + ) => { + const mappedIdxs = state.rows.slice(rowIdx + 1).map(r => { + return { ...r, _idx: r._idx - 1 }; + }); + setState({ + rows: [...state.rows.slice(0, rowIdx), ...mappedIdxs], + }); + }; + + const onRowInsertAbove = ( + _: React.MouseEvent, + { rowIdx }: RowObj, + ) => { + insertRow(rowIdx); + }; + + const onRowInsertBelow = ( + _: React.MouseEvent, + { rowIdx }: RowObj, + ) => { + insertRow(rowIdx + 1); + }; + + const onAddColumn = (insertColIdx: number) => { + const newCol = getColumn(insertColIdx, insertColIdx); + const mapped = state.columns.slice(insertColIdx).map(c => { + const newIdx = c._idx + 1; + return { + ...c, + _idx: newIdx, + key: `${Number(c.key) + 1}`, + name: getColumnLetterFromAlphabet(newIdx), + }; + }); + const newCols = [ + ...state.columns.slice(0, insertColIdx), + newCol, + ...mapped, + ]; + const mappedRows = state.rows.map(row => { + const keys = Object.keys(row); + const newRow: Row = { [newCol.key]: "", _id: row._id, _idx: row._idx }; + keys.forEach(key => { + const num = Number(key); + if (num >= insertColIdx) { + newRow[`${num + 1}`] = row[key]; + } else { + newRow[key] = row[key]; + } + }); + return newRow; + }); + setState({ columns: newCols, rows: mappedRows }); + }; + + const onDeleteColumn = ( + _: React.MouseEvent, + { column }: HeadObj, + ) => { + const mappedNames = state.columns.slice(column._idx + 1).map(c => { + return { + ...c, + name: getColumnLetterFromAlphabet(Number(c._idx) - 1), + _idx: c._idx - 1, + }; + }); + const newCols = [...state.columns.slice(0, column._idx), ...mappedNames]; + const newRows = state.rows.map(row => { + const newRow = row; + delete newRow[column.key]; + return newRow; + }); + setState({ columns: newCols, rows: newRows }); + }; + + function onFill({ columnKey, sourceRow, targetRows }: FillEvent): Row[] { + return targetRows.map(targetRow => { + return { ...targetRow, [columnKey]: sourceRow[columnKey as keyof Row] }; + }); + } + + // async function handleScroll(event: React.UIEvent) { + // if ( + // state.loading || + // fState.spreadsheetRows || + // !state.pageToken || + // // !existingTable || + // !isAtBottom(event) + // ) { + // return; + // } + + // setState({ loading: true }); + + // const res = await existingTable.loadMore(state.pageToken); + // let next = nextId; + // const newRows = res?.rows.list.map((row, i) => { + // const newRow: Row = { _id: next, _idx: state.rows.length + i }; + // state.columns.forEach((col, colI) => { + // const val = + // colI < row.columnValues.length + // ? getExistingRowValue(row.columnValues[colI].displayValue) + // : ""; + // newRow[col.key] = val; + // }); + // next += 1; + // setNextId(); + // return newRow; + // }); + + // setState({ + // rows: [...state.rows, ...(newRows ?? [])], + // loading: false, + // pageToken: res?.rows.nextPageToken ?? undefined, + // }); + // } + + const handlePaste = (e: ClipboardEvent) => { + e.preventDefault(); + if (!state.selectedCell) return; + const { idx, rowIdx } = state.selectedCell; + const pasteDataRows = defaultParsePaste( + e.clipboardData?.getData("text/plain"), + ); + const newCols = getColumnsFromPastedData(pasteDataRows, idx, state.columns); + const allRows = addEmptyRowsForPastedRows(pasteDataRows, rowIdx, newCols); + const newRows = mergePastedRowsIntoExistingRows( + pasteDataRows, + allRows, + newCols, + idx, + rowIdx, + ); + setState({ + columns: newCols, + rows: newRows, + }); + }; + + // If pasted rows go beyond row boundary, add more empty rows + function addEmptyRowsForPastedRows( + pastedRows: string[][], + rowIdx: number, + cols: Columns, + ): Row[] { + if (pastedRows.length === 0) return state.rows; + const numMoreRows = pastedRows.length - (state.rows.length - rowIdx); + if (numMoreRows <= 0) return state.rows; + const next = nextId; + const newRows = nTimesWithIndex(numMoreRows, n => { + setNextId(); + return getRow(next + n, n + state.rows.length, cols); + }); + return [...state.rows, ...newRows]; + } + + return { + state, + setState, + gridFunctions: { + onExport, + onRowDelete, + onRowInsertAbove, + onRowInsertBelow, + onAddColumn, + onDeleteColumn, + insertRow, + onFill, + // handleScroll, + handlePaste, + }, + }; +} + +// Splits strings copied from spreadsheet +export function defaultParsePaste(str?: string): string[][] { + return str?.split(/\r\n|\n|\r/).map(row => row.split("\t")) ?? []; +} diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/utils.test.ts b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/utils.test.ts new file mode 100644 index 00000000..38e1f657 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/utils.test.ts @@ -0,0 +1,111 @@ +import { + filterOutEmptyRowsAndCols, + getColumnLetterFromAlphabet, +} from "./utils"; +import { isNumeric } from "./validate"; + +describe("test editable table utils", () => { + it("filters out empty rows and columns", () => { + const tests: Array<{ rows: string[][]; filtered: string[][] }> = [ + { + rows: [ + ["", ""], + ["", ""], + ], + filtered: [], + }, + { + rows: [ + ["a", "b"], + ["", ""], + ], + filtered: [["a", "b"]], + }, + { + rows: [ + ["a", "b", ""], + ["c", "", ""], + ], + filtered: [ + ["a", "b"], + ["c", ""], + ], + }, + { + rows: [ + ["a", "b", "c"], + ["c", "", ""], + ["c", "d", ""], + ], + filtered: [ + ["a", "b", "c"], + ["c", "", ""], + ["c", "d", ""], + ], + }, + { + rows: [ + ["a", "b", "", ""], + ["c", "d", "e", ""], + ["f", "g", "", ""], + ], + filtered: [ + ["a", "b", ""], + ["c", "d", "e"], + ["f", "g", ""], + ], + }, + { + rows: [ + ["a", "b", "", ""], + ["c", "d", "", "e"], + ["f", "g", "", ""], + ], + filtered: [ + ["a", "b", ""], + ["c", "d", "e"], + ["f", "g", ""], + ], + }, + ]; + tests.forEach(test => { + expect(filterOutEmptyRowsAndCols(test.rows)).toEqual(test.filtered); + }); + }); + + it("gets the column letter from the alphabet", () => { + const tests: Array<{ index: number; letter: string }> = [ + { index: 0, letter: "A" }, + { index: 2, letter: "C" }, + { index: 25, letter: "Z" }, + { index: 26, letter: "AA" }, + { index: 28, letter: "AC" }, + { index: 51, letter: "AZ" }, + { index: 52, letter: "BA" }, + ]; + tests.forEach(test => { + expect(getColumnLetterFromAlphabet(test.index)).toEqual(test.letter); + }); + }); + + const areNumbers = [ + "1", + "392324329", + "3243.34232", + "0", + "-23920321762", + " 10", + ]; + const notNumbers = ["", " ", "a", "esr234", "232a", "a0"]; + + areNumbers.forEach(num => { + it(`"${num}" is numeric`, () => { + expect(isNumeric(num)).toBeTruthy(); + }); + }); + notNumbers.forEach(notNum => { + it(`"${notNum}" is not numeric`, () => { + expect(isNumeric(notNum)).toBeFalsy(); + }); + }); +}); diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/utils.ts b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/utils.ts new file mode 100644 index 00000000..19178f65 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/utils.ts @@ -0,0 +1,325 @@ +import { + ColumnForDataTableFragment, + RowForDataTableFragment, +} from "@gen/graphql-types"; +import { nTimesWithIndex } from "@lib/nTimes"; +import { isNullValue } from "@lib/null"; +import { KeyboardEvent, ReactElement, cloneElement } from "react"; +import { DataGridProps, TextEditor } from "react-data-grid"; +import { Column, Columns, GridState, Row } from "./types"; +import { getValidationClass, handleErrorClasses } from "./validate"; + +type Grid = { + csv: string; + rows: string[][]; +}; + +const defaultNumCols = 7; +const defaultNumRows = 51; +const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + +// EXPORT GRID + +export async function getGridAsCsv( + gridElement: ReactElement>, +): Promise { + const { body, foot } = await getGridContent(gridElement); + const rows = [...body, ...foot]; + const filtered = filterOutEmptyRowsAndCols(rows); + const csv = filtered + .map(cells => cells.map(serializeCellValue).join(",")) + .join("\n"); + return { csv, rows: filtered }; +} + +type GridContent = { + head: string[][]; + body: string[][]; + foot: string[][]; +}; + +async function getGridContent( + gridElement: ReactElement>, +): Promise { + try { + const { renderToStaticMarkup } = await import("react-dom/server"); + const grid = document.createElement("div"); + grid.innerHTML = renderToStaticMarkup( + cloneElement(gridElement, { + enableVirtualization: false, + }), + ); + + return { + head: getRows(grid, ".rdg-header-row"), + body: getRows(grid, ".rdg-row:not(.rdg-summary-row)"), + foot: getRows(grid, ".rdg-summary-row"), + }; + } catch (err) { + console.error(err); + return { head: [], body: [], foot: [] }; + } + + function getRows(grid: HTMLDivElement, selector: string): string[][] { + return Array.from(grid.querySelectorAll(selector)).map( + gridRow => + Array.from( + gridRow.querySelectorAll( + ".rdg-cell:not(.index-cell)", + ), + ).map(gridCell => gridCell.innerText), + ); + } +} + +export function filterOutEmptyRowsAndCols(rows: string[][]): string[][] { + const filteredEmptyRows = rows.filter(row => !row.every(cell => cell === "")); + if (filteredEmptyRows.length === 0) { + return []; + } + + const emptyColIndexes = filteredEmptyRows[0] + .map((_, colIdx) => colIdx) + .filter(colIdx => filteredEmptyRows.every(row => row[colIdx] === "")); + + const filteredEmptyCols = filteredEmptyRows.map(row => + row.filter((_, colIdx) => !emptyColIndexes.includes(colIdx)), + ); + + return filteredEmptyCols; +} + +function serializeCellValue(value: string): string { + const formattedValue = value.replace(/"/g, '""'); + return formattedValue.includes(",") || + formattedValue.includes('"') || + formattedValue.includes("\n") + ? `"${formattedValue}"` + : formattedValue; +} + +// DEFAULT STATE + +export function getDefaultState( + rows: string[][] | undefined, + existingCols?: ColumnForDataTableFragment[], +): GridState { + const numDefaultCols = getNumCols(rows, existingCols); + const columns: Column[] = []; + for (let i = 0; i < numDefaultCols; i++) { + const existing = existingCols ? existingCols[i] : undefined; + columns.push(getColumn(i, i, existing?.type)); + } + return { + columns, + rows: getDefaultRows(rows, columns, existingCols), + loading: false, + }; +} + +// GET COLUMNS + +export function getColumn(id: number, index: number, type?: string): Column { + return { + _idx: index, + name: getColumnLetterFromAlphabet(index), + key: `${id}`, + editable: true, + resizable: true, + editor: TextEditor, + width: 215, + cellClass: (row: Row) => { + const cl = getValidationClass(row._idx, row[id], type); + handleErrorClasses(); + return cl; + }, + }; +} + +// A, B, C, ..., Y, Z, AA, AB, AC, ... +export function getColumnLetterFromAlphabet(index: number): string { + if (index > 25) { + const first = Math.floor(index / 26); + const mod = 26 * first; + const second = Math.floor(index % mod); + return `${alphabet[first - 1]}${alphabet[second]}`; + } + return alphabet[index]; +} + +function getNumCols( + rows: string[][] | undefined, + existingCols?: ColumnForDataTableFragment[], +): number { + if (rows?.length) { + return rows[0].length; + } + if (existingCols) { + return existingCols.length; + } + return defaultNumCols; +} + +// If pasted rows go beyond columns boundary, add more empty columns +export function getColumnsFromPastedData( + pastedRows: string[][], + colIdx: number, + existingCols: Columns, +): Columns { + if (pastedRows.length === 0) return existingCols; + const numMoreCols = colIdx + pastedRows[0].length - existingCols.length - 1; + return numMoreCols > 0 + ? [ + ...existingCols, + ...nTimesWithIndex(numMoreCols, num => { + const newColIdx = num + existingCols.length; + return getColumn(newColIdx, newColIdx); + }), + ] + : existingCols; +} + +// GET ROWS + +function getEmptyRows( + cols: Columns, + numRows: number, + startingFrom: number, + existingCols?: ColumnForDataTableFragment[], +): Row[] { + const rows = []; + const start = existingCols ? startingFrom + 1 : startingFrom; + if (existingCols) { + rows.push(getRowFromExistingColumns(0, cols, existingCols)); + } + for (let i = start; i < numRows; i++) { + rows.push(getRow(i, i, cols)); + } + return rows; +} + +function getRowFromExistingColumns( + i: number, + cols: Columns, + existingCols: ColumnForDataTableFragment[], +): Row { + const row: Row = { _id: i, _idx: i }; + cols.forEach((col, idx) => { + const name = idx < existingCols.length ? existingCols[idx].name : ""; + row[col.key] = name; + }); + return row; +} + +export function getRow(i: number, idx: number, cols: Columns): Row { + const row: Row = { _id: i, _idx: idx }; + cols.forEach(col => { + row[col.key] = ""; + }); + return row; +} + +function getDefaultRows( + rows: string[][] | undefined, + columns: Column[], + existingCols?: ColumnForDataTableFragment[], + existingRows?: RowForDataTableFragment[], +): Row[] { + if (rows) { + return getRowsFromExistingCsv(columns, rows); + } + if (existingRows) { + const mappedRows = existingRows.map(r => + r.columnValues.map(v => getExistingRowValue(v.displayValue)), + ); + return getRowsFromExistingCsv(columns, mappedRows, existingCols); + } + return getEmptyRows(columns, defaultNumRows, 0, existingCols); +} + +export function getExistingRowValue(dv: string): string { + return isNullValue(dv) ? "" : dv; +} + +function getRowsFromExistingCsv( + cols: Column[], + rows: string[][], + existingCols?: ColumnForDataTableFragment[], +): Row[] { + const existingRows: Row[] = []; + if (existingCols) { + existingRows.push(getRowFromExistingColumns(0, cols, existingCols)); + } + rows.forEach((row, i) => { + const rIdx = existingCols ? i + 1 : i; + const newRow: Row = { _id: rIdx, _idx: rIdx }; + cols.forEach((col, colI) => { + newRow[col.key] = row[colI]; + }); + existingRows.push(newRow); + }); + + const numExisting = existingRows.length; + const emptyRows = + numExisting < defaultNumRows + ? getEmptyRows(cols, defaultNumRows - numExisting, numExisting) + : []; + return [...existingRows, ...emptyRows]; +} + +export function mergePastedRowsIntoExistingRows( + pastedRows: string[][], + existingRows: Row[], + cols: Columns, + colIdx: number, + rowIdx: number, +): Row[] { + const newRows = pastedRows.map((row, i) => { + const rowToUpdate = existingRows[i + rowIdx]; + cols.slice(colIdx - 1, colIdx + row.length - 1).forEach((col, j) => { + rowToUpdate[col.key] = row[j]; + }); + return rowToUpdate; + }); + + return [ + ...existingRows.slice(0, rowIdx), + ...newRows, + ...existingRows.slice(rowIdx + newRows.length), + ]; +} + +// NAVIGATION + +// Default onEditorNavigation, which is overridden for arrow keys in columns array +function onEditorNavigation({ + key, + target, +}: React.KeyboardEvent): boolean { + if ( + key === "Tab" && + (target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement) + ) { + return target.matches( + ".rdg-editor-container > :only-child, .rdg-editor-container > label:only-child > :only-child", + ); + } + return false; +} + +export function customOnNavigation( + event: KeyboardEvent, +): boolean { + return onEditorNavigation(event) || event.key.startsWith("Arrow"); +} + +export function isAtBottom({ + currentTarget, +}: React.UIEvent): boolean { + return ( + currentTarget.scrollTop + 10 >= + currentTarget.scrollHeight - currentTarget.clientHeight + ); +} diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/validate.ts b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/validate.ts new file mode 100644 index 00000000..0ed11222 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/validate.ts @@ -0,0 +1,64 @@ +import cx from "classnames"; +import css from "./index.module.css"; + +const yearRegex = /^\d{4}$/; // YYYY +const timeRegex = /(?:[01]\d|2[0-3]):(?:[0-5]\d):(?:[0-5]\d)/; // HH:MM:SS +const dateRegex = /^\d{4}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])$/; // YYYY-MM-DD +const datetimeRegex = + /^\d{4}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])\s(?:[01]\d|2[0-3]):(?:[0-5]\d):(?:[0-5]\d)/; // YYYY-MM-DD HH:MM:SS + +export function getValidationClass( + rowIdx: number, + value: string, + type?: string, +): string { + if (rowIdx === 0 || !type || value === "") return ""; + const lower = type.toLowerCase(); + if ( + lower.includes("int") || + lower.includes("float") || + lower.includes("double") || + lower.includes("decimal") + ) { + if (!isNumeric(value)) { + return cx(css.cellError, "int-err"); + } + return ""; + } + if (lower.includes("datetime") || lower.includes("timestamp")) { + return value.match(datetimeRegex) ? "" : cx(css.cellError, "datetime-err"); + } + if (lower.includes("time")) { + return value.match(timeRegex) ? "" : cx(css.cellError, "time-err"); + } + if (lower.includes("date")) { + return value.match(dateRegex) ? "" : cx(css.cellError, "date-err"); + } + if (lower.includes("year")) { + return value.match(yearRegex) ? "" : cx(css.cellError, "year-err"); + } + return ""; +} + +export function handleErrorClasses() { + const classes: string[][] = [ + ["int-err", "Not valid number"], + ["datetime-err", "Not valid datetime"], + ["time-err", "Not valid time"], + ["date-err", "Not valid date"], + ["year-err", "Not valid year"], + ]; + classes.forEach(errClass => setDataTextForClass(errClass[0], errClass[1])); +} + +function setDataTextForClass(className: string, text: string) { + [...document.getElementsByClassName(className)].forEach(errCell => { + errCell.setAttribute("data-text", text); + }); +} + +export function isNumeric(value: string): boolean { + const stripped = value.trim(); + if (stripped === "") return false; + return !Number.isNaN(Number(value)); +} diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/index.module.css b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/index.module.css new file mode 100644 index 00000000..2cbc93e4 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/index.module.css @@ -0,0 +1,42 @@ +.overlay { + @apply block fixed w-full h-full top-0 bottom-0 left-0 right-0 z-100; + background-color: rgba(0, 0, 0, 0.3); +} + +.container { + @apply flex relative bg-white justify-center mx-auto mt-5 rounded-lg overflow-hidden; + height: 95vh; + width: 95vw; +} + +.inner { + @apply w-full m-10 relative text-left; + + h1 { + @apply text-center mb-4; + } + + p { + @apply text-sm text-center; + } +} + +.reopenButton { + @apply px-0 mt-6; +} + +.closeTop { + @apply absolute right-0 top-0 text-primary; + + svg { + @apply text-2xl; + } +} + +.importInfo { + @apply flex justify-center; + + svg { + @apply ml-2; + } +} diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/index.tsx b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/index.tsx new file mode 100644 index 00000000..21ab33b3 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/index.tsx @@ -0,0 +1,45 @@ +import { useFileUploadContext } from "@components/pageComponents/FileUploadPage/contexts/fileUploadLocalForage"; +import QueryHandler from "@components/util/QueryHandler"; +import { useDataTableQuery } from "@gen/graphql-types"; +import { TableParams } from "@lib/params"; +import useUploadContext from "../contexts/upload"; +import TableEditorOverlay from "./TableEditorOverlay"; + +type InnerProps = { + params: TableParams; +}; + +function Inner(props: InnerProps) { + const tableRes = useDataTableQuery({ + variables: props.params, + }); + + return ( + } + /> + ); +} + +function WithQuery() { + const { state, dbParams } = useFileUploadContext(); + const params = { + ...dbParams, + refName: state.branchName, + tableName: state.tableName, + }; + return ; +} + +// Must be child of UploadProvider and FileUploadLocalForageProvider +// or UploadProvider and FileUploadLocalForageProvider +export default function EditableTable() { + const { + state: { spreadsheetOverlayOpen }, + } = useUploadContext(); + + if (!spreadsheetOverlayOpen) return null; + + return ; +} diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/OpenSpreadsheetZone.tsx b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/OpenSpreadsheetZone.tsx new file mode 100644 index 00000000..a1f4c95a --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/OpenSpreadsheetZone.tsx @@ -0,0 +1,76 @@ +import Button from "@components/Button"; +import { FiCheck } from "@react-icons/all-files/fi/FiCheck"; +import { GrTable } from "@react-icons/all-files/gr/GrTable"; +import cx from "classnames"; +import FileInfo from "../../FileInfo"; +import { useFileUploadContext } from "../../contexts/fileUploadLocalForage"; +import useUploadContext from "./contexts/upload"; +import css from "./index.module.css"; + +export default function OpenSpreadsheetZone() { + const { setState } = useUploadContext(); + const { state, setItem } = useFileUploadContext(); + + const onRemove = () => { + setState({ error: undefined }); + setItem("selectedFile", undefined); + }; + + const onClick = () => { + setState({ spreadsheetOverlayOpen: true }); + }; + + const onEdit = () => { + setState({ + spreadsheetOverlayOpen: true, + error: undefined, + }); + }; + + return ( +
    +
    + {state.spreadsheetRows ? ( +
    + + + Upload successful + + + edit + + } + upload + /> +
    + ) : ( +
    + +

    Create spreadsheet

    +
    + +
    +
    + )} +
    +
    + ); +} diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/contexts/queries.ts b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/contexts/queries.ts new file mode 100644 index 00000000..7cef4760 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/contexts/queries.ts @@ -0,0 +1,23 @@ +import { gql } from "@apollo/client"; + +export const LOAD_DATA = gql` + mutation LoadData( + $databaseName: String! + $refName: String! + $tableName: String! + $importOp: ImportOperation! + $fileType: FileType! + $file: Upload! + $modifier: LoadDataModifier + ) { + loadDataFile( + databaseName: $databaseName + refName: $refName + tableName: $tableName + importOp: $importOp + fileType: $fileType + file: $file + modifier: $modifier + ) + } +`; diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/contexts/upload.tsx b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/contexts/upload.tsx new file mode 100644 index 00000000..abcbe5b9 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/contexts/upload.tsx @@ -0,0 +1,120 @@ +import { ApolloError } from "@apollo/client"; +import { useLoadDataMutation } from "@gen/graphql-types"; +import useContextWithError from "@hooks/useContextWithError"; +import useMutation from "@hooks/useMutation"; +import useSetState from "@hooks/useSetState"; +import { createCustomContext } from "@lib/createCustomContext"; +import { TableParams } from "@lib/params"; +import { refetchTableUploadQueries } from "@lib/refetchQueries"; +import { table } from "@lib/urls"; +import { useRouter } from "next/router"; +import { Dispatch, ReactNode, useEffect } from "react"; +import { useFileUploadContext } from "../../../contexts/fileUploadLocalForage"; + +const defaultState = { + loading: false, + spreadsheetOverlayOpen: false, + error: undefined as ApolloError | Error | undefined, +}; + +export type UploadState = typeof defaultState; +export type UploadDispatch = Dispatch>; + +type UploadContextType = { + state: UploadState; + setState: UploadDispatch; + onUpload: () => Promise; + removeSpreadsheet: () => void; +}; + +export const UploadContext = + createCustomContext("UploadContext"); + +type Props = { + children: ReactNode; +}; + +export function UploadProvider(props: Props) { + const router = useRouter(); + const { + state: fuState, + dbParams, + setState: setForageState, + } = useFileUploadContext(); + const [state, setState] = useSetState({ + ...defaultState, + spreadsheetOverlayOpen: + router.query.spreadsheet === "true" && + !!router.query.branchName && + !!router.query.tableName, + }); + const tableParams: TableParams = { + ...dbParams, + refName: fuState.branchName, + tableName: fuState.tableName, + }; + const { + mutateFn: loadData, + err, + setErr, + } = useMutation({ + hook: useLoadDataMutation, + refetchQueries: refetchTableUploadQueries(tableParams), + }); + + useEffect(() => { + if (err) { + setState({ error: err }); + } + }, [err, setState]); + + const onUpload = async () => { + if (!fuState.selectedFile) return; + + try { + setState({ loading: true }); + const { errors } = await loadData({ + variables: { + ...tableParams, + file: fuState.selectedFile, + importOp: fuState.importOp, + fileType: fuState.fileType, + modifier: fuState.modifier, + }, + }); + if (errors?.length) return; + const { href, as } = table(tableParams); + router.push(href, as).catch(console.error); + } catch (e) { + setErr(e as ApolloError); + } finally { + setState({ loading: false }); + } + }; + + const removeSpreadsheet = () => { + setForageState({ + spreadsheetRows: undefined, + selectedFile: undefined, + colNames: "", + }); + setState({ error: undefined }); + }; + + return ( + + {props.children} + + ); +} + +export default function useUploadContext() { + return useContextWithError(UploadContext); +} diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/index.module.css b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/index.module.css new file mode 100644 index 00000000..73de4cec --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/index.module.css @@ -0,0 +1,86 @@ +.container { + @apply max-w-2xl mx-auto mt-8 mb-2; +} + +.dropContainer, +.editableTable { + @apply px-3 rounded-xl border border-dashed border-ld-darkgrey bg-ld-lightpurple h-56 pt-8; + min-width: 250px; +} + +.editableTable { + @apply mt-8 mb-8 px-6 pt-10 text-lg text-center; + + h4 { + @apply pt-1; + } +} + +.hover { + @apply border-acc-hoverlinkblue; + background: rgba(61, 145, 240, 0.1); +} + +.dropped { + @apply bg-acc-hovergreen bg-opacity-20 border-acc-hoverblue; +} + +.faded { + @apply opacity-30; +} + +.message { + @apply text-center font-semibold; +} + +.uploadText { + @apply text-lg; +} + +.uploadIcon { + @apply text-5xl mb-6 mx-auto; +} + +.uploadBottom { + @apply flex justify-center; +} + +.browseButton { + @apply text-lg; +} + +.uploadContainer { + @apply flex justify-between; +} + +.or { + @apply mx-8 mt-28 pt-4; +} + +.tableIcon { + @apply text-4xl mb-6 mx-auto; +} + +.openButton { + @apply mt-6 px-4; +} + +.helpPopup { + @apply mx-2 opacity-60; +} + +.fileTypes { + @apply text-sm flex text-ld-darkgrey mt-1 mx-3; +} + +.success { + @apply text-acc-green font-semibold text-base flex mt-6 ml-7; + + svg { + @apply text-lg mr-2 mt-1; + } +} + +.editBtn { + @apply mx-4; +} diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/index.tsx b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/index.tsx new file mode 100644 index 00000000..68be4990 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/index.tsx @@ -0,0 +1,60 @@ +import ErrorMsg from "@components/ErrorMsg"; +import Loader from "@components/Loader"; +import LoadDataInfo from "../../LoadDataInfo"; +import StepLayout from "../../StepLayout"; +import Summary from "../../Summary"; +import { useFileUploadContext } from "../../contexts/fileUploadLocalForage"; +import { UploadStage } from "../../enums"; +import DropZone from "./DropZone"; +import EditableTable from "./EditableTable"; +import OpenSpreadsheetZone from "./OpenSpreadsheetZone"; +import useUploadContext, { UploadProvider } from "./contexts/upload"; +import css from "./index.module.css"; + +export default function Upload() { + const { initialLoad, error } = useFileUploadContext(); + + if (initialLoad) return ; + if (error) return ; + + return ( + + + + ); +} + +function Inner() { + const { state, updateLoad, error, getUploadUrl } = useFileUploadContext(); + const { onUpload, state: uState } = useUploadContext(); + + return ( + +
    + + +
    + +
    or
    + +
    + + + +
    +
    + ); +} diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/useDropZone.ts b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/useDropZone.ts new file mode 100644 index 00000000..85eb45db --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/useDropZone.ts @@ -0,0 +1,125 @@ +import { FileType } from "@gen/graphql-types"; +import { handleCaughtError } from "@lib/errors/helpers"; +import { ChangeEvent, DragEvent, useEffect, useState } from "react"; +import { useFileUploadContext } from "../../contexts/fileUploadLocalForage"; +import useUploadContext from "./contexts/upload"; + +// set limit to prevent doing work on files that are +// too large for the server anyway +const oneMB = 1024 * 1024; +const numMB = 400; +const maxFileSize = numMB * oneMB; // ~ 150MB + +export const validTypes = ["csv", "psv"]; + +type ReturnType = { + dragOver: (e: DragEvent) => void; + dragLeave: (e: DragEvent) => void; + onDropFile: (e: DragEvent) => Promise; + onChooseFile: (e: ChangeEvent) => Promise; + err: string; + hover: boolean; +}; + +export function useDropZone(): ReturnType { + const { setState } = useUploadContext(); + const { state, setState: setFucState } = useFileUploadContext(); + const [err, setErr] = useState(""); + const [hover, setHover] = useState(false); + + useEffect(() => { + if (err) { + setState({ loading: false }); + } + }, [err, setState]); + + useEffect(() => { + if (!state.selectedFile) { + setErr(""); + } + }, [state.selectedFile, setErr]); + + const dragOver = (e: DragEvent) => { + e.preventDefault(); + setHover(true); + }; + + const dragLeave = (e: DragEvent) => { + e.preventDefault(); + setHover(false); + }; + + const fileDrop = (files: FileList | null) => { + setErr(""); + setState({ error: undefined, loading: true }); + + if (!files?.length) { + setErr("no files uploaded"); + return; + } + if (files.length > 1) { + setErr("cannot upload more than one file"); + return; + } + const file = files[0]; + if (file.size > maxFileSize) { + setErr(`file too large, must be < ${numMB}MB`); + return; + } + const extension = getExtension(file); + if (extension && validateFile(extension)) { + try { + const fileType = toFileType(extension); + setFucState({ + selectedFile: file, + fileType, + }); + } catch (e) { + handleCaughtError(e, er => setState({ error: er })); + } finally { + setState({ loading: false }); + } + } else { + setErr("file type not permitted"); + } + }; + + const onDropFile = async (e: DragEvent) => { + e.preventDefault(); + setHover(false); + fileDrop(e.dataTransfer.files); + }; + + const onChooseFile = async (e: ChangeEvent) => { + e.preventDefault(); + fileDrop(e.target.files); + }; + + return { + dragOver, + dragLeave, + onDropFile, + onChooseFile, + err, + hover, + }; +} + +function getExtension(file: File): string | undefined { + return file.name.split(".").pop(); +} + +function validateFile(extension: string): boolean { + return validTypes.indexOf(extension) !== -1; +} + +function toFileType(extension: string): FileType { + switch (extension) { + case "csv": + return FileType.Csv; + case "psv": + return FileType.Psv; + default: + throw new Error("invalid file type"); + } +} diff --git a/packages/web/components/pageComponents/FileUploadPage/Summary.tsx b/packages/web/components/pageComponents/FileUploadPage/Summary.tsx new file mode 100644 index 00000000..895f0256 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Summary.tsx @@ -0,0 +1,31 @@ +import Link from "@components/links/Link"; +import { ImportOperation } from "@gen/graphql-types"; +import { useFileUploadContext } from "./contexts/fileUploadLocalForage"; +import { FileUploadState } from "./contexts/fileUploadLocalForage/state"; + +export default function Summary() { + const { state, getUploadUrl } = useFileUploadContext(); + return ( +

    + {getOpVerb(state.importOp)} table{" "} + {state.tableName} + {getUploadMethod(state)} +

    + ); +} + +// Add -ing to import op verb +export function getOpVerb(op: ImportOperation): string { + const endsWithE = op.endsWith("e"); + return endsWithE ? `${op.slice(0, -1)}ing` : `${op}ing`; +} + +export function getUploadMethod(state: FileUploadState): string { + if (state.spreadsheetRows) { + return " using new spreadsheet"; + } + if (state.selectedFile) { + return " using uploaded file"; + } + return ""; +} diff --git a/packages/web/components/pageComponents/FileUploadPage/contexts/fileUploadLocalForage/index.tsx b/packages/web/components/pageComponents/FileUploadPage/contexts/fileUploadLocalForage/index.tsx new file mode 100644 index 00000000..3b1f6da8 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/contexts/fileUploadLocalForage/index.tsx @@ -0,0 +1,122 @@ +import { ImportOperation } from "@gen/graphql-types"; +import useContextWithError from "@hooks/useContextWithError"; +import useEffectAsync from "@hooks/useEffectAsync"; +import useEffectOnMount from "@hooks/useEffectOnMount"; +import useSetState from "@hooks/useSetState"; +import { createCustomContext } from "@lib/createCustomContext"; +import { handleCaughtError } from "@lib/errors/helpers"; +import { DatabaseParams, UploadParams } from "@lib/params"; +import { Route } from "@lib/urlUtils"; +import { uploadStage } from "@lib/urls"; +import localForage from "localforage"; +import { extendPrototype } from "localforage-getitems"; +import { ReactNode, useState } from "react"; +import { + FileUploadLocalForageContextType, + FileUploadState, + getDefaultState, +} from "./state"; + +// Allows usage of `store.getItems()` +extendPrototype(localForage); + +export const FileUploadLocalForageContext = + createCustomContext( + "FileUploadLocalForageContext", + ); + +type Props = { + params: UploadParams & { + tableName?: string; + branchName?: string; + }; + children: ReactNode; +}; + +export function FileUploadLocalForageProvider(props: Props) { + const dbParams: DatabaseParams = { + databaseName: props.params.databaseName, + }; + const name = `upload-${props.params.databaseName}-${props.params.uploadId}`; + const store = localForage.createInstance({ + name, + }); + const [initialLoad, setInitialLoad] = useState(true); + const [updateLoad, setUpdateLoad] = useState(false); + const [error, setError] = useState(); + const defaultState = getDefaultState(props.params); + const [state, _setState] = useSetState(defaultState); + + function setState(s: Partial) { + setUpdateLoad(true); + _setState(s); + Object.entries(s).forEach(([key, value]) => { + if (key in state) { + setItem(key as keyof FileUploadState, value); + } + }); + setUpdateLoad(false); + } + + function setItem(key: keyof FileUploadState, value: T) { + _setState({ [key]: value }); + store + .setItem(key, value) + .then(() => setUpdateLoad(false)) + .catch(e => { + handleCaughtError(e, setError); + setUpdateLoad(false); + }); + } + + // Set branchName and tableName if params provided + useEffectOnMount(() => { + if (props.params.tableName || props.params.branchName) { + setState({ + tableName: props.params.tableName ?? "", + branchName: props.params.branchName ?? "", + importOp: ImportOperation.Update, + }); + } + }); + + // Get local forage items on mount + useEffectAsync(async ({ subscribed }) => { + try { + const res = await store.getItems(); + if (subscribed) { + _setState({ ...defaultState, ...res }); + } + } catch (err) { + if (subscribed) handleCaughtError(err, setError); + } finally { + if (subscribed) setInitialLoad(false); + } + }, []); + + function getUploadUrl(stage: string): Route { + return uploadStage({ ...props.params, stage }); + } + + return ( + store.dropInstance({ name }), + state, + setItem, + dbParams, + getUploadUrl, + }} + > + {props.children} + + ); +} + +export function useFileUploadContext(): FileUploadLocalForageContextType { + return useContextWithError(FileUploadLocalForageContext); +} diff --git a/packages/web/components/pageComponents/FileUploadPage/contexts/fileUploadLocalForage/state.ts b/packages/web/components/pageComponents/FileUploadPage/contexts/fileUploadLocalForage/state.ts new file mode 100644 index 00000000..2278bb20 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/contexts/fileUploadLocalForage/state.ts @@ -0,0 +1,43 @@ +import { + FileType, + ImportOperation, + LoadDataModifier, +} from "@gen/graphql-types"; +import { ErrorType } from "@lib/errors/types"; +import { DatabaseParams } from "@lib/params"; +import { Route } from "@lib/urlUtils"; + +const defaultState = { + branchName: "", + tableName: "", + importOp: ImportOperation.Update, + selectedFile: undefined as File | undefined, // file upload only + spreadsheetRows: undefined as string[][] | undefined, // spreadsheet only + fileType: FileType.Csv, + modifier: undefined as LoadDataModifier | undefined, + colNames: "", +}; + +export type FileUploadState = typeof defaultState; + +export function getDefaultState(p: { + tableName?: string; + branchName?: string; +}): FileUploadState { + if (p.tableName && p.branchName) { + return { ...defaultState, ...p, importOp: ImportOperation.Update }; + } + return { ...defaultState, ...p }; +} + +export type FileUploadLocalForageContextType = { + setState: (s: Partial) => void; + setItem: (k: keyof FileUploadState, v: T) => void; + state: FileUploadState; + initialLoad: boolean; + updateLoad: boolean; + error: ErrorType; + clear: () => Promise; + dbParams: DatabaseParams; + getUploadUrl: (s: string) => Route; +}; diff --git a/packages/web/components/pageComponents/FileUploadPage/enums.ts b/packages/web/components/pageComponents/FileUploadPage/enums.ts new file mode 100644 index 00000000..13e56545 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/enums.ts @@ -0,0 +1,18 @@ +export enum UploadStage { + Branch = 1, + Table = 2, + Upload = 3, +} + +export function getUploadStage(s?: string): UploadStage { + switch (s) { + case "branch": + return UploadStage.Branch; + case "table": + return UploadStage.Table; + case "upload": + return UploadStage.Upload; + default: + return UploadStage.Branch; + } +} diff --git a/packages/web/components/pageComponents/FileUploadPage/index.module.css b/packages/web/components/pageComponents/FileUploadPage/index.module.css new file mode 100644 index 00000000..6113a9be --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/index.module.css @@ -0,0 +1,37 @@ +.noPerms { + @apply max-w-xl mx-auto; + + button { + @apply mt-6; + } +} + +.loadData { + @apply flex justify-center mt-5; + + p { + @apply mr-2; + } +} + +.loadHelp { + ul { + @apply mt-2 mx-4; + } + + li { + @apply list-disc; + } +} + +.modifierOptions { + @apply flex justify-center; +} + +.radioHelp { + @apply ml-3; + + &:first-of-type { + @apply mr-14; + } +} diff --git a/packages/web/components/pageComponents/FileUploadPage/index.tsx b/packages/web/components/pageComponents/FileUploadPage/index.tsx new file mode 100644 index 00000000..91e6c62f --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/index.tsx @@ -0,0 +1,59 @@ +import useOnRouteChange from "@hooks/useOnRouteChange"; +import { UploadParams } from "@lib/params"; +import { useFileUploadContext } from "./contexts/fileUploadLocalForage"; +import { getUploadStage, UploadStage } from "./enums"; +import Navigation from "./Navigation"; +import PageWrapper from "./PageWrapper"; +import Branch from "./Steps/Branch"; +import Table from "./Steps/Table"; +import Upload from "./Steps/Upload"; + +type InnerProps = { + stage?: string; +}; + +type Props = InnerProps & { + params: UploadParams & { + tableName?: string; + branchName?: string; + }; +}; + +function Inner(props: InnerProps) { + const activeStage = getUploadStage(props.stage); + const { clear } = useFileUploadContext(); + + useOnRouteChange(url => { + if (!url.includes("/upload")) { + clear().catch(console.error); + } + }); + + return ( +
    + + +
    + ); +} + +function Stage(props: { activeStage: UploadStage }) { + switch (props.activeStage) { + case UploadStage.Branch: + return ; + case UploadStage.Table: + return ; + case UploadStage.Upload: + return ; + default: + return ; + } +} + +export default function ForFileUpload(props: Props) { + return ( + + + + ); +} diff --git a/packages/web/gen/graphql-types.tsx b/packages/web/gen/graphql-types.tsx index 131e8003..20783a33 100644 --- a/packages/web/gen/graphql-types.tsx +++ b/packages/web/gen/graphql-types.tsx @@ -16,6 +16,7 @@ export type Scalars = { Int: { input: number; output: number; } Float: { input: number; output: number; } Timestamp: { input: any; output: any; } + Upload: { input: any; output: any; } }; export type Branch = { @@ -112,6 +113,11 @@ export type DoltWriter = { username?: Maybe; }; +export enum FileType { + Csv = 'Csv', + Psv = 'Psv' +} + export type ForeignKey = { __typename?: 'ForeignKey'; columnName: Scalars['String']['output']; @@ -126,6 +132,10 @@ export type ForeignKeyColumn = { referrerColumnIndex: Scalars['Float']['output']; }; +export enum ImportOperation { + Update = 'Update' +} + export type Index = { __typename?: 'Index'; columns: Array; @@ -140,6 +150,11 @@ export type IndexColumn = { sqlType?: Maybe; }; +export enum LoadDataModifier { + Ignore = 'Ignore', + Replace = 'Replace' +} + export type Mutation = { __typename?: 'Mutation'; addDatabaseConnection: Scalars['String']['output']; @@ -148,6 +163,7 @@ export type Mutation = { createTag: Tag; deleteBranch: Scalars['Boolean']['output']; deleteTag: Scalars['Boolean']['output']; + loadDataFile: Scalars['Boolean']['output']; }; @@ -188,6 +204,17 @@ export type MutationDeleteTagArgs = { tagName: Scalars['String']['input']; }; + +export type MutationLoadDataFileArgs = { + databaseName: Scalars['String']['input']; + file: Scalars['Upload']['input']; + fileType: FileType; + importOp: ImportOperation; + modifier?: InputMaybe; + refName: Scalars['String']['input']; + tableName: Scalars['String']['input']; +}; + export type Query = { __typename?: 'Query'; branch?: Maybe; @@ -616,6 +643,19 @@ export type DeleteTagMutationVariables = Exact<{ export type DeleteTagMutation = { __typename?: 'Mutation', deleteTag: boolean }; +export type LoadDataMutationVariables = Exact<{ + databaseName: Scalars['String']['input']; + refName: Scalars['String']['input']; + tableName: Scalars['String']['input']; + importOp: ImportOperation; + fileType: FileType; + file: Scalars['Upload']['input']; + modifier?: InputMaybe; +}>; + + +export type LoadDataMutation = { __typename?: 'Mutation', loadDataFile: boolean }; + export type AddDatabaseConnectionMutationVariables = Exact<{ url?: InputMaybe; useEnv?: InputMaybe; @@ -1775,6 +1815,51 @@ export function useDeleteTagMutation(baseOptions?: Apollo.MutationHookOptions; export type DeleteTagMutationResult = Apollo.MutationResult; export type DeleteTagMutationOptions = Apollo.BaseMutationOptions; +export const LoadDataDocument = gql` + mutation LoadData($databaseName: String!, $refName: String!, $tableName: String!, $importOp: ImportOperation!, $fileType: FileType!, $file: Upload!, $modifier: LoadDataModifier) { + loadDataFile( + databaseName: $databaseName + refName: $refName + tableName: $tableName + importOp: $importOp + fileType: $fileType + file: $file + modifier: $modifier + ) +} + `; +export type LoadDataMutationFn = Apollo.MutationFunction; + +/** + * __useLoadDataMutation__ + * + * To run a mutation, you first call `useLoadDataMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useLoadDataMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [loadDataMutation, { data, loading, error }] = useLoadDataMutation({ + * variables: { + * databaseName: // value for 'databaseName' + * refName: // value for 'refName' + * tableName: // value for 'tableName' + * importOp: // value for 'importOp' + * fileType: // value for 'fileType' + * file: // value for 'file' + * modifier: // value for 'modifier' + * }, + * }); + */ +export function useLoadDataMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(LoadDataDocument, options); + } +export type LoadDataMutationHookResult = ReturnType; +export type LoadDataMutationResult = Apollo.MutationResult; +export type LoadDataMutationOptions = Apollo.BaseMutationOptions; export const AddDatabaseConnectionDocument = gql` mutation AddDatabaseConnection($url: String, $useEnv: Boolean) { addDatabaseConnection(url: $url, useEnv: $useEnv) diff --git a/packages/web/hooks/useEffectAsync.ts b/packages/web/hooks/useEffectAsync.ts new file mode 100644 index 00000000..61e1263d --- /dev/null +++ b/packages/web/hooks/useEffectAsync.ts @@ -0,0 +1,17 @@ +import { DependencyList, useEffect } from "react"; + +// Prevents useEffect from attempting to update state of unmounted component +// Reference: https://dmitripavlutin.com/react-cleanup-async-effects/ +export default function useEffectAsync( + fn: (s: { subscribed: boolean }) => Promise, + deps: DependencyList = [], +) { + useEffect(() => { + const data = { subscribed: true }; + fn(data).catch(console.error); + return () => { + data.subscribed = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); +} diff --git a/packages/web/hooks/useOnRouteChange.ts b/packages/web/hooks/useOnRouteChange.ts new file mode 100644 index 00000000..548ac60a --- /dev/null +++ b/packages/web/hooks/useOnRouteChange.ts @@ -0,0 +1,17 @@ +import { useRouter } from "next/router"; +import useEffectOnMount from "./useEffectOnMount"; + +// Do something on route change +export default function useOnRouteChange(onRouteChange: (url: string) => void) { + const router = useRouter(); + + useEffectOnMount(() => { + const handleRouteChange = (url: string) => { + onRouteChange(url); + }; + router.events.on("routeChangeStart", handleRouteChange); + return () => { + router.events.off("routeChangeStart", handleRouteChange); + }; + }); +} diff --git a/packages/web/hooks/useRole.ts b/packages/web/hooks/useRole.ts index 4a0dc91e..b65f3281 100644 --- a/packages/web/hooks/useRole.ts +++ b/packages/web/hooks/useRole.ts @@ -1,5 +1,6 @@ type ReturnType = { // depRole: DeploymentRole | undefined; + loading: boolean; userIsAdmin: boolean; userHasReadPerms: boolean; userHasWritePerms: boolean; @@ -54,6 +55,7 @@ export default function useRole(): ReturnType { return { // ...res, // depRole, + loading: false, userIsAdmin, userHasWritePerms, userHasReadPerms, diff --git a/packages/web/lib/errors/helpers.ts b/packages/web/lib/errors/helpers.ts index a0e3ec37..0e4150c8 100644 --- a/packages/web/lib/errors/helpers.ts +++ b/packages/web/lib/errors/helpers.ts @@ -1,5 +1,5 @@ import { ApolloError } from "@apollo/client"; -import { ApolloErrorType } from "./types"; +import { ApolloErrorType, ErrorType } from "./types"; export function errorMatches( errString: string, @@ -36,6 +36,14 @@ export function improveErrorMsg(message: string): string { } } +export function handleCaughtError(err: unknown, cb: (e: ErrorType) => void) { + if (err instanceof Error) { + cb(err); + } else { + cb(new Error(String(err))); + } +} + export function getCaughtApolloError(err: unknown): ApolloErrorType { if (err instanceof ApolloError) { return err; diff --git a/packages/web/lib/params.ts b/packages/web/lib/params.ts index 72b888ed..5449a88a 100644 --- a/packages/web/lib/params.ts +++ b/packages/web/lib/params.ts @@ -50,3 +50,7 @@ export type CommitsParams = { export type RequiredCommitsParams = DatabaseParams & Required; export type DiffParams = RefParams & CommitsParams; + +export type UploadParams = DatabaseParams & { + uploadId: string; +}; diff --git a/packages/web/lib/refetchQueries.ts b/packages/web/lib/refetchQueries.ts index 5038eba0..7a3a5c5c 100644 --- a/packages/web/lib/refetchQueries.ts +++ b/packages/web/lib/refetchQueries.ts @@ -6,7 +6,7 @@ import { RefetchQueriesOptions, } from "@apollo/client"; import * as gen from "@gen/graphql-types"; -import { DatabaseParams } from "./params"; +import { DatabaseParams, RefParams, TableParams } from "./params"; export type RefetchQueries = Array; @@ -38,6 +38,47 @@ export const refetchBranchQueries = ( { query: gen.BranchListDocument, variables }, ]; +export const refetchResetChangesQueries = ( + variables: RefParams, +): RefetchQueries => + // const diffVariables: RequiredCommitsParams = { + // ...variables, + // fromCommitId: "HEAD", + // toCommitId: "WORKING", + // }; + [ + { query: gen.GetStatusDocument, variables }, + // { + // query: gen.DiffStatDocument, + // variables: { + // ...variables, + // fromRefName: diffVariables.fromCommitId, + // toRefName: diffVariables.toCommitId, + // }, + // }, + // { + // query: gen.DiffSummariesDocument, + // variables: diffVariables, + // }, + { + query: gen.TableNamesDocument, + variables: { ...variables, filterSystemTables: true }, + }, + ]; + +export const refetchTableQueries = (variables: TableParams) => [ + { query: gen.DataTableQueryDocument, variables }, + { + query: gen.RowsForDataTableQueryDocument, + variables, + }, +]; + +export const refetchTableUploadQueries = (variables: TableParams) => [ + ...refetchResetChangesQueries(variables), + ...refetchTableQueries(variables), +]; + export const refetchSqlUpdateQueriesCacheEvict: RefetchOptions = { updateCache(cache: TCacheShape) { [ diff --git a/packages/web/lib/urls.ts b/packages/web/lib/urls.ts index 557c1c66..8f92b95c 100644 --- a/packages/web/lib/urls.ts +++ b/packages/web/lib/urls.ts @@ -81,3 +81,27 @@ export const releases = (p: ps.OptionalRefParams): Route => export const newRelease = (p: ps.OptionalRefParams): Route => releases(p).addStatic("new").withQuery({ refName: p.refName }); + +export const upload = (p: ps.DatabaseParams): Route => + database(p).addStatic("upload"); + +export const uploadStage = ( + p: ps.UploadParams & { + refName?: string; + tableName?: string; + spreadsheet?: boolean; + stage: string; + }, +): Route => { + const q = p.refName + ? { + branchName: p.refName, + tableName: p.tableName, + spreadsheet: p.spreadsheet ? "true" : undefined, + } + : {}; + return upload(p) + .addDynamic("uploadId", p.uploadId) + .addDynamic("stage", p.stage) + .withQuery(q); +}; diff --git a/packages/web/package.json b/packages/web/package.json index 773a49e0..b9bef436 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -28,6 +28,8 @@ "github-markdown-css": "^5.3.0", "graphql": "^16.8.1", "isomorphic-unfetch": "^4.0.2", + "localforage": "^1.10.0", + "localforage-getitems": "^1.4.2", "lodash": "^4.17.21", "next": "latest", "next-useragent": "^2.8.0", @@ -35,6 +37,7 @@ "react": "latest", "react-ace": "^10.1.0", "react-copy-to-clipboard": "^5.1.0", + "react-data-grid": "^7.0.0-beta.31", "react-dom": "latest", "react-flow-renderer": "^10.3.17", "react-hotkeys": "^2.0.0", diff --git a/packages/web/pages/database/[databaseName]/upload/[uploadId]/[stage].tsx b/packages/web/pages/database/[databaseName]/upload/[uploadId]/[stage].tsx new file mode 100644 index 00000000..9b2f5fdb --- /dev/null +++ b/packages/web/pages/database/[databaseName]/upload/[uploadId]/[stage].tsx @@ -0,0 +1,47 @@ +import Page from "@components/util/Page"; +import { UploadParams } from "@lib/params"; +import FileUploadPage from "@pageComponents/FileUploadPage"; +import { GetServerSideProps, NextPage } from "next"; + +type Props = { + params: UploadParams & { + branchName?: string | null; + tableName?: string | null; + }; + stage?: string | null; +}; + +const DatabaseUploadStagePage: NextPage = ({ params, stage }) => ( + + + +); + +export const getServerSideProps: GetServerSideProps = async ({ + params, + query, +}) => { + return { + props: { + params: { + ...(params as UploadParams), + branchName: query.branchName ? String(query.branchName) : null, + tableName: query.tableName ? String(query.tableName) : null, + id: query.id ? String(query.id) : null, + }, + stage: params?.stage ? String(params.stage) : null, + }, + }; +}; + +export default DatabaseUploadStagePage; diff --git a/packages/web/pages/database/[databaseName]/upload/[uploadId]/index.tsx b/packages/web/pages/database/[databaseName]/upload/[uploadId]/index.tsx new file mode 100644 index 00000000..34fb2543 --- /dev/null +++ b/packages/web/pages/database/[databaseName]/upload/[uploadId]/index.tsx @@ -0,0 +1,40 @@ +import Page from "@components/util/Page"; +import { UploadParams } from "@lib/params"; +import FileUploadPage from "@pageComponents/FileUploadPage"; +import { GetServerSideProps, NextPage } from "next"; + +type Props = { + params: UploadParams & { + branchName?: string | null; + tableName?: string | null; + }; +}; + +const DatabaseUploadPage: NextPage = ({ params }) => ( + + + +); + +export const getServerSideProps: GetServerSideProps = async ({ + params, + query, +}) => { + return { + props: { + params: { + ...(params as UploadParams), + branchName: query.branchName ? String(query.branchName) : null, + tableName: query.tableName ? String(query.tableName) : null, + }, + }, + }; +}; + +export default DatabaseUploadPage; diff --git a/packages/web/pages/database/[databaseName]/upload/index.tsx b/packages/web/pages/database/[databaseName]/upload/index.tsx new file mode 100644 index 00000000..6491dfb1 --- /dev/null +++ b/packages/web/pages/database/[databaseName]/upload/index.tsx @@ -0,0 +1,41 @@ +import Page from "@components/util/Page"; +import { DatabaseParams } from "@lib/params"; +import FileUploadPage from "@pageComponents/FileUploadPage"; +import { GetServerSideProps, NextPage } from "next"; + +type Props = { + params: DatabaseParams & { + tableName?: string | null; + branchName?: string | null; + }; +}; + +const DatabaseUploadPage: NextPage = ({ params }) => ( + + + +); + +export const getServerSideProps: GetServerSideProps = async ({ + params, + query, +}) => { + return { + props: { + params: { + ...(params as DatabaseParams), + branchName: query.branchName ? String(query.branchName) : null, + tableName: query.tableName ? String(query.tableName) : null, + }, + }, + }; +}; + +export default DatabaseUploadPage; diff --git a/yarn.lock b/yarn.lock index d1b19783..9fc481c5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1584,6 +1584,7 @@ __metadata: cors: ^2.8.5 eslint: ^8.31.0 graphql: ^16.7.1 + graphql-upload: 13 jest: ^29.5.0 mysql2: ^3.1.0 prettier: ^2.8.3 @@ -1637,6 +1638,8 @@ __metadata: isomorphic-unfetch: ^4.0.2 jest: ^29.7.0 jest-environment-jsdom: ^29.7.0 + localforage: ^1.10.0 + localforage-getitems: ^1.4.2 lodash: ^4.17.21 next: latest next-useragent: ^2.8.0 @@ -1646,6 +1649,7 @@ __metadata: react: latest react-ace: ^10.1.0 react-copy-to-clipboard: ^5.1.0 + react-data-grid: ^7.0.0-beta.31 react-dom: latest react-flow-renderer: ^10.3.17 react-hotkeys: ^2.0.0 @@ -5827,6 +5831,15 @@ __metadata: languageName: node linkType: hard +"busboy@npm:^0.3.1": + version: 0.3.1 + resolution: "busboy@npm:0.3.1" + dependencies: + dicer: 0.3.0 + checksum: d2bcb788c4595edca4ea2168ab8bf7f9558b627ddcec2fb6bbaf0aa6a10b63da48dce35ce56936570f330c5268a3204f7037021a310a895a8b1a223568e0cc1b + languageName: node + linkType: hard + "bytes@npm:3.1.2": version: 3.1.2 resolution: "bytes@npm:3.1.2" @@ -6264,6 +6277,13 @@ __metadata: languageName: node linkType: hard +"clsx@npm:^1.1.1": + version: 1.2.1 + resolution: "clsx@npm:1.2.1" + checksum: 30befca8019b2eb7dbad38cff6266cf543091dae2825c856a62a8ccf2c3ab9c2907c4d12b288b73101196767f66812365400a227581484a05f968b0307cfaf12 + languageName: node + linkType: hard + "co@npm:^4.6.0": version: 4.6.0 resolution: "co@npm:4.6.0" @@ -7165,6 +7185,13 @@ __metadata: languageName: node linkType: hard +"depd@npm:~1.1.2": + version: 1.1.2 + resolution: "depd@npm:1.1.2" + checksum: 6b406620d269619852885ce15965272b829df6f409724415e0002c8632ab6a8c0a08ec1f0bd2add05dc7bd7507606f7e2cc034fa24224ab829580040b835ecd9 + languageName: node + linkType: hard + "dependency-graph@npm:^0.11.0": version: 0.11.0 resolution: "dependency-graph@npm:0.11.0" @@ -7210,6 +7237,15 @@ __metadata: languageName: node linkType: hard +"dicer@npm:0.3.0": + version: 0.3.0 + resolution: "dicer@npm:0.3.0" + dependencies: + streamsearch: 0.1.2 + checksum: 9f61aea61fcd81457f1b43967af7e66415b7a31d393336fa05a29b221b5ba065b99e5cac46476b2da36eb7af7665bf8dad6f9500409116dc6a35ada183841598 + languageName: node + linkType: hard + "didyoumean@npm:^1.2.2": version: 1.2.2 resolution: "didyoumean@npm:1.2.2" @@ -8584,6 +8620,13 @@ __metadata: languageName: node linkType: hard +"fs-capacitor@npm:^6.2.0": + version: 6.2.0 + resolution: "fs-capacitor@npm:6.2.0" + checksum: acdbeb92eedb1d7094f623e7be518c4d73075cb45ea97cc80ae23fc31adb066853723be824caf3e3c8f40944548c8cdc9bf0f0735eabb9413e734fe045b303b1 + languageName: node + linkType: hard + "fs-extra@npm:^10.0.0": version: 10.1.0 resolution: "fs-extra@npm:10.1.0" @@ -9042,6 +9085,20 @@ __metadata: languageName: node linkType: hard +"graphql-upload@npm:13": + version: 13.0.0 + resolution: "graphql-upload@npm:13.0.0" + dependencies: + busboy: ^0.3.1 + fs-capacitor: ^6.2.0 + http-errors: ^1.8.1 + object-path: ^0.11.8 + peerDependencies: + graphql: 0.13.1 - 16 + checksum: 3b4b7dd717d5cfcd1d59f3656bcc7281dbe1aaa0d64b5404eb7333db913976154349dd65cf9774a9efc3a6478b1578335c83dd0696c041e3f63efff589d7d457 + languageName: node + linkType: hard + "graphql-ws@npm:5.14.0": version: 5.14.0 resolution: "graphql-ws@npm:5.14.0" @@ -9274,6 +9331,19 @@ __metadata: languageName: node linkType: hard +"http-errors@npm:^1.8.1": + version: 1.8.1 + resolution: "http-errors@npm:1.8.1" + dependencies: + depd: ~1.1.2 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: ">= 1.5.0 < 2" + toidentifier: 1.0.1 + checksum: d3c7e7e776fd51c0a812baff570bdf06fe49a5dc448b700ab6171b1250e4cf7db8b8f4c0b133e4bfe2451022a5790c1ca6c2cae4094dedd6ac8304a1267f91d2 + languageName: node + linkType: hard + "http-proxy-agent@npm:^5.0.0": version: 5.0.0 resolution: "http-proxy-agent@npm:5.0.0" @@ -9370,6 +9440,13 @@ __metadata: languageName: node linkType: hard +"immediate@npm:~3.0.5": + version: 3.0.6 + resolution: "immediate@npm:3.0.6" + checksum: f9b3486477555997657f70318cc8d3416159f208bec4cca3ff3442fd266bc23f50f0c9bd8547e1371a6b5e82b821ec9a7044a4f7b944798b25aa3cc6d5e63e62 + languageName: node + linkType: hard + "immutable@npm:~3.7.6": version: 3.7.6 resolution: "immutable@npm:3.7.6" @@ -10866,6 +10943,15 @@ __metadata: languageName: node linkType: hard +"lie@npm:3.1.1": + version: 3.1.1 + resolution: "lie@npm:3.1.1" + dependencies: + immediate: ~3.0.5 + checksum: 6da9f2121d2dbd15f1eca44c0c7e211e66a99c7b326ec8312645f3648935bc3a658cf0e9fa7b5f10144d9e2641500b4f55bd32754607c3de945b5f443e50ddd1 + languageName: node + linkType: hard + "lilconfig@npm:^2.0.5, lilconfig@npm:^2.1.0": version: 2.1.0 resolution: "lilconfig@npm:2.1.0" @@ -10920,6 +11006,24 @@ __metadata: languageName: node linkType: hard +"localforage-getitems@npm:^1.4.2": + version: 1.4.2 + resolution: "localforage-getitems@npm:1.4.2" + dependencies: + localforage: ">=1.4.0" + checksum: 1c5588f7556a4a046e2c7c812246b98a60717a6c3efa021121ff5a3f4ddf78b46deead7c0570b499ed31c3aed943c66167313ed2a79c78d9c48f4212a8135d3d + languageName: node + linkType: hard + +"localforage@npm:>=1.4.0, localforage@npm:^1.10.0": + version: 1.10.0 + resolution: "localforage@npm:1.10.0" + dependencies: + lie: 3.1.1 + checksum: f2978b434dafff9bcb0d9498de57d97eba165402419939c944412e179cab1854782830b5ec196212560b22712d1dd03918939f59cf1d4fc1d756fca7950086cf + languageName: node + linkType: hard + "locate-path@npm:^5.0.0": version: 5.0.0 resolution: "locate-path@npm:5.0.0" @@ -12112,6 +12216,13 @@ __metadata: languageName: node linkType: hard +"object-path@npm:^0.11.8": + version: 0.11.8 + resolution: "object-path@npm:0.11.8" + checksum: 684ccf0fb6b82f067dc81e2763481606692b8485bec03eb2a64e086a44dbea122b2b9ef44423a08e09041348fe4b4b67bd59985598f1652f67df95f0618f5968 + languageName: node + linkType: hard + "object.assign@npm:^4.1.2, object.assign@npm:^4.1.4": version: 4.1.4 resolution: "object.assign@npm:4.1.4" @@ -13628,6 +13739,18 @@ __metadata: languageName: node linkType: hard +"react-data-grid@npm:^7.0.0-beta.31": + version: 7.0.0-canary.49 + resolution: "react-data-grid@npm:7.0.0-canary.49" + dependencies: + clsx: ^1.1.1 + peerDependencies: + react: ^16.14 || ^17.0 + react-dom: ^16.14 || ^17.0 + checksum: fe57d441a5e56dac39a0f7e06979a27d5606515653ead31fc06f9c2fe08ebaf88ff4ab70f1565dd356b343cf4612137dea2d188a40a7e246b85dd5aa2589da04 + languageName: node + linkType: hard + "react-dom@npm:^18.2.0, react-dom@npm:latest": version: 18.2.0 resolution: "react-dom@npm:18.2.0" @@ -14751,6 +14874,13 @@ __metadata: languageName: node linkType: hard +"statuses@npm:>= 1.5.0 < 2": + version: 1.5.0 + resolution: "statuses@npm:1.5.0" + checksum: c469b9519de16a4bb19600205cffb39ee471a5f17b82589757ca7bd40a8d92ebb6ed9f98b5a540c5d302ccbc78f15dc03cc0280dd6e00df1335568a5d5758a5c + languageName: node + linkType: hard + "stop-iteration-iterator@npm:^1.0.0": version: 1.0.0 resolution: "stop-iteration-iterator@npm:1.0.0" @@ -14760,6 +14890,13 @@ __metadata: languageName: node linkType: hard +"streamsearch@npm:0.1.2": + version: 0.1.2 + resolution: "streamsearch@npm:0.1.2" + checksum: d2db57cbfbf7947ab9c75a7b4c80a8ef8d24850cf0a1a24258bb6956c97317ce1eab7dbcbf9c5aba3e6198611af1053b02411057bbedb99bf9c64b8275248997 + languageName: node + linkType: hard + "streamsearch@npm:^1.1.0": version: 1.1.0 resolution: "streamsearch@npm:1.1.0" From 6e7ac583aab66c76b89c1d8d48a1e08e5f916aa7 Mon Sep 17 00:00:00 2001 From: Taylor Bantle Date: Mon, 23 Oct 2023 15:32:58 -0700 Subject: [PATCH 03/10] web,graphql: File upload works --- .../src/dataSources/dataSource.service.ts | 8 +- .../src/databases/database.resolver.ts | 2 +- .../src/tables/upload.resolver.ts | 5 +- .../AddItemDropdown/index.tsx | 8 +- packages/web/components/DatabaseNav/Item.tsx | 2 +- .../components/StatusWithOptions/index.tsx | 1 + .../layouts/DatabaseLayout/Wrapper.tsx | 12 ++- .../FileUploadPage/Layout/index.tsx | 6 +- .../FileUploadPage/PageWrapper.tsx | 9 +- .../Steps/Upload/contexts/upload.tsx | 4 +- .../contexts/fileUploadLocalForage/index.tsx | 5 ++ .../contexts/fileUploadLocalForage/state.ts | 4 +- .../pageComponents/FileUploadPage/enums.ts | 4 +- .../pageComponents/FileUploadPage/index.tsx | 6 +- packages/web/lib/apollo.tsx | 6 +- packages/web/lib/refetchQueries.ts | 10 ++- packages/web/package.json | 2 + yarn.lock | 82 +++++++++++++++++-- 18 files changed, 137 insertions(+), 39 deletions(-) diff --git a/packages/graphql-server/src/dataSources/dataSource.service.ts b/packages/graphql-server/src/dataSources/dataSource.service.ts index 75b8f423..4a47baac 100644 --- a/packages/graphql-server/src/dataSources/dataSource.service.ts +++ b/packages/graphql-server/src/dataSources/dataSource.service.ts @@ -74,7 +74,7 @@ export class DataSourceService { return res; } - const isDolt = await getIsDolt(qr); + const isDolt = await getIsDolt(qr.query); if (dbName) { await qr.query(useDBStatement(dbName, refName, isDolt)); } @@ -133,9 +133,11 @@ export function useDBStatement( return `USE \`${dbName}\``; } -export async function getIsDolt(qr: QueryRunner): Promise { +export async function getIsDolt( + query: (q: string) => Promise, +): Promise { try { - const res = await qr.query("SELECT dolt_version()"); + const res = await query("SELECT dolt_version()"); return !!res; } catch (_) { return false; diff --git a/packages/graphql-server/src/databases/database.resolver.ts b/packages/graphql-server/src/databases/database.resolver.ts index 8bd13d3d..790c7f73 100644 --- a/packages/graphql-server/src/databases/database.resolver.ts +++ b/packages/graphql-server/src/databases/database.resolver.ts @@ -76,7 +76,7 @@ export class DatabaseResolver { const hideDoltFeatures = this.configService.get("HIDE_DOLT_FEATURES"); const qr = this.dss.getQR(); try { - const isDolt = await getIsDolt(qr); + const isDolt = await getIsDolt(qr.query); return { isDolt, hideDoltFeatures: !!hideDoltFeatures && hideDoltFeatures === "true", diff --git a/packages/graphql-server/src/tables/upload.resolver.ts b/packages/graphql-server/src/tables/upload.resolver.ts index df16c581..d6ecc2b9 100644 --- a/packages/graphql-server/src/tables/upload.resolver.ts +++ b/packages/graphql-server/src/tables/upload.resolver.ts @@ -4,6 +4,7 @@ import { GraphQLUpload } from "graphql-upload"; import * as mysql from "mysql2/promise"; import { DataSourceService, + getIsDolt, useDBStatement, } from "../dataSources/dataSource.service"; import { TableArgs } from "../utils/commonTypes"; @@ -41,8 +42,8 @@ export class FileUploadResolver { async loadDataFile(@Args() args: TableImportArgs): Promise { const conn = await mysql.createConnection(this.dss.getMySQLConfig()); - const isDolt = conn.query("SELECT dolt_version()"); - await conn.query(useDBStatement(args.databaseName, args.refName, !!isDolt)); + const isDolt = await getIsDolt(conn.query); + await conn.query(useDBStatement(args.databaseName, args.refName, isDolt)); await conn.query("SET GLOBAL local_infile=ON;"); const { createReadStream, filename } = await args.file; diff --git a/packages/web/components/DatabaseHeaderAndNav/AddItemDropdown/index.tsx b/packages/web/components/DatabaseHeaderAndNav/AddItemDropdown/index.tsx index 5a532a45..e609189b 100644 --- a/packages/web/components/DatabaseHeaderAndNav/AddItemDropdown/index.tsx +++ b/packages/web/components/DatabaseHeaderAndNav/AddItemDropdown/index.tsx @@ -2,8 +2,9 @@ import Popup from "@components/Popup"; import NotDoltWrapper from "@components/util/NotDoltWrapper"; import useRole from "@hooks/useRole"; import { DatabaseParams } from "@lib/params"; -import { newRelease } from "@lib/urls"; +import { newRelease, upload } from "@lib/urls"; import { AiOutlineTag } from "@react-icons/all-files/ai/AiOutlineTag"; +import { AiOutlineUpload } from "@react-icons/all-files/ai/AiOutlineUpload"; import { FaCaretDown } from "@react-icons/all-files/fa/FaCaretDown"; import { FaCaretUp } from "@react-icons/all-files/fa/FaCaretUp"; import { FiPlus } from "@react-icons/all-files/fi/FiPlus"; @@ -31,13 +32,14 @@ export default function AddItemDropdown(props: Props) { >
      - {/* } + hide={!userHasWritePerms} data-cy="add-dropdown-upload-a-file-link" > Upload a file - */} + {/* } diff --git a/packages/web/components/DatabaseNav/Item.tsx b/packages/web/components/DatabaseNav/Item.tsx index ecd9394c..72e5cfd9 100644 --- a/packages/web/components/DatabaseNav/Item.tsx +++ b/packages/web/components/DatabaseNav/Item.tsx @@ -18,7 +18,7 @@ export default function NavItem(props: Props) { const lower = props.name.toLowerCase(); if (props.hide) return null; if (props.doltDisabled) { - const tooltipId = `disabled-tab-${lower}`; + const tooltipId = `disabled-tab-${lower.replace(" ", "-")}`; return (
    • ; if (res.error || !res.data || res.data.status.length === 0) { diff --git a/packages/web/components/layouts/DatabaseLayout/Wrapper.tsx b/packages/web/components/layouts/DatabaseLayout/Wrapper.tsx index 3f86fd5d..da1ac6c1 100644 --- a/packages/web/components/layouts/DatabaseLayout/Wrapper.tsx +++ b/packages/web/components/layouts/DatabaseLayout/Wrapper.tsx @@ -28,11 +28,19 @@ function Inner(props: Props) { export default function DatabaseLayoutWrapper(props: Props) { const { keyMap, handlers } = useHotKeys(); + return ( + + + {props.children} + + ); +} + +export function DatabaseLayoutWrapperOuter(props: Props) { return (
      - -
      +
      {props.children}
      diff --git a/packages/web/components/pageComponents/FileUploadPage/Layout/index.tsx b/packages/web/components/pageComponents/FileUploadPage/Layout/index.tsx index 955b9efc..97bab58e 100644 --- a/packages/web/components/pageComponents/FileUploadPage/Layout/index.tsx +++ b/packages/web/components/pageComponents/FileUploadPage/Layout/index.tsx @@ -1,6 +1,6 @@ import Button from "@components/Button"; import DatabaseBreadcrumbs from "@components/breadcrumbs/DatabaseBreadcrumbs"; -import DatabaseLayoutWrapper from "@components/layouts/DatabaseLayout/Wrapper"; +import { DatabaseLayoutWrapperOuter } from "@components/layouts/DatabaseLayout/Wrapper"; import DatabaseLink from "@components/links/DatabaseLink"; import KeyNav from "@components/util/KeyNav"; import { DatabaseParams } from "@lib/params"; @@ -15,7 +15,7 @@ type Props = { export default function Layout(props: Props) { return ( - + {/* */}
      @@ -37,6 +37,6 @@ export default function Layout(props: Props) {
      {props.children}
      - + ); } diff --git a/packages/web/components/pageComponents/FileUploadPage/PageWrapper.tsx b/packages/web/components/pageComponents/FileUploadPage/PageWrapper.tsx index 6d01364a..aaa1f665 100644 --- a/packages/web/components/pageComponents/FileUploadPage/PageWrapper.tsx +++ b/packages/web/components/pageComponents/FileUploadPage/PageWrapper.tsx @@ -1,4 +1,5 @@ import { useDefaultBranchPageQuery } from "@gen/graphql-types"; +import useIsDolt from "@hooks/useIsDolt"; import useRole from "@hooks/useRole"; import { DatabaseParams, UploadParams } from "@lib/params"; import { ReactNode } from "react"; @@ -18,6 +19,7 @@ type Props = { }; export default function PageWrapper(props: Props) { + const doltRes = useIsDolt(); const { userHasWritePerms, loading } = useRole(); const res = useDefaultBranchPageQuery({ variables: { ...props.params, filterSystemTables: true }, @@ -26,7 +28,7 @@ export default function PageWrapper(props: Props) { databaseName: props.params.databaseName, }; - if (loading) return ; + if (loading || doltRes.loading) return ; if (!userHasWritePerms) return ; return ( @@ -34,7 +36,10 @@ export default function PageWrapper(props: Props) { ( - + {props.children} )} diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/contexts/upload.tsx b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/contexts/upload.tsx index abcbe5b9..e98cc8f6 100644 --- a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/contexts/upload.tsx +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/contexts/upload.tsx @@ -1,6 +1,7 @@ import { ApolloError } from "@apollo/client"; import { useLoadDataMutation } from "@gen/graphql-types"; import useContextWithError from "@hooks/useContextWithError"; +import useIsDolt from "@hooks/useIsDolt"; import useMutation from "@hooks/useMutation"; import useSetState from "@hooks/useSetState"; import { createCustomContext } from "@lib/createCustomContext"; @@ -35,6 +36,7 @@ type Props = { }; export function UploadProvider(props: Props) { + const { isDolt } = useIsDolt(); const router = useRouter(); const { state: fuState, @@ -59,7 +61,7 @@ export function UploadProvider(props: Props) { setErr, } = useMutation({ hook: useLoadDataMutation, - refetchQueries: refetchTableUploadQueries(tableParams), + refetchQueries: refetchTableUploadQueries(tableParams, isDolt), }); useEffect(() => { diff --git a/packages/web/components/pageComponents/FileUploadPage/contexts/fileUploadLocalForage/index.tsx b/packages/web/components/pageComponents/FileUploadPage/contexts/fileUploadLocalForage/index.tsx index 3b1f6da8..2d3765f2 100644 --- a/packages/web/components/pageComponents/FileUploadPage/contexts/fileUploadLocalForage/index.tsx +++ b/packages/web/components/pageComponents/FileUploadPage/contexts/fileUploadLocalForage/index.tsx @@ -31,6 +31,7 @@ type Props = { branchName?: string; }; children: ReactNode; + isDolt: boolean; }; export function FileUploadLocalForageProvider(props: Props) { @@ -78,6 +79,9 @@ export function FileUploadLocalForageProvider(props: Props) { importOp: ImportOperation.Update, }); } + if (!props.isDolt) { + setState({ branchName: "main" }); + } }); // Get local forage items on mount @@ -110,6 +114,7 @@ export function FileUploadLocalForageProvider(props: Props) { setItem, dbParams, getUploadUrl, + isDolt: props.isDolt, }} > {props.children} diff --git a/packages/web/components/pageComponents/FileUploadPage/contexts/fileUploadLocalForage/state.ts b/packages/web/components/pageComponents/FileUploadPage/contexts/fileUploadLocalForage/state.ts index 2278bb20..29eb837f 100644 --- a/packages/web/components/pageComponents/FileUploadPage/contexts/fileUploadLocalForage/state.ts +++ b/packages/web/components/pageComponents/FileUploadPage/contexts/fileUploadLocalForage/state.ts @@ -24,9 +24,6 @@ export function getDefaultState(p: { tableName?: string; branchName?: string; }): FileUploadState { - if (p.tableName && p.branchName) { - return { ...defaultState, ...p, importOp: ImportOperation.Update }; - } return { ...defaultState, ...p }; } @@ -40,4 +37,5 @@ export type FileUploadLocalForageContextType = { clear: () => Promise; dbParams: DatabaseParams; getUploadUrl: (s: string) => Route; + isDolt: boolean; }; diff --git a/packages/web/components/pageComponents/FileUploadPage/enums.ts b/packages/web/components/pageComponents/FileUploadPage/enums.ts index 13e56545..f6903700 100644 --- a/packages/web/components/pageComponents/FileUploadPage/enums.ts +++ b/packages/web/components/pageComponents/FileUploadPage/enums.ts @@ -4,7 +4,7 @@ export enum UploadStage { Upload = 3, } -export function getUploadStage(s?: string): UploadStage { +export function getUploadStage(s?: string, isDolt?: boolean): UploadStage { switch (s) { case "branch": return UploadStage.Branch; @@ -13,6 +13,6 @@ export function getUploadStage(s?: string): UploadStage { case "upload": return UploadStage.Upload; default: - return UploadStage.Branch; + return isDolt ? UploadStage.Branch : UploadStage.Table; } } diff --git a/packages/web/components/pageComponents/FileUploadPage/index.tsx b/packages/web/components/pageComponents/FileUploadPage/index.tsx index 91e6c62f..622bf3da 100644 --- a/packages/web/components/pageComponents/FileUploadPage/index.tsx +++ b/packages/web/components/pageComponents/FileUploadPage/index.tsx @@ -20,8 +20,8 @@ type Props = InnerProps & { }; function Inner(props: InnerProps) { - const activeStage = getUploadStage(props.stage); - const { clear } = useFileUploadContext(); + const { clear, isDolt } = useFileUploadContext(); + const activeStage = getUploadStage(props.stage, isDolt); useOnRouteChange(url => { if (!url.includes("/upload")) { @@ -50,7 +50,7 @@ function Stage(props: { activeStage: UploadStage }) { } } -export default function ForFileUpload(props: Props) { +export default function FileUploadPage(props: Props) { return ( diff --git a/packages/web/lib/apollo.tsx b/packages/web/lib/apollo.tsx index 979e179d..330ffaef 100644 --- a/packages/web/lib/apollo.tsx +++ b/packages/web/lib/apollo.tsx @@ -1,10 +1,10 @@ import { ApolloClient, ApolloProvider, - HttpLink, InMemoryCache, NormalizedCacheObject, } from "@apollo/client"; +import { createUploadLink } from "apollo-upload-client"; import { IncomingMessage } from "http"; import fetch from "isomorphic-unfetch"; import { NextPage, NextPageContext } from "next"; @@ -17,7 +17,7 @@ export function createApolloClient( req?: IncomingMessage, ): ApolloClient { const headers: Record = { - // "Apollo-Require-Preflight": "true", + "Apollo-Require-Preflight": "true", cookie: req?.headers.cookie ?? "", }; @@ -27,7 +27,7 @@ export function createApolloClient( return new ApolloClient({ cache, - link: new HttpLink({ + link: createUploadLink({ fetch, credentials: "include", uri, diff --git a/packages/web/lib/refetchQueries.ts b/packages/web/lib/refetchQueries.ts index 7a3a5c5c..229e6516 100644 --- a/packages/web/lib/refetchQueries.ts +++ b/packages/web/lib/refetchQueries.ts @@ -40,6 +40,7 @@ export const refetchBranchQueries = ( export const refetchResetChangesQueries = ( variables: RefParams, + isDolt = false, ): RefetchQueries => // const diffVariables: RequiredCommitsParams = { // ...variables, @@ -47,7 +48,7 @@ export const refetchResetChangesQueries = ( // toCommitId: "WORKING", // }; [ - { query: gen.GetStatusDocument, variables }, + ...(isDolt ? [{ query: gen.GetStatusDocument, variables }] : []), // { // query: gen.DiffStatDocument, // variables: { @@ -74,8 +75,11 @@ export const refetchTableQueries = (variables: TableParams) => [ }, ]; -export const refetchTableUploadQueries = (variables: TableParams) => [ - ...refetchResetChangesQueries(variables), +export const refetchTableUploadQueries = ( + variables: TableParams, + isDolt = false, +) => [ + ...refetchResetChangesQueries(variables, isDolt), ...refetchTableQueries(variables), ]; diff --git a/packages/web/package.json b/packages/web/package.json index b9bef436..239f0632 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -22,6 +22,7 @@ "@apollo/client": "^3.8.4", "@react-icons/all-files": "^4.1.0", "ace-builds": "^1.28.0", + "apollo-upload-client": "^17.0.0", "chance": "^1.1.11", "classnames": "^2.3.2", "commit-graph": "^1.6.0", @@ -59,6 +60,7 @@ "@testing-library/jest-dom": "^6.1.4", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.5.1", + "@types/apollo-upload-client": "^17.0.4", "@types/chance": "^1.1.5", "@types/jest": "^29.5.6", "@types/lodash": "^4.14.199", diff --git a/yarn.lock b/yarn.lock index 9fc481c5..e6f7e481 100644 --- a/yarn.lock +++ b/yarn.lock @@ -123,6 +123,42 @@ __metadata: languageName: node linkType: hard +"@apollo/client@npm:^3.7.0": + version: 3.8.6 + resolution: "@apollo/client@npm:3.8.6" + dependencies: + "@graphql-typed-document-node/core": ^3.1.1 + "@wry/context": ^0.7.3 + "@wry/equality": ^0.5.6 + "@wry/trie": ^0.4.3 + graphql-tag: ^2.12.6 + hoist-non-react-statics: ^3.3.2 + optimism: ^0.17.5 + prop-types: ^15.7.2 + response-iterator: ^0.2.6 + symbol-observable: ^4.0.0 + ts-invariant: ^0.10.3 + tslib: ^2.3.0 + zen-observable-ts: ^1.2.5 + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 + graphql-ws: ^5.5.5 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + subscriptions-transport-ws: ^0.9.0 || ^0.11.0 + peerDependenciesMeta: + graphql-ws: + optional: true + react: + optional: true + react-dom: + optional: true + subscriptions-transport-ws: + optional: true + checksum: 34a917d3456c1f728834eaaee00a98f82c7f60de8e0e0c62154667f7e95a635740a2d43fe43f51f85950eb2abe06a3d326f32393a48dd4afc6e4fb4357876dc1 + languageName: node + linkType: hard + "@apollo/client@npm:^3.8.4": version: 3.8.4 resolution: "@apollo/client@npm:3.8.4" @@ -1614,6 +1650,7 @@ __metadata: "@testing-library/jest-dom": ^6.1.4 "@testing-library/react": ^14.0.0 "@testing-library/user-event": ^14.5.1 + "@types/apollo-upload-client": ^17.0.4 "@types/chance": ^1.1.5 "@types/jest": ^29.5.6 "@types/lodash": ^4.14.199 @@ -1625,6 +1662,7 @@ __metadata: "@types/react-timeago": ^4.1.4 "@types/testing-library__jest-dom": ^6.0.0 ace-builds: ^1.28.0 + apollo-upload-client: ^17.0.0 autoprefixer: latest babel-jest: ^29.7.0 chance: ^1.1.11 @@ -3714,6 +3752,17 @@ __metadata: languageName: node linkType: hard +"@types/apollo-upload-client@npm:^17.0.4": + version: 17.0.4 + resolution: "@types/apollo-upload-client@npm:17.0.4" + dependencies: + "@apollo/client": ^3.7.0 + "@types/extract-files": "*" + graphql: 14 - 16 + checksum: b2673e039f7631ae1602cff68db486dff824aa7d7b23ee61a5c846b99daf71cf113fc1831bbba76a38b98638a1ccd431af5834b2e70c79de685e298a18c97534 + languageName: node + linkType: hard + "@types/aria-query@npm:^5.0.1": version: 5.0.1 resolution: "@types/aria-query@npm:5.0.1" @@ -4129,6 +4178,13 @@ __metadata: languageName: node linkType: hard +"@types/extract-files@npm:*": + version: 8.1.2 + resolution: "@types/extract-files@npm:8.1.2" + checksum: dcc84f6a7ec31937780d04815d97a12aeeedb39e2d12821974a7ec5e5bf422b78d12c64e041b994683f6cd2c02333ec47183d413f7f4554559d1cddf5155885a + languageName: node + linkType: hard + "@types/geojson@npm:*": version: 7946.0.11 resolution: "@types/geojson@npm:7946.0.11" @@ -5181,6 +5237,18 @@ __metadata: languageName: node linkType: hard +"apollo-upload-client@npm:^17.0.0": + version: 17.0.0 + resolution: "apollo-upload-client@npm:17.0.0" + dependencies: + extract-files: ^11.0.0 + peerDependencies: + "@apollo/client": ^3.0.0 + graphql: 14 - 16 + checksum: e5aee12ae36f7d268a8bcd7f0d8c1f7cbb94b4a19f266185a5afb52f63a41c4bb9d6bc4edbdc437a953e697c5ebcac1a44cb2c8e863b96f4323df0060b976be6 + languageName: node + linkType: hard + "app-root-path@npm:^3.1.0": version: 3.1.0 resolution: "app-root-path@npm:3.1.0" @@ -9117,6 +9185,13 @@ __metadata: languageName: node linkType: hard +"graphql@npm:14 - 16, graphql@npm:^16.8.1": + version: 16.8.1 + resolution: "graphql@npm:16.8.1" + checksum: 8d304b7b6f708c8c5cc164b06e92467dfe36aff6d4f2cf31dd19c4c2905a0e7b89edac4b7e225871131fd24e21460836b369de0c06532644d15b461d55b1ccc0 + languageName: node + linkType: hard + "graphql@npm:^16.7.1": version: 16.7.1 resolution: "graphql@npm:16.7.1" @@ -9124,13 +9199,6 @@ __metadata: languageName: node linkType: hard -"graphql@npm:^16.8.1": - version: 16.8.1 - resolution: "graphql@npm:16.8.1" - checksum: 8d304b7b6f708c8c5cc164b06e92467dfe36aff6d4f2cf31dd19c4c2905a0e7b89edac4b7e225871131fd24e21460836b369de0c06532644d15b461d55b1ccc0 - languageName: node - linkType: hard - "hard-rejection@npm:^2.1.0": version: 2.1.0 resolution: "hard-rejection@npm:2.1.0" From 23343e54417a2725ad5bd9d3aa57f1de4fa2623f Mon Sep 17 00:00:00 2001 From: Taylor Bantle Date: Mon, 23 Oct 2023 16:36:08 -0700 Subject: [PATCH 04/10] web: Fix apollo client version --- .../components/StatusWithOptions/index.tsx | 1 - packages/web/package.json | 2 +- yarn.lock | 38 +------------------ 3 files changed, 2 insertions(+), 39 deletions(-) diff --git a/packages/web/components/StatusWithOptions/index.tsx b/packages/web/components/StatusWithOptions/index.tsx index 1956180d..d359fe08 100644 --- a/packages/web/components/StatusWithOptions/index.tsx +++ b/packages/web/components/StatusWithOptions/index.tsx @@ -85,7 +85,6 @@ function Inner(props: InnerProps) { } export default function StatusWithOptions(props: Props) { - console.log("in status"); const res = useGetStatusQuery({ variables: props.params }); if (res.loading) return ; if (res.error || !res.data || res.data.status.length === 0) { diff --git a/packages/web/package.json b/packages/web/package.json index 239f0632..60909a33 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -19,7 +19,7 @@ "test": "jest --env=jest-environment-jsdom" }, "dependencies": { - "@apollo/client": "^3.8.4", + "@apollo/client": "^3.7.0", "@react-icons/all-files": "^4.1.0", "ace-builds": "^1.28.0", "apollo-upload-client": "^17.0.0", diff --git a/yarn.lock b/yarn.lock index e6f7e481..f95c8988 100644 --- a/yarn.lock +++ b/yarn.lock @@ -159,42 +159,6 @@ __metadata: languageName: node linkType: hard -"@apollo/client@npm:^3.8.4": - version: 3.8.4 - resolution: "@apollo/client@npm:3.8.4" - dependencies: - "@graphql-typed-document-node/core": ^3.1.1 - "@wry/context": ^0.7.3 - "@wry/equality": ^0.5.6 - "@wry/trie": ^0.4.3 - graphql-tag: ^2.12.6 - hoist-non-react-statics: ^3.3.2 - optimism: ^0.17.5 - prop-types: ^15.7.2 - response-iterator: ^0.2.6 - symbol-observable: ^4.0.0 - ts-invariant: ^0.10.3 - tslib: ^2.3.0 - zen-observable-ts: ^1.2.5 - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 - graphql-ws: ^5.5.5 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 - subscriptions-transport-ws: ^0.9.0 || ^0.11.0 - peerDependenciesMeta: - graphql-ws: - optional: true - react: - optional: true - react-dom: - optional: true - subscriptions-transport-ws: - optional: true - checksum: 509e37cdce7462cacda0a86c413ce471cd8f618625fb8ac3a60d6347d12f37a4fc60e12fc3fc1a375799caa21e56ff58d709e13ef5e13ab15e4dfc828a527848 - languageName: node - linkType: hard - "@apollo/protobufjs@npm:1.2.7": version: 1.2.7 resolution: "@apollo/protobufjs@npm:1.2.7" @@ -1641,7 +1605,7 @@ __metadata: version: 0.0.0-use.local resolution: "@dolt-sql-workbench/web@workspace:packages/web" dependencies: - "@apollo/client": ^3.8.4 + "@apollo/client": ^3.7.0 "@graphql-codegen/cli": ^5.0.0 "@graphql-codegen/typescript-operations": ^4.0.1 "@graphql-codegen/typescript-react-apollo": ^4.0.0 From fc150a9d550c1b34279b08af4753b1d1c6c028db Mon Sep 17 00:00:00 2001 From: Taylor Bantle Date: Mon, 23 Oct 2023 17:01:55 -0700 Subject: [PATCH 05/10] web: Try to fix tooltip errors --- .../components/CustomFormSelect/DoltDisabledSelector.tsx | 4 ++-- packages/web/components/DatabaseNav/Item.tsx | 6 +----- packages/web/components/DatabaseNav/index.tsx | 2 ++ packages/web/components/TableList/ColumnList/index.tsx | 9 +-------- packages/web/components/TableList/index.tsx | 2 ++ packages/web/components/Views/ViewItem.tsx | 5 +---- packages/web/components/Views/index.tsx | 2 ++ 7 files changed, 11 insertions(+), 19 deletions(-) diff --git a/packages/web/components/CustomFormSelect/DoltDisabledSelector.tsx b/packages/web/components/CustomFormSelect/DoltDisabledSelector.tsx index d9982452..a9a7f96c 100644 --- a/packages/web/components/CustomFormSelect/DoltDisabledSelector.tsx +++ b/packages/web/components/CustomFormSelect/DoltDisabledSelector.tsx @@ -12,12 +12,12 @@ export default function DoltDisabledSelector(props: Props) {
      {props.val}
      - + ); } diff --git a/packages/web/components/DatabaseNav/Item.tsx b/packages/web/components/DatabaseNav/Item.tsx index 72e5cfd9..3148a325 100644 --- a/packages/web/components/DatabaseNav/Item.tsx +++ b/packages/web/components/DatabaseNav/Item.tsx @@ -1,4 +1,3 @@ -import Tooltip from "@components/Tooltip"; import Link from "@components/links/Link"; import { OptionalRefParams } from "@lib/params"; import cx from "classnames"; @@ -15,21 +14,18 @@ type Props = { }; export default function NavItem(props: Props) { - const lower = props.name.toLowerCase(); if (props.hide) return null; if (props.doltDisabled) { - const tooltipId = `disabled-tab-${lower.replace(" ", "-")}`; return (
    • {props.name} -
    • ); } diff --git a/packages/web/components/DatabaseNav/index.tsx b/packages/web/components/DatabaseNav/index.tsx index 7e2c991f..51af3278 100644 --- a/packages/web/components/DatabaseNav/index.tsx +++ b/packages/web/components/DatabaseNav/index.tsx @@ -1,4 +1,5 @@ import SmallLoader from "@components/SmallLoader"; +import Tooltip from "@components/Tooltip"; import NotDoltWrapper from "@components/util/NotDoltWrapper"; import { useGetBranchQuery, useGetTagQuery } from "@gen/graphql-types"; import useDefaultBranch from "@hooks/useDefaultBranch"; @@ -75,6 +76,7 @@ function Query(props: QueryProps) { function Inner(props: Props) { return (
      +
        {tabs.map((tab, i) => { const item = ; diff --git a/packages/web/components/TableList/ColumnList/index.tsx b/packages/web/components/TableList/ColumnList/index.tsx index af5b7a3a..697d4c2b 100644 --- a/packages/web/components/TableList/ColumnList/index.tsx +++ b/packages/web/components/TableList/ColumnList/index.tsx @@ -1,7 +1,6 @@ import Btn from "@components/Btn"; import Popup from "@components/Popup"; import SmallLoader from "@components/SmallLoader"; -import Tooltip from "@components/Tooltip"; import QueryHandler from "@components/util/QueryHandler"; import { Column, @@ -57,14 +56,8 @@ function ColumnItem({ col }: { col: ColumnForTableListFragment }) {
        {excerpt(col.name, 24)} {col.isPrimaryKey && ( - + - )}
        diff --git a/packages/web/components/TableList/index.tsx b/packages/web/components/TableList/index.tsx index 5e132789..0221bd51 100644 --- a/packages/web/components/TableList/index.tsx +++ b/packages/web/components/TableList/index.tsx @@ -1,5 +1,6 @@ import Section from "@components/DatabaseTableNav/Section"; import SchemaDiagramButton from "@components/SchemaDiagramButton"; +import Tooltip from "@components/Tooltip"; import Link from "@components/links/Link"; import HideForNoWritesWrapper from "@components/util/HideForNoWritesWrapper"; import QueryHandler from "@components/util/QueryHandler"; @@ -29,6 +30,7 @@ function Inner(props: InnerProps) { return (
        + {props.tables.length ? ( <>
          diff --git a/packages/web/components/Views/ViewItem.tsx b/packages/web/components/Views/ViewItem.tsx index 0ac760ab..e463ccd1 100644 --- a/packages/web/components/Views/ViewItem.tsx +++ b/packages/web/components/Views/ViewItem.tsx @@ -1,5 +1,4 @@ import Btn from "@components/Btn"; -import Tooltip from "@components/Tooltip"; import { useSqlEditorContext } from "@contexts/sqleditor"; import { RowForViewsFragment } from "@gen/graphql-types"; import { RefParams } from "@lib/params"; @@ -45,16 +44,14 @@ export default function ViewItem(props: Props) {
          -
          ); diff --git a/packages/web/components/Views/index.tsx b/packages/web/components/Views/index.tsx index 61e1de16..0c5a9fe5 100644 --- a/packages/web/components/Views/index.tsx +++ b/packages/web/components/Views/index.tsx @@ -1,5 +1,6 @@ import Section from "@components/DatabaseTableNav/Section"; import Loader from "@components/Loader"; +import Tooltip from "@components/Tooltip"; import { RowForViewsFragment } from "@gen/graphql-types"; import { RefParams } from "@lib/params"; import NoViews from "./NoViews"; @@ -18,6 +19,7 @@ type Props = ViewsProps & { function Inner({ rows, params }: Props) { return (
          + {rows?.length ? (
            {rows.map(r => ( From 4cad094cdb0faf15415db3d4dcbbfe28a36dda67 Mon Sep 17 00:00:00 2001 From: Taylor Bantle Date: Mon, 23 Oct 2023 17:10:34 -0700 Subject: [PATCH 06/10] graphql: Fix dolt version check --- .../src/dataSources/dataSource.service.ts | 8 +++----- .../graphql-server/src/databases/database.resolver.ts | 2 +- packages/graphql-server/src/tables/upload.resolver.ts | 10 ++++++++-- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/graphql-server/src/dataSources/dataSource.service.ts b/packages/graphql-server/src/dataSources/dataSource.service.ts index 4a47baac..75b8f423 100644 --- a/packages/graphql-server/src/dataSources/dataSource.service.ts +++ b/packages/graphql-server/src/dataSources/dataSource.service.ts @@ -74,7 +74,7 @@ export class DataSourceService { return res; } - const isDolt = await getIsDolt(qr.query); + const isDolt = await getIsDolt(qr); if (dbName) { await qr.query(useDBStatement(dbName, refName, isDolt)); } @@ -133,11 +133,9 @@ export function useDBStatement( return `USE \`${dbName}\``; } -export async function getIsDolt( - query: (q: string) => Promise, -): Promise { +export async function getIsDolt(qr: QueryRunner): Promise { try { - const res = await query("SELECT dolt_version()"); + const res = await qr.query("SELECT dolt_version()"); return !!res; } catch (_) { return false; diff --git a/packages/graphql-server/src/databases/database.resolver.ts b/packages/graphql-server/src/databases/database.resolver.ts index 790c7f73..8bd13d3d 100644 --- a/packages/graphql-server/src/databases/database.resolver.ts +++ b/packages/graphql-server/src/databases/database.resolver.ts @@ -76,7 +76,7 @@ export class DatabaseResolver { const hideDoltFeatures = this.configService.get("HIDE_DOLT_FEATURES"); const qr = this.dss.getQR(); try { - const isDolt = await getIsDolt(qr.query); + const isDolt = await getIsDolt(qr); return { isDolt, hideDoltFeatures: !!hideDoltFeatures && hideDoltFeatures === "true", diff --git a/packages/graphql-server/src/tables/upload.resolver.ts b/packages/graphql-server/src/tables/upload.resolver.ts index d6ecc2b9..eca3676a 100644 --- a/packages/graphql-server/src/tables/upload.resolver.ts +++ b/packages/graphql-server/src/tables/upload.resolver.ts @@ -4,7 +4,6 @@ import { GraphQLUpload } from "graphql-upload"; import * as mysql from "mysql2/promise"; import { DataSourceService, - getIsDolt, useDBStatement, } from "../dataSources/dataSource.service"; import { TableArgs } from "../utils/commonTypes"; @@ -42,7 +41,14 @@ export class FileUploadResolver { async loadDataFile(@Args() args: TableImportArgs): Promise { const conn = await mysql.createConnection(this.dss.getMySQLConfig()); - const isDolt = await getIsDolt(conn.query); + let isDolt = false; + try { + const res = await conn.query("SELECT dolt_version()"); + isDolt = !!res; + } catch (_) { + // ignore + } + await conn.query(useDBStatement(args.databaseName, args.refName, isDolt)); await conn.query("SET GLOBAL local_infile=ON;"); From 6d56881dc692c736f36559145e0f180644b67411 Mon Sep 17 00:00:00 2001 From: Taylor Bantle Date: Mon, 23 Oct 2023 17:12:41 -0700 Subject: [PATCH 07/10] web: Downgrade react-tooltip --- packages/web/package.json | 2 +- yarn.lock | 28 +++++++++++++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/web/package.json b/packages/web/package.json index 60909a33..dd59b087 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -47,7 +47,7 @@ "react-markdown": "^5.0.0", "react-select": "^5.7.5", "react-timeago": "^7.2.0", - "react-tooltip": "^5.21.5", + "react-tooltip": "5.8.3", "reactjs-popup": "^2.0.6", "tailwindcss": "latest", "timeago.js": "^4.0.2" diff --git a/yarn.lock b/yarn.lock index f95c8988..194b6de4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1661,7 +1661,7 @@ __metadata: react-select: ^5.7.5 react-select-event: ^5.5.1 react-timeago: ^7.2.0 - react-tooltip: ^5.21.5 + react-tooltip: 5.8.3 reactjs-popup: ^2.0.6 stylelint: ^15.10.3 stylelint-config-recommended: ^13.0.0 @@ -1831,7 +1831,7 @@ __metadata: languageName: node linkType: hard -"@floating-ui/core@npm:^1.4.2": +"@floating-ui/core@npm:^1.1.0, @floating-ui/core@npm:^1.4.2": version: 1.5.0 resolution: "@floating-ui/core@npm:1.5.0" dependencies: @@ -1840,6 +1840,15 @@ __metadata: languageName: node linkType: hard +"@floating-ui/dom@npm:1.1.1": + version: 1.1.1 + resolution: "@floating-ui/dom@npm:1.1.1" + dependencies: + "@floating-ui/core": ^1.1.0 + checksum: 8b7f3b98ed7ec0b634e4a0b735253b0442358c5cea8302935fc185b2bd882202a053622abe9248c76d0908645dd35f93adeaed2d64371b2ab76b36725ce3f7d3 + languageName: node + linkType: hard + "@floating-ui/dom@npm:^1.0.0, @floating-ui/dom@npm:^1.0.1": version: 1.5.3 resolution: "@floating-ui/dom@npm:1.5.3" @@ -13930,7 +13939,20 @@ __metadata: languageName: node linkType: hard -"react-tooltip@npm:^5.19.0, react-tooltip@npm:^5.21.5": +"react-tooltip@npm:5.8.3": + version: 5.8.3 + resolution: "react-tooltip@npm:5.8.3" + dependencies: + "@floating-ui/dom": 1.1.1 + classnames: ^2.3.2 + peerDependencies: + react: ">=16.14.0" + react-dom: ">=16.14.0" + checksum: 9743ab23f189fc4e05f6d6a7928c3a95821cf031a9bed0945ebae34c557910654b68a98cc198b6e2a2ac676224e7c4aad2165c03e3cc376b6cc81ec67d9e64e0 + languageName: node + linkType: hard + +"react-tooltip@npm:^5.19.0": version: 5.21.5 resolution: "react-tooltip@npm:5.21.5" dependencies: From 162bc92730c5e87b3f6f4266f1f5017d4d077188 Mon Sep 17 00:00:00 2001 From: Taylor Bantle Date: Mon, 23 Oct 2023 17:19:32 -0700 Subject: [PATCH 08/10] web: File upload links, database header --- .../links/DatabaseUploadStageLink.tsx | 28 ++++++++++ .../ForTable/EditTableButtons.tsx | 53 +++++++++++++++---- .../FileUploadPage/Layout/index.module.css | 13 ++--- .../FileUploadPage/Layout/index.tsx | 8 ++- 4 files changed, 77 insertions(+), 25 deletions(-) create mode 100644 packages/web/components/links/DatabaseUploadStageLink.tsx diff --git a/packages/web/components/links/DatabaseUploadStageLink.tsx b/packages/web/components/links/DatabaseUploadStageLink.tsx new file mode 100644 index 00000000..66643ba3 --- /dev/null +++ b/packages/web/components/links/DatabaseUploadStageLink.tsx @@ -0,0 +1,28 @@ +import { UploadParams } from "@lib/params"; +import { uploadStage } from "@lib/urls"; +import { ReactNode } from "react"; +import Link, { LinkProps } from "./Link"; + +type Props = LinkProps & { + children: ReactNode; + stage: string; + dataCyPrefix?: string; + + params: UploadParams & { + refName?: string; + tableName?: string; + spreadsheet?: boolean; + }; +}; + +export default function DatabaseUploadStageLink({ children, ...props }: Props) { + return ( + + {children} + + ); +} diff --git a/packages/web/components/pageComponents/DatabasePage/ForTable/EditTableButtons.tsx b/packages/web/components/pageComponents/DatabasePage/ForTable/EditTableButtons.tsx index bcc21aec..4a3f4da6 100644 --- a/packages/web/components/pageComponents/DatabasePage/ForTable/EditTableButtons.tsx +++ b/packages/web/components/pageComponents/DatabasePage/ForTable/EditTableButtons.tsx @@ -1,11 +1,16 @@ import Button from "@components/Button"; +import ErrorMsg from "@components/ErrorMsg"; +import DatabaseUploadStageLink from "@components/links/DatabaseUploadStageLink"; import Link from "@components/links/Link"; import { useDataTableContext } from "@contexts/dataTable"; import { useSqlEditorContext } from "@contexts/sqleditor"; +import { useTagListQuery } from "@gen/graphql-types"; import { TableParams } from "@lib/params"; import { table } from "@lib/urls"; import { AiOutlineCode } from "@react-icons/all-files/ai/AiOutlineCode"; import { AiOutlineDelete } from "@react-icons/all-files/ai/AiOutlineDelete"; +import { FiUpload } from "@react-icons/all-files/fi/FiUpload"; +import { ImTable2 } from "@react-icons/all-files/im/ImTable2"; import OptionSquare from "./OptionSquare"; import css from "./index.module.css"; import { getInsertQuery } from "./utils"; @@ -19,12 +24,13 @@ export default function EditTableButtons(props: Props) { useSqlEditorContext(); const { columns } = useDataTableContext(); - // const tagRes = useTagListQuery({ - // variables: props.params, - // }); - // const refIsTag = !!tagRes.data?.tags.list.find( - // t => t.tagName === props.params.refName, - // ); + const tagRes = useTagListQuery({ + variables: props.params, + }); + const refIsTag = !!tagRes.data?.tags.list.find( + t => t.tagName === props.params.refName, + ); + const uploadParams = { ...props.params, uploadId: String(Date.now()) }; const onWriteQuery = () => { setEditorString(getInsertQuery(props.params.tableName, columns)); @@ -44,24 +50,49 @@ export default function EditTableButtons(props: Props) { Edit table{" "} {props.params.tableName} - {/* {refIsTag && ( + {refIsTag && ( - )} */} + )}
            } - // disabled={refIsTag} + disabled={refIsTag} link={ - + SQL Query } /> + } + disabled={refIsTag} + link={ + + Spreadsheet Editor + + } + /> + } + disabled={refIsTag} + link={ + + File Upload + + } + />
            Drop Table diff --git a/packages/web/components/pageComponents/FileUploadPage/Layout/index.module.css b/packages/web/components/pageComponents/FileUploadPage/Layout/index.module.css index 66c057b3..f79e7a90 100644 --- a/packages/web/components/pageComponents/FileUploadPage/Layout/index.module.css +++ b/packages/web/components/pageComponents/FileUploadPage/Layout/index.module.css @@ -22,16 +22,11 @@ @apply mr-3 text-xl; } -.breadcrumbs { - span { - @apply text-white; - } - a { - @apply text-blue-300; +.databaseLink { + @apply text-white font-normal; - &:hover { - @apply text-blue-200; - } + &:hover { + @apply text-blue-200; } } diff --git a/packages/web/components/pageComponents/FileUploadPage/Layout/index.tsx b/packages/web/components/pageComponents/FileUploadPage/Layout/index.tsx index 97bab58e..16891b41 100644 --- a/packages/web/components/pageComponents/FileUploadPage/Layout/index.tsx +++ b/packages/web/components/pageComponents/FileUploadPage/Layout/index.tsx @@ -1,5 +1,4 @@ import Button from "@components/Button"; -import DatabaseBreadcrumbs from "@components/breadcrumbs/DatabaseBreadcrumbs"; import { DatabaseLayoutWrapperOuter } from "@components/layouts/DatabaseLayout/Wrapper"; import DatabaseLink from "@components/links/DatabaseLink"; import KeyNav from "@components/util/KeyNav"; @@ -22,10 +21,9 @@ export default function Layout(props: Props) { - + + {props.params.databaseName} +

          File Importer

          From 1ecd300d08357acaa1b2b453116b2942edadcef1 Mon Sep 17 00:00:00 2001 From: Taylor Bantle Date: Wed, 25 Oct 2023 14:02:18 -0700 Subject: [PATCH 09/10] web: Spreadsheet editor updates --- .../TableGrid/Buttons/index.module.css | 4 - .../EditableTable/TableGrid/Buttons/index.tsx | 35 +--- .../EditableTable/TableGrid/CellMenu.tsx | 61 ++++++ .../EditableTable/TableGrid/IndexColumn.tsx | 18 +- .../EditableTable/TableGrid/index.module.css | 27 ++- .../Upload/EditableTable/TableGrid/index.tsx | 62 +++--- .../Upload/EditableTable/TableGrid/types.ts | 30 +-- .../Upload/EditableTable/TableGrid/useGrid.ts | 138 ++----------- .../Upload/EditableTable/TableGrid/utils.ts | 184 +++--------------- .../EditableTable/TableGrid/validate.ts | 8 +- .../Upload/EditableTable/index.module.css | 4 +- packages/web/package.json | 2 +- yarn.lock | 24 +-- 13 files changed, 202 insertions(+), 395 deletions(-) create mode 100644 packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/CellMenu.tsx diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/Buttons/index.module.css b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/Buttons/index.module.css index 8ee55b06..6351fb75 100644 --- a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/Buttons/index.module.css +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/Buttons/index.module.css @@ -25,7 +25,3 @@ .exportButton { @apply text-center ml-4; } - -.rowLine { - @apply w-full border-b border-ld-lightgrey mb-1.5; -} diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/Buttons/index.tsx b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/Buttons/index.tsx index c9ab72c7..5674d7bd 100644 --- a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/Buttons/index.tsx +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/Buttons/index.tsx @@ -31,27 +31,14 @@ function Inner(props: InnerProps) { setInsertOpen(false); }; - function rowButton(position: string, insertAt: number, disabled?: boolean) { + function rowButton(position: string, insertAt: number) { return ( - onPopupClick(insertAt, insertRow)} - disabled={disabled} - > + onPopupClick(insertAt, insertRow)}> Row {position} ); } - // function colButton(position: string, insertAt: number) { - // return ( - // onPopupClick(insertAt, onAddColumn)} - // > - // Column {position} - // - // ); - // } - return (
          @@ -70,26 +57,10 @@ function Inner(props: InnerProps) { ) : ( <> - {rowButton( - "above", - props.state.selectedCell.rowIdx, - props.state.selectedCell.rowIdx === 0, - )} + {rowButton("above", props.state.selectedCell.rowIdx)} {rowButton("below", props.state.selectedCell.rowIdx + 1)} )} - {/*
          - {!props.state.selectedCell ? ( - <> - {colButton("start", 0)} - {colButton("end", props.state.columns.length)} - - ) : ( - <> - {colButton("left", props.state.selectedCell.idx - 1)} - {colButton("right", props.state.selectedCell.idx)} - - )} */}
          )}
          diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/CellMenu.tsx b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/CellMenu.tsx new file mode 100644 index 00000000..f188d393 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/CellMenu.tsx @@ -0,0 +1,61 @@ +import useOnClickOutside from "@hooks/useOnClickOutside"; +import { useRef } from "react"; +import "react-data-grid/lib/styles.css"; +import css from "./index.module.css"; +import { ContextMenuProps, GridDispatch, GridFunctions } from "./types"; + +type Props = { + gf: GridFunctions; + contextMenuProps: ContextMenuProps; + setState: GridDispatch; +}; + +export default function CellMenu(props: Props) { + const menuRef = useRef(null); + useOnClickOutside(menuRef, () => props.setState({ contextMenuProps: null })); + + return ( + +
        1. + +
        2. +
        3. + +
        4. +
        5. + +
        6. +
          + ); +} diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/IndexColumn.tsx b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/IndexColumn.tsx index 71015b28..203e343d 100644 --- a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/IndexColumn.tsx +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/IndexColumn.tsx @@ -1,13 +1,8 @@ import cx from "classnames"; -import { FormatterProps } from "react-data-grid"; import css from "./index.module.css"; -import { Column, Row } from "./types"; - -function IndexFormatter(props: FormatterProps) { - const name = props.row._idx === 0 ? "*" : props.row._idx; - return
          {name}
          ; -} +import { Column } from "./types"; +const cellClass = cx("index-cell", css.rowIndex); export const indexColumn: Column = { _idx: -1, key: "index-column", @@ -18,9 +13,8 @@ export const indexColumn: Column = { sortable: false, frozen: true, editable: false, - formatter: IndexFormatter, - cellClass: cx("index-cell", css.rowIndex), - editorOptions: { - editOnClick: false, - }, + renderCell: ({ row }) =>
          {row._idx + 1}
          , + renderSummaryCell: () =>
          *
          , + cellClass, + summaryCellClass: cellClass, }; diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/index.module.css b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/index.module.css index 0674dcfd..6b9afa91 100644 --- a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/index.module.css +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/index.module.css @@ -40,17 +40,8 @@ @apply text-xs italic mt-1; } -.headerRow { - @apply border-b-4 border-ld-lightgrey; - - div[role="gridcell"] { - @apply mb-1; - } -} - .rowIndex { - @apply font-semibold text-center; - background-color: #f9f9f9; + @apply font-semibold text-center bg-[#f9f9f9]; } .loading { @@ -65,3 +56,19 @@ content: attr(data-text); } } + +.menu { + @apply absolute bg-white border rounded; + + > li { + @apply px-2 py-1; + + &:not(:last-child) { + @apply border-b; + } + + > button { + @apply bg-white text-sm; + } + } +} diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/index.tsx b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/index.tsx index cf7ea80d..85893ff9 100644 --- a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/index.tsx +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/index.tsx @@ -3,10 +3,13 @@ import { ColumnForDataTableFragment } from "@gen/graphql-types"; import cx from "classnames"; import { useEffect } from "react"; import DataGrid from "react-data-grid"; +import "react-data-grid/lib/styles.css"; +import { createPortal } from "react-dom"; import Buttons from "./Buttons"; +import CellMenu from "./CellMenu"; import { indexColumn } from "./IndexColumn"; import css from "./index.module.css"; -import { GridDispatch, GridFunctions, GridState, Row } from "./types"; +import { GridDispatch, GridFunctions, GridState } from "./types"; import useGrid from "./useGrid"; type Props = { @@ -36,34 +39,39 @@ function Inner(props: InnerProps) { }; }), ]} + topSummaryRows={[{ _id: -1 }]} rows={props.state.rows} rowKeyGetter={row => row._id} onRowsChange={rows => props.setState({ rows })} - // onScroll={props.gf.handleScroll} onFill={props.gf.onFill} - // rowRenderer={RowRenderer} className={cx("rdg-light", css.dataGrid)} style={{ resize: "both" }} - rowHeight={({ row }) => ((row as Row)._id === 0 ? 35 : 30)} - rowClass={row => (row._id === 0 ? css.headerRow : undefined)} - onSelectedCellChange={c => props.setState({ selectedCell: c })} + rowHeight={r => (r.type === "ROW" && r.row._id === 0 ? 35 : 30)} + onCellClick={cell => { + props.setState({ + selectedCell: { rowIdx: cell.row._idx, idx: cell.column.idx }, + }); + }} + onCellKeyDown={cell => { + props.setState({ + selectedCell: { rowIdx: cell.row._idx, idx: cell.column.idx }, + }); + }} + onCellContextMenu={({ row }, e) => { + e.preventGridDefault(); + // Do not show the default context menu + e.preventDefault(); + props.setState({ + contextMenuProps: { + rowIdx: props.state.rows.indexOf(row), + top: e.clientY, + left: e.clientX, + }, + }); + }} /> ); - // const rowMenu = ( - // - // Delete Row - // Insert Row Above - // Insert Row Below - // - // ); - - // const headerMenu = ( - // - // Delete Column - // - // ); - return (
          @@ -77,11 +85,15 @@ function Inner(props: InnerProps) {
          {gridElement} - {/* {createPortal(rowMenu, document.body)} - {createPortal(headerMenu, document.body)} */} - {props.state.loading && ( -
          Loading more rows
          - )} + + {props.state.contextMenuProps !== null && + createPortal( + , + document.body, + )}
          ); } diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/types.ts b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/types.ts index 1a7fd3e9..6eb547d9 100644 --- a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/types.ts +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/types.ts @@ -11,33 +11,41 @@ export type Row = { [key: string]: any; }; -export type Column = GridColumn & { name: string; _idx: number }; +export type SummaryRow = { + _id: number; +}; + +export type Column = GridColumn & { + name: string; + _idx: number; +}; export type Columns = Column[]; +export type ContextMenuProps = { + rowIdx: number; + top: number; + left: number; +}; + export type GridState = { rows: Row[]; columns: Columns; - loading: boolean; - pageToken?: string; error?: Error; selectedCell?: { rowIdx: number; idx: number }; + contextMenuProps: ContextMenuProps | null; }; export type GridDispatch = Dispatch>; export type RowObj = { rowIdx: number }; -export type HeadObj = { column: Column }; export type GridFunctions = { onExport: (g: ReactElement>) => Promise; - onRowDelete: (e: React.MouseEvent, o: RowObj) => void; - onRowInsertAbove: (e: React.MouseEvent, o: RowObj) => void; - onRowInsertBelow: (e: React.MouseEvent, o: RowObj) => void; - onAddColumn: (i: number) => void; - onDeleteColumn: (e: React.MouseEvent, o: HeadObj) => void; - onFill: (e: FillEvent) => Row[]; + onRowDelete: (rowIdx: number) => void; + onRowInsertAbove: (rowIdx: number) => void; + onRowInsertBelow: (rowIdx: number) => void; + onFill: (e: FillEvent) => Row; insertRow: (i: number) => void; - // handleScroll: (event: React.UIEvent) => Promise; handlePaste: (e: ClipboardEvent) => void; }; diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/useGrid.ts b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/useGrid.ts index 5da690ad..7c9799ad 100644 --- a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/useGrid.ts +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/useGrid.ts @@ -8,11 +8,8 @@ import { ReactElement, useReducer, useState } from "react"; import { DataGridProps, FillEvent } from "react-data-grid"; import { useFileUploadContext } from "../../../../contexts/fileUploadLocalForage"; import useUploadContext from "../../contexts/upload"; -import { Columns, HeadObj, ReturnType, Row, RowObj } from "./types"; +import { Columns, ReturnType, Row } from "./types"; import { - getColumn, - getColumnLetterFromAlphabet, - getColumnsFromPastedData, getDefaultState, getGridAsCsv, getRow, @@ -25,9 +22,7 @@ export default function useGrid( const { onUpload, setState: setUcState } = useUploadContext(); const { state: fState, setState: setForageState } = useFileUploadContext(); - const [state, setState] = useSetState( - getDefaultState(fState.spreadsheetRows, existingCols), - ); + const [state, setState] = useSetState(getDefaultState(existingCols)); const [nextId, setNextId] = useReducer( (id: number) => id + 1, @@ -76,7 +71,6 @@ export default function useGrid( const file = new File([contents], "editor.csv", { type: "text/csv" }); setForageState({ selectedFile: file, - spreadsheetRows: rows, fileType: FileType.Csv, colNames: firstRow, }); @@ -97,10 +91,7 @@ export default function useGrid( setNextId(); }; - const onRowDelete = ( - _: React.MouseEvent, - { rowIdx }: RowObj, - ) => { + const onRowDelete = (rowIdx: number) => { const mappedIdxs = state.rows.slice(rowIdx + 1).map(r => { return { ...r, _idx: r._idx - 1 }; }); @@ -109,114 +100,18 @@ export default function useGrid( }); }; - const onRowInsertAbove = ( - _: React.MouseEvent, - { rowIdx }: RowObj, - ) => { + const onRowInsertAbove = (rowIdx: number) => { insertRow(rowIdx); }; - const onRowInsertBelow = ( - _: React.MouseEvent, - { rowIdx }: RowObj, - ) => { + const onRowInsertBelow = (rowIdx: number) => { insertRow(rowIdx + 1); }; - const onAddColumn = (insertColIdx: number) => { - const newCol = getColumn(insertColIdx, insertColIdx); - const mapped = state.columns.slice(insertColIdx).map(c => { - const newIdx = c._idx + 1; - return { - ...c, - _idx: newIdx, - key: `${Number(c.key) + 1}`, - name: getColumnLetterFromAlphabet(newIdx), - }; - }); - const newCols = [ - ...state.columns.slice(0, insertColIdx), - newCol, - ...mapped, - ]; - const mappedRows = state.rows.map(row => { - const keys = Object.keys(row); - const newRow: Row = { [newCol.key]: "", _id: row._id, _idx: row._idx }; - keys.forEach(key => { - const num = Number(key); - if (num >= insertColIdx) { - newRow[`${num + 1}`] = row[key]; - } else { - newRow[key] = row[key]; - } - }); - return newRow; - }); - setState({ columns: newCols, rows: mappedRows }); - }; - - const onDeleteColumn = ( - _: React.MouseEvent, - { column }: HeadObj, - ) => { - const mappedNames = state.columns.slice(column._idx + 1).map(c => { - return { - ...c, - name: getColumnLetterFromAlphabet(Number(c._idx) - 1), - _idx: c._idx - 1, - }; - }); - const newCols = [...state.columns.slice(0, column._idx), ...mappedNames]; - const newRows = state.rows.map(row => { - const newRow = row; - delete newRow[column.key]; - return newRow; - }); - setState({ columns: newCols, rows: newRows }); - }; - - function onFill({ columnKey, sourceRow, targetRows }: FillEvent): Row[] { - return targetRows.map(targetRow => { - return { ...targetRow, [columnKey]: sourceRow[columnKey as keyof Row] }; - }); + function onFill({ columnKey, sourceRow, targetRow }: FillEvent): Row { + return { ...targetRow, [columnKey]: sourceRow[columnKey as keyof Row] }; } - // async function handleScroll(event: React.UIEvent) { - // if ( - // state.loading || - // fState.spreadsheetRows || - // !state.pageToken || - // // !existingTable || - // !isAtBottom(event) - // ) { - // return; - // } - - // setState({ loading: true }); - - // const res = await existingTable.loadMore(state.pageToken); - // let next = nextId; - // const newRows = res?.rows.list.map((row, i) => { - // const newRow: Row = { _id: next, _idx: state.rows.length + i }; - // state.columns.forEach((col, colI) => { - // const val = - // colI < row.columnValues.length - // ? getExistingRowValue(row.columnValues[colI].displayValue) - // : ""; - // newRow[col.key] = val; - // }); - // next += 1; - // setNextId(); - // return newRow; - // }); - - // setState({ - // rows: [...state.rows, ...(newRows ?? [])], - // loading: false, - // pageToken: res?.rows.nextPageToken ?? undefined, - // }); - // } - const handlePaste = (e: ClipboardEvent) => { e.preventDefault(); if (!state.selectedCell) return; @@ -224,19 +119,19 @@ export default function useGrid( const pasteDataRows = defaultParsePaste( e.clipboardData?.getData("text/plain"), ); - const newCols = getColumnsFromPastedData(pasteDataRows, idx, state.columns); - const allRows = addEmptyRowsForPastedRows(pasteDataRows, rowIdx, newCols); + const allRows = addEmptyRowsForPastedRows( + pasteDataRows, + rowIdx, + state.columns, + ); const newRows = mergePastedRowsIntoExistingRows( pasteDataRows, allRows, - newCols, + state.columns, idx, rowIdx, ); - setState({ - columns: newCols, - rows: newRows, - }); + setState({ rows: newRows }); }; // If pasted rows go beyond row boundary, add more empty rows @@ -264,11 +159,8 @@ export default function useGrid( onRowDelete, onRowInsertAbove, onRowInsertBelow, - onAddColumn, - onDeleteColumn, insertRow, onFill, - // handleScroll, handlePaste, }, }; @@ -276,5 +168,5 @@ export default function useGrid( // Splits strings copied from spreadsheet export function defaultParsePaste(str?: string): string[][] { - return str?.split(/\r\n|\n|\r/).map(row => row.split("\t")) ?? []; + return str?.split(/\r\n|\n|\r/).map(row => row.split(/\t|,/)) ?? []; } diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/utils.ts b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/utils.ts index 19178f65..9f0f313c 100644 --- a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/utils.ts +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/utils.ts @@ -1,11 +1,6 @@ -import { - ColumnForDataTableFragment, - RowForDataTableFragment, -} from "@gen/graphql-types"; -import { nTimesWithIndex } from "@lib/nTimes"; -import { isNullValue } from "@lib/null"; -import { KeyboardEvent, ReactElement, cloneElement } from "react"; -import { DataGridProps, TextEditor } from "react-data-grid"; +import { ColumnForDataTableFragment } from "@gen/graphql-types"; +import { ReactElement, cloneElement } from "react"; +import { DataGridProps, textEditor } from "react-data-grid"; import { Column, Columns, GridState, Row } from "./types"; import { getValidationClass, handleErrorClasses } from "./validate"; @@ -23,8 +18,8 @@ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; export async function getGridAsCsv( gridElement: ReactElement>, ): Promise { - const { body, foot } = await getGridContent(gridElement); - const rows = [...body, ...foot]; + const { head, body } = await getGridContent(gridElement); + const rows = [...head, ...body]; const filtered = filterOutEmptyRowsAndCols(rows); const csv = filtered .map(cells => cells.map(serializeCellValue).join(",")) @@ -35,7 +30,6 @@ export async function getGridAsCsv( type GridContent = { head: string[][]; body: string[][]; - foot: string[][]; }; async function getGridContent( @@ -51,13 +45,12 @@ async function getGridContent( ); return { - head: getRows(grid, ".rdg-header-row"), + head: getRows(grid, ".rdg-summary-row"), body: getRows(grid, ".rdg-row:not(.rdg-summary-row)"), - foot: getRows(grid, ".rdg-summary-row"), }; } catch (err) { console.error(err); - return { head: [], body: [], foot: [] }; + return { head: [], body: [] }; } function getRows(grid: HTMLDivElement, selector: string): string[][] { @@ -101,38 +94,45 @@ function serializeCellValue(value: string): string { // DEFAULT STATE export function getDefaultState( - rows: string[][] | undefined, - existingCols?: ColumnForDataTableFragment[], + existingCols: ColumnForDataTableFragment[], ): GridState { - const numDefaultCols = getNumCols(rows, existingCols); + const numDefaultCols = getNumCols(existingCols); const columns: Column[] = []; for (let i = 0; i < numDefaultCols; i++) { - const existing = existingCols ? existingCols[i] : undefined; - columns.push(getColumn(i, i, existing?.type)); + const existing = existingCols[i]; + columns.push(getColumn(i, i, existing.type, existing.name)); } return { columns, - rows: getDefaultRows(rows, columns, existingCols), - loading: false, + rows: getEmptyRows(columns, defaultNumRows), + contextMenuProps: null, }; } // GET COLUMNS -export function getColumn(id: number, index: number, type?: string): Column { +export function getColumn( + id: number, + index: number, + type: string, + name: string, +): Column { return { _idx: index, name: getColumnLetterFromAlphabet(index), key: `${id}`, editable: true, resizable: true, - editor: TextEditor, + renderEditCell: textEditor, width: 215, cellClass: (row: Row) => { - const cl = getValidationClass(row._idx, row[id], type); + const cl = getValidationClass(row[id], type); handleErrorClasses(); return cl; }, + renderSummaryCell() { + return name; + }, }; } @@ -147,70 +147,23 @@ export function getColumnLetterFromAlphabet(index: number): string { return alphabet[index]; } -function getNumCols( - rows: string[][] | undefined, - existingCols?: ColumnForDataTableFragment[], -): number { - if (rows?.length) { - return rows[0].length; - } +function getNumCols(existingCols?: ColumnForDataTableFragment[]): number { if (existingCols) { return existingCols.length; } return defaultNumCols; } -// If pasted rows go beyond columns boundary, add more empty columns -export function getColumnsFromPastedData( - pastedRows: string[][], - colIdx: number, - existingCols: Columns, -): Columns { - if (pastedRows.length === 0) return existingCols; - const numMoreCols = colIdx + pastedRows[0].length - existingCols.length - 1; - return numMoreCols > 0 - ? [ - ...existingCols, - ...nTimesWithIndex(numMoreCols, num => { - const newColIdx = num + existingCols.length; - return getColumn(newColIdx, newColIdx); - }), - ] - : existingCols; -} - // GET ROWS -function getEmptyRows( - cols: Columns, - numRows: number, - startingFrom: number, - existingCols?: ColumnForDataTableFragment[], -): Row[] { +function getEmptyRows(cols: Columns, numRows: number): Row[] { const rows = []; - const start = existingCols ? startingFrom + 1 : startingFrom; - if (existingCols) { - rows.push(getRowFromExistingColumns(0, cols, existingCols)); - } - for (let i = start; i < numRows; i++) { + for (let i = 0; i < numRows; i++) { rows.push(getRow(i, i, cols)); } return rows; } -function getRowFromExistingColumns( - i: number, - cols: Columns, - existingCols: ColumnForDataTableFragment[], -): Row { - const row: Row = { _id: i, _idx: i }; - cols.forEach((col, idx) => { - const name = idx < existingCols.length ? existingCols[idx].name : ""; - row[col.key] = name; - }); - return row; -} - export function getRow(i: number, idx: number, cols: Columns): Row { const row: Row = { _id: i, _idx: idx }; cols.forEach(col => { @@ -219,54 +172,6 @@ export function getRow(i: number, idx: number, cols: Columns): Row { return row; } -function getDefaultRows( - rows: string[][] | undefined, - columns: Column[], - existingCols?: ColumnForDataTableFragment[], - existingRows?: RowForDataTableFragment[], -): Row[] { - if (rows) { - return getRowsFromExistingCsv(columns, rows); - } - if (existingRows) { - const mappedRows = existingRows.map(r => - r.columnValues.map(v => getExistingRowValue(v.displayValue)), - ); - return getRowsFromExistingCsv(columns, mappedRows, existingCols); - } - return getEmptyRows(columns, defaultNumRows, 0, existingCols); -} - -export function getExistingRowValue(dv: string): string { - return isNullValue(dv) ? "" : dv; -} - -function getRowsFromExistingCsv( - cols: Column[], - rows: string[][], - existingCols?: ColumnForDataTableFragment[], -): Row[] { - const existingRows: Row[] = []; - if (existingCols) { - existingRows.push(getRowFromExistingColumns(0, cols, existingCols)); - } - rows.forEach((row, i) => { - const rIdx = existingCols ? i + 1 : i; - const newRow: Row = { _id: rIdx, _idx: rIdx }; - cols.forEach((col, colI) => { - newRow[col.key] = row[colI]; - }); - existingRows.push(newRow); - }); - - const numExisting = existingRows.length; - const emptyRows = - numExisting < defaultNumRows - ? getEmptyRows(cols, defaultNumRows - numExisting, numExisting) - : []; - return [...existingRows, ...emptyRows]; -} - export function mergePastedRowsIntoExistingRows( pastedRows: string[][], existingRows: Row[], @@ -288,38 +193,3 @@ export function mergePastedRowsIntoExistingRows( ...existingRows.slice(rowIdx + newRows.length), ]; } - -// NAVIGATION - -// Default onEditorNavigation, which is overridden for arrow keys in columns array -function onEditorNavigation({ - key, - target, -}: React.KeyboardEvent): boolean { - if ( - key === "Tab" && - (target instanceof HTMLInputElement || - target instanceof HTMLTextAreaElement || - target instanceof HTMLSelectElement) - ) { - return target.matches( - ".rdg-editor-container > :only-child, .rdg-editor-container > label:only-child > :only-child", - ); - } - return false; -} - -export function customOnNavigation( - event: KeyboardEvent, -): boolean { - return onEditorNavigation(event) || event.key.startsWith("Arrow"); -} - -export function isAtBottom({ - currentTarget, -}: React.UIEvent): boolean { - return ( - currentTarget.scrollTop + 10 >= - currentTarget.scrollHeight - currentTarget.clientHeight - ); -} diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/validate.ts b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/validate.ts index 0ed11222..4c880a7b 100644 --- a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/validate.ts +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/validate.ts @@ -7,12 +7,8 @@ const dateRegex = /^\d{4}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])$/; // YYYY-MM const datetimeRegex = /^\d{4}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])\s(?:[01]\d|2[0-3]):(?:[0-5]\d):(?:[0-5]\d)/; // YYYY-MM-DD HH:MM:SS -export function getValidationClass( - rowIdx: number, - value: string, - type?: string, -): string { - if (rowIdx === 0 || !type || value === "") return ""; +export function getValidationClass(value: string, type?: string): string { + if (!type || value === "") return ""; const lower = type.toLowerCase(); if ( lower.includes("int") || diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/index.module.css b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/index.module.css index 2cbc93e4..b4b96efe 100644 --- a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/index.module.css +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/index.module.css @@ -10,7 +10,7 @@ } .inner { - @apply w-full m-10 relative text-left; + @apply w-full p-10 relative text-left; h1 { @apply text-center mb-4; @@ -26,7 +26,7 @@ } .closeTop { - @apply absolute right-0 top-0 text-primary; + @apply absolute right-5 top-5 text-primary; svg { @apply text-2xl; diff --git a/packages/web/package.json b/packages/web/package.json index dd59b087..11722090 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -38,7 +38,7 @@ "react": "latest", "react-ace": "^10.1.0", "react-copy-to-clipboard": "^5.1.0", - "react-data-grid": "^7.0.0-beta.31", + "react-data-grid": "7.0.0-beta.40", "react-dom": "latest", "react-flow-renderer": "^10.3.17", "react-hotkeys": "^2.0.0", diff --git a/yarn.lock b/yarn.lock index 194b6de4..4e59d1a5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1651,7 +1651,7 @@ __metadata: react: latest react-ace: ^10.1.0 react-copy-to-clipboard: ^5.1.0 - react-data-grid: ^7.0.0-beta.31 + react-data-grid: 7.0.0-beta.40 react-dom: latest react-flow-renderer: ^10.3.17 react-hotkeys: ^2.0.0 @@ -6318,10 +6318,10 @@ __metadata: languageName: node linkType: hard -"clsx@npm:^1.1.1": - version: 1.2.1 - resolution: "clsx@npm:1.2.1" - checksum: 30befca8019b2eb7dbad38cff6266cf543091dae2825c856a62a8ccf2c3ab9c2907c4d12b288b73101196767f66812365400a227581484a05f968b0307cfaf12 +"clsx@npm:^2.0.0": + version: 2.0.0 + resolution: "clsx@npm:2.0.0" + checksum: a2cfb2351b254611acf92faa0daf15220f4cd648bdf96ce369d729813b85336993871a4bf6978ddea2b81b5a130478339c20d9d0b5c6fc287e5147f0c059276e languageName: node linkType: hard @@ -13780,15 +13780,15 @@ __metadata: languageName: node linkType: hard -"react-data-grid@npm:^7.0.0-beta.31": - version: 7.0.0-canary.49 - resolution: "react-data-grid@npm:7.0.0-canary.49" +"react-data-grid@npm:7.0.0-beta.40": + version: 7.0.0-beta.40 + resolution: "react-data-grid@npm:7.0.0-beta.40" dependencies: - clsx: ^1.1.1 + clsx: ^2.0.0 peerDependencies: - react: ^16.14 || ^17.0 - react-dom: ^16.14 || ^17.0 - checksum: fe57d441a5e56dac39a0f7e06979a27d5606515653ead31fc06f9c2fe08ebaf88ff4ab70f1565dd356b343cf4612137dea2d188a40a7e246b85dd5aa2589da04 + react: ^18.0 + react-dom: ^18.0 + checksum: 2fcc640e16c33b6893e3716dbe786f79316aae1dcc89e624c929e9cd14a329f02a76e2ab4c3b7866b106ffc0f315691f870d83c1c5f708ff568d6144ad610bca languageName: node linkType: hard From bc00b1071740540c85240e11fb89833d716ae0f0 Mon Sep 17 00:00:00 2001 From: Taylor Bantle Date: Wed, 25 Oct 2023 14:05:17 -0700 Subject: [PATCH 10/10] web: Remove spreadsheetRows --- .../FileUploadPage/FileInfo/index.tsx | 13 ++-- .../FileUploadPage/Navigation/index.tsx | 2 +- .../FileUploadPage/Steps/Upload/DropZone.tsx | 9 +-- .../Steps/Upload/OpenSpreadsheetZone.tsx | 62 ++++--------------- .../Steps/Upload/contexts/upload.tsx | 17 +---- .../FileUploadPage/Steps/Upload/index.tsx | 5 +- .../pageComponents/FileUploadPage/Summary.tsx | 3 - .../contexts/fileUploadLocalForage/state.ts | 1 - 8 files changed, 22 insertions(+), 90 deletions(-) diff --git a/packages/web/components/pageComponents/FileUploadPage/FileInfo/index.tsx b/packages/web/components/pageComponents/FileUploadPage/FileInfo/index.tsx index 9d4dd4ed..4e471a86 100644 --- a/packages/web/components/pageComponents/FileUploadPage/FileInfo/index.tsx +++ b/packages/web/components/pageComponents/FileUploadPage/FileInfo/index.tsx @@ -17,13 +17,12 @@ export default function FileInfo(props: Props) { const removeFile = () => { setState({ selectedFile: undefined, - spreadsheetRows: undefined, colNames: "", }); if (props.onRemove) props.onRemove(); }; - if (!state.selectedFile && !state.spreadsheetRows) return null; + if (!state.selectedFile) return null; return (
          @@ -32,14 +31,10 @@ export default function FileInfo(props: Props) { > - - {state.selectedFile?.name ?? "editor.csv"} + {state.selectedFile.name} + + {fileSize(state.selectedFile.size)} - {state.selectedFile && ( - - {fileSize(state.selectedFile.size)} - - )}
          {props.editButton} diff --git a/packages/web/components/pageComponents/FileUploadPage/Navigation/index.tsx b/packages/web/components/pageComponents/FileUploadPage/Navigation/index.tsx index efc171df..ad9daec7 100644 --- a/packages/web/components/pageComponents/FileUploadPage/Navigation/index.tsx +++ b/packages/web/components/pageComponents/FileUploadPage/Navigation/index.tsx @@ -75,7 +75,7 @@ function getComplete( case UploadStage.Table: return !!state.tableName; case UploadStage.Upload: - return !!(state.selectedFile ?? state.spreadsheetRows); + return !!state.selectedFile; default: return false; } diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/DropZone.tsx b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/DropZone.tsx index 74f0c584..8171c92f 100644 --- a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/DropZone.tsx +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/DropZone.tsx @@ -28,7 +28,6 @@ export default function DropZone() { className={cx(css.dropContainer, { [css.hover]: dz.hover, [css.dropped]: state.selectedFile, - [css.faded]: !!state.spreadsheetRows, })} onDragOver={dz.dragOver} onDragLeave={dz.dragLeave} @@ -53,11 +52,7 @@ export default function DropZone() {
          Drag a file here
          or
          - + Browse files @@ -73,7 +68,7 @@ export default function DropZone() {
          - {!state.selectedFile && !state.spreadsheetRows && ( + {!state.selectedFile && (
          File types: {fileTypes}
          )}
          diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/OpenSpreadsheetZone.tsx b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/OpenSpreadsheetZone.tsx index a1f4c95a..0c2e8c89 100644 --- a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/OpenSpreadsheetZone.tsx +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/OpenSpreadsheetZone.tsx @@ -1,75 +1,39 @@ import Button from "@components/Button"; -import { FiCheck } from "@react-icons/all-files/fi/FiCheck"; import { GrTable } from "@react-icons/all-files/gr/GrTable"; import cx from "classnames"; -import FileInfo from "../../FileInfo"; import { useFileUploadContext } from "../../contexts/fileUploadLocalForage"; import useUploadContext from "./contexts/upload"; import css from "./index.module.css"; export default function OpenSpreadsheetZone() { const { setState } = useUploadContext(); - const { state, setItem } = useFileUploadContext(); - - const onRemove = () => { - setState({ error: undefined }); - setItem("selectedFile", undefined); - }; + const { state } = useFileUploadContext(); const onClick = () => { setState({ spreadsheetOverlayOpen: true }); }; - const onEdit = () => { - setState({ - spreadsheetOverlayOpen: true, - error: undefined, - }); - }; - return (
          - {state.spreadsheetRows ? ( +
          + +

          Create spreadsheet

          - - - Upload successful - - - edit - - } - upload - /> -
          - ) : ( -
          - -

          Create spreadsheet

          -
          - -
          + Open editor +
          - )} +
          ); diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/contexts/upload.tsx b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/contexts/upload.tsx index e98cc8f6..d14d186f 100644 --- a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/contexts/upload.tsx +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/contexts/upload.tsx @@ -25,7 +25,6 @@ type UploadContextType = { state: UploadState; setState: UploadDispatch; onUpload: () => Promise; - removeSpreadsheet: () => void; }; export const UploadContext = @@ -38,11 +37,7 @@ type Props = { export function UploadProvider(props: Props) { const { isDolt } = useIsDolt(); const router = useRouter(); - const { - state: fuState, - dbParams, - setState: setForageState, - } = useFileUploadContext(); + const { state: fuState, dbParams } = useFileUploadContext(); const [state, setState] = useSetState({ ...defaultState, spreadsheetOverlayOpen: @@ -94,22 +89,12 @@ export function UploadProvider(props: Props) { } }; - const removeSpreadsheet = () => { - setForageState({ - spreadsheetRows: undefined, - selectedFile: undefined, - colNames: "", - }); - setState({ error: undefined }); - }; - return ( {props.children} diff --git a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/index.tsx b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/index.tsx index 68be4990..70d8031f 100644 --- a/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/index.tsx +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/index.tsx @@ -33,10 +33,7 @@ function Inner() { title="Upload file" stage={UploadStage.Upload} disabled={ - !!uState.error || - (!state.spreadsheetRows && !state.selectedFile) || - updateLoad || - uState.loading + !!uState.error || !state.selectedFile || updateLoad || uState.loading } onNext={onUpload} backUrl={getUploadUrl("table")} diff --git a/packages/web/components/pageComponents/FileUploadPage/Summary.tsx b/packages/web/components/pageComponents/FileUploadPage/Summary.tsx index 895f0256..b6926ff1 100644 --- a/packages/web/components/pageComponents/FileUploadPage/Summary.tsx +++ b/packages/web/components/pageComponents/FileUploadPage/Summary.tsx @@ -21,9 +21,6 @@ export function getOpVerb(op: ImportOperation): string { } export function getUploadMethod(state: FileUploadState): string { - if (state.spreadsheetRows) { - return " using new spreadsheet"; - } if (state.selectedFile) { return " using uploaded file"; } diff --git a/packages/web/components/pageComponents/FileUploadPage/contexts/fileUploadLocalForage/state.ts b/packages/web/components/pageComponents/FileUploadPage/contexts/fileUploadLocalForage/state.ts index 29eb837f..e7a9c6ea 100644 --- a/packages/web/components/pageComponents/FileUploadPage/contexts/fileUploadLocalForage/state.ts +++ b/packages/web/components/pageComponents/FileUploadPage/contexts/fileUploadLocalForage/state.ts @@ -12,7 +12,6 @@ const defaultState = { tableName: "", importOp: ImportOperation.Update, selectedFile: undefined as File | undefined, // file upload only - spreadsheetRows: undefined as string[][] | undefined, // spreadsheet only fileType: FileType.Csv, modifier: undefined as LoadDataModifier | undefined, colNames: "",