Add Grid view for the operations view (#283)

* [REMPL_APOLLO_DEVTOOLS] apollo operations - grid view added

---------

Co-authored-by: Jyoti Prakash Sahoo <jysahoo@microsoft.com>
This commit is contained in:
vejrj 2023-03-27 14:41:52 +01:00 коммит произвёл GitHub
Родитель 39e59f9c11
Коммит 5d7d2620ec
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
35 изменённых файлов: 2750 добавлений и 825 удалений

Просмотреть файл

@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "Apollo operations - grid view added",
"packageName": "@graphitation/rempl-apollo-devtools",
"email": "jakubvejr@microsoft.com",
"dependentChangeType": "patch"
}

Просмотреть файл

@ -18,8 +18,10 @@
},
"devDependencies": {
"@apollo/client": "^3.4.11",
"@fluentui/react-components": "9.0.0-rc.11",
"@fluentui/react-tabster": "^9.0.0-rc.7",
"@fluentui/react-components": "^9.0.0-rc.15",
"@fluentui/react-data-grid-react-window": "^9.0.0-beta.14",
"@fluentui/react-icons": "2.0.166-rc.3",
"@fluentui/react-tabster": "^9.0.0-rc.14",
"@types/lodash.debounce": "^4.0.6",
"@types/lodash.isequal": "^4.5.5",
"@types/react": "^17.0.2",
@ -27,7 +29,7 @@
"@types/react-router": "^5.1.18",
"@types/react-router-dom": "^5.3.3",
"@types/uuid": "^8.3.4",
"apollo-inspector": "^1.11.0",
"apollo-inspector": "^1.16.3",
"eslint-plugin-react": "^7.25.2",
"graphiql": "^2.0.9",
"hotkeys-js": "^3.8.7",

Просмотреть файл

@ -1,6 +1,6 @@
import React, { createRef, useEffect } from "react";
import { dialogStyles } from "./activity-dialog.styles";
import { Text, Headline, Button } from "@fluentui/react-components";
import { Text, Button, Title1 } from "@fluentui/react-components";
import { Dismiss20Regular } from "@fluentui/react-icons";
import { RecentActivityRaw, WatchedQuery, Mutation } from "../../types";
@ -27,9 +27,9 @@ export const ActivityDialog = React.memo(
}}
>
<div className={classes.header}>
<Headline>{`${value.data?.name} (${
<Title1>{`${value.data?.name} (${
value.isMutation ? "Mutation" : "Watched Query"
})`}</Headline>
})`}</Title1>
<Button
appearance="transparent"
ref={closeIcon}

Просмотреть файл

@ -1,6 +1,6 @@
import React, { createRef, useEffect } from "react";
import { dialogStyles } from "./dialog.styles";
import { Text, Headline, Button } from "@fluentui/react-components";
import { Text, Button, Title1 } from "@fluentui/react-components";
import { CacheObjectWithSize } from "../../subscriber/apollo-cache/types";
import { Dismiss20Regular } from "@fluentui/react-icons";
@ -26,7 +26,7 @@ export const Dialog = React.memo(({ value, onClose }: DialogProps) => {
}}
>
<div className={classes.header}>
<Headline className={classes.name}>{value?.key}</Headline>
<Title1 className={classes.name}>{value?.key}</Title1>
<Button
appearance="transparent"
ref={closeIcon}

Просмотреть файл

