diff --git a/graphql-server/schema.gql b/graphql-server/schema.gql index fcb679cb6..f0ecbd142 100644 --- a/graphql-server/schema.gql +++ b/graphql-server/schema.gql @@ -339,6 +339,37 @@ type TagList { list: [Tag!]! } +type Test { + _id: ID! + databaseName: String! + refName: String! + testName: String! + testGroup: String! + testQuery: String! + assertionType: String! + assertionComparator: String! + assertionValue: String! +} + +type TestList { + list: [Test!]! +} + +type TestResult { + _id: ID! + databaseName: String! + refName: String! + testName: String! + testGroupName: String + query: String! + status: String! + message: String! +} + +type TestResultList { + list: [TestResult!]! +} + type Query { branch(databaseName: String!, branchName: String!): Branch branchOrDefault(databaseName: String!, branchName: String): Branch @@ -378,6 +409,8 @@ type Query { tables(schemaName: String, refName: String!, databaseName: String!, filterSystemTables: Boolean): [Table!]! tags(databaseName: String!): TagList! tag(databaseName: String!, tagName: String!): Tag + tests(databaseName: String!, refName: String!): TestList! + runTests(refName: String!, databaseName: String!, testIdentifier: TestIdentifierArgs): TestResultList! } enum SortBranchesBy { @@ -405,6 +438,11 @@ enum DiffRowType { All } +input TestIdentifierArgs { + testName: String + groupName: String +} + type Mutation { createBranch(databaseName: String!, newBranchName: String!, fromRefName: String!): String! deleteBranch(databaseName: String!, branchName: String!): Boolean! @@ -426,6 +464,7 @@ type Mutation { restoreAllTables(databaseName: String!, refName: String!): Boolean! createTag(tagName: String!, databaseName: String!, message: String, fromRefName: String!, author: AuthorInfo): String! deleteTag(databaseName: String!, tagName: String!): Boolean! + saveTests(refName: String!, databaseName: String!, tests: TestListArgs!): TestList! } enum ImportOperation { @@ -449,4 +488,17 @@ enum LoadDataModifier { input AuthorInfo { name: String! email: String! +} + +input TestListArgs { + list: [TestArgs!]! +} + +input TestArgs { + testName: String! + testGroup: String! + testQuery: String! + assertionType: String! + assertionComparator: String! + assertionValue: String! } \ No newline at end of file diff --git a/graphql-server/src/queryFactory/dolt/doltEntityManager.ts b/graphql-server/src/queryFactory/dolt/doltEntityManager.ts index 58afd93d3..e6eae4218 100644 --- a/graphql-server/src/queryFactory/dolt/doltEntityManager.ts +++ b/graphql-server/src/queryFactory/dolt/doltEntityManager.ts @@ -1,4 +1,4 @@ -import { EntityManager } from "typeorm"; +import { EntityManager, InsertResult } from "typeorm"; import { SchemaType } from "../../schemas/schema.enums"; import { SchemaItem } from "../../schemas/schema.model"; import { DoltSystemTable } from "../../systemTables/systemTable.enums"; @@ -6,6 +6,7 @@ import { ROW_LIMIT, handleTableNotFound } from "../../utils"; import { tableWithSchema } from "../postgres/utils"; import * as t from "../types"; import { getOrderByColForBranches } from "./queries"; +import { TestArgs } from "../types"; export async function getDoltSchemas( em: EntityManager, @@ -184,3 +185,42 @@ export async function getDoltRemotesPaginated( .limit(ROW_LIMIT + 1) .getRawMany(); } + +export async function getDoltTests(em: EntityManager): t.PR { + return em + .createQueryBuilder() + .select("*") + .from("dolt_tests", "") + .getRawMany(); +} + +export async function saveDoltTests( + em: EntityManager, + tests: TestArgs[], +): Promise { + return em.transaction(async transactionalEntityManager => { + await transactionalEntityManager + .createQueryBuilder() + .delete() + .from("dolt_tests") + .execute(); + + return transactionalEntityManager + .createQueryBuilder() + .insert() + .into("dolt_tests") + .values( + tests.map(t => { + return { + test_name: t.testName, + test_group: t.testGroup, + test_query: t.testQuery, + assertion_type: t.assertionType, + assertion_comparator: t.assertionComparator, + assertion_value: t.assertionValue, + }; + }), + ) + .execute(); + }); +} diff --git a/graphql-server/src/queryFactory/dolt/index.ts b/graphql-server/src/queryFactory/dolt/index.ts index a13465db6..50154f096 100644 --- a/graphql-server/src/queryFactory/dolt/index.ts +++ b/graphql-server/src/queryFactory/dolt/index.ts @@ -16,6 +16,7 @@ import * as t from "../types"; import * as dem from "./doltEntityManager"; import * as qh from "./queries"; import { getAuthorString, handleRefNotFound, unionCols } from "./utils"; +import { InsertResult } from "typeorm"; export class DoltQueryFactory extends MySQLQueryFactory @@ -584,6 +585,38 @@ export class DoltQueryFactory qr.query(qh.callDoltClone, [args.remoteDbPath, args.databaseName]), ); } + + async getTests(args: t.RefArgs): t.PR { + return this.queryForBuilder( + async em => dem.getDoltTests(em), + args.databaseName, + args.refName, + ); + } + + async runTests(args: t.RunTestsArgs): t.PR { + const withTestIdentifierArg = + args.testIdentifier && + (args.testIdentifier.testName !== undefined || + args.testIdentifier.groupName !== undefined); + + return this.query( + qh.doltTestRun(withTestIdentifierArg), + withTestIdentifierArg + ? [args.testIdentifier?.testName ?? args.testIdentifier?.groupName] + : undefined, + args.databaseName, + args.refName, + ); + } + + async saveTests(args: t.SaveTestsArgs): Promise { + return this.queryForBuilder( + async em => dem.saveDoltTests(em, args.tests.list), + args.databaseName, + args.refName, + ); + } } async function getTableInfoWithQR( diff --git a/graphql-server/src/queryFactory/dolt/queries.ts b/graphql-server/src/queryFactory/dolt/queries.ts index a6f1872bb..4a718a948 100644 --- a/graphql-server/src/queryFactory/dolt/queries.ts +++ b/graphql-server/src/queryFactory/dolt/queries.ts @@ -181,3 +181,6 @@ export const callResetHard = `CALL DOLT_RESET("--hard")`; export const callCheckoutTable = `CALL DOLT_CHECKOUT(?)`; export const callDoltClone = `CALL DOLT_CLONE(?,?)`; + +export const doltTestRun = (withArg?: boolean): string => + `SELECT * FROM DOLT_TEST_RUN(${withArg ? "?" : ""})`; diff --git a/graphql-server/src/queryFactory/doltgres/index.ts b/graphql-server/src/queryFactory/doltgres/index.ts index 816f6be10..35e4b4814 100644 --- a/graphql-server/src/queryFactory/doltgres/index.ts +++ b/graphql-server/src/queryFactory/doltgres/index.ts @@ -1,4 +1,4 @@ -import { QueryRunner } from "typeorm"; +import { InsertResult, QueryRunner } from "typeorm"; import { QueryFactory } from ".."; import * as column from "../../columns/column.model"; import { CommitDiffType } from "../../diffSummaries/diffSummary.enums"; @@ -559,6 +559,38 @@ export class DoltgresQueryFactory args.databaseName, ); } + + async getTests(args: t.RefArgs): t.PR { + return this.queryForBuilder( + async em => dem.getDoltTests(em), + args.databaseName, + args.refName, + ); + } + + async runTests(args: t.RunTestsArgs): t.PR { + const withTestIdentifierArg = + args.testIdentifier && + (args.testIdentifier.testName !== undefined || + args.testIdentifier.groupName !== undefined); + + return this.query( + qh.doltTestRun(withTestIdentifierArg), + withTestIdentifierArg + ? [args.testIdentifier?.testName ?? args.testIdentifier?.groupName] + : undefined, + args.databaseName, + args.refName, + ); + } + + async saveTests(args: t.SaveTestsArgs): Promise { + return this.queryForBuilder( + async em => dem.saveDoltTests(em, args.tests.list), + args.databaseName, + args.refName, + ); + } } async function getTableInfoWithQR( diff --git a/graphql-server/src/queryFactory/doltgres/queries.ts b/graphql-server/src/queryFactory/doltgres/queries.ts index 79d5b17a4..52d9dcc9c 100644 --- a/graphql-server/src/queryFactory/doltgres/queries.ts +++ b/graphql-server/src/queryFactory/doltgres/queries.ts @@ -192,3 +192,6 @@ export const callPushRemote = `SELECT DOLT_PUSH($1::text, $2::text)`; export const callFetchRemote = `SELECT DOLT_FETCH($1::text)`; export const callCreateBranchFromRemote = `CALL DOLT_BRANCH($1::text, $2::text)`; + +export const doltTestRun = (withArg?: boolean): string => + `SELECT * FROM DOLT_TEST_RUN(${withArg ? "$1::text" : ""})`; diff --git a/graphql-server/src/queryFactory/index.ts b/graphql-server/src/queryFactory/index.ts index 2d471ef26..64c8cab9d 100644 --- a/graphql-server/src/queryFactory/index.ts +++ b/graphql-server/src/queryFactory/index.ts @@ -1,4 +1,4 @@ -import { DataSource, EntityManager, QueryRunner } from "typeorm"; +import { DataSource, EntityManager, InsertResult, QueryRunner } from "typeorm"; import { CommitDiffType } from "../diffSummaries/diffSummary.enums"; import { SchemaType } from "../schemas/schema.enums"; import { SchemaItem } from "../schemas/schema.model"; @@ -180,4 +180,10 @@ export declare class QueryFactory { callCreateBranchFromRemote(args: t.RemoteArgs): t.PR; getMergeBase(args: t.RefsArgs): Promise; + + getTests(args: t.RefArgs): t.PR; + + runTests(args: t.RunTestsArgs): t.PR; + + saveTests(args: t.SaveTestsArgs): Promise; } diff --git a/graphql-server/src/queryFactory/mysql/index.ts b/graphql-server/src/queryFactory/mysql/index.ts index 6b86c1f86..f944a35dc 100644 --- a/graphql-server/src/queryFactory/mysql/index.ts +++ b/graphql-server/src/queryFactory/mysql/index.ts @@ -1,4 +1,4 @@ -import { EntityManager, QueryRunner } from "typeorm"; +import { EntityManager, InsertResult, QueryRunner } from "typeorm"; import { QueryFactory } from ".."; import { SchemaType } from "../../schemas/schema.enums"; import { SchemaItem } from "../../schemas/schema.model"; @@ -367,4 +367,16 @@ export class MySQLQueryFactory async callCreateBranchFromRemote(_: t.RemoteBranchArgs): t.PR { throw notDoltError("create branch from remote"); } + + async getTests(_: t.RefArgs): t.PR { + throw notDoltError("get tests"); + } + + async runTests(_: t.RefArgs): t.PR { + throw notDoltError("run tests"); + } + + async saveTests(_: t.SaveTestsArgs): Promise { + throw notDoltError("save tests"); + } } diff --git a/graphql-server/src/queryFactory/types.ts b/graphql-server/src/queryFactory/types.ts index 327f6c46e..322439c76 100644 --- a/graphql-server/src/queryFactory/types.ts +++ b/graphql-server/src/queryFactory/types.ts @@ -68,3 +68,28 @@ export type TableRowPagination = { pkCols: string[]; offset: number }; export type DiffRes = Promise<{ colsUnion: RawRows; diff: RawRows }>; export type CommitsRes = Promise<{ fromCommitId: string; toCommitId: string }>; export type CommitAuthor = { name: string; email: string }; + +export type TestArgs = { + testName: string; + testGroup: string; + testQuery: string; + assertionType: string; + assertionComparator: string; + assertionValue: string; +}; + +export type TestListArgs = { + list: TestArgs[]; +}; +export type SaveTestsArgs = RefArgs & { + tests: TestListArgs; +}; + +export type TestIdentifierArgs = { + testName?: string; + groupName?: string; +}; + +export type RunTestsArgs = RefArgs & { + testIdentifier?: TestIdentifierArgs; +}; diff --git a/graphql-server/src/resolvers.ts b/graphql-server/src/resolvers.ts index 241a6e7d4..05702181d 100644 --- a/graphql-server/src/resolvers.ts +++ b/graphql-server/src/resolvers.ts @@ -16,6 +16,7 @@ import { StatusResolver } from "./status/status.resolver"; import { TableResolver } from "./tables/table.resolver"; import { FileUploadResolver } from "./tables/upload.resolver"; import { TagResolver } from "./tags/tag.resolver"; +import { TestResolver } from "./tests/test.resolver"; const resolvers = [ BranchResolver, @@ -36,6 +37,7 @@ const resolvers = [ StatusResolver, TableResolver, TagResolver, + TestResolver, ]; export default resolvers; diff --git a/graphql-server/src/systemTables/systemTable.enums.ts b/graphql-server/src/systemTables/systemTable.enums.ts index b5d09e542..9c4a57cb2 100644 --- a/graphql-server/src/systemTables/systemTable.enums.ts +++ b/graphql-server/src/systemTables/systemTable.enums.ts @@ -3,6 +3,7 @@ export enum DoltSystemTable { QUERY_CATALOG = "dolt_query_catalog", SCHEMAS = "dolt_schemas", PROCEDURES = "dolt_procedures", + TESTS = "dolt_tests", } export const systemTableValues = Object.values(DoltSystemTable); diff --git a/graphql-server/src/tests/test.model.ts b/graphql-server/src/tests/test.model.ts new file mode 100644 index 000000000..eb7d110ad --- /dev/null +++ b/graphql-server/src/tests/test.model.ts @@ -0,0 +1,107 @@ +import { Field, ID, ObjectType } from "@nestjs/graphql"; +import { RawRow } from "../queryFactory/types"; +import { ObjectLiteral } from "typeorm"; + +@ObjectType() +export class Test { + @Field(_type => ID) + _id: string; + + @Field() + databaseName: string; + + @Field() + refName: string; + + @Field() + testName: string; + + @Field() + testGroup: string; + + @Field() + testQuery: string; + + @Field() + assertionType: string; + + @Field() + assertionComparator: string; + + @Field() + assertionValue: string; +} + +@ObjectType() +export class TestList { + @Field(_type => [Test]) + list: Test[]; +} + +@ObjectType() +export class TestResult { + @Field(_type => ID) + _id: string; + + @Field() + databaseName: string; + + @Field() + refName: string; + + @Field() + testName: string; + + @Field({ nullable: true }) + testGroupName?: string; + + @Field() + query: string; + + @Field() + status: string; + + @Field() + message: string; +} + +@ObjectType() +export class TestResultList { + @Field(_type => [TestResult]) + list: TestResult[]; +} + +export function fromDoltTestRowRes( + databaseName: string, + refName: string, + test: RawRow | ObjectLiteral, +): Test { + return { + _id: `databases/${databaseName}/refs/${refName}/tests/${test.test_name}`, + databaseName: databaseName, + refName: refName, + testName: test.test_name, + testGroup: test.test_group, + testQuery: test.test_query, + assertionType: test.assertion_type, + assertionComparator: test.assertion_comparator, + assertionValue: test.assertion_value, + }; +} + +export function fromDoltTestResultRowRes( + databaseName: string, + refName: string, + testResult: RawRow, +): TestResult { + return { + _id: `databases/${databaseName}/refs/${refName}/testResults/${testResult.test_name}`, + databaseName: databaseName, + refName: refName, + testName: testResult.test_name, + testGroupName: testResult.test_group_name, + query: testResult.query, + status: testResult.status, + message: testResult.message, + }; +} diff --git a/graphql-server/src/tests/test.resolver.ts b/graphql-server/src/tests/test.resolver.ts new file mode 100644 index 000000000..7e26520e3 --- /dev/null +++ b/graphql-server/src/tests/test.resolver.ts @@ -0,0 +1,104 @@ +import { + Args, + ArgsType, + Field, + InputType, + Mutation, + Query, + Resolver, +} from "@nestjs/graphql"; +import { ConnectionProvider } from "../connections/connection.provider"; +import { RefArgs } from "../utils/commonTypes"; +import { + Test, + TestList, + fromDoltTestRowRes, + TestResultList, + fromDoltTestResultRowRes, +} from "./test.model"; + +@InputType() +class TestArgs { + @Field() + testName: string; + + @Field() + testGroup: string; + + @Field() + testQuery: string; + + @Field() + assertionType: string; + + @Field() + assertionComparator: string; + + @Field() + assertionValue: string; +} + +@InputType() +class TestListArgs { + @Field(_type => [TestArgs]) + list: TestArgs[]; +} + +@ArgsType() +class SaveTestsArgs extends RefArgs { + @Field() + tests: TestListArgs; +} + +@InputType() +class TestIdentifierArgs { + @Field({ nullable: true }) + testName?: string; + + @Field({ nullable: true }) + groupName?: string; +} + +@ArgsType() +class RunTestsArgs extends RefArgs { + @Field({ nullable: true }) + testIdentifier?: TestIdentifierArgs; +} + +@Resolver(_of => Test) +export class TestResolver { + constructor(private readonly conn: ConnectionProvider) {} + + @Query(_returns => TestList) + async tests(@Args() args: RefArgs): Promise { + const conn = this.conn.connection(); + const res = await conn.getTests(args); + return { + list: res.map(t => + fromDoltTestRowRes(args.databaseName, args.refName, t), + ), + }; + } + + @Query(_returns => TestResultList) + async runTests(@Args() args: RunTestsArgs): Promise { + const conn = this.conn.connection(); + const res = await conn.runTests(args); + return { + list: res.map(t => + fromDoltTestResultRowRes(args.databaseName, args.refName, t), + ), + }; + } + + @Mutation(_returns => TestList) + async saveTests(@Args() args: SaveTestsArgs): Promise { + const conn = this.conn.connection(); + const res = await conn.saveTests(args); + return { + list: res.generatedMaps.map(t => + fromDoltTestRowRes(args.databaseName, args.refName, t), + ), + }; + } +} diff --git a/web/renderer/components/DatabaseNav/index.module.css b/web/renderer/components/DatabaseNav/index.module.css index 6c9ffe69e..9fd1b7cef 100644 --- a/web/renderer/components/DatabaseNav/index.module.css +++ b/web/renderer/components/DatabaseNav/index.module.css @@ -17,7 +17,7 @@ } li { - @apply min-w-[150px]; + @apply min-w-[130px]; } @screen lg { diff --git a/web/renderer/components/DatabaseNav/index.tsx b/web/renderer/components/DatabaseNav/index.tsx index 462600d52..5b11fbb59 100644 --- a/web/renderer/components/DatabaseNav/index.tsx +++ b/web/renderer/components/DatabaseNav/index.tsx @@ -1,5 +1,6 @@ import NotDoltWrapper from "@components/util/NotDoltWrapper"; import { Tooltip } from "@dolthub/react-components"; +import useDatabaseDetails from "@hooks/useDatabaseDetails"; import { OptionalRefParams } from "@lib/params"; import NavItem from "./Item"; import Wrapper from "./Wrapper"; @@ -24,11 +25,18 @@ export default function DatabaseNav(props: Props) { } function Inner(props: Props) { + const { isDolt, isPostgres } = useDatabaseDetails(); + return (
    {tabs.map((tab, i) => { + // Hide Tests tab for doltgres databases for now + if (tab === "Tests" && isDolt && isPostgres) { + return null; + } + const item = ; if (tab === "Database") { return item; diff --git a/web/renderer/components/DatabaseNav/tabs.ts b/web/renderer/components/DatabaseNav/tabs.ts index aeba46d92..8e9b74122 100644 --- a/web/renderer/components/DatabaseNav/tabs.ts +++ b/web/renderer/components/DatabaseNav/tabs.ts @@ -5,4 +5,5 @@ export const tabs = [ "Releases", "Pull Requests", "Remotes", + "Tests", ]; diff --git a/web/renderer/components/DatabaseNav/utils.ts b/web/renderer/components/DatabaseNav/utils.ts index 4bd859867..c316d22aa 100644 --- a/web/renderer/components/DatabaseNav/utils.ts +++ b/web/renderer/components/DatabaseNav/utils.ts @@ -10,6 +10,7 @@ import { RefUrl, releases, remotes, + tests, } from "@lib/urls"; function getUrlFromName(name: string): [DatabaseUrl, RefUrl?] { @@ -26,6 +27,8 @@ function getUrlFromName(name: string): [DatabaseUrl, RefUrl?] { return [pulls]; case "Remotes": return [remotes]; + case "Tests": + return [database, tests]; default: return [database, ref]; } diff --git a/web/renderer/components/pageComponents/DatabasePage/ForPulls/PullActions/Merge/Arrow.tsx b/web/renderer/components/pageComponents/DatabasePage/ForPulls/PullActions/Merge/Arrow.tsx index 3c812e696..20d002d3d 100644 --- a/web/renderer/components/pageComponents/DatabasePage/ForPulls/PullActions/Merge/Arrow.tsx +++ b/web/renderer/components/pageComponents/DatabasePage/ForPulls/PullActions/Merge/Arrow.tsx @@ -1,13 +1,18 @@ import cx from "classnames"; import css from "./index.module.css"; -export function Arrow(props: { red?: boolean; green?: boolean }) { +export function Arrow(props: { + red?: boolean; + green?: boolean; + orange?: boolean; +}) { return (
    diff --git a/web/renderer/components/pageComponents/DatabasePage/ForPulls/PullActions/Merge/index.module.css b/web/renderer/components/pageComponents/DatabasePage/ForPulls/PullActions/Merge/index.module.css index c61980542..48c5f7276 100644 --- a/web/renderer/components/pageComponents/DatabasePage/ForPulls/PullActions/Merge/index.module.css +++ b/web/renderer/components/pageComponents/DatabasePage/ForPulls/PullActions/Merge/index.module.css @@ -1,3 +1,15 @@ +.wrapper { + @apply flex justify-between mt-4 flex-col-reverse; + @screen md { + @apply flex-row; + } +} + +.mergeDetails { + @screen md { + @apply w-9/12 mx-auto; + } +} .outer { @apply mx-auto max-w-3xl flex flex-col; @@ -6,14 +18,14 @@ } @screen lg { - @apply mt-6 flex-row; + @apply flex-row; } } .container { @apply w-full mb-4 rounded-xl min-w-[15rem]; @screen lg { - @apply mx-3 mb-0; + @apply w-[calc(100%-4rem)] mx-3 mb-0; } } @@ -140,6 +152,10 @@ @apply bg-green-50 border-green-500; } +.arrowOrange { + @apply bg-orange-50 border-orange-500; +} + .arrowLeft { @apply w-4 h-4 absolute rounded-sm rotate-45 border-l border-t -top-2 left-3; @screen lg { @@ -150,3 +166,265 @@ .conflictsLoader { @apply mb-2 ml-0; } + +.testRow { + @apply bg-indigo-50 border-x border-indigo-500 px-6 py-3 text-sm; +} + +.testRowPassed { + @apply bg-green-50 border-green-500 text-green-600; +} + +.testRowFailed { + @apply bg-red-50 border-red-500 text-red-600; +} + +.testContent { + @apply flex justify-between items-start flex-wrap w-full; + + @screen lg { + @apply items-center; + } +} + +.testInfo { + @apply flex-1 mr-4; +} + +.testActions { + @apply flex flex-row gap-2 items-center justify-end; +} + +.testButton { + @apply px-3 py-1.5 flex items-center justify-center rounded-md border border-stone-300 bg-white hover:bg-stone-50 transition-colors text-sm font-medium; + + &:disabled { + @apply opacity-50 cursor-not-allowed; + } + + &:hover:not(:disabled) { + @apply border-stone-400; + } +} + +.buttonContent { + @apply flex items-center gap-1.5; +} + +.buttonIcon { + @apply w-3 h-3 text-green-600; +} + +.testErrors { + @apply mt-2 ml-4; + + li { + @apply mb-1 text-xs; + } +} + +.dropdown { + @apply relative; +} + +.dropdownButton { + @apply flex items-center gap-1.5; +} + +.chevron { + @apply w-4 h-4 transition-transform duration-200; +} + +.chevronOpen { + @apply rotate-180; +} + +.dropdownMenu { + @apply absolute right-0 top-full mt-2 bg-white border border-stone-300 rounded-lg shadow-xl z-50 min-w-[160px] py-1; + box-shadow: + 0 10px 25px -5px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); +} + +.dropdownItem { + @apply w-full text-left px-4 py-2.5 text-sm text-stone-700 hover:bg-stone-50 hover:text-stone-700 border-none bg-transparent cursor-pointer transition-colors duration-150 font-medium; + + &:first-child { + @apply rounded-t-lg; + } + + &:last-child { + @apply rounded-b-lg; + } + + &:hover { + @apply bg-indigo-50 text-indigo-700; + } +} + +.dropdownLink { + @apply block w-full text-left px-4 py-2.5 text-sm text-stone-700 hover:bg-indigo-50 hover:text-indigo-700 no-underline transition-colors duration-150 font-medium; + + &:first-child { + @apply rounded-t-lg; + } + + &:last-child { + @apply rounded-b-lg; + } +} + +.testResultsOuter { + @apply mx-auto max-w-3xl flex flex-col mb-4 mt-4; + + h3 { + @apply text-center mt-4 mb-4; + } + + @screen lg { + @apply flex-row; + } +} + +.testResultsContainer { + @apply w-full mb-0 rounded-xl min-w-[15rem]; + @screen lg { + @apply w-[calc(100%-4rem)] mx-3 mb-0; + } +} + +.testResultsTop { + @apply bg-green-50 pl-4 pr-4 py-2 border border-green-500 flex justify-between items-start rounded-t-xl flex-wrap w-full; + + @screen lg { + @apply items-center pl-6; + } +} + +.testResultsPicContainer { + @apply w-7 h-7 mb-4 mr-1 text-white relative rounded-full bg-green-400 flex-shrink-0; + + @screen lg { + @apply mb-0 mt-3 ml-2; + } + svg { + @apply text-white mt-1 ml-1 w-5 h-5; + } +} + +.testResultsRedIcon { + @apply bg-red-300; +} + +.testResultsOrangeIcon { + @apply bg-orange-300; +} + +.testResultsTests { + @apply border-x border-b rounded-b-xl bg-white border-green-500; + li:last-child { + @apply rounded-b-xl overflow-hidden; + } +} + +.testResultsGreen { + @apply text-green-500; +} + +.testResultsTestsStatusIcon { + @apply inline-block mr-2 text-2xl; +} + +.testResultsRed { + @apply text-red-400 flex border-red-400 bg-coral-50; +} + +.testResultsTestsRed { + @apply border-red-400; +} + +.testResultsOrange { + @apply text-orange-500 flex border-orange-300 bg-orange-50; +} + +.testResultsTestsOrange { + @apply border-orange-300; +} + +.testResultsTestsGreen { + @apply border-green-500; +} + +.testResultsNoTests { + @apply py-4 px-6 text-center text-stone-500 text-sm; +} + +.testResultsRunButton { + @apply mx-2; + @screen lg { + @apply mx-0; + } +} + +.testResultsTitleSection { + @apply flex items-center; +} + +.testResultsGreenButton { + @apply bg-green-500 text-white border-green-500; +} + +.testResultsRedButton { + @apply bg-red-500 text-white border-red-500; +} + +.testResultsOrangeButton { + @apply bg-orange-500 text-white border-orange-500; +} + +.testResultsItemContainer { + @apply border-b border-stone-300 py-3 px-4 flex items-start gap-3 hover:bg-stone-50 transition-colors; +} + +li:last-child .testResultsItemContainer { + @apply border-b-0; +} + +.testResultsIcon { + @apply flex-shrink-0 mt-0.5; + svg { + @apply w-4 h-4; + } +} + +.testResultsSuccessIcon { + @apply text-green-500; +} + +.testResultsFailureIcon { + @apply text-red-400; +} + +.testResultsPendingIcon { + @apply text-orange-500; +} + +.testResultsContent { + @apply flex-1 min-w-0; +} + +.testResultsTestTitle { + @apply flex flex-col gap-1; +} + +.testResultsTestName { + @apply font-medium text-stone-700 text-sm; +} + +.testResultsTestMessage { + @apply text-xs text-stone-600 break-words; +} + +.testResultsLinkContent { + @apply flex items-start gap-3 w-full no-underline; +} diff --git a/web/renderer/components/pageComponents/DatabasePage/ForPulls/PullActions/Merge/index.tsx b/web/renderer/components/pageComponents/DatabasePage/ForPulls/PullActions/Merge/index.tsx index d9a1d50b4..cc6e6467f 100644 --- a/web/renderer/components/pageComponents/DatabasePage/ForPulls/PullActions/Merge/index.tsx +++ b/web/renderer/components/pageComponents/DatabasePage/ForPulls/PullActions/Merge/index.tsx @@ -1,12 +1,23 @@ +import { useCallback, useState } from "react"; import HeaderUserCheckbox from "@components/HeaderUserCheckbox"; import { Button, SmallLoader } from "@dolthub/react-components"; -import { PullDetailsFragment } from "@gen/graphql-types"; +import { + PullDetailsFragment, + TestResult, + useRunTestsLazyQuery, + useTestListQuery, +} from "@gen/graphql-types"; import useDatabaseDetails from "@hooks/useDatabaseDetails"; import { ApolloErrorType } from "@lib/errors/types"; -import { PullDiffParams } from "@lib/params"; +import { PullDiffParams, RefParams } from "@lib/params"; import { FiGitPullRequest } from "@react-icons/all-files/fi/FiGitPullRequest"; +import { FiCheck } from "@react-icons/all-files/fi/FiCheck"; +import { FiX } from "@react-icons/all-files/fi/FiX"; +import { FiCircle } from "@react-icons/all-files/fi/FiCircle"; +import { excerpt } from "@dolthub/web-utils"; +import Link from "@components/links/Link"; +import { tests as testsUrl } from "@lib/urls"; import cx from "classnames"; -import { useState } from "react"; import { Arrow } from "./Arrow"; import ErrorsWithDirections from "./ErrorsWithDirections"; import MergeConflictsDirections from "./ErrorsWithDirections/MergeConflictsDirections"; @@ -35,66 +46,72 @@ export default function Merge(props: Props) { mergeState, resolveState, } = useMergeButton(props.params); + const { isPostgres } = useDatabaseDetails(); const red = hasConflicts; return ( -
    - - - - -
    -
    - - - {mergeState.err && ( - setState({ showDirections: s })} - /> - )} -
    - -
    - setState({ addAuthor: a })} - userHeaders={userHeaders} - className={css.userCheckbox} - kind="merge commit" - /> - - {hasConflicts && ( - - )} - - View{" "} - - setState({ showDirections: !state.showDirections }) - } - > - manual merge instructions - - . +
    +
    + {!isPostgres && } +
    + + - {state.showDirections && } + +
    +
    + + + {mergeState.err && ( + setState({ showDirections: s })} + /> + )} +
    + +
    + setState({ addAuthor: a })} + userHeaders={userHeaders} + className={css.userCheckbox} + kind="merge commit" + /> + + {hasConflicts && ( + + )} + + View{" "} + + setState({ showDirections: !state.showDirections }) + } + > + manual merge instructions + + . + + {state.showDirections && } +
    +
    @@ -147,3 +164,268 @@ function MergeButton(props: MergeButtonProps) {
    ); } + +type TestStatusColors = { + red: boolean; + orange: boolean; + green: boolean; +}; + +function TestResultsTitle({ + red, + green, + orange, + onRunTests, +}: TestStatusColors & { onRunTests: () => void }) { + return ( + <> +
    + + Tests +
    + + + ); +} + +function TestResultsIconSwitch({ + red, + green, + orange, + className, +}: TestStatusColors & { className?: string }) { + if (red) return ; + if (orange) return ; + if (green) return ; + return null; +} + +function TestTitle({ test }: { test: TestResult }) { + return ( +
    + + {excerpt(test.testName, 50)} + + {test.message && ( + + {excerpt(test.message, 100)} + + )} +
    + ); +} + +function TestResultsListItemIconSwitch({ test }: { test: TestResult }) { + if (test.status === "PASS") { + return ; + } + return ; +} + +function TestResultsListItem({ + test, + params, +}: { + test: TestResult; + params: RefParams; +}) { + const isSuccess = test.status === "PASS"; + const isFailure = !isSuccess; + + return ( +
  • + +
    + +
    +
    + +
    + +
  • + ); +} + +function TestResults({ params }: { params: RefParams }) { + const [testResults, setTestResults] = useState([]); + const [runTestError, setRunTestError] = useState(null); + const [runTests, { error: runTestsError }] = useRunTestsLazyQuery(); + const { data, loading, error } = useTestListQuery({ + variables: { + databaseName: params.databaseName, + refName: params.refName, + }, + }); + + const handleRunTests = useCallback(async () => { + setRunTestError(null); + try { + const result = await runTests({ + variables: { + databaseName: params.databaseName, + refName: params.refName, + }, + }); + + if (result.error) { + console.error("Error running tests:", result.error); + setRunTestError(result.error.message); + setTestResults([]); + return; + } + + setTestResults(result.data?.runTests.list ?? []); + } catch (err) { + console.error("Error running tests:", err); + setRunTestError( + err instanceof Error ? err.message : "Failed to run tests", + ); + setTestResults([]); + } + }, [runTests, params.databaseName, params.refName]); + + if (loading) { + return ( +
    + +
    + ); + } + + if (error) { + console.error("Error loading test list:", error); + return ( +
    +
    +
    + + Failed to load tests: {error.message} + +
    +
    +
    + ); + } + + if (runTestsError) { + console.error("Run Tests query error:", runTestsError); + } + + if (!data?.tests.list || data.tests.list.length === 0) { + return null; + } + + const { red, orange, green } = getTestStatusColors(testResults); + + return ( +
    + + + + +
    +
    + +
    +
    + {(runTestError || runTestsError) && ( +
    + Error running tests: {runTestError || runTestsError?.message} +
    + )} +
      + {testResults.length > 0 ? ( + testResults.map(test => ( + + )) + ) : ( +
    • + Run tests to see results here. +
    • + )} +
    +
    +
    +
    + ); +} + +function getTestStatusColors(tests: TestResult[]): TestStatusColors { + if (tests.length === 0) { + return { + red: false, + green: false, + orange: true, + }; + } + + const failedTests = tests.filter(t => t.status !== "PASS"); + + if (failedTests.length > 0) { + return { + red: true, + green: false, + orange: false, + }; + } + + return { + red: false, + green: true, + orange: false, + }; +} diff --git a/web/renderer/components/pageComponents/DatabasePage/ForTests/ConfirmationModal/index.module.css b/web/renderer/components/pageComponents/DatabasePage/ForTests/ConfirmationModal/index.module.css new file mode 100644 index 000000000..58435f156 --- /dev/null +++ b/web/renderer/components/pageComponents/DatabasePage/ForTests/ConfirmationModal/index.module.css @@ -0,0 +1,7 @@ +.modalInner { + @apply pb-2; +} + +.message { + @apply text-stone-600 m-0 leading-relaxed; +} diff --git a/web/renderer/components/pageComponents/DatabasePage/ForTests/ConfirmationModal/index.tsx b/web/renderer/components/pageComponents/DatabasePage/ForTests/ConfirmationModal/index.tsx new file mode 100644 index 000000000..31fbf23b0 --- /dev/null +++ b/web/renderer/components/pageComponents/DatabasePage/ForTests/ConfirmationModal/index.tsx @@ -0,0 +1,40 @@ +import { + Button, + ModalButtons, + ModalInner, + ModalOuter, +} from "@dolthub/react-components"; +import css from "./index.module.css"; + +type Props = { + isOpen: boolean; + title: string; + message: string; + confirmText?: string; + onConfirm: () => void; + onCancel: () => void; + destructive?: boolean; +}; + +export default function ConfirmationModal({ + isOpen, + title, + message, + confirmText = "Confirm", + onConfirm, + onCancel, + destructive = false, +}: Props) { + return ( + + +

    {message}

    +
    + + + +
    + ); +} diff --git a/web/renderer/components/pageComponents/DatabasePage/ForTests/NewGroupModal/index.module.css b/web/renderer/components/pageComponents/DatabasePage/ForTests/NewGroupModal/index.module.css new file mode 100644 index 000000000..46121127e --- /dev/null +++ b/web/renderer/components/pageComponents/DatabasePage/ForTests/NewGroupModal/index.module.css @@ -0,0 +1,19 @@ +.overlay { + @apply fixed inset-0 bg-stone-700 bg-opacity-50 flex items-center justify-center z-50; +} + +.modal { + @apply bg-white rounded-lg p-6 w-96 border border-stone-300 shadow-lg; +} + +.title { + @apply text-lg font-semibold mb-4; +} + +.fieldInput { + @apply w-full p-2 border border-stone-100 rounded text-sm; +} + +.buttonGroup { + @apply flex gap-2 mt-4; +} diff --git a/web/renderer/components/pageComponents/DatabasePage/ForTests/NewGroupModal/index.tsx b/web/renderer/components/pageComponents/DatabasePage/ForTests/NewGroupModal/index.tsx new file mode 100644 index 000000000..1a6482f37 --- /dev/null +++ b/web/renderer/components/pageComponents/DatabasePage/ForTests/NewGroupModal/index.tsx @@ -0,0 +1,61 @@ +import { + Button, + FormInput, + ModalButtons, + ModalInner, + ModalOuter, +} from "@dolthub/react-components"; +import { SyntheticEvent } from "react"; + +type Props = { + isOpen: boolean; + groupName: string; + onGroupNameChange: (name: string) => void; + onCreateGroup: () => void; + onClose: () => void; +}; + +export default function NewGroupModal({ + isOpen, + groupName, + onGroupNameChange, + onCreateGroup, + onClose, +}: Props) { + const handleClose = () => { + onGroupNameChange(""); + onClose(); + }; + + const handleSubmit = (e: SyntheticEvent) => { + e.preventDefault(); + if (groupName.trim()) { + onCreateGroup(); + } + }; + + return ( + +
    + + + + + + +
    +
    + ); +} diff --git a/web/renderer/components/pageComponents/DatabasePage/ForTests/QueryEditor/index.module.css b/web/renderer/components/pageComponents/DatabasePage/ForTests/QueryEditor/index.module.css new file mode 100644 index 000000000..7dac06636 --- /dev/null +++ b/web/renderer/components/pageComponents/DatabasePage/ForTests/QueryEditor/index.module.css @@ -0,0 +1,3 @@ +.container { + @apply bg-storm-600 rounded-sm border border-storm-500 overflow-hidden p-2; +} diff --git a/web/renderer/components/pageComponents/DatabasePage/ForTests/QueryEditor/index.tsx b/web/renderer/components/pageComponents/DatabasePage/ForTests/QueryEditor/index.tsx new file mode 100644 index 000000000..4cfb097ce --- /dev/null +++ b/web/renderer/components/pageComponents/DatabasePage/ForTests/QueryEditor/index.tsx @@ -0,0 +1,36 @@ +import dynamic from "next/dynamic"; +import css from "./index.module.css"; + +const AceEditor = dynamic(async () => import("@components/AceEditor"), { + ssr: false, +}); + +type Props = { + value: string; + onChange: (value: string) => void; + placeholder?: string; +}; + +export default function QueryEditor({ value, onChange, placeholder }: Props) { + return ( +
    + +
    + ); +} diff --git a/web/renderer/components/pageComponents/DatabasePage/ForTests/TestGroup/index.module.css b/web/renderer/components/pageComponents/DatabasePage/ForTests/TestGroup/index.module.css new file mode 100644 index 000000000..e46ac201d --- /dev/null +++ b/web/renderer/components/pageComponents/DatabasePage/ForTests/TestGroup/index.module.css @@ -0,0 +1,115 @@ +.groupHeader { + @apply bg-stone-50 rounded mb-2 cursor-pointer; + transition: all 0.2s ease; +} + +.groupHeader:hover { + @apply bg-stone-50; +} + +.groupExpanded { + @apply mb-2; +} + +.groupHeaderContent { + @apply flex justify-between items-center py-3 pl-3 pr-9; +} + +.groupHeaderLeft { + @apply flex items-center cursor-pointer; +} + +.groupHeaderRight { + @apply flex items-center gap-1; +} + +.groupExpandIcon { + @apply mr-3 transition-transform duration-200 text-storm-200; +} + +.groupExpanded .groupExpandIcon { + @apply rotate-90; +} + +.groupName { + @apply text-base font-semibold text-storm-500 mr-3; +} + +.testCount { + @apply text-sm text-storm-200 font-medium; +} + +.inlineEditInput { + @apply bg-transparent border border-transparent text-base font-semibold text-storm-500 px-1 py-0.5 mt-0 mb-0 ml-0 mr-3 outline-none rounded transition-all; + min-width: 200px; +} + +.inlineEditInput:hover { + @apply bg-stone-50 border-stone-300; +} + +.inlineEditInput:focus { + @apply bg-stone-50 border-sky-400 shadow-sm; +} + +.groupActionBtn { + @apply p-1.5 text-xs rounded opacity-75 transition-opacity duration-200; +} + +.groupActionBtn:hover { + @apply opacity-100; +} + +.groupActionBtn.deleteBtn { + @apply opacity-0 transition-opacity duration-200; +} + +.groupHeader:hover .groupActionBtn.deleteBtn { + @apply opacity-75; +} + +.groupActionBtn.deleteBtn:hover { + @apply opacity-100; +} + +.runBtn { + @apply text-green-600 mr-3; +} + +.runBtn:hover { + @apply text-green-400; +} + +.groupActionBtn.createBtn { + @apply text-sky-600 opacity-0 transition-opacity duration-200; +} + +.groupHeader:hover .groupActionBtn.createBtn { + @apply opacity-75; +} + +.groupActionBtn.createBtn:hover { + @apply text-sky-400 opacity-100; +} + +.groupResult { + @apply flex items-center gap-1 px-2 py-1 rounded text-sm text-stone-700; + min-width: 80px; + justify-content: center; +} + +.groupResultPassed { + @apply bg-green-100; +} + +.groupResultFailed { + @apply bg-red-100; +} + +.groupResultIcon { + @apply w-3 h-3; +} + +.confirmDeleteModal { + @apply text-stone-700; +} diff --git a/web/renderer/components/pageComponents/DatabasePage/ForTests/TestGroup/index.tsx b/web/renderer/components/pageComponents/DatabasePage/ForTests/TestGroup/index.tsx new file mode 100644 index 000000000..4244324bc --- /dev/null +++ b/web/renderer/components/pageComponents/DatabasePage/ForTests/TestGroup/index.tsx @@ -0,0 +1,210 @@ +import { FaChevronRight } from "@react-icons/all-files/fa/FaChevronRight"; +import { FaPlay } from "@react-icons/all-files/fa/FaPlay"; +import { FaTrash } from "@react-icons/all-files/fa/FaTrash"; +import { FaCheck } from "@react-icons/all-files/fa/FaCheck"; +import { FaTimes } from "@react-icons/all-files/fa/FaTimes"; +import { FaPlus } from "@react-icons/all-files/fa/FaPlus"; +import { Button } from "@dolthub/react-components"; +import { useState, KeyboardEvent, ChangeEvent, MouseEvent } from "react"; +import cx from "classnames"; +import css from "./index.module.css"; +import ConfirmationModal from "../ConfirmationModal"; +import { pluralize } from "@dolthub/web-utils"; +import { useTestContext } from "../context"; +import { getGroupResult } from "@pageComponents/DatabasePage/ForTests/utils"; + +type Props = { + group: string; + className?: string; +}; + +export default function TestGroup({ group, className }: Props) { + const { + tests, + expandedGroups, + groupedTests, + testResults, + emptyGroups, + toggleGroupExpanded, + handleRunGroup, + setState, + handleCreateTest, + } = useTestContext(); + + const groupName = group || "No Group"; + const [localGroupName, setLocalGroupName] = useState(groupName); + const [isEditing, setIsEditing] = useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + const isExpanded = expandedGroups.has(group); + const testCount = groupedTests[group].length || 0; + const groupResult = getGroupResult(group, groupedTests, testResults); + + const handleRunGroupClick = async (e: MouseEvent) => { + e.stopPropagation(); + await handleRunGroup(group); + }; + + const handleDeleteGroup = (groupName: string) => { + const newExpandedGroups = new Set(expandedGroups); + newExpandedGroups.delete(groupName); + const newEmptyGroups = new Set(emptyGroups); + newEmptyGroups.delete(groupName); + setState({ + tests: tests.filter(test => test.testGroup !== groupName), + expandedGroups: newExpandedGroups, + emptyGroups: newEmptyGroups, + hasUnsavedChanges: true, + }); + setShowDeleteConfirm(false); + }; + + const handleRenameGroup = (oldName: string, newName: string) => { + if (newName.trim() && oldName !== newName.trim()) { + const newExpandedGroups = new Set(expandedGroups); + if (newExpandedGroups.has(oldName)) { + newExpandedGroups.delete(oldName); + newExpandedGroups.add(newName.trim()); + } + setState({ + tests: tests.map(test => + test.testGroup === oldName + ? { ...test, testGroup: newName.trim() } + : test, + ), + expandedGroups: newExpandedGroups, + hasUnsavedChanges: true, + }); + } + }; + + const handleDeleteClick = (e: MouseEvent) => { + e.stopPropagation(); + setShowDeleteConfirm(true); + }; + + const handleCreateTestClick = (e: MouseEvent) => { + e.stopPropagation(); + handleCreateTest(group); + }; + + const handleCancelDelete = () => { + setShowDeleteConfirm(false); + }; + + const handleInputChange = (e: ChangeEvent) => { + setLocalGroupName(e.target.value); + }; + + const handleInputBlur = () => { + if (localGroupName.trim() && localGroupName !== groupName) { + handleRenameGroup(groupName, localGroupName.trim()); + } else { + setLocalGroupName(groupName); + } + setIsEditing(false); + }; + + const handleInputKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter") { + handleInputBlur(); + } else if (e.key === "Escape") { + setLocalGroupName(groupName); + setIsEditing(false); + } + }; + + const handleHeaderClick = () => { + if (!isEditing) { + toggleGroupExpanded(group); + } + }; + + return ( +
    +
    +
    + + setIsEditing(true)} + onClick={e => e.stopPropagation()} + /> + + {testCount} {pluralize(testCount, "test")} + +
    +
    + {groupResult && ( +
    + {groupResult === "passed" ? ( + <> + + Passed + + ) : ( + <> + + Failed + + )} +
    + )} + + + + + + + + + +
    +
    + +
    + handleDeleteGroup(groupName)} + onCancel={handleCancelDelete} + destructive={true} + /> +
    +
    + ); +} diff --git a/web/renderer/components/pageComponents/DatabasePage/ForTests/TestItem/index.module.css b/web/renderer/components/pageComponents/DatabasePage/ForTests/TestItem/index.module.css new file mode 100644 index 000000000..a5f70ee8c --- /dev/null +++ b/web/renderer/components/pageComponents/DatabasePage/ForTests/TestItem/index.module.css @@ -0,0 +1,112 @@ +.item { + @apply border-2 border-stone-50 rounded-lg my-2 py-3 px-4 text-sm relative; +} + +.groupedItem { + @apply mx-4; +} + +.expanded .expandIcon { + @apply rotate-90; +} + +.itemTop { + @apply flex items-center cursor-pointer gap-1; +} + +.testName { + @apply text-sm flex items-center; + flex: 1 1 auto; + min-width: 0; +} + +.expandIcon { + @apply mr-3 transition-transform duration-200 text-storm-200; +} + +.editableTestName { + @apply bg-transparent border border-transparent text-sm px-1 py-0.5 m-0 outline-none rounded transition-all flex-1; + min-width: 150px; + cursor: text; +} + +.editableTestName:hover { + @apply text-sky-600 bg-stone-50 border-stone-300; +} + +.editableTestName:focus { + @apply bg-white border-sky-400 shadow-sm; +} + +.testActions { + @apply flex items-center gap-1 opacity-0 transition-opacity duration-200; +} + +.item:hover .testActions { + @apply opacity-100; +} + +.testActionBtn { + @apply p-1.5 text-xs rounded opacity-75 transition-opacity duration-200; +} + +.testActionBtn:hover { + @apply opacity-100; +} + +.runBtn { + @apply text-green-600 mr-10; +} + +.runBtn:hover { + @apply text-green-400; +} + +.expandedContent { + @apply mt-4 px-4 pb-4 bg-white rounded; +} + +.separator { + @apply border-t-2 border-stone-50 mb-4 -mx-4; +} + +.fieldGroup { + @apply mb-4; +} + +.fieldLabel { + @apply block text-sm font-semibold mb-2 text-storm-500; +} + +.fullWidthFormInput { + @apply w-full !important; + width: 100% !important; + min-width: 100% !important; + max-width: 100% !important; +} + +.testResult { + @apply flex items-center gap-1 px-2 py-1 rounded text-sm text-stone-700; + min-width: 80px; + justify-content: center; +} + +.testResultPassed { + @apply bg-green-100; +} + +.testResultFailed { + @apply bg-red-100; +} + +.testResultIcon { + @apply w-3 h-3; +} + +.errorMessage { + @apply mb-4 p-3 bg-red-50 border border-red-400 rounded text-red-600 text-sm; +} + +.expandedContent :global(select) { + @apply relative z-10 bg-white; +} diff --git a/web/renderer/components/pageComponents/DatabasePage/ForTests/TestItem/index.tsx b/web/renderer/components/pageComponents/DatabasePage/ForTests/TestItem/index.tsx new file mode 100644 index 000000000..cc61ae84a --- /dev/null +++ b/web/renderer/components/pageComponents/DatabasePage/ForTests/TestItem/index.tsx @@ -0,0 +1,292 @@ +import { Button, FormSelect, FormInput } from "@dolthub/react-components"; +import { FaChevronRight } from "@react-icons/all-files/fa/FaChevronRight"; +import { FaPlay } from "@react-icons/all-files/fa/FaPlay"; +import { FaTrash } from "@react-icons/all-files/fa/FaTrash"; +import { FaCheck } from "@react-icons/all-files/fa/FaCheck"; +import { FaTimes } from "@react-icons/all-files/fa/FaTimes"; +import cx from "classnames"; +import css from "./index.module.css"; +import QueryEditor from "../QueryEditor"; +import { MouseEvent, useState, useRef, useEffect, useCallback } from "react"; +import ConfirmationModal from "@pageComponents/DatabasePage/ForTests/ConfirmationModal"; +import { useTestContext } from "../context"; +import { Test } from "@gen/graphql-types"; + +type Props = { + test: Test; + className?: string; +}; + +export default function TestItem({ test, className }: Props) { + const { + expandedItems, + editingTestNames, + testResults, + sortedGroupEntries, + tests, + toggleExpanded, + handleRunTest, + setState, + } = useTestContext(); + + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [localAssertionValue, setLocalAssertionValue] = useState( + test.assertionValue, + ); + const debounceRef = useRef | null>(null); + + const updateTest = useCallback( + (name: string, field: keyof Test, value: string) => { + setState({ + tests: tests.map((test: Test) => + test.testName === name ? { ...test, [field]: value } : test, + ), + hasUnsavedChanges: true, + }); + }, + [tests, setState], + ); + + const handleDeleteTest = (testName: string) => { + const newExpandedItems = new Set(expandedItems); + newExpandedItems.delete(testName); + setState({ + tests: tests.filter(test => test.testName !== testName), + expandedItems: newExpandedItems, + hasUnsavedChanges: true, + }); + }; + + const handleTestNameEdit = (testId: string, name: string) => { + setState({ + editingTestNames: { + ...editingTestNames, + [testId]: name, + }, + }); + }; + + const handleTestNameBlur = (testName: string) => { + const newName = editingTestNames[testName]; + const test = tests.find(t => t.testName === testName); + if (newName.trim() && newName !== test?.testName) { + updateTest(testName, "testName", newName.trim()); + } + const newEditingTestNames = { ...editingTestNames }; + delete newEditingTestNames[testName]; + setState({ editingTestNames: newEditingTestNames }); + }; + + const groupOptions = sortedGroupEntries + .map(entry => entry[0]) + .filter(group => group !== ""); + const isExpanded = expandedItems.has(test.testName); + const editingName = editingTestNames[test.testName]; + const testResult = testResults[test.testName]; + + const debouncedOnUpdateTest = (field: keyof Test, value: string) => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + debounceRef.current = setTimeout(() => { + updateTest(test.testName, field, value); + }, 500); // 500ms debounce + }; + + useEffect( + () => () => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + }, + [], + ); + + useEffect(() => { + setLocalAssertionValue(test.assertionValue); + }, [test.assertionValue]); + + const handleDeleteClick = (e: MouseEvent) => { + e.stopPropagation(); + setShowDeleteConfirm(true); + }; + + const handleConfirmDelete = () => { + setShowDeleteConfirm(false); + handleDeleteTest(test.testName); + }; + + const handleCancelDelete = () => { + setShowDeleteConfirm(false); + }; + + const handleAssertionValueBlur = () => { + if (localAssertionValue !== test.assertionValue) { + updateTest(test.testName, "assertionValue", localAssertionValue); + } + }; + + return ( +
  • +
    toggleExpanded(test.testName)} + > +
    + + handleTestNameEdit(test.testName, e.target.value)} + onFocus={() => handleTestNameEdit(test.testName, test.testName)} + onBlur={() => handleTestNameBlur(test.testName)} + onClick={e => e.stopPropagation()} + placeholder="Test name" + /> +
    + {testResult && ( +
    + {testResult.status === "passed" ? ( + <> + + Passed + + ) : ( + <> + + Failed + + )} +
    + )} +
    + { + e.stopPropagation(); + await handleRunTest(test.testName); + }} + className={cx(css.testActionBtn, css.runBtn)} + data-tooltip-content="Run test" + > + + + + + +
    +
    + {isExpanded && ( +
    + {testResult?.status === "failed" && testResult.error && ( +
    + Error: {testResult.error} +
    + )} +
    +
    + option !== "") + .map(option => { + return { + value: option, + label: option, + }; + }), + ]} + val={test.testGroup || ""} + onChangeValue={value => + updateTest(test.testName, "testGroup", value || "") + } + /> +
    +
    + + debouncedOnUpdateTest("testQuery", value)} + placeholder="Enter SQL query" + /> +
    +
    + + updateTest(test.testName, "assertionType", value || "") + } + /> +
    +
    + ", label: ">" }, + { value: "<", label: "<" }, + { value: ">=", label: ">=" }, + { value: "<=", label: "<=" }, + ]} + val={test.assertionComparator} + onChangeValue={value => + updateTest(test.testName, "assertionComparator", value || "") + } + /> +
    +
    + +
    +
    + )} + + +
  • + ); +} diff --git a/web/renderer/components/pageComponents/DatabasePage/ForTests/TestList/CreateDropdown.tsx b/web/renderer/components/pageComponents/DatabasePage/ForTests/TestList/CreateDropdown.tsx new file mode 100644 index 000000000..c596fe4e8 --- /dev/null +++ b/web/renderer/components/pageComponents/DatabasePage/ForTests/TestList/CreateDropdown.tsx @@ -0,0 +1,66 @@ +import { Popup } from "@dolthub/react-components"; +import { fakeEscapePress } from "@dolthub/web-utils"; +import { FaCaretDown } from "@react-icons/all-files/fa/FaCaretDown"; +import { FaCaretUp } from "@react-icons/all-files/fa/FaCaretUp"; +import { FaFile } from "@react-icons/all-files/fa/FaFile"; +import { FaFolder } from "@react-icons/all-files/fa/FaFolder"; +import { FiPlus } from "@react-icons/all-files/fi/FiPlus"; +import css from "./index.module.css"; +import { useTestContext } from "../context"; + +type Props = { + onCreateGroup: () => void; +}; + +export default function CreateDropdown({ onCreateGroup }: Props) { + const { handleCreateTest } = useTestContext(); + return ( + ( + + )} + > +
    +
      +
    • + +
    • +
    • + +
    • +
    +
    +
    + ); +} diff --git a/web/renderer/components/pageComponents/DatabasePage/ForTests/TestList/index.module.css b/web/renderer/components/pageComponents/DatabasePage/ForTests/TestList/index.module.css new file mode 100644 index 000000000..6916d1160 --- /dev/null +++ b/web/renderer/components/pageComponents/DatabasePage/ForTests/TestList/index.module.css @@ -0,0 +1,216 @@ +.container { + @apply mx-auto px-6; +} + +.top { + @apply flex justify-between pt-6; +} + +.tagContainer { + @apply pb-4 text-primary; +} + +.list { + @apply mt-10 px-6; + + @screen lg { + @apply px-10; + } +} + +.header { + @apply relative font-semibold pb-2; +} + +.bullet { + @apply border-stone-100 h-3 w-3 absolute bg-white rounded-full border-[1.5px] -left-[1.9rem] top-[0.4rem]; + + @screen lg { + @apply -left-[2.9rem]; + } +} + +.latest { + @apply inline-block px-4 mx-4 rounded-full bg-white border-2 border-green-400 text-green-400 text-sm font-semibold py-[0.05rem]; +} + +.timeago { + @apply text-storm-200; +} + +.commitId { + @apply font-semibold text-storm-200 mb-1.5; + + &:hover { + @apply text-stone-100; + } + + @screen lg { + @apply mx-4 mb-0; + } +} + +.commitIcon { + @apply hidden; + + @screen lg { + @apply inline; + } +} + +.releaseName { + @apply pb-2 max-w-4xl text-base; +} + +.releaseNotes > p { + @apply py-4; +} + +.loading { + @apply mx-12 my-4; +} + +.noTests { + @apply text-center text-lg m-6; +} + +.errorContainer { + @apply text-center m-6; +} + +.errorText { + @apply text-red-600 text-lg; +} + +.fieldInput { + @apply w-full p-2 border border-stone-100 rounded text-sm; +} + +.fieldTextarea { + @apply w-full p-2 border border-stone-100 rounded text-sm min-h-[80px]; + resize: vertical; +} + +.fieldSelect { + @apply w-full p-2 border border-stone-100 rounded text-sm bg-white; +} + +.groupedTests { + @apply mb-4; +} + +.groupedList { + @apply list-none p-0 m-0; +} + +.itemBottom { + @apply text-storm-200 font-normal flex flex-col-reverse; + a { + @apply font-normal; + } + + @screen lg { + @apply block; + } +} + +.bold { + @apply font-semibold; +} + +.delete { + @apply hidden; + + @screen lg { + @apply block ml-2; + } +} + +.actionArea { + @apply flex flex-col sm:flex-row sm:justify-between sm:items-center gap-2 mb-6; +} + +.createActions { + @apply flex gap-2; +} + +.createActions button { + @apply px-3 py-1.5 text-sm font-semibold; + min-width: 100px; +} + +.primaryActions { + @apply flex gap-3; +} + +.primaryActions button { + @apply px-3 py-1.5 text-sm; + min-width: 100px; +} + +.createButton { + @apply flex items-center justify-center px-3 py-1.5 text-sm font-semibold bg-sky-600 text-white rounded hover:bg-sky-700 transition-colors duration-200; + min-width: 100px; +} + +.createButton:focus { + @apply outline-none ring-2 ring-sky-500 ring-offset-2; +} + +.plus { + @apply mr-1.5; +} + +.caret { + @apply ml-1.5; +} + +.createPopup { + @apply text-primary; +} + +.createPopupItem { + @apply mx-2 my-3 text-sm; +} + +.createPopupItem button { + @apply w-full text-left text-sky-600 hover:text-sky-700 font-semibold; + border: none; + background: transparent; + outline: none; +} + +.createPopupItem button svg { + @apply inline-block mr-2.5 mb-0.5 text-lg; +} + +.saveActions { + @apply flex gap-2 mt-4 pt-4 border-t border-stone-300; +} + +.saveActions button { + @apply px-2 py-1 text-xs; +} + +.inlineGroupSelect { + @apply bg-transparent border border-stone-300 rounded text-sm px-2 py-1 ml-2 mr-2; + font-size: 13px; + width: auto; + min-width: 120px; +} + +.inlineGroupSelect:focus { + @apply border-sky-400 outline-none; +} + +.ungroupedDivider { + @apply font-semibold text-center w-full my-8 text-base text-storm-500; +} + +.ungroupedTests { + @apply mb-4; +} + +.runAllButton { + @apply bg-green-600 text-white; +} diff --git a/web/renderer/components/pageComponents/DatabasePage/ForTests/TestList/index.tsx b/web/renderer/components/pageComponents/DatabasePage/ForTests/TestList/index.tsx new file mode 100644 index 000000000..b3c5de6ab --- /dev/null +++ b/web/renderer/components/pageComponents/DatabasePage/ForTests/TestList/index.tsx @@ -0,0 +1,143 @@ +import HideForNoWritesWrapper from "@components/util/HideForNoWritesWrapper"; +import { Button } from "@dolthub/react-components"; +import { useState, useEffect } from "react"; +import css from "./index.module.css"; +import NewGroupModal from "../NewGroupModal"; +import TestGroup from "@pageComponents/DatabasePage/ForTests/TestGroup"; +import { useTestContext } from "../context"; +import CreateDropdown from "./CreateDropdown"; +import Link from "@components/links/Link"; +import { workingDiff } from "@lib/urls"; +import { Test } from "@gen/graphql-types"; +import TestItem from "@pageComponents/DatabasePage/ForTests/TestItem"; +import { RefParams } from "@lib/params"; + +type Props = { + params: RefParams; +}; + +export default function TestList({ params }: Props) { + const [showNewGroupModal, setShowNewGroupModal] = useState(false); + const [newGroupName, setNewGroupName] = useState(""); + + const { + expandedGroups, + tests, + groupedTests, + sortedGroupEntries, + emptyGroups, + testsLoading, + testsError, + setState, + handleRunAll, + handleHashNavigation, + } = useTestContext(); + + useEffect(() => { + handleHashNavigation(); + }, [handleHashNavigation]); + + const handleCreateGroup = ( + groupName: string, + groupedTests: Record, + ) => { + if ( + groupName.trim() && + !Object.keys(groupedTests).includes(groupName.trim()) && + !emptyGroups.has(groupName.trim()) + ) { + setState({ + emptyGroups: new Set([...emptyGroups, groupName.trim()]), + expandedGroups: new Set([...expandedGroups, groupName.trim()]), + }); + return true; + } + return false; + }; + + const onCreateGroup = () => { + if (handleCreateGroup(newGroupName, groupedTests)) { + setNewGroupName(""); + setShowNewGroupModal(false); + } + }; + + const getTestItems = (testItems: Test[]) => + testItems.map(test => ); + return ( +
    +
    +

    Tests

    + {!testsLoading && !testsError && ( +
    +
    + + setShowNewGroupModal(true)} + /> + + + + +
    + +
    + {tests.length > 0 && ( + + )} +
    +
    + )} +
    + + setShowNewGroupModal(false)} + /> + {tests.length ? ( +
    +
    + {sortedGroupEntries + .filter(([groupName]) => groupName !== "") + .map(([groupName, groupTests]) => { + const isGroupExpanded = expandedGroups.has(groupName); + return ( +
    + + {isGroupExpanded && ( +
      + {getTestItems(groupTests)} +
    + )} +
    + ); + })} + {(groupedTests[""] ?? []).length > 0 && ( + <> +
    Ungrouped
    +
    +
      + {getTestItems(groupedTests[""])} +
    +
    + + )} +
    +
    + ) : testsError ? ( +
    +

    Failed to load tests: {testsError}

    +
    + ) : testsLoading ? ( +

    Loading tests...

    + ) : ( +

    No tests found

    + )} +
    + ); +} diff --git a/web/renderer/components/pageComponents/DatabasePage/ForTests/context/index.tsx b/web/renderer/components/pageComponents/DatabasePage/ForTests/context/index.tsx new file mode 100644 index 000000000..0e4cd1933 --- /dev/null +++ b/web/renderer/components/pageComponents/DatabasePage/ForTests/context/index.tsx @@ -0,0 +1,502 @@ +import { createCustomContext } from "@dolthub/react-contexts"; +import { useContextWithError, useSetState } from "@dolthub/react-hooks"; +import { + Test, + useRunTestsLazyQuery, + useSaveTestsMutation, + useTestListQuery, +} from "@gen/graphql-types"; +import { RefParams } from "@lib/params"; +import { useRouter } from "next/router"; +import { ReactNode, useMemo, useEffect, useRef, useCallback } from "react"; +import { TestContextType, defaultState } from "./state"; +import { getResults, groupTests, sortGroupEntries } from "../utils"; + +export const TestContext = createCustomContext("TestContext"); + +type Props = { + children: ReactNode; + params: RefParams; +}; + +export function TestProvider({ children, params }: Props) { + const router = useRouter(); + const { + data, + loading: testsLoading, + error: testsError, + } = useTestListQuery({ + variables: { + databaseName: params.databaseName, + refName: params.refName, + }, + }); + + const [ + { + expandedItems, + expandedGroups, + editingTestNames, + hasUnsavedChanges, + tests, + emptyGroups, + testResults, + hasHandledHash, + }, + setState, + ] = useSetState(defaultState); + + const [runTests] = useRunTestsLazyQuery(); + const autoRunExecutedRef = useRef(false); + + const [saveTestsMutation] = useSaveTestsMutation({ + variables: { + databaseName: params.databaseName, + refName: params.refName, + tests: { + list: tests.map( + ({ _id, databaseName: _databaseName, refName: _refName, ...test }) => + test, + ), + }, + }, + }); + + const handleSaveAll = useCallback(async () => { + console.log("Saving all changes:", tests); + await saveTestsMutation(); + }, [saveTestsMutation, tests]); + + useEffect(() => { + if (testsError) { + console.error("Error loading tests:", testsError); + } + }, [testsError]); + + useEffect(() => { + if (!data?.tests.list) return; + const initialTests = data.tests.list.map(({ __typename, ...test }) => test); + setState({ tests: initialTests }); + }, [data?.tests.list, setState]); + + useEffect(() => { + if (!hasUnsavedChanges) return; + const save = async () => { + await handleSaveAll(); + setState({ hasUnsavedChanges: false }); + }; + void save(); + }, [hasUnsavedChanges, handleSaveAll, setState]); + + useEffect(() => { + const shouldRunTests = router.query.runTests === "true"; + + if (shouldRunTests) { + autoRunExecutedRef.current = false; + } + + if (shouldRunTests && tests.length > 0 && !autoRunExecutedRef.current) { + const runAllTests = async () => { + autoRunExecutedRef.current = true; + try { + const result = await runTests({ + variables: { + databaseName: params.databaseName, + refName: params.refName, + }, + }); + + const testResultsList = result.data?.runTests.list ?? []; + const allResults = getResults(testResultsList); + + setState({ testResults: allResults }); + } catch (error) { + console.error("Error auto-running tests:", error); + } + }; + + void runAllTests(); + } + }, [ + router.query.runTests, + tests.length, + router, + runTests, + params.databaseName, + params.refName, + data?.tests.list, + setState, + ]); + + const toggleExpanded = useCallback( + (testName: string) => { + const newExpanded = new Set(expandedItems); + if (newExpanded.has(testName)) { + newExpanded.delete(testName); + } else { + newExpanded.add(testName); + } + setState({ expandedItems: newExpanded }); + }, + [expandedItems, setState], + ); + + const toggleGroupExpanded = useCallback( + (groupName: string) => { + const newExpandedGroups = new Set(expandedGroups); + if (newExpandedGroups.has(groupName)) { + newExpandedGroups.delete(groupName); + } else { + newExpandedGroups.add(groupName); + } + setState({ expandedGroups: newExpandedGroups }); + }, + [expandedGroups, setState], + ); + + const handleTestError = useCallback( + (error: string, targetTests: Test[]) => { + const errorResults = targetTests.reduce( + (acc, test) => { + acc[test.testName] = { + status: "failed", + error, + }; + return acc; + }, + {} as Record, + ); + + setState({ + testResults: { + ...testResults, + ...errorResults, + }, + }); + }, + [testResults, setState], + ); + + const handleRunTest = useCallback( + async (testName: string) => { + try { + const result = await runTests({ + variables: { + databaseName: params.databaseName, + refName: params.refName, + testIdentifier: { + testName, + }, + }, + }); + + if (result.error) { + console.error("Error running test:", result.error); + const targetTest = tests.find(t => t.testName === testName); + if (targetTest) { + handleTestError(result.error.message, [targetTest]); + } + return; + } + + const testPassed = + result.data && + result.data.runTests.list.length > 0 && + result.data.runTests.list[0].status === "PASS"; + + setState({ + testResults: { + ...testResults, + [testName]: { + status: testPassed ? "passed" : "failed", + error: testPassed + ? undefined + : result.data?.runTests.list[0].message, + }, + }, + }); + } catch (err) { + console.error("Error running test:", err); + const targetTest = tests.find(t => t.testName === testName); + if (targetTest) { + const errorMessage = + err instanceof Error ? err.message : "Failed to run test"; + handleTestError(errorMessage, [targetTest]); + } + } + }, + [ + runTests, + params.databaseName, + params.refName, + testResults, + setState, + tests, + handleTestError, + ], + ); + + const handleRunGroup = useCallback( + async (groupName: string) => { + try { + const result = await runTests({ + variables: { + databaseName: params.databaseName, + refName: params.refName, + testIdentifier: { + groupName, + }, + }, + }); + + if (result.error) { + console.error("Error running group tests:", result.error); + const groupTests = tests.filter(test => test.testGroup === groupName); + handleTestError(result.error.message, groupTests); + return; + } + + const testResultsList = + result.data && result.data.runTests.list.length > 0 + ? result.data.runTests.list + : []; + const groupResults = getResults(testResultsList); + + setState({ + testResults: { + ...testResults, + ...groupResults, + }, + }); + } catch (err) { + console.error("Error running group tests:", err); + const groupTests = tests.filter(test => test.testGroup === groupName); + const errorMessage = + err instanceof Error ? err.message : "Failed to run group tests"; + handleTestError(errorMessage, groupTests); + } + }, + [ + runTests, + params.databaseName, + params.refName, + testResults, + setState, + tests, + handleTestError, + ], + ); + + const handleRunAll = useCallback(async () => { + try { + const result = await runTests({ + variables: { + databaseName: params.databaseName, + refName: params.refName, + }, + }); + + if (result.error) { + console.error("Error running all tests:", result.error); + handleTestError(result.error.message, tests); + return; + } + + const testResultsList = + result.data && result.data.runTests.list.length > 0 + ? result.data.runTests.list + : []; + const allResults = getResults(testResultsList); + + setState({ + testResults: { + ...testResults, + ...allResults, + }, + }); + } catch (err) { + console.error("Error running all tests:", err); + const errorMessage = + err instanceof Error ? err.message : "Failed to run all tests"; + handleTestError(errorMessage, tests); + } + }, [ + runTests, + params.databaseName, + params.refName, + testResults, + setState, + tests, + handleTestError, + ]); + + const handleCreateTest = useCallback( + (groupName?: string) => { + const baseTestName = "New Test"; + const existingNames = tests.map(t => t.testName); + let uniqueTestName = baseTestName; + let counter = 1; + + while (existingNames.includes(uniqueTestName)) { + uniqueTestName = `${baseTestName} ${counter}`; + counter += 1; + } + + const newTest: Test = { + testName: uniqueTestName, + testQuery: "SELECT * FROM tablename", + assertionType: "expected_rows", + assertionComparator: "==", + assertionValue: "", + testGroup: groupName ?? "", + _id: "", + databaseName: params.databaseName, + refName: params.refName, + }; + + setState({ + tests: [...tests, newTest], + expandedItems: new Set([...expandedItems, newTest.testName]), + editingTestNames: { + ...editingTestNames, + [newTest.testName]: newTest.testName, + }, + hasUnsavedChanges: true, + }); + + if (groupName && emptyGroups.has(groupName)) { + const newEmptyGroups = new Set(emptyGroups); + newEmptyGroups.delete(groupName); + setState({ emptyGroups: newEmptyGroups }); + } + + if (groupName && !expandedGroups.has(groupName)) { + setState({ expandedGroups: new Set([...expandedGroups, groupName]) }); + } + + setTimeout(() => { + const testElement = document.querySelector( + `[data-test-name="${newTest.testName}"]`, + ); + testElement?.scrollIntoView({ behavior: "smooth", block: "center" }); + }, 100); + }, + [ + tests, + expandedItems, + editingTestNames, + emptyGroups, + expandedGroups, + params.databaseName, + params.refName, + setState, + ], + ); + + const groupedTests = useMemo( + () => groupTests(tests, emptyGroups), + [tests, emptyGroups], + ); + + const sortedGroupEntries = useMemo( + () => sortGroupEntries(groupedTests), + [groupedTests], + ); + + const handleHashNavigation = useCallback(() => { + const hash = window.location.hash.slice(1); + if (!hash || tests.length === 0 || hasHandledHash) return; + + const decodedHash = decodeURIComponent(hash); + const targetTest = tests.find(test => test.testName === decodedHash); + if (!targetTest) return; + + const containingGroup = Object.entries(groupedTests).find( + ([, groupTests]) => + groupTests.some(test => test.testName === decodedHash), + )?.[0]; + + if ( + containingGroup && + containingGroup !== "" && + !expandedGroups.has(containingGroup) + ) { + toggleGroupExpanded(containingGroup); + } + + if (!expandedItems.has(decodedHash)) { + toggleExpanded(decodedHash); + } + + setState({ hasHandledHash: true }); + + setTimeout(() => { + const testElement = document.querySelector( + `[data-test-name="${decodedHash}"]`, + ); + if (testElement) { + testElement.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + } + }, 100); + }, [ + tests, + groupedTests, + expandedGroups, + expandedItems, + toggleGroupExpanded, + toggleExpanded, + hasHandledHash, + setState, + ]); + + const value = useMemo(() => { + return { + expandedItems, + expandedGroups, + emptyGroups, + editingTestNames, + tests, + groupedTests, + sortedGroupEntries, + testResults, + testsLoading, + testsError: testsError?.message, + toggleExpanded, + toggleGroupExpanded, + handleRunTest, + handleRunGroup, + handleRunAll, + handleCreateTest, + handleHashNavigation, + setState, + }; + }, [ + expandedItems, + expandedGroups, + emptyGroups, + editingTestNames, + tests, + groupedTests, + sortedGroupEntries, + testResults, + testsLoading, + testsError, + toggleExpanded, + toggleGroupExpanded, + handleRunTest, + handleRunGroup, + handleRunAll, + handleCreateTest, + handleHashNavigation, + setState, + ]); + + return {children}; +} + +export function useTestContext(): TestContextType { + return useContextWithError(TestContext); +} diff --git a/web/renderer/components/pageComponents/DatabasePage/ForTests/context/state.ts b/web/renderer/components/pageComponents/DatabasePage/ForTests/context/state.ts new file mode 100644 index 000000000..7aa625576 --- /dev/null +++ b/web/renderer/components/pageComponents/DatabasePage/ForTests/context/state.ts @@ -0,0 +1,41 @@ +import { Test } from "@gen/graphql-types"; + +export const defaultState = { + expandedItems: new Set(), + expandedGroups: new Set(), + editingTestNames: {} as Record, + hasUnsavedChanges: false, + tests: [] as Test[], + emptyGroups: new Set(), + testResults: {} as Record< + string, + { status: "passed" | "failed"; error?: string } | undefined + >, + hasHandledHash: false, +}; + +export type TestState = typeof defaultState; + +export type TestContextType = { + expandedItems: Set; + expandedGroups: Set; + emptyGroups: Set; + editingTestNames: Record; + tests: Test[]; + groupedTests: Record; + sortedGroupEntries: Array<[string, Test[]]>; + testResults: Record< + string, + { status: "passed" | "failed"; error?: string } | undefined + >; + testsLoading: boolean; + testsError?: string; + toggleExpanded: (testName: string) => void; + toggleGroupExpanded: (groupName: string) => void; + handleRunTest: (testName: string) => Promise; + handleRunGroup: (groupName: string) => Promise; + handleRunAll: () => Promise; + handleCreateTest: (groupName?: string) => void; + handleHashNavigation: () => void; + setState: (state: Partial) => void; +}; diff --git a/web/renderer/components/pageComponents/DatabasePage/ForTests/index.tsx b/web/renderer/components/pageComponents/DatabasePage/ForTests/index.tsx new file mode 100644 index 000000000..035c73cc1 --- /dev/null +++ b/web/renderer/components/pageComponents/DatabasePage/ForTests/index.tsx @@ -0,0 +1,35 @@ +import NotDoltWrapper from "@components/util/NotDoltWrapper"; +import { RefParams } from "@lib/params"; +import { tests as testsUrl } from "@lib/urls"; +import ForDefaultBranch from "../ForDefaultBranch"; +import TestList from "./TestList"; +import { TestProvider } from "./context"; +import useDatabaseDetails from "@hooks/useDatabaseDetails"; + +type Props = { + params: RefParams; +}; + +export default function ForTests(props: Props): JSX.Element { + const { isPostgres } = useDatabaseDetails(); + + return ( + + + {!isPostgres ? ( + + + + ) : ( +
    + )} + + + ); +} diff --git a/web/renderer/components/pageComponents/DatabasePage/ForTests/queries.ts b/web/renderer/components/pageComponents/DatabasePage/ForTests/queries.ts new file mode 100644 index 000000000..2504ee9bf --- /dev/null +++ b/web/renderer/components/pageComponents/DatabasePage/ForTests/queries.ts @@ -0,0 +1,63 @@ +import { gql } from "@apollo/client"; + +export const LIST_TESTS = gql` + query TestList($databaseName: String!, $refName: String!) { + tests(databaseName: $databaseName, refName: $refName) { + list { + _id + databaseName + refName + testName + testQuery + testGroup + assertionType + assertionComparator + assertionValue + } + } + } +`; + +export const SAVE_TESTS = gql` + mutation SaveTests( + $databaseName: String! + $refName: String! + $tests: TestListArgs! + ) { + saveTests(databaseName: $databaseName, refName: $refName, tests: $tests) { + list { + testName + testGroup + testQuery + assertionType + assertionComparator + assertionValue + } + } + } +`; + +export const RUN_TESTS = gql` + query RunTests( + $databaseName: String! + $refName: String! + $testIdentifier: TestIdentifierArgs + ) { + runTests( + databaseName: $databaseName + refName: $refName + testIdentifier: $testIdentifier + ) { + list { + _id + databaseName + refName + testName + testGroupName + query + status + message + } + } + } +`; diff --git a/web/renderer/components/pageComponents/DatabasePage/ForTests/utils.ts b/web/renderer/components/pageComponents/DatabasePage/ForTests/utils.ts new file mode 100644 index 000000000..cf9f82aee --- /dev/null +++ b/web/renderer/components/pageComponents/DatabasePage/ForTests/utils.ts @@ -0,0 +1,86 @@ +import { Test, TestResult } from "@gen/graphql-types"; + +export function getResults( + testResults: TestResult[], +): Record { + const results: Record< + string, + { status: "passed" | "failed"; error?: string } + > = {}; + for (const testResult of testResults) { + if (testResult.status === "PASS") { + results[testResult.testName] = { + status: "passed", + }; + } else { + results[testResult.testName] = { + status: "failed", + error: testResult.message, + }; + } + } + return results; +} + +export function groupTests( + tests: Test[], + emptyGroups: Set, +): Record { + const groups: Record = {}; + tests.forEach(test => { + const groupName = test.testGroup || ""; + groups[groupName] ??= []; + groups[groupName].push(test); + }); + + emptyGroups.forEach(groupName => { + groups[groupName] ??= []; + }); + + return groups; +} + +export function sortGroupEntries( + groupedTests: Record, +): Array<[string, Test[]]> { + const entries = Object.entries(groupedTests); + const groupOrder = entries.map(entry => entry[0]); + + entries.sort(([a], [b]) => { + if (a === "" && b === "") return 0; + if (a === "") return 1; + if (b === "") return -1; + + const indexA = groupOrder.indexOf(a); + const indexB = groupOrder.indexOf(b); + + if (indexA !== -1 && indexB !== -1) { + return indexA - indexB; + } + if (indexA !== -1) return -1; + if (indexB !== -1) return 1; + + return a.localeCompare(b); + }); + + return entries; +} + +export function getGroupResult( + groupName: string, + groupedTests: Record, + testResults: Record< + string, + { status: "passed" | "failed"; error?: string } | undefined + >, +): "passed" | "failed" | undefined { + const groupTests = groupedTests[groupName]; + if (groupTests.length === 0) return undefined; + + const results = groupTests.map(test => testResults[test.testName]); + + if (results.some(result => !result)) return undefined; + + const allPassed = results.every(result => result?.status === "passed"); + return allPassed ? "passed" : "failed"; +} diff --git a/web/renderer/components/pageComponents/DatabasePage/index.tsx b/web/renderer/components/pageComponents/DatabasePage/index.tsx index ca1eaaddc..b23d00437 100644 --- a/web/renderer/components/pageComponents/DatabasePage/index.tsx +++ b/web/renderer/components/pageComponents/DatabasePage/index.tsx @@ -14,6 +14,7 @@ import ForRemotes from "./ForRemotes"; import ForSchema from "./ForSchema"; import ForTable from "./ForTable"; import DatabasePage from "./component"; +import ForTests from "./ForTests"; export default Object.assign(DatabasePage, { ForBranches, @@ -31,4 +32,5 @@ export default Object.assign(DatabasePage, { ForRemotes, ForSchema, ForTable, + ForTests, }); diff --git a/web/renderer/gen/graphql-types.tsx b/web/renderer/gen/graphql-types.tsx index 7d1de1e6d..e99d16651 100644 --- a/web/renderer/gen/graphql-types.tsx +++ b/web/renderer/gen/graphql-types.tsx @@ -257,6 +257,7 @@ export type Mutation = { removeDatabaseConnection: Scalars['Boolean']['output']; resetDatabase: Scalars['Boolean']['output']; restoreAllTables: Scalars['Boolean']['output']; + saveTests: TestList; }; @@ -405,6 +406,13 @@ export type MutationRestoreAllTablesArgs = { refName: Scalars['String']['input']; }; + +export type MutationSaveTestsArgs = { + databaseName: Scalars['String']['input']; + refName: Scalars['String']['input']; + tests: TestListArgs; +}; + export type PullConflictSummary = { __typename?: 'PullConflictSummary'; _id: Scalars['ID']['output']; @@ -494,6 +502,7 @@ export type Query = { remotes: RemoteList; rowDiffs: RowDiffList; rows: RowList; + runTests: TestResultList; schemaDiff?: Maybe; schemas: Array; sqlSelect: SqlSelect; @@ -505,6 +514,7 @@ export type Query = { tables: Array; tag?: Maybe; tags: TagList; + tests: TestList; views: Array; }; @@ -682,6 +692,13 @@ export type QueryRowsArgs = { }; +export type QueryRunTestsArgs = { + databaseName: Scalars['String']['input']; + refName: Scalars['String']['input']; + testIdentifier?: InputMaybe; +}; + + export type QuerySchemaDiffArgs = { databaseName: Scalars['String']['input']; fromRefName: Scalars['String']['input']; @@ -757,6 +774,12 @@ export type QueryTagsArgs = { }; +export type QueryTestsArgs = { + databaseName: Scalars['String']['input']; + refName: Scalars['String']['input']; +}; + + export type QueryViewsArgs = { databaseName: Scalars['String']['input']; refName: Scalars['String']['input']; @@ -914,6 +937,59 @@ export type TagList = { list: Array; }; +export type Test = { + __typename?: 'Test'; + _id: Scalars['ID']['output']; + assertionComparator: Scalars['String']['output']; + assertionType: Scalars['String']['output']; + assertionValue: Scalars['String']['output']; + databaseName: Scalars['String']['output']; + refName: Scalars['String']['output']; + testGroup: Scalars['String']['output']; + testName: Scalars['String']['output']; + testQuery: Scalars['String']['output']; +}; + +export type TestArgs = { + assertionComparator: Scalars['String']['input']; + assertionType: Scalars['String']['input']; + assertionValue: Scalars['String']['input']; + testGroup: Scalars['String']['input']; + testName: Scalars['String']['input']; + testQuery: Scalars['String']['input']; +}; + +export type TestIdentifierArgs = { + groupName?: InputMaybe; + testName?: InputMaybe; +}; + +export type TestList = { + __typename?: 'TestList'; + list: Array; +}; + +export type TestListArgs = { + list: Array; +}; + +export type TestResult = { + __typename?: 'TestResult'; + _id: Scalars['ID']['output']; + databaseName: Scalars['String']['output']; + message: Scalars['String']['output']; + query: Scalars['String']['output']; + refName: Scalars['String']['output']; + status: Scalars['String']['output']; + testGroupName?: Maybe; + testName: Scalars['String']['output']; +}; + +export type TestResultList = { + __typename?: 'TestResultList'; + list: Array; +}; + export type TextDiff = { __typename?: 'TextDiff'; leftLines: Scalars['String']['output']; @@ -1509,6 +1585,32 @@ export type PushToRemoteMutationVariables = Exact<{ export type PushToRemoteMutation = { __typename?: 'Mutation', pushToRemote: { __typename?: 'PushRes', success: boolean, message: string } }; +export type TestListQueryVariables = Exact<{ + databaseName: Scalars['String']['input']; + refName: Scalars['String']['input']; +}>; + + +export type TestListQuery = { __typename?: 'Query', tests: { __typename?: 'TestList', list: Array<{ __typename?: 'Test', _id: string, databaseName: string, refName: string, testName: string, testQuery: string, testGroup: string, assertionType: string, assertionComparator: string, assertionValue: string }> } }; + +export type SaveTestsMutationVariables = Exact<{ + databaseName: Scalars['String']['input']; + refName: Scalars['String']['input']; + tests: TestListArgs; +}>; + + +export type SaveTestsMutation = { __typename?: 'Mutation', saveTests: { __typename?: 'TestList', list: Array<{ __typename?: 'Test', testName: string, testGroup: string, testQuery: string, assertionType: string, assertionComparator: string, assertionValue: string }> } }; + +export type RunTestsQueryVariables = Exact<{ + databaseName: Scalars['String']['input']; + refName: Scalars['String']['input']; + testIdentifier?: InputMaybe; +}>; + + +export type RunTestsQuery = { __typename?: 'Query', runTests: { __typename?: 'TestResultList', list: Array<{ __typename?: 'TestResult', _id: string, databaseName: string, refName: string, testName: string, testGroupName?: string | null, query: string, status: string, message: string }> } }; + export type LoadDataMutationVariables = Exact<{ databaseName: Scalars['String']['input']; refName: Scalars['String']['input']; @@ -4462,6 +4564,154 @@ export function usePushToRemoteMutation(baseOptions?: Apollo.MutationHookOptions export type PushToRemoteMutationHookResult = ReturnType; export type PushToRemoteMutationResult = Apollo.MutationResult; export type PushToRemoteMutationOptions = Apollo.BaseMutationOptions; +export const TestListDocument = gql` + query TestList($databaseName: String!, $refName: String!) { + tests(databaseName: $databaseName, refName: $refName) { + list { + _id + databaseName + refName + testName + testQuery + testGroup + assertionType + assertionComparator + assertionValue + } + } +} + `; + +/** + * __useTestListQuery__ + * + * To run a query within a React component, call `useTestListQuery` and pass it any options that fit your needs. + * When your component renders, `useTestListQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useTestListQuery({ + * variables: { + * databaseName: // value for 'databaseName' + * refName: // value for 'refName' + * }, + * }); + */ +export function useTestListQuery(baseOptions: Apollo.QueryHookOptions & ({ variables: TestListQueryVariables; skip?: boolean; } | { skip: boolean; }) ) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(TestListDocument, options); + } +export function useTestListLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(TestListDocument, options); + } +export function useTestListSuspenseQuery(baseOptions?: Apollo.SkipToken | Apollo.SuspenseQueryHookOptions) { + const options = baseOptions === Apollo.skipToken ? baseOptions : {...defaultOptions, ...baseOptions} + return Apollo.useSuspenseQuery(TestListDocument, options); + } +export type TestListQueryHookResult = ReturnType; +export type TestListLazyQueryHookResult = ReturnType; +export type TestListSuspenseQueryHookResult = ReturnType; +export type TestListQueryResult = Apollo.QueryResult; +export const SaveTestsDocument = gql` + mutation SaveTests($databaseName: String!, $refName: String!, $tests: TestListArgs!) { + saveTests(databaseName: $databaseName, refName: $refName, tests: $tests) { + list { + testName + testGroup + testQuery + assertionType + assertionComparator + assertionValue + } + } +} + `; +export type SaveTestsMutationFn = Apollo.MutationFunction; + +/** + * __useSaveTestsMutation__ + * + * To run a mutation, you first call `useSaveTestsMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useSaveTestsMutation` 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 [saveTestsMutation, { data, loading, error }] = useSaveTestsMutation({ + * variables: { + * databaseName: // value for 'databaseName' + * refName: // value for 'refName' + * tests: // value for 'tests' + * }, + * }); + */ +export function useSaveTestsMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(SaveTestsDocument, options); + } +export type SaveTestsMutationHookResult = ReturnType; +export type SaveTestsMutationResult = Apollo.MutationResult; +export type SaveTestsMutationOptions = Apollo.BaseMutationOptions; +export const RunTestsDocument = gql` + query RunTests($databaseName: String!, $refName: String!, $testIdentifier: TestIdentifierArgs) { + runTests( + databaseName: $databaseName + refName: $refName + testIdentifier: $testIdentifier + ) { + list { + _id + databaseName + refName + testName + testGroupName + query + status + message + } + } +} + `; + +/** + * __useRunTestsQuery__ + * + * To run a query within a React component, call `useRunTestsQuery` and pass it any options that fit your needs. + * When your component renders, `useRunTestsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useRunTestsQuery({ + * variables: { + * databaseName: // value for 'databaseName' + * refName: // value for 'refName' + * testIdentifier: // value for 'testIdentifier' + * }, + * }); + */ +export function useRunTestsQuery(baseOptions: Apollo.QueryHookOptions & ({ variables: RunTestsQueryVariables; skip?: boolean; } | { skip: boolean; }) ) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(RunTestsDocument, options); + } +export function useRunTestsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(RunTestsDocument, options); + } +export function useRunTestsSuspenseQuery(baseOptions?: Apollo.SkipToken | Apollo.SuspenseQueryHookOptions) { + const options = baseOptions === Apollo.skipToken ? baseOptions : {...defaultOptions, ...baseOptions} + return Apollo.useSuspenseQuery(RunTestsDocument, options); + } +export type RunTestsQueryHookResult = ReturnType; +export type RunTestsLazyQueryHookResult = ReturnType; +export type RunTestsSuspenseQueryHookResult = ReturnType; +export type RunTestsQueryResult = Apollo.QueryResult; export const LoadDataDocument = gql` mutation LoadData($databaseName: String!, $refName: String!, $schemaName: String, $tableName: String!, $importOp: ImportOperation!, $fileType: FileType!, $file: Upload!, $modifier: LoadDataModifier) { loadDataFile( diff --git a/web/renderer/lib/doltSystemTables.ts b/web/renderer/lib/doltSystemTables.ts index e38cde861..43d847c91 100644 --- a/web/renderer/lib/doltSystemTables.ts +++ b/web/renderer/lib/doltSystemTables.ts @@ -9,6 +9,7 @@ const editableSystemTables = [ "dolt_query_catalog", "dolt_branches", "dolt_docs", + "dolt_tests", ]; export function isUneditableDoltSystemTable(t: Maybe): boolean { diff --git a/web/renderer/lib/urls.ts b/web/renderer/lib/urls.ts index 35b3d9bdd..7128c2210 100644 --- a/web/renderer/lib/urls.ts +++ b/web/renderer/lib/urls.ts @@ -103,6 +103,9 @@ function getDiffRange(p: ps.DiffParams): string { export const releases = (p: ps.OptionalRefParams): Route => database(p).addStatic("releases").withQuery({ refName: p.refName }); +export const tests = (p: ps.RefParams): Route => + database(p).addStatic("tests").addDynamic("refName", p.refName, ENCODE); + export const remotes = (p: ps.OptionalRefParams): Route => database(p).addStatic("remotes").withQuery({ refName: p.refName }); @@ -157,3 +160,9 @@ export const uploadStage = ( .addDynamic("stage", p.stage) .withQuery(q); }; + +export const workingDiff = (p: ps.RefParams): Route => + database(p) + .addStatic("compare") + .addDynamic("refName", p.refName, ENCODE) + .addStatic("STAGED..WORKING"); diff --git a/web/renderer/pages/database/[databaseName]/tests/[refName]/index.tsx b/web/renderer/pages/database/[databaseName]/tests/[refName]/index.tsx new file mode 100644 index 000000000..6b32bf92f --- /dev/null +++ b/web/renderer/pages/database/[databaseName]/tests/[refName]/index.tsx @@ -0,0 +1,32 @@ +import Page from "@components/util/Page"; +import { RefParams } from "@lib/params"; +import DatabasePage from "@pageComponents/DatabasePage"; +import { GetServerSideProps, NextPage } from "next"; + +type Props = { + params: RefParams; +}; + +const DatabaseTestsPage: NextPage = ({ params }) => ( + + + +); + +// #!if !isElectron +export const getServerSideProps: GetServerSideProps = async ({ + params, +}) => { + return { + props: { params: params as RefParams }, + }; +}; + +// #!endif + +export default DatabaseTestsPage;