diff --git a/backend/src/definitions/relation.ts b/backend/src/definitions/relation.ts index bdb4ef1..90dad4d 100644 --- a/backend/src/definitions/relation.ts +++ b/backend/src/definitions/relation.ts @@ -30,7 +30,6 @@ export type Relation = z.infer; export const CreateRelationSchema = RelationSchema.omit({ id: true, organization_id: true, - generated: true, created_at: true, updated_at: true, deleted_at: true, diff --git a/backend/src/relations/relations.service.ts b/backend/src/relations/relations.service.ts index 3947132..21ea788 100644 --- a/backend/src/relations/relations.service.ts +++ b/backend/src/relations/relations.service.ts @@ -23,7 +23,6 @@ export class RelationsService { const relation = await this.prismaService.relation.create({ data: { ...createRelationDto, - generated: false, organization_id: orgId, deleted_at: null, }, @@ -97,6 +96,7 @@ export class RelationsService { const newRelation = { database_id: databaseId, type: relationType, + generated: true, table_1: table1.id, table_2: table2.id, column_1: constraint.foreign_column_name, diff --git a/frontend/src/app/databases/[databaseId]/database-details.tsx b/frontend/src/app/databases/[databaseId]/database-details.tsx index 56d5fff..bf82762 100644 --- a/frontend/src/app/databases/[databaseId]/database-details.tsx +++ b/frontend/src/app/databases/[databaseId]/database-details.tsx @@ -7,6 +7,7 @@ import { IconName, InputGroup, MenuItem, + Popover, Text, TextArea, } from "@blueprintjs/core"; @@ -19,7 +20,9 @@ import { useState } from "react"; import { useField } from "@/utils/use-field"; import * as _ from "lodash"; import { CleanDatabase, UpdateDatabaseSchema } from "@/definitions"; -import { useUpdateDatabase } from "@/data/use-database"; +import { useDatabaseSchemas, useUpdateDatabase } from "@/data/use-database"; +import { useTables } from "@/data/use-tables"; +import { getFormattedDateStrings } from "@/utils/value-format"; const DatabaseDetails = ({ database }: { database: CleanDatabase }) => { const [editDetails, setEditDetails] = useState(false); @@ -29,6 +32,18 @@ const DatabaseDetails = ({ database }: { database: CleanDatabase }) => { database.id, ); + const { + data: schemas, + isLoading: isLoadingSchemas, + error: schemasError, + } = useDatabaseSchemas(database.id); + + const { + data: tables, + isLoading: isLoadingTables, + error: tablesError, + } = useTables(database.id); + function resetFields() { nameField.onValueChange(database.name); } @@ -50,6 +65,9 @@ const DatabaseDetails = ({ database }: { database: CleanDatabase }) => { } function renderDatabaseDetails() { + const { localString, utcString } = getFormattedDateStrings( + database.created_at, + ); return ( <>
@@ -62,11 +80,43 @@ const DatabaseDetails = ({ database }: { database: CleanDatabase }) => { onClick={() => setEditDetails(true)} />
-
+ +
Name {database.name}
+
+ External name + + {database.external_name} + +
+
+ Added on + + {utcString} +
+ } + interactionKind="hover" + placement="top" + > + + {localString} + + +
+ +
+ Schema count + {schemas!.length} +
+
+ Table count + {tables!.length} +
); @@ -111,6 +161,14 @@ const DatabaseDetails = ({ database }: { database: CleanDatabase }) => { ); } + if (isLoadingSchemas || isLoadingTables) { + return ; + } + + if (schemasError || tablesError) { + return ; + } + return (
{editDetails ? renderEditableDetails() : renderDatabaseDetails()} diff --git a/frontend/src/app/databases/[databaseId]/database-relations.tsx b/frontend/src/app/databases/[databaseId]/database-relations.tsx new file mode 100644 index 0000000..0fb0082 --- /dev/null +++ b/frontend/src/app/databases/[databaseId]/database-relations.tsx @@ -0,0 +1,64 @@ +"use client"; +import { CleanDatabase, Relation } from "@/definitions"; +import { Card, CardList, Icon, Text } from "@blueprintjs/core"; +import Loading from "@/app/loading"; +import { ErrorDisplay } from "@/components/error-display"; +import RelationCard from "@/components/relation/relation-card"; +import CreateRelation from "@/components/relation/create-relation"; +import DeleteRelation from "@/components/relation/delete-relation"; +import { useRelations } from "@/data/use-relations"; +import { useState } from "react"; +import * as _ from "lodash"; + +export default function DatabaseRelations({ + database, +}: { + database: CleanDatabase; +}) { + const [createRelationToggle, setCreateRelationToggle] = + useState(false); + const [deleteRelation, setDeleteRelation] = useState(null); + const { + data: relations, + isLoading: isLoadingRelations, + error: relationsError, + } = useRelations(database.id); + + if (isLoadingRelations) { + return ; + } + + if (relationsError) { + return ; + } + + return ( + + setCreateRelationToggle(true)} + > +
+ + New Relation +
+
+ {_.map(relations, (relation: Relation) => ( + + ))} + + +
+ ); +} diff --git a/frontend/src/app/databases/[databaseId]/page.tsx b/frontend/src/app/databases/[databaseId]/page.tsx index 119b1a5..943f0e0 100644 --- a/frontend/src/app/databases/[databaseId]/page.tsx +++ b/frontend/src/app/databases/[databaseId]/page.tsx @@ -19,9 +19,11 @@ import React, { useState } from "react"; import * as _ from "lodash"; import DeleteDatabase from "./delete-database"; import { useDatabase } from "@/data/use-database"; +import DatabaseRelations from "./database-relations"; enum DatabaseTabEnum { DETAILS = "DETAILS", + RELATIONS = "RELATIONS", } export default function Page({ params }: { params: { databaseId: string } }) { @@ -42,7 +44,6 @@ export default function Page({ params }: { params: { databaseId: string } }) { } if (databaseError || !database) { - console.log("Database error", databaseError); return ( } /> + + } + /> ; + case DatabaseTabEnum.DETAILS: default: return ; } @@ -107,7 +121,11 @@ export default function Page({ params }: { params: { databaseId: string } }) {
+ } title={

{database!.name}

} subtitle={database?.created_at.toString().split("T")[0]} diff --git a/frontend/src/app/navigation-bar.tsx b/frontend/src/app/navigation-bar.tsx index 99da5ad..b81650d 100644 --- a/frontend/src/app/navigation-bar.tsx +++ b/frontend/src/app/navigation-bar.tsx @@ -9,21 +9,18 @@ import { MenuItem, MenuDivider, IconName, - Text, Icon, Collapse, } from "@blueprintjs/core"; import logo from "@assets/logo.svg"; import darkLogo from "@assets/logo-dark.svg"; import Image from "next/image"; -import CreateRelation from "@/components/relation/create-relation"; import DatabaseSelector from "./databases/database-selector"; import { usePathname, useRouter } from "next/navigation"; import { UserProfileButton } from "../components/account-management/user-profile-button"; import { useTables } from "@/data/use-tables"; import { useSelectedDatabase } from "@/stores"; import { useCreateUserQuery, useUserQueries } from "@/data/use-user-query"; -import { useRelations } from "@/data/use-relations"; import { useDarkModeContext } from "@/components/context/dark-mode-context"; import React, { useEffect, useState } from "react"; import * as _ from "lodash"; @@ -47,11 +44,8 @@ export default function NavigationBar({}) { NavigationTabEnums | undefined >(); const [selectedDatabase, setSelectedDatabase] = useSelectedDatabase(); - const [createRelationToggle, setCreateRelationToggle] = - useState(false); const { darkMode, setDarkMode } = useDarkModeContext(); const [tablesToggle, setTablesToggle] = useState(true); - const [relationsToggle, setRelationsToggle] = useState(true); const [queriesToggle, setQueriesToggle] = useState(true); const { @@ -60,12 +54,6 @@ export default function NavigationBar({}) { error: tablesError, } = useTables(selectedDatabase.id); - const { - data: relations, - isLoading: isLoadingRelations, - error: relationsError, - } = useRelations(selectedDatabase.id); - const handlePageChange = (id: string) => { router.push(`/${id}`); }; @@ -103,18 +91,6 @@ export default function NavigationBar({}) { } } - function getRelationIcon(relation: Relation): IconName { - switch (relation.type) { - case "many_to_many": - return "many-to-many" as IconName; - case "one_to_many": - return "one-to-many" as IconName; - case "one_to_one": - default: - return "one-to-one" as IconName; - } - } - return ( -
setRelationsToggle(!relationsToggle)} - > -
Relations
- -
- - <> - setCreateRelationToggle(true)} - popoverProps={{ - usePortal: true, - }} - /> - {relations?.map((relation) => { - const table1 = _.find(tables, { id: relation.table_1 }); - const table2 = _.find(tables, { id: relation.table_2 }); - return table1 && table2 ? ( - - {`${table1?.name} - ${table2?.name}`} - - {`${relation.column_1} - ${relation.column_2}`} - - - } - // TODO: onClick={() => handlePageChange(`relations/${relation.id}`)} - /> - ) : null; - })} - - - -
setQueriesToggle(!queriesToggle)} diff --git a/frontend/src/components/relation/create-relation.tsx b/frontend/src/components/relation/create-relation.tsx index 97f2dc7..8064570 100644 --- a/frontend/src/components/relation/create-relation.tsx +++ b/frontend/src/components/relation/create-relation.tsx @@ -5,6 +5,7 @@ import { InferredSchemaColumn, HydratedTable, RelationType, + CleanDatabase, } from "@/definitions"; import { Button, @@ -22,20 +23,20 @@ import SingleTableSelector from "../table/selectors/single-table-selector/single import SingleColumnSelector from "../column-selectors/single-column-selector/single-column-selector"; import { useState } from "react"; import { useTables } from "@/data/use-tables"; -import { useSelectedDatabase } from "@/stores"; import { useCreateRelation } from "@/data/use-relations"; import * as _ from "lodash"; interface CreateRelationProps { + selectedDatabase: CleanDatabase; isOpen: boolean; setIsOpen: (isOpen: boolean) => void; } export default function CreateRelation({ + selectedDatabase, isOpen, setIsOpen, }: CreateRelationProps) { - const [selectedDatabase] = useSelectedDatabase(); const [relationType, setRelationType] = useState(null); const [table1, setTable1] = useState(null); const [table2, setTable2] = useState(null); @@ -78,6 +79,7 @@ export default function CreateRelation({ const newRelation = { database_id: selectedDatabase.id, type: relationType, + generated: false, table_1: table1?.id || undefined, column_1: column1?.name || undefined, table_2: table2?.id || undefined, @@ -94,6 +96,7 @@ export default function CreateRelation({ const parsedRelation = CreateRelationSchema.parse({ database_id: selectedDatabase.id, type: relationType, + generated: false, table_1: table1?.id, column_1: column1?.name, table_2: table2?.id, @@ -103,6 +106,9 @@ export default function CreateRelation({ join_column_2: joinColumn2?.name || undefined, }); await trigger(parsedRelation); + resetBaseFields(); + resetJoinFields(); + setRelationType(null); setIsOpen(false); } @@ -337,7 +343,12 @@ export default function CreateRelation({ return ( setIsOpen(false)} + onClose={() => { + resetBaseFields(); + resetJoinFields(); + setRelationType(null); + setIsOpen(false); + }} isCloseButtonShown title="Create a new relation" > diff --git a/frontend/src/components/relation/delete-relation.tsx b/frontend/src/components/relation/delete-relation.tsx new file mode 100644 index 0000000..be50851 --- /dev/null +++ b/frontend/src/components/relation/delete-relation.tsx @@ -0,0 +1,209 @@ +"use client"; +import { Relation } from "@/definitions"; +import { + Button, + Callout, + Dialog, + DialogBody, + DialogFooter, + Divider, + Icon, + IconName, + Tag, + Text, +} from "@blueprintjs/core"; +import SquareIcon, { SquareIconSize } from "../icon/square-icon"; +import { ErrorDisplay } from "../error-display"; +import { useDeleteRelation } from "@/data/use-relations"; +import { mutate } from "swr"; +import { useTable } from "@/data/use-tables"; +import Loading from "@/app/loading"; + +export default function DeleteRelation({ + relation, + setRelation, +}: { + relation: Relation | null; + setRelation: (relation: Relation | null) => void; +}) { + const { isMutating, trigger: deleteRelation } = useDeleteRelation( + relation?.id, + ); + const { + data: table1, + isLoading: isLoadingTable1, + error: table1Error, + } = useTable(relation?.table_1); + const { + data: table2, + isLoading: isLoadingTable2, + error: table2Error, + } = useTable(relation?.table_2); + const { + data: joinTable, + isLoading: isLoadingJoinTable, + error: joinTableError, + } = useTable(relation?.join_table ?? undefined); + + async function submitDelete() { + await deleteRelation(); + mutate(`/relations/db/${relation!.database_id}`); + setRelation(null); + } + + function getRelationIcon(): IconName { + switch (relation!.type) { + case "many_to_many": + return "many-to-many" as IconName; + case "one_to_many": + return "one-to-many" as IconName; + case "one_to_one": + default: + return "one-to-one" as IconName; + } + } + + function renderDialogContent() { + if ( + table1Error || + table2Error || + (joinTableError && relation!.type === "many_to_many") + ) { + return ( + + + + ); + } else if (isLoadingTable1 || isLoadingTable2 || isLoadingJoinTable) { + return ( + + + + ); + } else if (isMutating) { + return ( + +
+ + Deleting relation... +
+
+ ); + } else if ( + !table1 || + !table2 || + (!joinTable && relation!.type === "many_to_many") + ) { + return null; + } else { + return ( + <> + +
+
+ + } + > +
+ {table1!.name} + + {relation!.column_1} + +
+
+ + + } + > +
+ {table2!.name} + + {relation!.column_2} + +
+
+
+ {relation!.type === "many_to_many" && joinTable && ( +
+ via + + } + > +
+ + {joinTable.name} + + + {relation!.join_column_1} + + + + {relation!.join_column_2} + +
+
+
+ )} +
+ + + To confirm deletion, please click "Delete" below. + +
+ submitDelete()} + /> + } + /> + + ); + } + } + + return ( + { + setRelation(null); + }} + > + {renderDialogContent()} + + ); +} diff --git a/frontend/src/components/relation/relation-card.tsx b/frontend/src/components/relation/relation-card.tsx new file mode 100644 index 0000000..25127a5 --- /dev/null +++ b/frontend/src/components/relation/relation-card.tsx @@ -0,0 +1,152 @@ +"use client"; +import { Relation } from "@/definitions"; +import { + Button, + Card, + Icon, + IconName, + Menu, + MenuItem, + Popover, + Tag, + Text, +} from "@blueprintjs/core"; +import { ErrorDisplay } from "../error-display"; +import SquareIcon, { SquareIconSize } from "../icon/square-icon"; +import { useTable } from "@/data/use-tables"; + +export default function RelationCard({ + relation, + setDeleteRelation, +}: { + relation: Relation; + setDeleteRelation: (relation: Relation | null) => void; +}) { + const { + data: table1, + isLoading: isLoadingTable1, + error: table1Error, + } = useTable(relation.table_1); + const { + data: table2, + isLoading: isLoadingTable2, + error: table2Error, + } = useTable(relation.table_2); + const { + data: joinTable, + isLoading: isLoadingJoinTable, + error: joinTableError, + } = useTable(relation?.join_table); + + if ( + table1Error || + table2Error || + (joinTableError && relation.type === "many_to_many") + ) { + return ( + + + + ); + } + + if (!table1 || !table2 || (!joinTable && relation.type === "many_to_many")) { + return null; + } + + function getRelationIcon(): IconName { + switch (relation.type) { + case "many_to_many": + return "many-to-many" as IconName; + case "one_to_many": + return "one-to-many" as IconName; + case "one_to_one": + default: + return "one-to-one" as IconName; + } + } + + return ( + +
+ + } + > +
+ {table1!.name} + {relation.column_1} +
+
+ + + } + > +
+ {table2!.name} + {relation.column_2} +
+
+ {relation.type === "many_to_many" && joinTable && ( + <> + via + + } + > +
+ {joinTable.name} + + {relation.join_column_1} + + + + {relation.join_column_2} + +
+
+ + )} +
+
+ {relation.generated ? ( + Database + ) : ( + + )} +
+
+ ); +} diff --git a/frontend/src/data/use-relations.ts b/frontend/src/data/use-relations.ts index 62acf1d..e70a9ce 100644 --- a/frontend/src/data/use-relations.ts +++ b/frontend/src/data/use-relations.ts @@ -81,7 +81,7 @@ export const useUpdateRelation = () => { export const useDeleteRelation = (id?: string) => { const { data, error, trigger, isMutating } = useSWRMutation( - `/relations/${id}`, + id ? `/relations/${id}` : null, (url: string) => backendDelete(url), ); diff --git a/frontend/src/data/use-tables.ts b/frontend/src/data/use-tables.ts index 2d5890b..32f76ce 100644 --- a/frontend/src/data/use-tables.ts +++ b/frontend/src/data/use-tables.ts @@ -15,7 +15,7 @@ export function useTables(databaseId?: string) { return { data, isLoading, isValidating, error }; } -export const useTable = (id?: string) => { +export const useTable = (id?: string | null) => { const { data, error, isLoading, isValidating, mutate } = useSWR(id ? `/tables/${id}` : null, backendGet); diff --git a/frontend/src/definitions/relation.ts b/frontend/src/definitions/relation.ts index bdb4ef1..90dad4d 100644 --- a/frontend/src/definitions/relation.ts +++ b/frontend/src/definitions/relation.ts @@ -30,7 +30,6 @@ export type Relation = z.infer; export const CreateRelationSchema = RelationSchema.omit({ id: true, organization_id: true, - generated: true, created_at: true, updated_at: true, deleted_at: true,