@ -5,6 +5,7 @@ import {
IStopTracking,
IInspectorTrackingConfig,
IDataView,
IVerboseOperation,
} from "apollo-inspector";
import { WrapperCallbackParams } from "../../types";
@ -14,6 +15,7 @@ export class ApolloOperationsTrackerPublisher {
protected stopTracking: IStopTracking | undefined;
protected activeClient: ApolloClient<NormalizedCacheObject> | undefined;
protected isRecording: boolean;
protected lastDataReceived: Map<number, IVerboseOperation> | null;
constructor(remplWrapper: RemplWrapper) {
this.remplWrapper = remplWrapper;
@ -25,6 +27,7 @@ export class ApolloOperationsTrackerPublisher {
);
this.apolloPublisher = remplWrapper.publisher;
this.attachMethodsToPublisher();
this.lastDataReceived = null;
}
protected attachMethodsToPublisher() {
@ -50,6 +53,11 @@ export class ApolloOperationsTrackerPublisher {
try {
const data = this.stopTracking?.();
this.publishApolloOperations(data);
const operationsMap = new Map();
data.verboseOperations?.forEach((op) => {
operationsMap.set(op.id, op);
});
this.lastDataReceived = operationsMap;
} catch (error) {
// publish error to subscriber to show error UX
this.publishApolloOperations({
@ -62,6 +70,33 @@ export class ApolloOperationsTrackerPublisher {
this.stopTracking = undefined;
}
});
this.apolloPublisher.provide(
"copyOperationsData",
(ids: number[] | undefined) => {
if (ids && ids.length === 1 && ids[0] === -1) {
const stringified = JSON.stringify(
(this.activeClient?.cache as any).data.data,
);
window.navigator.clipboard.writeText(stringified);
return;
}
if (this.lastDataReceived) {
const copiedOperations: IVerboseOperation[] = [];
ids?.forEach((id) => {
if (this.lastDataReceived?.has(id)) {
copiedOperations.push(
this.lastDataReceived.get(id) as IVerboseOperation,
);
}
});
copiedOperations.sort((a, b) => a.id - b.id);
const stringified = JSON.stringify(copiedOperations);
window.navigator.clipboard.writeText(stringified);
}
},
);
}
protected publishApolloOperations(data: IDataView) {

Просмотреть файл

@ -0,0 +1,62 @@
import * as React from "react";
import { IAffectedQueryMap, IDueToOperation } from "apollo-inspector";
import { AffectedQueriesRenderer } from "./affected-queries-renderer";
import { SelectTabData } from "@fluentui/react-components";
export interface IAffectedQueriesContainerProps {
affectedQueries: IAffectedQueryMap | null;
}
export const AffectedQueriesContainer = (
props: IAffectedQueriesContainerProps,
) => {
const { affectedQueries } = props;
const listOfItems = React.useMemo(() => getListOfItems(affectedQueries), [
affectedQueries,
]);
const [selectedItem, setSelectedItem] = React.useState<string>(
(listOfItems && listOfItems.length > 0 && listOfItems[0].name) || "",
);
const gridItems = React.useMemo(
() => getGridItems(affectedQueries, selectedItem),
[affectedQueries, selectedItem],
);
const onTabSelect = React.useCallback(
(_, { value }: SelectTabData) => {
setSelectedItem(value as string);
},
[setSelectedItem],
);
return (
<AffectedQueriesRenderer
listOfItems={listOfItems}
gridItems={gridItems}
selectedListItem={selectedItem}
onTabSelect={onTabSelect}
/>
);
};
const getGridItems = (
affectedQueries: IAffectedQueryMap | null,
selectedItem: string,
): IDueToOperation[] | undefined => {
if (affectedQueries) {
return affectedQueries[selectedItem]?.dueToOperations;
}
return undefined;
};
const getListOfItems = (affectedQueries: IAffectedQueryMap | null) => {
const items = [];
for (const key in affectedQueries) {
if (affectedQueries.hasOwnProperty(key)) {
items.push({ name: key, value: key });
}
}
return items;
};

Просмотреть файл

@ -0,0 +1,7 @@
import { makeStyles } from "@fluentui/react-components";
export const useStyles = makeStyles({
root: {
minHeight: 0,
},
});

Просмотреть файл

@ -0,0 +1,130 @@
import * as React from "react";
import {
TableColumnDefinition,
createTableColumn,
TableCellLayout,
useScrollbarWidth,
useFluent,
Text,
} from "@fluentui/react-components";
import { IDueToOperation } from "apollo-inspector";
import {
DataGridBody,
DataGrid,
DataGridRow,
DataGridHeader,
DataGridCell,
DataGridHeaderCell,
} from "@fluentui/react-data-grid-react-window";
import { useStyles } from "./affected-queries-grid-renderer-styles";
import debounce from "lodash.debounce";
const ItemSize = 40;
export interface IAffectedQueriesGridRenderers {
items: IDueToOperation[] | undefined;
}
export const AffectedQueriesGridRenderers = (
props: IAffectedQueriesGridRenderers,
) => {
const { items } = props;
if (!items) {
return null;
}
const { targetDocument } = useFluent();
const scrollbarWidth = useScrollbarWidth({ targetDocument });
const divRef = React.useRef<HTMLDivElement | null>(null);
const [gridHeight, setGridHeight] = React.useState(400);
const classes = useStyles();
React.useEffect(() => {
const height = divRef.current?.getBoundingClientRect().height;
setGridHeight(height ? height - ItemSize : 400);
const resizeObserver = new ResizeObserver(
debounce(() => {
const height = divRef.current?.getBoundingClientRect().height;
const calcualtedHeight = height ? height - ItemSize : 400;
setGridHeight(calcualtedHeight);
}, 300),
);
resizeObserver.observe(document.body);
return () => {
resizeObserver.unobserve(document.body);
};
}, [divRef.current, setGridHeight]);
return (
<div ref={divRef} className={classes.root}>
<DataGrid
items={items}
columns={columns}
focusMode="cell"
resizableColumns
columnSizingOptions={{
operationName: { minWidth: 330, defaultWidth: 350 },
id: { minWidth: 20, defaultWidth: 20 },
}}
>
<DataGridHeader style={{ paddingRight: scrollbarWidth }}>
<DataGridRow>
{({ renderHeaderCell }) => (
<DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>
)}
</DataGridRow>
</DataGridHeader>
<DataGridBody<IDueToOperation> itemSize={40} height={gridHeight}>
{({ item, rowId }, style) => (
<DataGridRow<IDueToOperation> key={rowId} style={style}>
{({ renderCell }) => (
<DataGridCell>{renderCell(item)}</DataGridCell>
)}
</DataGridRow>
)}
</DataGridBody>
</DataGrid>
</div>
);
};
const columns: TableColumnDefinition<IDueToOperation>[] = [
createTableColumn<IDueToOperation>({
columnId: "id",
compare: (a, b) => {
return a.id - b.id;
},
renderHeaderCell: () => {
return <Text weight="bold">{"Id"}</Text>;
},
renderCell: (item) => {
return <TableCellLayout>{item.id}</TableCellLayout>;
},
}),
createTableColumn({
columnId: "Type",
compare: (a, b) => {
return a.operationType.localeCompare(b.operationType);
},
renderHeaderCell: () => {
return <Text weight="bold">{"Type"}</Text>;
},
renderCell: (item) => {
return <TableCellLayout>{item.operationType}</TableCellLayout>;
},
}),
createTableColumn({
columnId: "operationName",
compare: (a, b) => {
if (a.operationName) {
return a.operationName?.localeCompare(b.operationName || "");
}
return 0;
},
renderHeaderCell: () => {
return <Text weight="bold">{"Operation Name"}</Text>;
},
renderCell: (item) => {
return <TableCellLayout truncate>{item.operationName}</TableCellLayout>;
},
}),
];

Просмотреть файл

@ -0,0 +1,19 @@
import { makeStyles } from "@fluentui/react-components";
export const useStyles = makeStyles({
root: {
display: "flex",
justifyContent: "space-between",
minHeight: 0,
},
rightPane: {
display: "flex",
flexDirection: "column",
minHeight: 0,
marginRight: "2rem",
},
rightPaneHeader: {
display: "flex",
marginBottom: "0.5rem",
},
});

Просмотреть файл

@ -0,0 +1,53 @@
import * as React from "react";
import {
TabList,
Tab,
SelectTabEvent,
SelectTabData,
Text,
} from "@fluentui/react-components";
import { AffectedQueriesGridRenderers } from "./affected-queries-grid-renderer";
import { IDueToOperation } from "apollo-inspector";
import { useStyles } from "./affected-queries-renderer-styles";
export interface IAffectedQueriesRendererProps {
listOfItems: any[];
gridItems: IDueToOperation[] | undefined;
selectedListItem: string;
onTabSelect: (event: SelectTabEvent, data: SelectTabData) => void;
}
export const AffectedQueriesRenderer = (
props: IAffectedQueriesRendererProps,
) => {
const { listOfItems, gridItems, selectedListItem, onTabSelect } = props;
const classes = useStyles();
const tabItems = listOfItems.map((element: any) => {
return (
<Tab key={element.name} value={element.name}>
{element.value}
</Tab>
);
});
return (
<div className={classes.root}>
<TabList
selectedValue={selectedListItem}
onTabSelect={onTabSelect}
vertical={true}
>
{tabItems}
</TabList>
<div className={classes.rightPane}>
<div className={classes.rightPaneHeader}>
<Text weight="bold" size={300}>{`${selectedListItem}`}</Text>
<span>&nbsp;</span>
<Text
size={300}
>{`is re-rendered due to following operations in the table`}</Text>
</div>
<AffectedQueriesGridRenderers items={gridItems} />
</div>
</div>
);
};

Просмотреть файл

@ -0,0 +1 @@
export * from "./affected-queries-container";

Просмотреть файл

@ -0,0 +1,41 @@
import React from "react";
import { Button, Title2 } from "@fluentui/react-components";
export interface IErrorBoundaryState {
error?: IError;
}
export interface IError {
error?: boolean;
message?: string;
}
export class ErrorBoundary extends React.PureComponent<
React.PropsWithChildren<unknown>,
IErrorBoundaryState
> {
public state: IErrorBoundaryState = {};
public componentDidCatch(): void {
this.setState({ error: { error: true, message: "Something went wrong" } });
}
public render(): JSX.Element {
const { children } = this.props;
if (this.state.error?.error) {
return (
<div>
<Title2>{this.state.error.message}</Title2>
<Button onClick={this.resetError}>Retry</Button>
</div>
);
}
return children as JSX.Element;
}
private resetError() {
this.setState({
error: undefined,
});
}
}

Просмотреть файл

@ -1,16 +1,18 @@
export interface IReducerState {
export interface ICountReducerState {
verboseOperationsCount: number;
allOperationsCount: number;
cacheOperationsCount: number;
}
export enum ReducerActionEnum {
export enum CountReducerActionEnum {
UpdateVerboseOperationsCount,
UpdateAllOperationsCount,
UpdateCacheOperationsCount,
ClearVerboseOperations,
}
export interface IReducerAction {
type: ReducerActionEnum;
export interface ICountReducerAction {
type: CountReducerActionEnum;
value: any;
}

Просмотреть файл

@ -5,32 +5,39 @@ import { TabHeaders } from "../../../types";
import { VerboseOperationsContainer } from "../verbose-operation/verbose-operations-container";
import { useStyles } from "./operations-tracker-body-styles";
import {
IReducerState,
IReducerAction,
ReducerActionEnum,
ICountReducerState,
ICountReducerAction,
CountReducerActionEnum,
} from "./operations-tracker-body.interface";
import { AffectedQueriesContainer } from "../affected-queries";
import {
IOperationsAction,
IOperationsReducerState,
} from "../operations-tracker-container-helper";
export interface IOperationViewRendererProps {
selectedTab: TabHeaders;
data: IDataView;
filter: string;
dispatchOperationsCount: React.Dispatch<IReducerAction>;
operationsState: IOperationsReducerState;
dispatchOperationsCount: React.Dispatch<ICountReducerAction>;
dispatchOperationsState: React.Dispatch<IOperationsAction>;
}
export interface IOperationViewContainer {
data: IDataView | null;
filter: string;
operationsState: IOperationsReducerState;
dispatchOperationsState: React.Dispatch<IOperationsAction>;
}
const tabHeaders = [
{ key: TabHeaders.AllOperationsView, name: "All operations" },
{ key: TabHeaders.OperationsView, name: "Only Cache operations" },
{ key: TabHeaders.VerboseOperationView, name: "Verbose operations" },
{ key: TabHeaders.VerboseOperationView, name: "Operations" },
{ key: TabHeaders.AffectedQueriesView, name: "Affected Queries" },
];
export const OperationsTrackerBody = (props: IOperationViewContainer) => {
const { data, filter } = props;
const { data, operationsState, dispatchOperationsState } = props;
const [selectedTab, setSelectedTab] = React.useState(
TabHeaders.VerboseOperationView,
);
@ -45,12 +52,13 @@ export const OperationsTrackerBody = (props: IOperationViewContainer) => {
const classes = useStyles();
const updatedTabItems = React.useMemo(() => {
const newTabHeaders = tabHeaders.filter(
(tabHeader) => tabHeader.key === TabHeaders.VerboseOperationView,
(tabHeader) =>
tabHeader.key === TabHeaders.VerboseOperationView ||
tabHeader.key === TabHeaders.AffectedQueriesView,
);
return newTabHeaders;
}, []);
const tabs = React.useMemo(() => {
const items = updatedTabItems.map((item) => {
return (
@ -85,23 +93,39 @@ export const OperationsTrackerBody = (props: IOperationViewContainer) => {
<OperationsViewRenderer
data={data}
selectedTab={selectedTab}
filter={filter}
operationsState={operationsState}
dispatchOperationsCount={dispatchOperationsCount}
dispatchOperationsState={dispatchOperationsState}
/>
</div>
);
};
const OperationsViewRenderer = (props: IOperationViewRendererProps) => {
const { selectedTab, data, filter, dispatchOperationsCount } = props;
const {
selectedTab,
data,
operationsState,
dispatchOperationsCount,
dispatchOperationsState,
} = props;
switch (selectedTab) {
case TabHeaders.VerboseOperationView: {
return (
<VerboseOperationsContainer
operations={data.verboseOperations}
filter={filter}
operationsState={operationsState}
dispatchOperationsCount={dispatchOperationsCount}
dispatchOperationsState={dispatchOperationsState}
/>
);
}
case TabHeaders.AffectedQueriesView: {
return (
<AffectedQueriesContainer
affectedQueries={data.affectedQueriesOperations}
/>
);
}
@ -112,7 +136,7 @@ const OperationsViewRenderer = (props: IOperationViewRendererProps) => {
}
};
const getCount = (key: TabHeaders, data: IReducerState) => {
const getCount = (key: TabHeaders, data: ICountReducerState) => {
switch (key) {
case TabHeaders.VerboseOperationView: {
return data.verboseOperationsCount;
@ -131,17 +155,17 @@ const getCount = (key: TabHeaders, data: IReducerState) => {
};
const reducer = (
state: IReducerState,
action: IReducerAction,
): IReducerState => {
state: ICountReducerState,
action: ICountReducerAction,
): ICountReducerState => {
switch (action.type) {
case ReducerActionEnum.UpdateAllOperationsCount: {
case CountReducerActionEnum.UpdateAllOperationsCount: {
return { ...state, allOperationsCount: action.value };
}
case ReducerActionEnum.UpdateCacheOperationsCount: {
case CountReducerActionEnum.UpdateCacheOperationsCount: {
return { ...state, cacheOperationsCount: action.value };
}
case ReducerActionEnum.UpdateVerboseOperationsCount: {
case CountReducerActionEnum.UpdateVerboseOperationsCount: {
return { ...state, verboseOperationsCount: action.value };
}
@ -151,7 +175,9 @@ const reducer = (
}
};
const computeInitialReducerState = (data: IDataView | null): IReducerState => {
const computeInitialReducerState = (
data: IDataView | null,
): ICountReducerState => {
return {
allOperationsCount: data?.allOperations?.length || 0,
verboseOperationsCount: data?.verboseOperations?.length || 0,

Просмотреть файл

@ -0,0 +1,56 @@
import { IVerboseOperation } from "apollo-inspector";
export interface IOperationsReducerState {
searchText: string;
checkedOperations: IVerboseOperation[] | null;
filteredOperations: IVerboseOperation[] | null;
selectedOperation: IVerboseOperation | null | undefined;
}
export interface IOperationsAction {
type: OperationReducerActionEnum;
value: any;
}
export enum OperationReducerActionEnum {
UpdateSearchText,
UpdateCheckedOperations,
UpdateFilteredOperations,
UpdateSelectedOperation,
}
export const reducers = (
state: IOperationsReducerState,
action: IOperationsAction,
): IOperationsReducerState => {
switch (action.type) {
case OperationReducerActionEnum.UpdateSearchText: {
return { ...state, searchText: action.value };
}
case OperationReducerActionEnum.UpdateCheckedOperations: {
return { ...state, checkedOperations: action.value };
}
case OperationReducerActionEnum.UpdateFilteredOperations: {
return { ...state, filteredOperations: action.value };
}
case OperationReducerActionEnum.UpdateSelectedOperation: {
return { ...state, selectedOperation: action.value };
}
default: {
return state;
}
}
};
export const getInitialState = (): IOperationsReducerState => {
return {
checkedOperations: null,
filteredOperations: null,
searchText: "",
selectedOperation: null,
};
};

Просмотреть файл

@ -7,6 +7,8 @@ export const useStyles = makeStyles({
flexBasic: 0,
...shorthands.padding("10px"),
display: "flex",
minWidth: 0,
minHeight: 0,
},
innerContainer: {
display: "flex",

Просмотреть файл

@ -1,4 +1,8 @@
import { IDataView } from "apollo-inspector";
import {
IOperationsAction,
IOperationsReducerState,
} from "./operations-tracker-container-helper";
export interface IError {
error: any;
@ -22,7 +26,8 @@ export interface IUseMainSlotParams {
error: IError | null;
loader: ILoader;
apollOperationsData: IDataView | null;
filter: string;
operationsState: IOperationsReducerState;
dispatchOperationsState: React.Dispatch<IOperationsAction>;
}
export interface IUseMainSlotService {

Просмотреть файл

@ -11,6 +11,12 @@ import {
IUseMainSlotParams,
IUseMainSlotService,
} from "./operations-tracker-container.interface";
import { ErrorBoundary } from "./operation-tracker-error-boundary";
import {
getInitialState,
OperationReducerActionEnum,
reducers,
} from "./operations-tracker-container-helper";
export const OperationsTrackerContainer = () => {
const [openDescription, setOpenDescription] = useState<boolean>(false);
@ -24,7 +30,12 @@ export const OperationsTrackerContainer = () => {
});
const [error, setError] = React.useState<IError | null>(null);
const [isRecording, setIsRecording] = useState<boolean>(false);
const [filter, setFilter] = useState<string>("");
const [operationsState, dispatchOperationsState] = React.useReducer(
reducers,
getInitialState(),
);
const classes = useStyles();
useSubscribeToPublisher(setError, setApolloOperationsData, setLoader);
@ -34,6 +45,13 @@ export const OperationsTrackerContainer = () => {
setLoader,
setError,
);
React.useMemo(() => {
return null;
}, [operationsState]);
const clearApolloOperations = useCallback(() => {
setApolloOperationsData(null);
}, [setApolloOperationsData]);
useEffect(() => {
return () => {
@ -42,53 +60,86 @@ export const OperationsTrackerContainer = () => {
}, [remplSubscriber]);
const mainSlot = useMainSlot(
{ error, loader, apollOperationsData, filter },
{
error,
loader,
apollOperationsData,
operationsState,
dispatchOperationsState,
},
{ classes },
);
const setSearchText = React.useCallback(
(text) => {
dispatchOperationsState({
type: OperationReducerActionEnum.UpdateSearchText,
value: text,
});
},
[dispatchOperationsState],
);
return (
<div className={classes.root}>
<div
className={mergeClasses(
classes.innerContainer,
openDescription && classes.innerContainerDescription,
)}
>
<OperationsTrackerHeader
isRecording={isRecording}
openDescription={openDescription}
setOpenDescription={setOpenDescription}
toggleRecording={toggleRecording}
setFilter={setFilter}
/>
{mainSlot}
<ErrorBoundary>
<div className={classes.root}>
<div
className={mergeClasses(
classes.innerContainer,
openDescription && classes.innerContainerDescription,
)}
>
<OperationsTrackerHeader
isRecording={isRecording}
openDescription={openDescription}
setOpenDescription={setOpenDescription}
toggleRecording={toggleRecording}
setSearchText={setSearchText}
operationsState={operationsState}
apollOperationsData={apollOperationsData}
clearApolloOperations={clearApolloOperations}
showClear={!!apollOperationsData?.verboseOperations}
/>
{mainSlot}
</div>
</div>
</div>
</ErrorBoundary>
);
};
const useMainSlot = (
{ apollOperationsData, error, loader, filter }: IUseMainSlotParams,
{
apollOperationsData,
error,
loader,
dispatchOperationsState,
operationsState,
}: IUseMainSlotParams,
{ classes }: IUseMainSlotService,
) =>
React.useMemo(() => {
if (error) {
return (
<div className={classes.centerDiv}>
<Title2>{error.message}</Title2>
</div>
);
}
if (loader.loading) {
return (
<div className={classes.centerDiv}>
<Spinner labelPosition="below" label={loader.message} />
</div>
);
}
) => {
if (error) {
return (
<div className={classes.centerDiv}>
<Title2>{error.message}</Title2>
</div>
);
}
if (loader.loading) {
return (
<div className={classes.centerDiv}>
<Spinner labelPosition="below" label={loader.message} />
</div>
);
}
return <OperationsTrackerBody data={apollOperationsData} filter={filter} />;
}, [error, loader, apollOperationsData, classes, filter]);
return (
<OperationsTrackerBody
dispatchOperationsState={dispatchOperationsState}
data={apollOperationsData}
operationsState={operationsState}
/>
);
};
const useToggleRecording = (
setIsRecording: React.Dispatch<React.SetStateAction<boolean>>,

Просмотреть файл

@ -0,0 +1,7 @@
import { makeStyles } from "@fluentui/react-components";
export const useStyles = makeStyles({
button: {
marginLeft: "0.5rem",
},
});

Просмотреть файл

@ -0,0 +1,110 @@
import {
Menu,
MenuItem,
MenuList,
MenuPopover,
MenuTrigger,
SplitButton,
MenuButtonProps,
Button,
} from "@fluentui/react-components";
import { remplSubscriber } from "../../rempl";
import * as React from "react";
import { IOperationsReducerState } from "../operations-tracker-container-helper";
import { useStyles } from "./operations-copy-button-styles";
import { IDataView } from "apollo-inspector";
export interface ICopyButtonProps {
hideCopy: boolean;
operationsState: IOperationsReducerState;
apolloOperationsData: IDataView | null;
}
export const CopyButton = (props: ICopyButtonProps) => {
const classes = useStyles();
const { operationsState, hideCopy, apolloOperationsData } = props;
const copyAll = React.useCallback(() => {
const ids: number[] = [];
apolloOperationsData?.verboseOperations?.forEach((op) => {
ids.push(op.id);
});
remplSubscriber.callRemote("copyOperationsData", ids);
}, [apolloOperationsData]);
const copyFiltered = React.useCallback(() => {
const ids: number[] = [];
operationsState.filteredOperations?.forEach((op) => {
ids.push(op.id);
});
remplSubscriber.callRemote("copyOperationsData", ids);
}, [operationsState]);
const copyChecked = React.useCallback(() => {
const ids: number[] = [];
operationsState.checkedOperations?.forEach((op) => {
ids.push(op.id);
});
remplSubscriber.callRemote("copyOperationsData", ids);
}, [operationsState]);
const copySelected = React.useCallback(() => {
if (operationsState.selectedOperation?.id) {
const ids: number[] = [operationsState.selectedOperation.id];
remplSubscriber.callRemote("copyOperationsData", ids);
}
}, [operationsState]);
const copyCache = React.useCallback(() => {
remplSubscriber.callRemote("copyOperationsData", [-1]);
}, [operationsState]);
if (hideCopy) {
return (
<div className={classes.button}>
<Button onClick={copyCache}>Copy Whole Apollo Cache</Button>
</div>
);
}
return (
<div className={classes.button}>
<Menu positioning="below-end">
<MenuTrigger disableButtonEnhancement>
{(triggerProps: MenuButtonProps) => (
<SplitButton
disabled={hideCopy}
onClick={copyAll}
menuButton={triggerProps}
>
Copy All
</SplitButton>
)}
</MenuTrigger>
<MenuPopover>
<MenuList>
<MenuItem onClick={copyAll}>Copy All Operations</MenuItem>
{(operationsState.filteredOperations?.length || 0) > 0 ? (
<MenuItem onClick={copyFiltered}>
Copy Filtered Operations
</MenuItem>
) : null}
{(operationsState.checkedOperations?.length || 0) > 0 ? (
<MenuItem onClick={copyChecked}>Copy Checked Operations</MenuItem>
) : null}
{operationsState.selectedOperation ? (
<MenuItem onClick={copySelected}>
Copy currently Opened Operation
</MenuItem>
) : null}
<MenuItem onClick={copyCache}>Copy Whole Apollo Cache</MenuItem>
</MenuList>
</MenuPopover>
</Menu>
</div>
);
};

Просмотреть файл

@ -23,4 +23,7 @@ export const useStyles = makeStyles({
height: "auto",
...shorthands.overflow("hidden", "auto"),
},
buttonContainer: {
display: "flex",
},
});

Просмотреть файл

@ -4,60 +4,86 @@ import { Info20Regular } from "@fluentui/react-icons";
import { useStyles } from "./operations-tracker-header-styles";
import { Search } from "../../../components";
import debounce from "lodash.debounce";
import { CopyButton } from "./operations-copy-button";
import { IOperationsReducerState } from "../operations-tracker-container-helper";
import { IDataView } from "apollo-inspector";
export interface IOperationsTrackerHeaderProps {
setOpenDescription: React.Dispatch<React.SetStateAction<boolean>>;
openDescription: boolean;
toggleRecording: () => void;
isRecording: boolean;
setFilter: React.Dispatch<React.SetStateAction<string>>;
setSearchText: React.Dispatch<React.SetStateAction<string>>;
operationsState: IOperationsReducerState;
apollOperationsData: IDataView | null;
clearApolloOperations: () => void;
showClear: boolean;
}
export const OperationsTrackerHeader = (
props: IOperationsTrackerHeaderProps,
) => {
const classes = useStyles();
const {
isRecording,
openDescription,
setOpenDescription,
toggleRecording,
setFilter,
} = props;
export const OperationsTrackerHeader = React.memo(
(props: IOperationsTrackerHeaderProps) => {
const classes = useStyles();
const {
isRecording,
openDescription,
setOpenDescription,
toggleRecording,
setSearchText,
operationsState,
clearApolloOperations,
showClear,
apollOperationsData,
} = props;
const debouncedFilter = React.useCallback(
debounce((e: React.SyntheticEvent) => {
const input = e.target as HTMLInputElement;
setFilter(input.value);
}, 200),
[setFilter],
);
return (
<>
<div className={classes.header}>
<div>
<Button
title="Information"
tabIndex={0}
className={classes.infoButton}
onClick={() => setOpenDescription(!openDescription)}
>
<Info20Regular />
</Button>
<Button onClick={toggleRecording}>
{isRecording ? "Stop recording" : "Record recent activity"}
</Button>
const debouncedFilter = React.useCallback(
debounce((e: React.SyntheticEvent) => {
const input = e.target as HTMLInputElement;
setSearchText(input.value);
}, 200),
[setSearchText],
);
return (
<>
<div className={classes.header}>
<div className={classes.buttonContainer}>
<Button
title="Information"
tabIndex={0}
className={classes.infoButton}
onClick={() => setOpenDescription(!openDescription)}
>
<Info20Regular />
</Button>
<Button onClick={toggleRecording}>
{isRecording ? "Stop" : "Record"}
</Button>
<CopyButton
hideCopy={isRecording || !showClear}
operationsState={operationsState}
apolloOperationsData={apollOperationsData}
/>
{isRecording || !showClear ? null : (
<Button
style={{ marginLeft: "0.5rem" }}
onClick={clearApolloOperations}
disabled={!showClear}
>
Clear All
</Button>
)}
</div>
<div>
<Search onSearchChange={debouncedFilter} />
</div>
</div>
<div>
<Search onSearchChange={debouncedFilter} />
</div>
</div>
{openDescription && (
<div className={classes.description}>
It monitors changes in cache, fired mutations and
activated/deactivated queries.
</div>
)}
</>
);
};
{openDescription && (
<div className={classes.description}>
It monitors changes in cache, fired mutations and
activated/deactivated queries.
</div>
)}
</>
);
},
);

Просмотреть файл

@ -1,10 +1,7 @@
import { DocumentNode, OperationDefinitionNode } from "graphql";
import { DocumentNode, getOperationAST } from "graphql";
export const getOperationName = (query: DocumentNode) => {
const definition =
query && query.definitions && query.definitions.length > 0
? (query.definitions[0] as OperationDefinitionNode)
: null;
const definition = getOperationAST(query);
const operationName = definition ? definition.name?.value : "name_not_found";
return operationName;
@ -20,9 +17,49 @@ export const isNumber = (input: string | number | undefined = "NA") => {
};
export const copyToClipboard = async (obj: unknown) => {
try {
await window.navigator.clipboard.writeText(JSON.stringify(obj));
} catch (error) {
console.log(`failed to copy`, error);
}
await window.navigator.clipboard.writeText(JSON.stringify(obj));
};
export const secondsToTime = (time: number) => {
const seconds = parseFloat((time / 1000).toFixed(4));
let min = -1;
let hour = -1;
let format = "";
if (seconds > 60) {
min = parseFloat((seconds / 60).toFixed(4));
}
if (min > 60) {
hour = parseFloat((min / 60).toFixed(4));
}
if (hour >= 1) {
format += `${hour} hour `;
}
if (min >= 1) {
format += `${min} min `;
}
format += `${seconds} sec`;
return format.trim();
};
export const sizeInBytes = (size: number) => {
if (!size) {
return "";
}
const kb = parseFloat((size / 1024).toFixed(4));
const mb = parseFloat((kb / 1024).toFixed(4));
const gb = parseFloat((mb / 1024).toFixed(4));
if (gb >= 1) {
return `${gb}GB`;
}
if (mb >= 1) {
return `${mb}MB`;
}
return `${kb}KB`;
};

Просмотреть файл

@ -0,0 +1,338 @@
import * as React from "react";
import {
TableCellLayout,
createTableColumn,
Text,
} from "@fluentui/react-components";
import {
IOperationResult,
IVerboseOperation,
OperationType,
} from "apollo-inspector";
import { fragmentSubTypes, IFilterSet, querySubTypes } from "./filter-view";
import {
secondsToTime,
sizeInBytes,
} from "../utils/apollo-operations-tracker-utils";
import {
EditRegular,
ReadingListRegular,
PipelineRegular,
DatabaseLinkRegular,
BookDatabaseRegular,
DatabaseSearchRegular,
DatabaseRegular,
BookOpenRegular,
BookLetterRegular,
} from "@fluentui/react-icons";
export type Duration = {
totalTime: number;
};
export type Timing = {
queuedAt: number;
dataWrittenToCacheCompletedAt: number;
responseReceivedFromServerAt: number;
};
export type Result = {
size: number;
};
export type Item = {
operationType: string;
operationName: string;
isActive: boolean;
duration: Duration;
timing: Timing;
status: string;
fetchPolicy: string;
result: Result[];
id: number;
};
export const getColumns = (
anyOperationSelected: boolean,
classes: Record<
| "gridRow"
| "gridBody"
| "gridHeader"
| "gridView"
| "selectedAndFailedRow"
| "failedRow"
| "selectedRow"
| "operationText",
string
>,
) => {
if (anyOperationSelected) {
return [
createTableColumn<Item>({
columnId: "id",
renderHeaderCell: () => {
return "Id";
},
compare: (a, b) => {
return b.id - a.id;
},
renderCell: (item) => {
return <TableCellLayout truncate>{item.id}</TableCellLayout>;
},
}),
createTableColumn<Item>({
columnId: "operationType",
renderHeaderCell: () => {
return "Type";
},
compare: (a, b) => {
return compareString(b.operationType, a.operationType);
},
renderCell: (item) => {
return (
<TableCellLayout
truncate
media={getOperationIcon(item.operationType)}
>
{item.operationType}
</TableCellLayout>
);
},
}),
createTableColumn<Item>({
columnId: "operationName",
renderHeaderCell: () => {
return "Name";
},
compare: (a, b) => {
return compareString(b.operationName, a.operationName);
},
renderCell: (item) => {
return (
<TableCellLayout truncate>
<Text truncate wrap={false} className={classes.operationText}>
{item.operationName}
</Text>
</TableCellLayout>
);
},
}),
];
}
return [
createTableColumn<Item>({
columnId: "id",
renderHeaderCell: () => {
return "Id";
},
compare: (a, b) => {
return b.id - a.id;
},
renderCell: (item) => {
return <TableCellLayout truncate>{item.id}</TableCellLayout>;
},
}),
createTableColumn<Item>({
columnId: "operationType",
renderHeaderCell: () => {
return "Type";
},
compare: (a, b) => {
return compareString(b.operationType, a.operationType);
},
renderCell: (item) => {
return (
<TableCellLayout
truncate
media={getOperationIcon(item.operationType)}
>
{item.operationType}
</TableCellLayout>
);
},
}),
createTableColumn<Item>({
columnId: "operationName",
renderHeaderCell: () => {
return "Name";
},
compare: (a, b) => {
return compareString(b.operationName, a.operationName);
},
renderCell: (item) => {
return <TableCellLayout truncate>{item.operationName}</TableCellLayout>;
},
}),
createTableColumn<Item>({
columnId: "status",
compare: (a, b) => {
return compareString(b.status, a.status);
},
renderHeaderCell: () => {
return "Status";
},
renderCell: (item) => {
return <TableCellLayout truncate>{item.status}</TableCellLayout>;
},
}),
createTableColumn<Item>({
columnId: "fetchPolicy",
compare: (a, b) => {
return compareString(b.fetchPolicy, a.fetchPolicy);
},
renderHeaderCell: () => {
return "Fetch Policy";
},
renderCell: (item) => {
return <TableCellLayout truncate>{item.fetchPolicy}</TableCellLayout>;
},
}),
createTableColumn<Item>({
columnId: "totalTime",
compare: (a, b) => {
return b.duration.totalTime - a.duration.totalTime;
},
renderHeaderCell: () => {
return "Total Exec time";
},
renderCell: (item) => {
if (isNaN(item.duration.totalTime)) {
return <TableCellLayout truncate>{``}</TableCellLayout>;
}
return (
<TableCellLayout truncate>
{item.duration.totalTime > 1000
? secondsToTime(item.duration.totalTime)
: `${item.duration.totalTime} ms`}
</TableCellLayout>
);
},
}),
createTableColumn<Item>({
columnId: "queuedAt",
compare: (a, b) => {
return b.timing.queuedAt - a.timing.queuedAt;
},
renderHeaderCell: () => {
return "Queued at";
},
renderCell: (item) => {
return (
<TableCellLayout truncate>
{item.timing.queuedAt > 1000
? secondsToTime(item.timing.queuedAt)
: `${item.timing.queuedAt} ms`}
</TableCellLayout>
);
},
}),
createTableColumn<Item>({
columnId: "size",
renderHeaderCell: () => {
return "Size";
},
compare: (a, b) => {
return (b.result[0]?.size || 0) - (a.result[0]?.size || 0);
},
renderCell: (item) => {
return (
<TableCellLayout truncate>
{sizeInBytes(item.result[0]?.size)}
</TableCellLayout>
);
},
}),
];
};
export const getFilteredItems = (
items: IVerboseOperation[] | null | undefined,
searchText: string,
filters: IFilterSet | null,
) => {
let filteredItems = items || [];
if (searchText.length > 0) {
const tokens = searchText
.split(",")
.map((x) => x.trim())
.filter((x) => x !== "");
filteredItems =
tokens.length > 0
? filteredItems.filter((item) => {
return tokens.find((x) =>
item.operationName?.toLowerCase().includes(x.toLowerCase()),
);
})
: filteredItems;
}
if (filters) {
// filtering based on types
let filterTypes = filters.types.concat([]).map((x) => x.toLowerCase());
if (filterTypes.includes(OperationType.Query.toLowerCase())) {
filterTypes = filterTypes.concat(querySubTypes);
}
if (filterTypes.includes(OperationType.Fragment.toLowerCase())) {
filterTypes = filterTypes.concat(fragmentSubTypes);
}
if (filterTypes.length > 0) {
filteredItems = filteredItems.filter((item) =>
filterTypes.includes(item.operationType.toLowerCase()),
);
}
// filtering based on results
const results = filters.results.concat([]).map((x) => x.toLowerCase());
if (results.length > 0) {
filteredItems = filteredItems.filter((item) => {
const fromResult = (item.result?.[0] as IOperationResult)?.from;
return results.includes((fromResult || "").toLowerCase());
});
}
// filtering based on status
const statuses = filters.statuses.concat([]).map((x) => x.toLowerCase());
if (statuses.length > 0) {
filteredItems = filteredItems.filter((item) =>
statuses.includes(item.status.toLowerCase()),
);
}
}
return filteredItems;
};
const getOperationIcon = (type: string) => {
switch (type) {
case OperationType.Query: {
return <ReadingListRegular />;
}
case OperationType.Mutation: {
return <EditRegular />;
}
case OperationType.Subscription: {
return <PipelineRegular />;
}
case OperationType.CacheReadQuery: {
return <DatabaseSearchRegular />;
}
case OperationType.CacheWriteQuery: {
return <DatabaseLinkRegular />;
}
case OperationType.CacheReadFragment: {
return <BookDatabaseRegular />;
}
case OperationType.CacheWriteFragment: {
return <DatabaseRegular />;
}
case OperationType.ClientReadFragment: {
return <BookOpenRegular />;
}
case OperationType.ClientWriteFragment: {
return <BookLetterRegular />;
}
}
return null;
};
const compareString = (a: string | undefined, b: string | undefined) => {
return (a || "").localeCompare(b || "");
};

Просмотреть файл

@ -0,0 +1,78 @@
import { makeStyles, shorthands } from "@fluentui/react-components";
export const useStyles = makeStyles({
gridBody: {
position: "relative",
willChange: "transform",
direction: "ltr",
cursor: "pointer",
width: "100%",
height: "100%",
overflowY: "scroll",
"::-webkit-scrollbar": {
display: "none",
},
},
gridRow: {
":hover": {
backgroundColor: "unset",
color: "unset",
},
},
gridHeader: {
":hover": {
backgroundColor: "unset !important",
color: "unset !important",
},
},
gridView: {
minWidth: 0,
height: "100%",
flexGrow: 2,
display: "flex",
"&:hover": {
backgroundColor: "unset !important",
color: "unset !important",
},
},
selectedAndFailedRow: {
color: "darkred",
backgroundColor: "darkgrey",
fontWeight: "bold",
"&:hover": {
backgroundColor: "darkgrey",
color: "darkred",
},
},
failedRow: {
"&:hover": {
backgroundColor: "unset",
color: "red",
},
color: "red",
},
selectedRow: {
backgroundColor: "darkgrey",
color: "white",
fontWeight: "bold",
"&:hover": {
backgroundColor: "darkgrey",
color: "white",
},
},
operationText: {
...shorthands.overflow("hidden"),
display: "block",
},
selectedOperationGridWrapper: {
minWidth: 0,
flexGrow: 2,
},
gridWrapper: {
flexGrow: 2,
},
filterViewWrapper: {
flexGrow: 1,
minWidth: 0,
},
});

Просмотреть файл

@ -0,0 +1,241 @@
import * as React from "react";
import { useScrollbarWidth, useFluent } from "@fluentui/react-components";
import {
DataGridBody,
DataGrid,
DataGridRow,
DataGridHeader,
DataGridCell,
DataGridHeaderCell,
} from "@fluentui/react-data-grid-react-window";
import { IVerboseOperation } from "apollo-inspector";
import { useStyles } from "./data-grid-view.styles";
import {
ICountReducerAction,
CountReducerActionEnum,
} from "../operations-tracker-body";
import { FilterView, IFilterSet } from "./filter-view";
import debounce from "lodash.debounce";
import { getColumns, getFilteredItems, Item } from "./data-grid-view-helper";
import {
IOperationsAction,
IOperationsReducerState,
OperationReducerActionEnum,
} from "../operations-tracker-container-helper";
export interface IDataGridView {
operations: IVerboseOperation[] | null;
operationsState: IOperationsReducerState;
dispatchOperationsCount: React.Dispatch<ICountReducerAction>;
dispatchOperationsState: React.Dispatch<IOperationsAction>;
}
const ItemSize = 40;
export const DataGridView = (props: IDataGridView) => {
const {
operations,
operationsState,
dispatchOperationsCount,
dispatchOperationsState,
} = props;
const { targetDocument } = useFluent();
const scrollbarWidth = useScrollbarWidth({ targetDocument });
const [gridHeight, setGridHeight] = React.useState(400);
const divRef = React.useRef<HTMLDivElement | null>(null);
React.useEffect(() => {
const height = divRef.current?.getBoundingClientRect().height;
setGridHeight(height ? height - ItemSize : 400);
const resizeObserver = new ResizeObserver(
debounce(() => {
const height = divRef.current?.getBoundingClientRect().height;
const calcualtedHeight = height ? height - ItemSize : 400;
setGridHeight(calcualtedHeight);
}, 300),
);
resizeObserver.observe(document.body);
return () => {
resizeObserver.unobserve(document.body);
};
}, [divRef.current, setGridHeight]);
const classes = useStyles();
const filteredOperations: IVerboseOperation[] =
props.operations?.concat([]) ?? [];
const [filters, setFilters] = React.useState<IFilterSet | null>(null);
const [filteredItems, setFilteredItems] = React.useState(operations || []);
React.useEffect(() => {
const items = getFilteredItems(
operations,
operationsState.searchText,
filters,
);
setFilteredItems(items);
dispatchOperationsCount({
type: CountReducerActionEnum.UpdateVerboseOperationsCount,
value: items?.length,
});
dispatchOperationsState({
type: OperationReducerActionEnum.UpdateFilteredOperations,
value: items,
});
}, [
filters,
operationsState.searchText,
operations,
dispatchOperationsCount,
setFilteredItems,
dispatchOperationsState,
]);
const columns = React.useMemo(
() => getColumns(!!operationsState.selectedOperation, classes),
[operationsState.selectedOperation, classes],
);
const operationsMap = React.useMemo(() => {
const map = new Map<number, IVerboseOperation>();
filteredOperations?.forEach((op) => {
map.set(op.id, op);
});
return map;
}, [filteredOperations]);
const onClick = React.useCallback(
(item) => {
const operation = operationsMap.get(item.id);
dispatchOperationsState({
type: OperationReducerActionEnum.UpdateSelectedOperation,
value: operation,
});
},
[dispatchOperationsState, operationsMap],
);
const updateFilters = React.useCallback(
(input: IFilterSet | null) => {
setFilters(input);
},
[setFilters],
);
const updateVerboseOperations = React.useCallback(
(e, { selectedItems }) => {
setTimeout(() => {
const operations: IVerboseOperation[] = [];
[...selectedItems].forEach((index) =>
operations.push(filteredItems[index]),
);
dispatchOperationsState({
type: OperationReducerActionEnum.UpdateCheckedOperations,
value: operations,
});
}, 0);
},
[dispatchOperationsState, filteredItems],
);
return (
<div className={classes.gridView} ref={divRef}>
<div className={classes.filterViewWrapper}>
<FilterView setFilters={updateFilters} />
</div>
<div
{...(operationsState.selectedOperation
? { className: classes.selectedOperationGridWrapper }
: { className: classes.filterViewWrapper })}
>
<DataGrid
items={filteredItems as any}
columns={columns}
focusMode="cell"
sortable
resizableColumns
columnSizingOptions={{
id: {
minWidth: 40,
defaultWidth: 50,
},
status: {
minWidth: 30,
defaultWidth: 80,
},
fetchPolicy: {
minWidth: 30,
},
totalTime: {
minWidth: 30,
defaultWidth: 70,
},
queuedAt: {
minWidth: 30,
defaultWidth: 90,
},
size: {
minWidth: 30,
},
}}
selectionMode="multiselect"
onSelectionChange={updateVerboseOperations}
>
<DataGridHeader
style={{
paddingRight: scrollbarWidth,
backgroundColor: "#e0e0e0",
}}
className={classes.gridHeader}
>
<DataGridRow>
{({ renderHeaderCell }) => (
<DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>
)}
</DataGridRow>
</DataGridHeader>
<DataGridBody
className={classes.gridBody}
itemSize={40}
height={gridHeight}
>
{({ item, rowId }, style) => {
const isRowSelected =
operationsState.selectedOperation?.id === (item as Item).id;
const isFailed = (item as Item).status
.toLowerCase()
.includes("failed");
const rowClassName =
isRowSelected && isFailed
? classes.selectedAndFailedRow
: isFailed
? classes.failedRow
: isRowSelected
? classes.selectedRow
: classes.gridRow;
return (
<DataGridRow<Item>
key={rowId}
style={style as React.CSSProperties}
className={rowClassName}
>
{({ renderCell }) => {
const cb = React.useCallback(() => onClick(item), [item]);
return (
<DataGridCell onClick={cb}>
{renderCell(item as Item)}
</DataGridCell>
);
}}
</DataGridRow>
);
}}
</DataGridBody>
</DataGrid>
</div>
</div>
);
};

Просмотреть файл

@ -0,0 +1,33 @@
import { makeStyles } from "@fluentui/react-components";
export const useStyles = makeStyles({
filterView: {
display: "flex",
flexDirection: "column",
overflowY: "scroll",
paddingLeft: "10px",
backgroundColor: "#d6d6d6",
paddingRight: "10px",
height: "100%",
"::-webkit-scrollbar": {
display: "none",
},
},
filters: {
display: "flex",
alignItems: "center",
borderBottom: "0.5px solid grey" as any,
},
type: {
display: "flex",
flexDirection: "column",
borderBottom: "0.5px solid grey" as any,
paddingBottom: "10px",
},
operationType: {
display: "flex",
flexDirection: "column",
borderBottom: "0.5px solid grey" as any,
paddingBottom: "10px",
},
});

Просмотреть файл

@ -0,0 +1,272 @@
import * as React from "react";
import { Checkbox } from "@fluentui/react-components";
import { OperationType, ResultsFrom } from "apollo-inspector";
import { useStyles } from "./filter-view.styles";
interface IFilterView {
setFilters: (filterSet: IFilterSet | null) => void;
}
export enum OperationStatus {
InFlight = "InFlight",
Succeded = "Succeded",
Failed = "Failed",
PartialSuccess = "PartialSuccess",
Unknown = "Unknown",
}
export interface IFilterSet {
results: string[];
types: string[];
statuses: string[];
}
export const querySubTypes = [
OperationType.CacheReadQuery,
OperationType.CacheWriteQuery,
OperationType.ClientReadQuery,
OperationType.ClientWriteQuery,
];
export const fragmentSubTypes = [
OperationType.CacheReadFragment,
OperationType.CacheWriteFragment,
OperationType.ClientReadFragment,
OperationType.ClientWriteFragment,
OperationType.Fragment,
];
export const FilterView = React.memo((props: IFilterView) => {
const [operationTypesFilter, setOperationTypesFilter] = React.useState<
string[]
>([]);
const [resultFromFilter, setResultFromFilter] = React.useState<string[]>([]);
const [statusFilter, setStatusFilter] = React.useState<string[]>([]);
const { setFilters } = props;
const classes = useStyles();
const operationTypes = useOperationTypesCheckBox({
operationTypesFilter,
setOperationTypesFilter,
setFilters,
resultFromFilter,
statusFilter,
});
const onResultChange = useOnResultChange(
resultFromFilter,
setResultFromFilter,
setFilters,
operationTypesFilter,
statusFilter,
);
const onStatusChange = useOnStatusChange(
statusFilter,
setStatusFilter,
setFilters,
resultFromFilter,
operationTypesFilter,
);
const statues = Object.entries(OperationStatus)
.filter((status) => status[0] !== OperationStatus.InFlight)
.map((value, key) => {
const checkboxValue = ((value as unknown) as Array<string>)[0];
return (
<Checkbox
onChange={onStatusChange}
value={checkboxValue}
label={checkboxValue}
key={key}
/>
);
});
const resultsFrom = Object.entries(ResultsFrom).map((value, key) => {
const checkboxValue = ((value as unknown) as Array<string>)[0];
return (
<Checkbox
onChange={onResultChange}
value={checkboxValue}
label={checkboxValue}
key={key}
/>
);
});
return (
<div className={classes.filterView}>
<div>
<div className={classes.filters}>
<h3 key="operationType">{`Filters`}&nbsp;</h3>
</div>
</div>
<div className={classes.type}>
<div>
<h5 key="operationType">{`Type`}&nbsp;</h5>
</div>
<div style={{ display: "flex", flexDirection: "column" }}>
{operationTypes}
</div>
</div>
<div className={classes.operationType}>
<div>
<h5 key="operationType">{`Result from`}&nbsp;</h5>
</div>
<div style={{ display: "flex", flexDirection: "column" }}>
{resultsFrom}
</div>
</div>
<div style={{ display: "flex", flexDirection: "column" }}>
<div>
<h5 key="operationType">{`Status`}&nbsp;</h5>
</div>
<div style={{ display: "flex", flexDirection: "column" }}>
{statues}
</div>
</div>
</div>
);
});
interface IUseOperationTypesCheckBoxParams {
operationTypesFilter: string[];
setOperationTypesFilter: React.Dispatch<React.SetStateAction<string[]>>;
setFilters: (filterSet: IFilterSet | null) => void;
resultFromFilter: string[];
statusFilter: string[];
}
const useOperationTypesCheckBox = ({
operationTypesFilter,
setOperationTypesFilter,
setFilters,
resultFromFilter,
statusFilter,
}: IUseOperationTypesCheckBoxParams) => {
const onOperationTypeChange = React.useCallback(
({ target: { value } }, { checked }) => {
let typesFilter = operationTypesFilter.concat([]);
if (checked) {
typesFilter.push(value);
if (value == OperationType.Query) {
querySubTypes.forEach((type) => {
typesFilter.push(type);
});
fragmentSubTypes.forEach((type) => {
typesFilter.push(type);
});
}
} else {
typesFilter = typesFilter.filter((x) => x !== value);
if (value == OperationType.Query) {
typesFilter = typesFilter.filter((x) => {
if (x === value) {
return x === value;
}
if (querySubTypes.find((type) => type === x)) {
return false;
}
if (fragmentSubTypes.find((type) => type === x)) {
return false;
}
return true;
});
}
}
setOperationTypesFilter(typesFilter);
setTimeout(() => {
setFilters({
types: typesFilter,
results: resultFromFilter,
statuses: statusFilter,
});
}, 0);
},
[operationTypesFilter, setOperationTypesFilter, setFilters],
);
const operationTypes = React.useMemo(() => {
return Object.entries(OperationType)
.filter(
(value) =>
!querySubTypes.includes(
((value as unknown) as Array<string>)[0] as OperationType,
),
)
.filter(
(value) =>
!fragmentSubTypes.includes(
((value as unknown) as Array<string>)[0] as OperationType,
),
)
.map((value, key) => {
const checkboxValue = ((value as unknown) as Array<string>)[0];
return (
<Checkbox
onChange={onOperationTypeChange}
value={checkboxValue}
label={checkboxValue}
key={key}
/>
);
});
}, [onOperationTypeChange]);
return operationTypes;
};
const useOnStatusChange = (
statusFilter: string[],
setStatusFilter: React.Dispatch<React.SetStateAction<string[]>>,
setFilters: (filterSet: IFilterSet | null) => void,
resultFromFilter: string[],
operationTypesFilter: string[],
) =>
React.useCallback(
({ target: { value } }, { checked }) => {
let arr = statusFilter.concat([]);
if (checked) {
arr.push(value);
} else {
arr = arr.filter((x) => x !== value);
}
setStatusFilter(arr);
setTimeout(() => {
setFilters({
results: resultFromFilter,
types: operationTypesFilter,
statuses: arr,
});
}, 0);
},
[statusFilter, setStatusFilter],
);
const useOnResultChange = (
resultFromFilter: string[],
setResultFromFilter: React.Dispatch<React.SetStateAction<string[]>>,
setFilters: (filterSet: IFilterSet | null) => void,
operationTypesFilter: string[],
statusFilter: string[],
) =>
React.useCallback(
({ target: { value } }, { checked }) => {
let arr = resultFromFilter.concat([]);
if (checked) {
arr.push(value);
} else {
arr = arr.filter((x) => x !== value);
}
setResultFromFilter(arr);
setTimeout(() => {
setFilters({
results: arr,
types: operationTypesFilter,
statuses: statusFilter,
});
}, 0);
},
[resultFromFilter, setResultFromFilter],
);

Просмотреть файл

@ -8,6 +8,9 @@ export const useStyles = makeStyles({
flexGrow: 1,
flexShrink: 1,
flexBasis: "auto",
backgroundColor: "darkgrey",
minWidth: 0,
color: "black",
},
operationNameAccPanel: {
whiteSpace: "pre-wrap",
@ -36,6 +39,9 @@ export const useStyles = makeStyles({
operationDetails: {
minHeight: 0,
overflowY: "auto",
"::-webkit-scrollbar": {
display: "none",
},
},
operationName: {
display: "flex",
@ -45,6 +51,25 @@ export const useStyles = makeStyles({
minHeight: "32px",
marginLeft: "1rem",
},
subHeading: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
height: "32.5px",
backgroundColor: "#e0e0e0",
},
closeButton: {
width: "40px",
marginRight: "10px",
},
accordionWrapper: {
minHeight: 0,
overflowX: "scroll",
},
accordioPreWrapper: {
maxWidth: "40rem",
},
});
export type stylesKeys =

Просмотреть файл

@ -6,6 +6,8 @@ import {
AccordionPanel,
Tooltip,
Text,
Button,
Body1Strong,
} from "@fluentui/react-components";
import {
IVerboseOperation,
@ -17,41 +19,84 @@ import { OperationVariables, WatchQueryFetchPolicy } from "@apollo/client";
import {
getOperationName,
isNumber,
sizeInBytes,
} from "../utils/apollo-operations-tracker-utils";
import { DocumentNode } from "graphql";
import { ResultsFrom } from "../../../types";
import {
IOperationsAction as IOperationsReducerActions,
OperationReducerActionEnum,
} from "../operations-tracker-container-helper";
const spaceForStringify = 2;
interface IVerboseOperationViewProps {
operation: IVerboseOperation | undefined;
operation: IVerboseOperation | undefined | null;
dispatchOperationsState: React.Dispatch<IOperationsReducerActions>;
}
export const VerboseOperationView = (props: IVerboseOperationViewProps) => {
const classes = useStyles();
const { operation } = props;
const { operation, dispatchOperationsState } = props;
if (!operation) {
return <></>;
}
const classes = useStyles();
const { operationType } = operation;
const { operationType, operationName } = operation;
const accordionItems = React.useMemo(
() => getAccordionItems(operation, classes),
[operation, classes],
);
const closePreview = React.useCallback(() => {
dispatchOperationsState({
type: OperationReducerActionEnum.UpdateSelectedOperation,
value: undefined,
});
}, [dispatchOperationsState]);
if (!operation) {
return null;
}
return (
<div className={classes.operationView}>
<h2 key="operationType">{operationType}</h2>
<Accordion
className={classes.operationDetails}
key={"operationnViewAccordionn"}
multiple
collapsible
<div className={classes.operationView} key="verboseOperationView">
<div className={classes.subHeading} key="verboseHeader">
<div
style={{ display: "flex", alignItems: "center" }}
key="operationType1"
>
<h3 key="operationType">{`${operationType} : `}&nbsp;</h3>
<Body1Strong underline key="operationName1ç">
{operationName}
</Body1Strong>
</div>
<Button
size="small"
className={classes.closeButton}
onClick={closePreview}
appearance="primary"
key="closeButton"
>
Close
</Button>
</div>
<div
className={classes.accordionWrapper}
key="operationnViewAccordionnWrapper"
>
{...accordionItems}
</Accordion>
<div className={classes.accordioPreWrapper}>
<Accordion
className={classes.operationDetails}
key={"operationnViewAccordionn"}
multiple
collapsible
>
{...accordionItems}
</Accordion>
</div>
</div>
</div>
);
};
@ -95,7 +140,9 @@ const getOperationNamePanel = (
) => {
return (
<AccordionItem value="operationName" key="operationName">
<AccordionHeader>{operationName}</AccordionHeader>
<AccordionHeader>
<Text style={{ fontWeight: "bold" }}>{operationName}</Text>
</AccordionHeader>
<AccordionPanel>
<div className={classes.operationNameAccPanel}>{operationString}</div>
</AccordionPanel>
@ -109,7 +156,9 @@ const getVariablesPanel = (
) => (
<AccordionItem value="variables" key="variables">
<Tooltip content={"Variables for the operation"} relationship="label">
<AccordionHeader>{"Variables"}</AccordionHeader>
<AccordionHeader>
<Text style={{ fontWeight: "bold" }}>{"Variables"}</Text>
</AccordionHeader>
</Tooltip>
<AccordionPanel>
<div className={classes.operationVariablesAccPanel}>
@ -125,7 +174,11 @@ const getFetchPolicyPanel = (
) => (
<AccordionItem value="fetchPolicy" key="fetchPolicy">
<Tooltip content={"Fetch policy of the operation"} relationship="label">
<AccordionHeader>{"Fetch Policy"}</AccordionHeader>
<AccordionHeader>
<Text style={{ fontWeight: "bold" }}>{`Fetch Policy ${
fetchPolicy ? `(${fetchPolicy})` : ``
}`}</Text>
</AccordionHeader>
</Tooltip>
<AccordionPanel>
<div className={classes.fetchPolicyAccPanel}> {fetchPolicy}</div>
@ -150,7 +203,11 @@ const getAffectedQueriesPanel = (
}
relationship="label"
>
<AccordionHeader>{`Affected watch queries (${affectedQueriesItems.length})`}</AccordionHeader>
<AccordionHeader>
<Text
style={{ fontWeight: "bold" }}
>{`Affected watch queries (${affectedQueriesItems.length})`}</Text>
</AccordionHeader>
</Tooltip>
<AccordionPanel>
<div className={classes.affectedQueriesAccPanel}>
@ -168,12 +225,16 @@ const getResultPanel = (
result: IOperationResult[],
classes: Record<stylesKeys, string>,
) => {
const items = result.map((res) => {
const items = result.map((res: IOperationResult) => {
const resultFrom = getResultFromString(res.from);
return (
<AccordionItem value={resultFrom} key={resultFrom}>
<AccordionHeader>{resultFrom}</AccordionHeader>
<AccordionHeader>
<Text style={{ fontWeight: "bold" }}>{`${resultFrom} ${
res.size ? `(${sizeInBytes(res.size)})` : ``
}`}</Text>
</AccordionHeader>
<AccordionPanel>
<div className={classes.resultPanel}>
{`${JSON.stringify(res.result, null, spaceForStringify)}`}
@ -191,9 +252,11 @@ const getResultPanel = (
}
relationship="label"
>
<AccordionHeader>{`Result ${
isOptimistic ? "(Optimistic result)" : ""
}`}</AccordionHeader>
<AccordionHeader>
<Text style={{ fontWeight: "bold" }}>{`Result ${
isOptimistic ? "(Optimistic result)" : ""
}`}</Text>
</AccordionHeader>
</Tooltip>
<AccordionPanel>
<Accordion collapsible>{...items}</Accordion>
@ -220,7 +283,11 @@ const getErrorPanel = (error: unknown, classes: Record<stylesKeys, string>) => (
content={"Error message for operation failure"}
relationship="label"
>
<AccordionHeader>{`Error ${error ? "(failed)" : ""}`}</AccordionHeader>
<AccordionHeader>
<Text style={{ fontWeight: "bold" }}>{`Error ${
error ? "(failed)" : ""
}`}</Text>
</AccordionHeader>
</Tooltip>
<AccordionPanel>
<div className={classes.errorAccPanel}> {JSON.stringify(error)}</div>
@ -239,7 +306,9 @@ const getWarningPanel = (
}
relationship="label"
>
<AccordionHeader>{`Warning`}</AccordionHeader>
<AccordionHeader>
<Text style={{ fontWeight: "bold" }}>{`Warning`}</Text>
</AccordionHeader>
</Tooltip>
<AccordionPanel>
<div className={classes.warningAccPanel}> {JSON.stringify(warning)}</div>
@ -256,7 +325,11 @@ const getDurationPanel = (
content={"Detailed time info for operation in milliSeconds"}
relationship="label"
>
<AccordionHeader>{`Duration (ms)`}</AccordionHeader>
<AccordionHeader>
<Text style={{ fontWeight: "bold" }}>{`Duration ${
duration?.totalTime ? `(${duration.totalTime} ms)` : `(ms)`
}`}</Text>
</AccordionHeader>
</Tooltip>
<AccordionPanel>
<div className={classes.durationAccPanel}>

Просмотреть файл

@ -1,39 +1,47 @@
import * as React from "react";
import { IVerboseOperation } from "apollo-inspector";
import { useStyles } from "./verbose-operations-list-view-styles";
import { useStyles } from "./verbose-operations-container-styles";
import { VerboseOperationView } from "./verbose-operation-view";
import { VerboseOperationsListView } from "./verbose-operations-list-view";
import { IReducerAction } from "../operations-tracker-body/operations-tracker-body.interface";
import { DataGridView } from "./data-grid-view";
import { ICountReducerAction } from "../operations-tracker-body/operations-tracker-body.interface";
import {
IOperationsAction,
IOperationsReducerState,
} from "../operations-tracker-container-helper";
export interface IVerboseOperationsContainerProps {
operations: IVerboseOperation[] | null;
filter: string;
dispatchOperationsCount: React.Dispatch<IReducerAction>;
operationsState: IOperationsReducerState;
dispatchOperationsCount: React.Dispatch<ICountReducerAction>;
dispatchOperationsState: React.Dispatch<IOperationsAction>;
}
export const VerboseOperationsContainer = (
props: IVerboseOperationsContainerProps,
) => {
const { operations, filter, dispatchOperationsCount } = props;
const [selectedOperation, setSelectedOperation] = React.useState(
props.operations?.[0],
);
const classes = useStyles();
const {
operations,
operationsState,
dispatchOperationsCount,
dispatchOperationsState,
} = props;
const classes = useStyles();
return (
<div className={classes.root}>
<div className={classes.operations}>
<VerboseOperationsListView
key={"VerboseOperationsListView"}
<DataGridView
key={"OperationsDataGridView"}
operations={operations}
filter={filter}
setSelectedOperation={setSelectedOperation}
selectedOperation={selectedOperation}
operationsState={operationsState}
dispatchOperationsCount={dispatchOperationsCount}
dispatchOperationsState={dispatchOperationsState}
/>
<VerboseOperationView
key={"VerboseOperationView"}
operation={selectedOperation}
operation={operationsState.selectedOperation}
dispatchOperationsState={dispatchOperationsState}
/>
</div>
</div>

Просмотреть файл

@ -1,132 +0,0 @@
import * as React from "react";
import { TabList, Tab, Text, Tooltip } from "@fluentui/react-components";
import { IVerboseOperation } from "apollo-inspector";
import { useStyles } from "./verbose-operations-list-view-styles";
import {
IReducerAction,
ReducerActionEnum,
} from "../operations-tracker-body/operations-tracker-body.interface";
export interface IVerboseOperationViewRendererProps {
operations: IVerboseOperation[] | null;
filter: string;
setSelectedOperation: React.Dispatch<
React.SetStateAction<IVerboseOperation | undefined>
>;
selectedOperation: IVerboseOperation | undefined;
dispatchOperationsCount: React.Dispatch<IReducerAction>;
}
export const VerboseOperationsListView = (
props: IVerboseOperationViewRendererProps,
) => {
const {
operations,
filter,
selectedOperation,
setSelectedOperation,
dispatchOperationsCount,
} = props;
const [filteredOperations, setFilteredOperations] = React.useState<
IVerboseOperation[] | null | undefined
>(operations);
const classes = useStyles();
const tabListItems = useOperationListNames(filteredOperations, classes);
const operationsMap = React.useMemo(() => {
const map = new Map<number, IVerboseOperation>();
filteredOperations?.forEach((op) => {
map.set(op.id, op);
});
return map;
}, [filteredOperations]);
const onTabSelect = React.useCallback(
(_, props) => {
const operation = operationsMap.get(props.value);
setSelectedOperation(operation);
},
[setSelectedOperation, filteredOperations],
);
React.useEffect(() => {
const filtereItems = getFilteredItems(operations, filter);
setFilteredOperations(filtereItems);
dispatchOperationsCount({
type: ReducerActionEnum.UpdateVerboseOperationsCount,
value: filtereItems?.length,
});
}, [filter, setFilteredOperations, operations, dispatchOperationsCount]);
return (
<div className={classes.operationsNameListWrapper}>
<TabList
className={classes.operationsList}
vertical
selectedValue={selectedOperation?.id}
onTabSelect={onTabSelect}
key="operationNameList"
>
{tabListItems}
</TabList>
</div>
);
};
const useOperationListNames = (
filteredOperations: IVerboseOperation[] | null | undefined,
classes: Record<
| "operationName"
| "root"
| "operations"
| "operationsList"
| "operationNameWrapper"
| "operationsNameListWrapper"
| "opCountTxt"
| "copyAllOpBtn",
string
>,
) =>
React.useMemo(() => {
const tabItems = filteredOperations?.map((op) => {
return (
<Tab key={op.id} value={op.id}>
<div className={classes.operationNameWrapper}>
{(op.operationName?.length || 0) > 30 ? (
<Tooltip content={op.operationName || ""} relationship="label">
<Text className={classes.operationName} weight={"semibold"}>
{op.operationName}
</Text>
</Tooltip>
) : (
<Text className={classes.operationName} weight={"semibold"}>
{op.operationName}
</Text>
)}
<Text>{`(${op.id}):${op.operationType}:${op.fetchPolicy}`}</Text>
</div>
</Tab>
);
});
return tabItems || [];
}, [filteredOperations]);
const getFilteredItems = (
items: IVerboseOperation[] | null | undefined,
filter: string,
) => {
if (filter.length === 0) {
return items;
} else {
const filteredItems = items?.filter((item) => {
return (
item.operationName?.toLowerCase().indexOf(filter.toLowerCase()) != -1
);
});
return filteredItems;
}
};

Просмотреть файл

@ -10,7 +10,7 @@
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^5.10.0",
"@typescript-eslint/parser": "^5.10.0",
"esbuild": "^0.14.38",
"esbuild": "^0.17.12",
"eslint": "^8.7.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",

1353
yarn.lock

Разница между файлами не показана из-за своего большого размера Загрузить разницу