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..eca3676a --- /dev/null +++ b/packages/graphql-server/src/tables/upload.resolver.ts @@ -0,0 +1,71 @@ +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()); + + 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;"); + + const { createReadStream, filename } = await args.file; + + await conn.query({ + sql: getLoadDataQuery( + filename, + args.tableName, + args.fileType, + args.modifier, + ), + infileStreamFactory: createReadStream, + }); + + conn.destroy(); + + return true; + } +} 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/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/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..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}`; 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/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/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/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/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 => ( 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/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/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/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..4e471a86 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/FileInfo/index.tsx @@ -0,0 +1,56 @@ +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, + colNames: "", + }); + if (props.onRemove) props.onRemove(); + }; + + if (!state.selectedFile) return null; + + return ( +
        +
        + + + {state.selectedFile.name} + + {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..f79e7a90 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Layout/index.module.css @@ -0,0 +1,51 @@ +.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; +} + +.databaseLink { + @apply text-white font-normal; + + &: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..16891b41 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Layout/index.tsx @@ -0,0 +1,40 @@ +import Button from "@components/Button"; +import { DatabaseLayoutWrapperOuter } 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 ( + + {/* */} +
        +
        + + + + + {props.params.databaseName} + +
        +

        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..ad9daec7 --- /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 ( +
      1. + + + +
      2. + ); +} + +// 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; + 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..aaa1f665 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/PageWrapper.tsx @@ -0,0 +1,69 @@ +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"; +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 doltRes = useIsDolt(); + const { userHasWritePerms, loading } = useRole(); + const res = useDefaultBranchPageQuery({ + variables: { ...props.params, filterSystemTables: true }, + }); + const dbParams = { + databaseName: props.params.databaseName, + }; + + if (loading || doltRes.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..8171c92f --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/DropZone.tsx @@ -0,0 +1,84 @@ +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 && ( +
        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..6351fb75 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/Buttons/index.module.css @@ -0,0 +1,27 @@ +.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; +} 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..5674d7bd --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/Buttons/index.tsx @@ -0,0 +1,84 @@ +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) { + return ( + onPopupClick(insertAt, insertRow)}> + Row {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)} + {rowButton("below", props.state.selectedCell.rowIdx + 1)} + + )} +
        + )} +
        + { + 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/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 ( + +
      3. + +
      4. +
      5. + +
      6. +
      7. + +
      8. +
        + ); +} 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..203e343d --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/IndexColumn.tsx @@ -0,0 +1,20 @@ +import cx from "classnames"; +import css from "./index.module.css"; +import { Column } from "./types"; + +const cellClass = cx("index-cell", css.rowIndex); +export const indexColumn: Column = { + _idx: -1, + key: "index-column", + name: "", + width: 37, + maxWidth: 37, + resizable: false, + sortable: false, + frozen: true, + editable: 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 new file mode 100644 index 00000000..6b9afa91 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/index.module.css @@ -0,0 +1,74 @@ +.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; +} + +.rowIndex { + @apply font-semibold text-center bg-[#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); + } +} + +.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 new file mode 100644 index 00000000..85893ff9 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/index.tsx @@ -0,0 +1,131 @@ +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 "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 } 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, + }; + }), + ]} + topSummaryRows={[{ _id: -1 }]} + rows={props.state.rows} + rowKeyGetter={row => row._id} + onRowsChange={rows => props.setState({ rows })} + onFill={props.gf.onFill} + className={cx("rdg-light", css.dataGrid)} + style={{ resize: "both" }} + 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, + }, + }); + }} + /> + ); + + return ( +
        +
        +
        * First row should contain column names
        + +
        + + {gridElement} + + {props.state.contextMenuProps !== null && + createPortal( + , + document.body, + )} +
        + ); +} + +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..6eb547d9 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/types.ts @@ -0,0 +1,56 @@ +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 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; + error?: Error; + selectedCell?: { rowIdx: number; idx: number }; + contextMenuProps: ContextMenuProps | null; +}; + +export type GridDispatch = Dispatch>; + +export type RowObj = { rowIdx: number }; + +export type GridFunctions = { + onExport: (g: ReactElement>) => Promise; + onRowDelete: (rowIdx: number) => void; + onRowInsertAbove: (rowIdx: number) => void; + onRowInsertBelow: (rowIdx: number) => void; + onFill: (e: FillEvent) => Row; + insertRow: (i: number) => void; + 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..7c9799ad --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/useGrid.ts @@ -0,0 +1,172 @@ +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, ReturnType, Row } from "./types"; +import { + 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(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, + 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 = (rowIdx: number) => { + 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 = (rowIdx: number) => { + insertRow(rowIdx); + }; + + const onRowInsertBelow = (rowIdx: number) => { + insertRow(rowIdx + 1); + }; + + function onFill({ columnKey, sourceRow, targetRow }: FillEvent): Row { + return { ...targetRow, [columnKey]: sourceRow[columnKey as keyof Row] }; + } + + const handlePaste = (e: ClipboardEvent) => { + e.preventDefault(); + if (!state.selectedCell) return; + const { idx, rowIdx } = state.selectedCell; + const pasteDataRows = defaultParsePaste( + e.clipboardData?.getData("text/plain"), + ); + const allRows = addEmptyRowsForPastedRows( + pasteDataRows, + rowIdx, + state.columns, + ); + const newRows = mergePastedRowsIntoExistingRows( + pasteDataRows, + allRows, + state.columns, + idx, + rowIdx, + ); + setState({ 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, + insertRow, + onFill, + 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..9f0f313c --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/utils.ts @@ -0,0 +1,195 @@ +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"; + +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 { head, body } = await getGridContent(gridElement); + const rows = [...head, ...body]; + 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[][]; +}; + +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-summary-row"), + body: getRows(grid, ".rdg-row:not(.rdg-summary-row)"), + }; + } catch (err) { + console.error(err); + return { head: [], body: [] }; + } + + 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( + existingCols: ColumnForDataTableFragment[], +): GridState { + const numDefaultCols = getNumCols(existingCols); + const columns: Column[] = []; + for (let i = 0; i < numDefaultCols; i++) { + const existing = existingCols[i]; + columns.push(getColumn(i, i, existing.type, existing.name)); + } + return { + columns, + rows: getEmptyRows(columns, defaultNumRows), + contextMenuProps: null, + }; +} + +// GET COLUMNS + +export function getColumn( + id: number, + index: number, + type: string, + name: string, +): Column { + return { + _idx: index, + name: getColumnLetterFromAlphabet(index), + key: `${id}`, + editable: true, + resizable: true, + renderEditCell: textEditor, + width: 215, + cellClass: (row: Row) => { + const cl = getValidationClass(row[id], type); + handleErrorClasses(); + return cl; + }, + renderSummaryCell() { + return name; + }, + }; +} + +// 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(existingCols?: ColumnForDataTableFragment[]): number { + if (existingCols) { + return existingCols.length; + } + return defaultNumCols; +} + +// GET ROWS + +function getEmptyRows(cols: Columns, numRows: number): Row[] { + const rows = []; + for (let i = 0; i < numRows; i++) { + rows.push(getRow(i, i, cols)); + } + return rows; +} + +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; +} + +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), + ]; +} 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..4c880a7b --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/EditableTable/TableGrid/validate.ts @@ -0,0 +1,60 @@ +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(value: string, type?: string): string { + if (!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..b4b96efe --- /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 p-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-5 top-5 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..0c2e8c89 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/OpenSpreadsheetZone.tsx @@ -0,0 +1,40 @@ +import Button from "@components/Button"; +import { GrTable } from "@react-icons/all-files/gr/GrTable"; +import cx from "classnames"; +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 } = useFileUploadContext(); + + const onClick = () => { + setState({ spreadsheetOverlayOpen: true }); + }; + + return ( +
        +
        +
        + +

        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..d14d186f --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/contexts/upload.tsx @@ -0,0 +1,107 @@ +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"; +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; +}; + +export const UploadContext = + createCustomContext("UploadContext"); + +type Props = { + children: ReactNode; +}; + +export function UploadProvider(props: Props) { + const { isDolt } = useIsDolt(); + const router = useRouter(); + const { state: fuState, dbParams } = 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, isDolt), + }); + + 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 }); + } + }; + + 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..70d8031f --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Steps/Upload/index.tsx @@ -0,0 +1,57 @@ +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..b6926ff1 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/Summary.tsx @@ -0,0 +1,28 @@ +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.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..2d3765f2 --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/contexts/fileUploadLocalForage/index.tsx @@ -0,0 +1,127 @@ +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; + isDolt: boolean; +}; + +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, + }); + } + if (!props.isDolt) { + setState({ branchName: "main" }); + } + }); + + // 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, + isDolt: props.isDolt, + }} + > + {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..e7a9c6ea --- /dev/null +++ b/packages/web/components/pageComponents/FileUploadPage/contexts/fileUploadLocalForage/state.ts @@ -0,0 +1,40 @@ +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 + fileType: FileType.Csv, + modifier: undefined as LoadDataModifier | undefined, + colNames: "", +}; + +export type FileUploadState = typeof defaultState; + +export function getDefaultState(p: { + tableName?: string; + branchName?: string; +}): FileUploadState { + 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; + isDolt: boolean; +}; diff --git a/packages/web/components/pageComponents/FileUploadPage/enums.ts b/packages/web/components/pageComponents/FileUploadPage/enums.ts new file mode 100644 index 00000000..f6903700 --- /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, isDolt?: boolean): UploadStage { + switch (s) { + case "branch": + return UploadStage.Branch; + case "table": + return UploadStage.Table; + case "upload": + return UploadStage.Upload; + default: + return isDolt ? UploadStage.Branch : UploadStage.Table; + } +} 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..622bf3da --- /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 { clear, isDolt } = useFileUploadContext(); + const activeStage = getUploadStage(props.stage, isDolt); + + 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 FileUploadPage(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/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/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..229e6516 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,51 @@ export const refetchBranchQueries = ( { query: gen.BranchListDocument, variables }, ]; +export const refetchResetChangesQueries = ( + variables: RefParams, + isDolt = false, +): RefetchQueries => + // const diffVariables: RequiredCommitsParams = { + // ...variables, + // fromCommitId: "HEAD", + // toCommitId: "WORKING", + // }; + [ + ...(isDolt ? [{ 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, + isDolt = false, +) => [ + ...refetchResetChangesQueries(variables, isDolt), + ...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..11722090 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -19,15 +19,18 @@ "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", "chance": "^1.1.11", "classnames": "^2.3.2", "commit-graph": "^1.6.0", "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 +38,7 @@ "react": "latest", "react-ace": "^10.1.0", "react-copy-to-clipboard": "^5.1.0", + "react-data-grid": "7.0.0-beta.40", "react-dom": "latest", "react-flow-renderer": "^10.3.17", "react-hotkeys": "^2.0.0", @@ -43,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" @@ -56,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/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..4e59d1a5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -123,9 +123,9 @@ __metadata: languageName: node linkType: hard -"@apollo/client@npm:^3.8.4": - version: 3.8.4 - resolution: "@apollo/client@npm:3.8.4" +"@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 @@ -155,7 +155,7 @@ __metadata: optional: true subscriptions-transport-ws: optional: true - checksum: 509e37cdce7462cacda0a86c413ce471cd8f618625fb8ac3a60d6347d12f37a4fc60e12fc3fc1a375799caa21e56ff58d709e13ef5e13ab15e4dfc828a527848 + checksum: 34a917d3456c1f728834eaaee00a98f82c7f60de8e0e0c62154667f7e95a635740a2d43fe43f51f85950eb2abe06a3d326f32393a48dd4afc6e4fb4357876dc1 languageName: node linkType: hard @@ -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 @@ -1604,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 @@ -1613,6 +1614,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 @@ -1624,6 +1626,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 @@ -1637,6 +1640,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 +1651,7 @@ __metadata: react: latest react-ace: ^10.1.0 react-copy-to-clipboard: ^5.1.0 + react-data-grid: 7.0.0-beta.40 react-dom: latest react-flow-renderer: ^10.3.17 react-hotkeys: ^2.0.0 @@ -1655,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 @@ -1825,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: @@ -1834,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" @@ -3710,6 +3725,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" @@ -4125,6 +4151,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" @@ -5177,6 +5210,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" @@ -5827,6 +5872,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 +6318,13 @@ __metadata: languageName: node linkType: hard +"clsx@npm:^2.0.0": + version: 2.0.0 + resolution: "clsx@npm:2.0.0" + checksum: a2cfb2351b254611acf92faa0daf15220f4cd648bdf96ce369d729813b85336993871a4bf6978ddea2b81b5a130478339c20d9d0b5c6fc287e5147f0c059276e + languageName: node + linkType: hard + "co@npm:^4.6.0": version: 4.6.0 resolution: "co@npm:4.6.0" @@ -7165,6 +7226,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 +7278,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 +8661,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 +9126,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" @@ -9060,6 +9158,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" @@ -9067,13 +9172,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" @@ -9274,6 +9372,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 +9481,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 +10984,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 +11047,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 +12257,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 +13780,18 @@ __metadata: languageName: node linkType: hard +"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: ^2.0.0 + peerDependencies: + react: ^18.0 + react-dom: ^18.0 + checksum: 2fcc640e16c33b6893e3716dbe786f79316aae1dcc89e624c929e9cd14a329f02a76e2ab4c3b7866b106ffc0f315691f870d83c1c5f708ff568d6144ad610bca + 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" @@ -13775,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: @@ -14751,6 +14928,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 +14944,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"