Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
### Other changes

- **Added** query editor for Gremlin connections
([#878](https://github.com/aws/graph-explorer/pull/878))
([#878](https://github.com/aws/graph-explorer/pull/878),
[#855](https://github.com/aws/graph-explorer/pull/855))
- **Added** ability to customize default neighbor expansion limit
([#925](https://github.com/aws/graph-explorer/pull/925))
- **Added** ability to resize the sidebar
Expand Down
23 changes: 0 additions & 23 deletions packages/graph-explorer/src/connector/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ import {
Explorer,
KeywordSearchRequest,
KeywordSearchResponse,
RawQueryRequest,
RawQueryResponse,
SchemaResponse,
toMappedQueryResults,
VertexDetailsRequest,
Expand Down Expand Up @@ -146,27 +144,6 @@ export function edgeDetailsQuery(
});
}

export function rawQueryQuery(
request: RawQueryRequest,
updateSchema: (entities: { vertices: Vertex[]; edges: Edge[] }) => void,
explorer: Explorer,
queryClient: QueryClient
) {
return queryOptions({
queryKey: ["db", "raw-query", request, explorer, queryClient],
queryFn: async ({ signal }): Promise<RawQueryResponse> => {
const results = await explorer.rawQuery(request, { signal });

// Update the schema and the cache
updateVertexDetailsCache(explorer, queryClient, results.vertices);
updateEdgeDetailsCache(explorer, queryClient, results.edges);
updateSchema(results);

return results;
},
});
}

/** Sets the vertex details cache for the given vertices. */
export function updateVertexDetailsCache(
explorer: Explorer,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,16 @@ import {
Label,
Checkbox,
Input,
LoadingSpinner,
PanelEmptyState,
PanelError,
SearchSadIcon,
} from "@/components";

import { useTranslations } from "@/hooks";
import { KeywordSearchResponse } from "@/connector";
import { UseQueryResult } from "@tanstack/react-query";
import { useCancelKeywordSearch } from "./useKeywordSearchQuery";

export function FilterSearchTabContent() {
const t = useTranslations();
Expand Down Expand Up @@ -108,7 +115,52 @@ export function FilterSearchTabContent() {
</div>
</div>

<SearchResultsList query={query} />
<SearchResultsListContainer query={query} />
</div>
);
}

function SearchResultsListContainer({
query,
}: {
query: UseQueryResult<KeywordSearchResponse | null, Error>;
}) {
const cancelAll = useCancelKeywordSearch();

if (query.isLoading) {
return (
<PanelEmptyState
title="Searching..."
subtitle="Looking for matching results"
actionLabel="Cancel"
onAction={() => cancelAll()}
icon={<LoadingSpinner />}
className="p-8"
/>
);
}

if (query.isError && !query.data) {
return (
<PanelError error={query.error} onRetry={query.refetch} className="p-8" />
);
}

if (
!query.data ||
(query.data.vertices.length === 0 &&
query.data.edges.length === 0 &&
query.data.scalars.length === 0)
) {
return (
<PanelEmptyState
title="No Results"
subtitle="Your criteria does not match with any record"
icon={<SearchSadIcon />}
className="p-8"
/>
);
}

return <SearchResultsList {...query.data} />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,86 +4,90 @@ import {
FormControl,
FormField,
FormItem,
Label,
LoadingSpinner,
PanelEmptyState,
PanelError,
SearchSadIcon,
TextArea,
} from "@/components";
import { rawQueryQuery } from "@/connector";
import { useExplorer, useUpdateSchemaFromEntities } from "@/core";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { CornerDownRightIcon } from "lucide-react";
import { SearchResultsList } from "./SearchResultsList";
import { useCallback, useDeferredValue } from "react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { logger } from "@/utils";
import { useAtom } from "jotai";
import { SearchResultsList } from "./SearchResultsList";
import { atomWithReset } from "jotai/utils";
import { updateEdgeDetailsCache, updateVertexDetailsCache } from "@/connector";
import { useRef } from "react";

const formDataSchema = z.object({
query: z.string().default(""),
});

type FormData = z.infer<typeof formDataSchema>;

/**
* Stores the current query text.
*
* This is used to restore the query text when the user switches tabs in the
* sidebar, which forces React to create this view from scratch.
*/
export const queryTextAtom = atomWithReset("");

export function QuerySearchTabContent() {
const [queryText, setQueryText] = useAtom(queryTextAtom);
const form = useForm<FormData>({
const { mutation, cancel } = useRawQueryMutation();

const form = useForm({
resolver: zodResolver(formDataSchema),
defaultValues: {
query: queryText,
},
});

const query = useQuerySearch(queryText);
// Execute the query when the form is submitted
const onSubmit = (data: FormData) => {
logger.debug("Executing query:", data);
setQueryText(data.query);
mutation.mutate(data.query);
};

const executeQuery = useCallback(
(data: FormData) => {
logger.debug("Executing query", data);
if (data.query !== queryText) {
setQueryText(data.query);
} else {
query.refetch();
}
},
[query, queryText, setQueryText]
);
// Submit the form when the user presses cmd+enter or ctrl+enter
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
};

return (
<div className="bg-background-default flex h-full flex-col">
<Form {...form}>
<form
className="border-divider flex shrink-0 flex-col gap-3 border-b p-3"
onSubmit={form.handleSubmit(executeQuery)}
className="border-divider shrink-0 space-y-3 border-b p-3"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="query"
render={({ field }) => (
<FormItem>
<Label htmlFor="query" className="sr-only">
Query
</Label>
<FormItem className="space-y-0 p-[1px]">
<FormControl>
<TextArea
{...field}
aria-label="Query"
className="h-full min-h-[5lh] w-full font-mono text-lg"
className="h-full min-h-[5lh] w-full font-mono text-sm"
placeholder="e.g. g.V().limit(10)"
onKeyDown={e => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
form.handleSubmit(executeQuery)();
}
}}
onKeyDown={onKeyDown}
/>
</FormControl>
</FormItem>
)}
/>
<div className="flex gap-6">
<div className="flex gap-3">
<Button className="w-full" variant="filled" type="submit">
<CornerDownRightIcon />
Run query
Expand All @@ -92,24 +96,112 @@ export function QuerySearchTabContent() {
</form>
</Form>

<SearchResultsList query={query} />
<SearchResultsListContainer
mutation={mutation}
cancel={cancel}
retry={() => mutation.mutate(queryText)}
/>
</div>
);
}

function useQuerySearch(query: string) {
function SearchResultsListContainer({
mutation,
cancel,
retry,
}: {
mutation: RawQueryMutationResult;
cancel: () => void;
retry: () => void;
}) {
if (mutation.isPending) {
return (
<PanelEmptyState
title="Executing query..."
icon={<LoadingSpinner />}
className="p-8"
actionLabel="Cancel"
onAction={() => cancel()}
/>
);
}

if (
mutation.isError &&
!mutation.data &&
mutation.error.name !== "AbortError"
) {
return (
<PanelError error={mutation.error} onRetry={retry} className="p-8" />
);
}

if (
!mutation.data ||
(mutation.data.vertices.length === 0 &&
mutation.data.edges.length === 0 &&
mutation.data.scalars.length === 0)
) {
return (
<PanelEmptyState
title="No Results"
subtitle="Your query does not produce any results"
icon={<SearchSadIcon />}
className="p-8"
/>
);
}

return <SearchResultsList {...mutation.data} />;
}

/**
* Execute raw queries against the database using a mutation.
*
* This is implemented as a mutation to prevent accidental duplicate query
* execution due to React re-renders or cache miss. This is important because
* mutations can be executed and if they are executed more than once, could lead
* to undesirable outcomes.
*/
function useRawQueryMutation() {
const explorer = useExplorer();
const queryClient = useQueryClient();
const updateSchema = useUpdateSchemaFromEntities();
const delayedQuery = useDeferredValue(query);

return useQuery({
...rawQueryQuery(
{ query: delayedQuery },
updateSchema,
explorer,
queryClient
),
staleTime: 0,

const abortControllerRef = useRef<AbortController | null>(null);

/** Cancels the active request */
const cancel = () => {
logger.debug("Cancelling query");
abortControllerRef.current?.abort();
};

const mutation = useMutation({
mutationFn: async (query: string) => {
// Create the abort controller and assign to the ref so the request can be cancelled
const abortController = new AbortController();
abortControllerRef.current = abortController;

const results = await explorer.rawQuery(
{ query },
{ signal: abortController.signal }
);

// Update the schema and the cache
updateVertexDetailsCache(explorer, queryClient, results.vertices);
updateEdgeDetailsCache(explorer, queryClient, results.edges);
updateSchema(results);

return results;
},
});

return {
mutation,
cancel,
};
}

type RawQueryMutationResult = ReturnType<
typeof useRawQueryMutation
>["mutation"];
Loading