Make Google Drive connectors editable (#237)

This commit is contained in:
Chris Weaver
2023-07-26 22:20:12 -07:00
committed by GitHub
parent 9e6467a0c9
commit 3b546ba1c3
8 changed files with 396 additions and 71 deletions

View File

@@ -0,0 +1,55 @@
import { UpdateConnectorForm } from "@/components/admin/connectors/ConnectorForm";
import { TextArrayFieldBuilder } from "@/components/admin/connectors/Field";
import { XIcon } from "@/components/icons/icons";
import { Connector, GoogleDriveConfig } from "@/lib/types";
import * as Yup from "yup";
import { googleDriveConnectorNameBuilder } from "./utils";
interface Props {
existingConnector: Connector<GoogleDriveConfig>;
onSubmit: () => void;
}
export const ConnectorEditPopup = ({ existingConnector, onSubmit }: Props) => {
return (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
onClick={onSubmit}
>
<div
className="bg-gray-800 p-6 rounded border border-gray-700 shadow-lg relative w-1/2 text-sm"
onClick={(event) => event.stopPropagation()}
>
<div className="flex border-b border-gray-600 pb-2 mb-2">
<h3 className="text-lg font-semibold w-full">
Update Google Drive Connector
</h3>
<div onClick={onSubmit}>
<XIcon
size={30}
className="my-auto flex flex-shrink-0 cursor-pointer hover:text-blue-400"
/>
</div>
</div>
<UpdateConnectorForm<GoogleDriveConfig>
nameBuilder={googleDriveConnectorNameBuilder}
existingConnector={existingConnector}
formBodyBuilder={TextArrayFieldBuilder({
name: "folder_paths",
label: "Folder Paths",
})}
validationSchema={Yup.object().shape({
folder_paths: Yup.array()
.of(
Yup.string().required(
"Please specify a folder path for your google drive e.g. 'Engineering/Materials'"
)
)
.required(),
})}
onSubmit={onSubmit}
/>
</div>
</div>
);
};

View File

@@ -0,0 +1,150 @@
import { Button } from "@/components/Button";
import { BasicTable } from "@/components/admin/connectors/BasicTable";
import { PopupSpec } from "@/components/admin/connectors/Popup";
import { StatusRow } from "@/components/admin/connectors/table/ConnectorsTable";
import { PencilIcon } from "@/components/icons/icons";
import { deleteConnector } from "@/lib/connector";
import { GoogleDriveConfig, ConnectorIndexingStatus } from "@/lib/types";
import { useSWRConfig } from "swr";
import { useState } from "react";
import { ConnectorEditPopup } from "./ConnectorEditPopup";
interface EditableColumnProps {
connectorIndexingStatus: ConnectorIndexingStatus<GoogleDriveConfig>;
}
const EditableColumn = ({ connectorIndexingStatus }: EditableColumnProps) => {
const { mutate } = useSWRConfig();
const [isEditing, setIsEditing] = useState<boolean>(false);
return (
<>
{isEditing && (
<ConnectorEditPopup
existingConnector={connectorIndexingStatus.connector}
onSubmit={() => {
setIsEditing(false);
mutate("/api/manage/admin/connector/indexing-status");
}}
/>
)}
<div className="flex w-4">
<div
onClick={() => {
setIsEditing(true);
}}
className="cursor-pointer"
>
<div className="mr-2">
<PencilIcon size={20} />
</div>
</div>
</div>
</>
);
};
interface TableProps {
googleDriveConnectorIndexingStatuses: ConnectorIndexingStatus<GoogleDriveConfig>[];
setPopup: (popupSpec: PopupSpec | null) => void;
}
export const GoogleDriveConnectorsTable = ({
googleDriveConnectorIndexingStatuses,
setPopup,
}: TableProps) => {
const { mutate } = useSWRConfig();
// Sorting to maintain a consistent ordering
const sortedGoogleDriveConnectorIndexingStatuses = [
...googleDriveConnectorIndexingStatuses,
];
sortedGoogleDriveConnectorIndexingStatuses.sort(
(a, b) => a.connector.id - b.connector.id
);
return (
<BasicTable
columns={[
{
header: "",
key: "edit",
width: 4,
},
{
header: "Folder Paths",
key: "folder_paths",
},
{
header: "Status",
key: "status",
},
{
header: "Delete",
key: "delete",
},
]}
data={sortedGoogleDriveConnectorIndexingStatuses.map(
(connectorIndexingStatus) => ({
edit: (
<EditableColumn connectorIndexingStatus={connectorIndexingStatus} />
),
folder_paths:
(
connectorIndexingStatus.connector.connector_specific_config
.folder_paths || []
).length > 0 ? (
<div key={connectorIndexingStatus.connector.id}>
{(
connectorIndexingStatus.connector.connector_specific_config
.folder_paths || []
).map((path) => (
<div key={path}>
<i> - {path}</i>
</div>
))}
</div>
) : (
<i>All Folders</i>
),
status: (
<StatusRow
connectorIndexingStatus={connectorIndexingStatus}
hasCredentialsIssue={
connectorIndexingStatus.connector.credential_ids.length === 0
}
setPopup={setPopup}
onUpdate={() => {
mutate("/api/manage/admin/connector/indexing-status");
}}
/>
),
delete: (
<Button
onClick={() => {
deleteConnector(connectorIndexingStatus.connector.id).then(
(errorMsg) => {
if (errorMsg) {
setPopup({
message: `Unable to delete existing connector - ${errorMsg}`,
type: "error",
});
} else {
setPopup({
message: "Successfully deleted connector!",
type: "success",
});
mutate("/api/manage/admin/connector/indexing-status");
}
}
);
}}
>
Delete Connector
</Button>
),
})
)}
/>
);
};

View File

@@ -16,14 +16,14 @@ import {
GoogleDriveConfig,
GoogleDriveCredentialJson,
} from "@/lib/types";
import { deleteConnector } from "@/lib/connector";
import { StatusRow } from "@/components/admin/connectors/table/ConnectorsTable";
import { setupGoogleDriveOAuth } from "@/lib/googleDrive";
import Cookies from "js-cookie";
import { GOOGLE_DRIVE_AUTH_IS_ADMIN_COOKIE_NAME } from "@/lib/constants";
import { deleteCredential, linkCredential } from "@/lib/credential";
import { ConnectorForm } from "@/components/admin/connectors/ConnectorForm";
import { TextArrayFieldBuilder } from "@/components/admin/connectors/Field";
import { GoogleDriveConnectorsTable } from "./GoogleDriveConnectorsTable";
import { googleDriveConnectorNameBuilder } from "./utils";
const AppCredentialUpload = ({
setPopup,
@@ -100,6 +100,7 @@ interface GoogleDriveConnectorManagementProps {
| Credential<GoogleDriveCredentialJson>
| undefined;
googleDriveConnectorIndexingStatus: ConnectorIndexingStatus<GoogleDriveConfig> | null;
googleDriveConnectorIndexingStatuses: ConnectorIndexingStatus<GoogleDriveConfig>[];
credentialIsLinked: boolean;
setPopup: (popupSpec: PopupSpec | null) => void;
}
@@ -107,6 +108,7 @@ interface GoogleDriveConnectorManagementProps {
const GoogleDriveConnectorManagement = ({
googleDrivePublicCredential,
googleDriveConnectorIndexingStatus,
googleDriveConnectorIndexingStatuses,
credentialIsLinked,
setPopup,
}: GoogleDriveConnectorManagementProps) => {
@@ -133,9 +135,7 @@ const GoogleDriveConnectorManagement = ({
<div className="border-solid border-gray-600 border rounded-md p-6 mt-4">
<h2 className="font-bold mb-3">Add Connector</h2>
<ConnectorForm<GoogleDriveConfig>
nameBuilder={(values) =>
`GoogleDriveConnector-${values.folder_paths.join("_")}`
}
nameBuilder={googleDriveConnectorNameBuilder}
source="google_drive"
inputType="poll"
formBodyBuilder={TextArrayFieldBuilder({
@@ -205,39 +205,7 @@ const GoogleDriveConnectorManagement = ({
return (
<div>
<div className="text-sm">
<div className="flex">
The Google Drive connector is setup! <b className="mx-2">Status:</b>{" "}
<StatusRow
connectorIndexingStatus={googleDriveConnectorIndexingStatus}
hasCredentialsIssue={
googleDriveConnectorIndexingStatus.connector.credential_ids
.length === 0
}
setPopup={setPopup}
onUpdate={() => {
mutate("/api/manage/admin/connector/indexing-status");
}}
/>
</div>
{/* Need to do the seemingly unnecessary handling for undefined `folder_paths` for backwards compatibility */}
{(
googleDriveConnectorIndexingStatus.connector.connector_specific_config
.folder_paths || []
).length > 0 && (
<div className="mt-3">
It is setup to index the following folders:{" "}
<div className="mx-2">
{googleDriveConnectorIndexingStatus.connector.connector_specific_config.folder_paths.map(
(path) => (
<div key={path}>
- <i>{path}</i>
</div>
)
)}
</div>
</div>
)}
<p className="mt-3">
<p className="my-3">
Checkout the{" "}
<a href="/admin/indexing/status" className="text-blue-500">
status page
@@ -246,29 +214,58 @@ const GoogleDriveConnectorManagement = ({
Google Drive every <b>10</b> minutes.
</p>
</div>
<Button
onClick={() => {
deleteConnector(googleDriveConnectorIndexingStatus.connector.id).then(
(errorMsg) => {
if (errorMsg) {
setPopup({
message: `Unable to delete existing connector - ${errorMsg}`,
type: "error",
});
} else {
setPopup({
message: "Successfully deleted connector!",
type: "success",
});
mutate("/api/manage/admin/connector/indexing-status");
}
{googleDriveConnectorIndexingStatuses.length > 0 && (
<div className="text-sm mb-2 font-bold">Existing Connectors:</div>
)}
<GoogleDriveConnectorsTable
googleDriveConnectorIndexingStatuses={
googleDriveConnectorIndexingStatuses
}
setPopup={setPopup}
/>
<h2 className="font-bold mt-3 text-sm">Add New Connector:</h2>
<div className="border-solid border-gray-600 border rounded-md p-6 mt-2">
<ConnectorForm<GoogleDriveConfig>
nameBuilder={(values) =>
`GoogleDriveConnector-${
values.folder_paths && values.folder_paths.join("_")
}`
}
source="google_drive"
inputType="poll"
formBodyBuilder={TextArrayFieldBuilder({
name: "folder_paths",
label: "Folder Paths",
subtext:
"Specify 0 or more folder paths to index! For example, specifying the path " +
"'Engineering/Materials' will cause us to only index all files contained " +
"within the 'Materials' folder within the 'Engineering' folder. " +
"If no folder paths are specified, we will index all documents in your drive.",
})}
validationSchema={Yup.object().shape({
folder_paths: Yup.array()
.of(
Yup.string().required(
"Please specify a folder path for your google drive e.g. 'Engineering/Materials'"
)
)
.required(),
})}
initialValues={{
folder_paths: [],
}}
refreshFreq={10 * 60} // 10 minutes
onSubmit={async (isSuccess, responseJson) => {
if (isSuccess && responseJson) {
await linkCredential(
responseJson.id,
googleDrivePublicCredential.id
);
mutate("/api/manage/admin/connector/indexing-status");
}
);
}}
className="mt-2"
>
Delete Connector
</Button>
}}
/>
</div>
</div>
);
};
@@ -486,6 +483,9 @@ const Main = () => {
<GoogleDriveConnectorManagement
googleDrivePublicCredential={googleDrivePublicCredential}
googleDriveConnectorIndexingStatus={googleDriveConnectorIndexingStatus}
googleDriveConnectorIndexingStatuses={
googleDriveConnectorIndexingStatuses
}
credentialIsLinked={credentialIsLinked}
setPopup={setPopupWithExpiration}
/>

View File

@@ -0,0 +1,6 @@
import { GoogleDriveConfig } from "@/lib/types";
export const googleDriveConnectorNameBuilder = (values: GoogleDriveConfig) =>
`GoogleDriveConnector-${
values.folder_paths && values.folder_paths.join("_")
}`;

View File

@@ -3,6 +3,7 @@ import React, { FC } from "react";
type Column = {
header: string;
key: string;
width?: number;
};
type TableData = {
@@ -26,7 +27,8 @@ export const BasicTable: FC<BasicTableProps> = ({ columns, data }) => {
className={
"px-4 py-2 font-bold" +
(index === 0 ? " rounded-tl-sm" : "") +
(index === columns.length - 1 ? " rounded-tr-sm" : "")
(index === columns.length - 1 ? " rounded-tr-sm" : "") +
(column.width ? ` w-${column.width}` : "")
}
>
{column.header}
@@ -41,7 +43,10 @@ export const BasicTable: FC<BasicTableProps> = ({ columns, data }) => {
return (
<td
key={colIndex}
className="py-2 px-4 border-b border-gray-800"
className={
"py-2 px-4 border-b border-gray-800" +
(column.width ? ` w-${column.width}` : "")
}
>
{row[column.key]}
</td>

View File

@@ -11,18 +11,26 @@ import {
import { deleteConnectorIfExists } from "@/lib/connector";
import { FormBodyBuilder, RequireAtLeastOne } from "./types";
const BASE_CONNECTOR_URL = "/api/manage/admin/connector";
export async function submitConnector<T>(
connector: ConnectorBase<T>
connector: ConnectorBase<T>,
connectorId?: number
): Promise<{ message: string; isSuccess: boolean; response?: Connector<T> }> {
const isUpdate = connectorId !== undefined;
let isSuccess = false;
try {
const response = await fetch(`/api/manage/admin/connector`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(connector),
});
const response = await fetch(
BASE_CONNECTOR_URL + (isUpdate ? `/${connectorId}` : ""),
{
method: isUpdate ? "PATCH" : "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(connector),
}
);
if (response.ok) {
isSuccess = true;
@@ -139,3 +147,88 @@ export function ConnectorForm<T extends Yup.AnyObject>({
</>
);
}
interface UpdateConnectorBaseProps<T extends Yup.AnyObject> {
nameBuilder?: (values: T) => string;
existingConnector: Connector<T>;
// If both are specified, uses formBody
formBody?: JSX.Element | null;
formBodyBuilder?: FormBodyBuilder<T>;
validationSchema: Yup.ObjectSchema<T>;
onSubmit?: (isSuccess: boolean, responseJson?: Connector<T>) => void;
}
type UpdateConnectorFormProps<T extends Yup.AnyObject> = RequireAtLeastOne<
UpdateConnectorBaseProps<T>,
"formBody" | "formBodyBuilder"
>;
export function UpdateConnectorForm<T extends Yup.AnyObject>({
nameBuilder,
existingConnector,
formBody,
formBodyBuilder,
validationSchema,
onSubmit,
}: UpdateConnectorFormProps<T>): JSX.Element {
const [popup, setPopup] = useState<{
message: string;
type: "success" | "error";
} | null>(null);
return (
<>
{popup && <Popup message={popup.message} type={popup.type} />}
<Formik
initialValues={existingConnector.connector_specific_config}
validationSchema={validationSchema}
onSubmit={async (values, formikHelpers) => {
formikHelpers.setSubmitting(true);
const { message, isSuccess, response } = await submitConnector<T>(
{
name: nameBuilder ? nameBuilder(values) : existingConnector.name,
source: existingConnector.source,
input_type: existingConnector.input_type,
connector_specific_config: values,
refresh_freq: existingConnector.refresh_freq,
disabled: false,
},
existingConnector.id
);
setPopup({ message, type: isSuccess ? "success" : "error" });
formikHelpers.setSubmitting(false);
if (isSuccess) {
formikHelpers.resetForm();
}
setTimeout(() => {
setPopup(null);
}, 4000);
if (onSubmit) {
onSubmit(isSuccess, response);
}
}}
>
{({ isSubmitting, values }) => (
<Form>
{formBody ? formBody : formBodyBuilder && formBodyBuilder(values)}
<div className="flex">
<button
type="submit"
disabled={isSubmitting}
className={
"bg-slate-500 hover:bg-slate-700 text-white " +
"font-bold py-2 px-4 rounded focus:outline-none " +
"focus:shadow-outline w-full max-w-sm mx-auto"
}
>
Update
</button>
</div>
</Form>
)}
</Formik>
</>
);
}

View File

@@ -10,6 +10,8 @@ import {
Link,
Plug,
Brain,
PencilSimple,
X,
} from "@phosphor-icons/react";
import { SiBookstack } from "react-icons/si";
import { FaFile, FaGlobe } from "react-icons/fa";
@@ -101,6 +103,20 @@ export const BrainIcon = ({
return <Brain size={size} className={className} />;
};
export const PencilIcon = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => {
return <PencilSimple size={size} className={className} />;
};
export const XIcon = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => {
return <X size={size} className={className} />;
};
//
// COMPANY LOGOS
//

View File

@@ -48,7 +48,7 @@ export interface GithubConfig {
}
export interface GoogleDriveConfig {
folder_paths: string[];
folder_paths?: string[];
}
export interface BookstackConfig {}