feat: Schema tab UI update (#37420)

## Description

Updated schema tab as per new design.
https://www.figma.com/design/8L9BXMzNTKboGWlHpdXyYP/Appsmith-IDE?node-id=3071-101845&node-type=text&m=dev


Fixes #35289

## Automation

/ok-to-test tags="@tag.Sanity"

### 🔍 Cypress test results
<!-- This is an auto-generated comment: Cypress test results  -->
> [!TIP]
> 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉
> Workflow run:
<https://github.com/appsmithorg/appsmith/actions/runs/12022221992>
> Commit: c38fc9948554344a45c172fe291f49a0a5cd9b61
> <a
href="https://internal.appsmith.com/app/cypress-dashboard/rundetails-65890b3c81d7400d08fa9ee5?branch=master&workflowId=12022221992&attempt=2"
target="_blank">Cypress dashboard</a>.
> Tags: `@tag.Sanity`
> Spec:
> <hr>Tue, 26 Nov 2024 03:45:12 UTC
<!-- end of auto-generated comment: Cypress test results  -->


## Communication
Should the DevRel and Marketing teams inform users about this change?
- [ ] Yes
- [x] No


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Release Notes

- **New Features**
- Introduced new components: `CurrentDataSource`, `DatasourceSelector`,
`SchemaTables`, `TableColumns`, `StatusDisplay`,
`CurrentDataSourceLink`, `Schema`, `MenuField`, and custom hooks
`useCreateDatasource` and `useGoToDatasource`.
- Added constants for improved user feedback in schema-related messages.
  
- **Improvements**
- Enhanced layout and styling for various components, including
`BottomView` and `Schema`.
  - Updated test identifiers for better consistency and testability.

- **Bug Fixes**
  - Adjusted test cases to ensure accurate schema validation.

- **Refactor**
- Updated internal logic for handling datasource interactions and state
management across components.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
albinAppsmith 2024-11-26 09:42:33 +05:30 committed by GitHub
parent d0fc6d793b
commit b778d2cf6a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 967 additions and 241 deletions

View File

@ -79,7 +79,7 @@ describe(
"public.users",
);
dataSources.SelectTableFromPreviewSchemaList("public.users");
dataSources.VerifyColumnSchemaOnQueryEditor("id", 1);
dataSources.VerifyColumnSchemaOnQueryEditor("id", 0);
},
);

View File

@ -265,7 +265,7 @@ export class DataSources {
"')]/ancestor::div[@class='form-config-top']/following-sibling::div//div[contains(@class, 'rc-select-multiple')]";
private _datasourceSchemaRefreshBtn = ".datasourceStructure-refresh";
private _datasourceStructureHeader = ".datasourceStructure-header";
_datasourceSchemaColumn = ".t--datasource-column";
_datasourceSchemaColumn = ".t--datasource-column .t--field-name";
_datasourceStructureSearchInput = ".datasourceStructure-search input";
_jsModeSortingControl = ".t--actionConfiguration\\.formData\\.sortBy\\.data";
public _queryEditorCollapsibleIcon = ".collapsible-icon";
@ -296,7 +296,7 @@ export class DataSources {
_imgFireStoreLogo = "//img[contains(@src, 'firestore.svg')]";
_dsVirtuosoElement = `div .t--schema-virtuoso-container`;
private _dsVirtuosoList = `[data-test-id="virtuoso-item-list"]`;
private _dsSchemaContainer = `[data-testid="datasource-schema-container"]`;
private _dsSchemaContainer = `[data-testid="t--datasource-schema-container"]`;
private _dsVirtuosoElementTable = (targetTableName: string) =>
`${this._dsSchemaEntityItem}[data-testid='t--entity-item-${targetTableName}']`;
private _dsPageTabListItem = (buttonText: string) =>

View File

@ -38,6 +38,7 @@ const ViewWrapper = styled.div`
& {
.ads-v2-tabs__list {
padding: var(--ads-v2-spaces-1) var(--ads-v2-spaces-7);
padding-left: var(--ads-v2-spaces-3);
}
}

View File

@ -1,210 +0,0 @@
import { Button, Flex, Link } from "@appsmith/ads";
import React, { useCallback, useEffect, useState } from "react";
import {
DatasourceStructureContext,
type DatasourceColumns,
type DatasourceKeys,
} from "entities/Datasource";
import { DatasourceStructureContainer as DatasourceStructureList } from "pages/Editor/DatasourceInfo/DatasourceStructureContainer";
import { useDispatch, useSelector } from "react-redux";
import {
getDatasourceStructureById,
getIsFetchingDatasourceStructure,
getPluginImages,
getPluginIdFromDatasourceId,
getPluginDatasourceComponentFromId,
} from "ee/selectors/entitiesSelector";
import DatasourceField from "pages/Editor/DatasourceInfo/DatasourceField";
import { find } from "lodash";
import type { AppState } from "ee/reducers";
import RenderInterimDataState from "pages/Editor/DatasourceInfo/RenderInterimDataState";
import { getPluginActionDebuggerState } from "../../../store";
import {
fetchDatasourceStructure,
refreshDatasourceStructure,
} from "actions/datasourceActions";
import history from "utils/history";
import { datasourcesEditorIdURL } from "ee/RouteBuilder";
import { EntityIcon } from "pages/Editor/Explorer/ExplorerIcons";
import { getAssetUrl } from "ee/utils/airgapHelpers";
import { DatasourceComponentTypes } from "api/PluginApi";
interface Props {
datasourceId: string;
datasourceName: string;
currentActionId: string;
}
const Schema = (props: Props) => {
const dispatch = useDispatch();
const datasourceStructure = useSelector((state) =>
getDatasourceStructureById(state, props.datasourceId),
);
const { responseTabHeight } = useSelector(getPluginActionDebuggerState);
const pluginId = useSelector((state) =>
getPluginIdFromDatasourceId(state, props.datasourceId),
);
const pluginImages = useSelector((state) => getPluginImages(state));
const datasourceIcon = pluginId ? pluginImages[pluginId] : undefined;
const [selectedTable, setSelectedTable] = useState<string>();
const selectedTableItems = find(datasourceStructure?.tables, [
"name",
selectedTable,
]);
const columnsAndKeys: Array<DatasourceColumns | DatasourceKeys> = [];
if (selectedTableItems) {
columnsAndKeys.push(...selectedTableItems.keys);
columnsAndKeys.push(...selectedTableItems.columns);
}
const columns =
find(datasourceStructure?.tables, ["name", selectedTable])?.columns || [];
const isLoading = useSelector((state: AppState) =>
getIsFetchingDatasourceStructure(state, props.datasourceId),
);
const pluginDatasourceForm = useSelector((state) =>
getPluginDatasourceComponentFromId(state, pluginId || ""),
);
useEffect(() => {
setSelectedTable(undefined);
}, [props.datasourceId]);
useEffect(() => {
if (
props.datasourceId &&
datasourceStructure === undefined &&
pluginDatasourceForm !== DatasourceComponentTypes.RestAPIDatasourceForm
) {
dispatch(
fetchDatasourceStructure(
props.datasourceId,
true,
DatasourceStructureContext.QUERY_EDITOR,
),
);
}
}, [props.datasourceId, datasourceStructure, dispatch, pluginDatasourceForm]);
useEffect(() => {
if (!selectedTable && datasourceStructure?.tables?.length && !isLoading) {
setSelectedTable(datasourceStructure.tables[0].name);
}
}, [selectedTable, props.datasourceId, isLoading, datasourceStructure]);
const refreshStructure = useCallback(() => {
dispatch(
refreshDatasourceStructure(
props.datasourceId,
DatasourceStructureContext.QUERY_EDITOR,
),
);
}, [dispatch, props.datasourceId]);
const goToDatasource = useCallback(() => {
history.push(datasourcesEditorIdURL({ datasourceId: props.datasourceId }));
}, [props.datasourceId]);
if (!datasourceStructure) {
return (
<Flex alignItems="center" flex="1" height="100%" justifyContent="center">
{isLoading ? (
<RenderInterimDataState state="LOADING" />
) : (
<RenderInterimDataState state="NODATA" />
)}
</Flex>
);
}
return (
<Flex
flexDirection="row"
gap="spaces-3"
height={`${responseTabHeight - 45}px`}
maxWidth="70rem"
overflow="hidden"
>
<Flex
data-testid="datasource-schema-container"
flex="1"
flexDirection="column"
gap="spaces-3"
overflow="hidden"
padding="spaces-3"
paddingRight="spaces-0"
>
<Flex
alignItems={"center"}
gap="spaces-2"
justifyContent={"space-between"}
>
<Link onClick={goToDatasource}>
<Flex
alignItems={"center"}
gap="spaces-1"
justifyContent={"center"}
>
<EntityIcon height={`16px`} width={`16px`}>
<img alt="entityIcon" src={getAssetUrl(datasourceIcon)} />
</EntityIcon>
{props.datasourceName}
</Flex>
</Link>
<Button
className="datasourceStructure-refresh"
isIconButton
kind="tertiary"
onClick={refreshStructure}
size="sm"
startIcon="refresh"
/>
</Flex>
<DatasourceStructureList
context={DatasourceStructureContext.QUERY_EDITOR}
datasourceStructure={datasourceStructure}
onEntityTableClick={setSelectedTable}
step={0}
tableName={selectedTable}
{...props}
/>
</Flex>
<Flex
borderLeft="1px solid var(--ads-v2-color-border)"
flex="1"
flexDirection="column"
height={`${responseTabHeight - 45}px`}
justifyContent={
isLoading || columns.length === 0 ? "center" : "flex-start"
}
overflowY="scroll"
padding="spaces-3"
>
{isLoading ? <RenderInterimDataState state="LOADING" /> : null}
{!isLoading && columns.length === 0 ? (
<RenderInterimDataState state="NOCOLUMNS" />
) : null}
{!isLoading &&
columnsAndKeys.map((field, index) => {
return (
<DatasourceField
field={field}
key={`${field.name}${index}`}
step={0}
/>
);
})}
</Flex>
</Flex>
);
};
export default Schema;

View File

@ -0,0 +1,34 @@
import React from "react";
import { Flex } from "@appsmith/ads";
import { getAssetUrl } from "ee/utils/airgapHelpers";
import { EntityIcon } from "pages/Editor/Explorer/ExplorerIcons";
import { useSelector } from "react-redux";
import {
getPluginIdFromDatasourceId,
getPluginImages,
} from "ee/selectors/entitiesSelector";
interface Props {
datasourceId: string;
datasourceName: string;
}
const CurrentDataSource = ({ datasourceId, datasourceName }: Props) => {
const { pluginId, pluginImages } = useSelector((state) => ({
pluginId: getPluginIdFromDatasourceId(state, datasourceId),
pluginImages: getPluginImages(state),
}));
const datasourceIcon = pluginId ? pluginImages?.[pluginId] : undefined;
return (
<Flex alignItems="center" gap="spaces-2">
<EntityIcon height="16px" width="16px">
<img alt="entityIcon" src={getAssetUrl(datasourceIcon)} />
</EntityIcon>
{datasourceName}
</Flex>
);
};
export { CurrentDataSource };

View File

@ -0,0 +1,30 @@
import React, { useCallback } from "react";
import { Link } from "@appsmith/ads";
import { CurrentDataSource } from "./CurrentDataSource";
import { useGoToDatasource } from "PluginActionEditor/components/PluginActionResponse/hooks/useGoToDatasource";
const CurrentDataSourceLink = ({
datasourceId,
datasourceName,
}: {
datasourceId: string;
datasourceName: string;
}) => {
const { goToDatasource } = useGoToDatasource();
const handleClick = useCallback(
() => goToDatasource(datasourceId),
[datasourceId, goToDatasource],
);
return (
<Link onClick={handleClick}>
<CurrentDataSource
datasourceId={datasourceId}
datasourceName={datasourceName}
/>
</Link>
);
};
export { CurrentDataSourceLink };

View File

@ -0,0 +1,138 @@
import React from "react";
import { useSelector } from "react-redux";
import { Flex } from "@appsmith/ads";
import { CREATE_NEW_DATASOURCE, createMessage } from "ee/constants/messages";
import { useFeatureFlag } from "utils/hooks/useFeatureFlag";
import { FEATURE_FLAG } from "ee/entities/FeatureFlag";
import {
getHasCreateDatasourcePermission,
getHasManageActionPermission,
} from "ee/utils/BusinessFeatures/permissionPageHelpers";
import { doesPluginRequireDatasource } from "ee/entities/Engine/actionHelpers";
import {
getActionByBaseId,
getDatasourceByPluginId,
getPlugin,
getPluginImages,
} from "ee/selectors/entitiesSelector";
import type { Datasource } from "entities/Datasource";
import type { AppState } from "ee/reducers";
import { getCurrentAppWorkspace } from "ee/selectors/selectedWorkspaceSelectors";
import { useActiveActionBaseId } from "ee/pages/Editor/Explorer/hooks";
import { INTEGRATION_TABS } from "constants/routes";
import { QUERY_EDITOR_FORM_NAME } from "ee/constants/forms";
import MenuField from "components/editorComponents/form/fields/MenuField";
import type { InjectedFormProps } from "redux-form";
import { reduxForm } from "redux-form";
import type { Action } from "entities/Action";
import { CurrentDataSourceLink } from "./CurrentDataSourceLink";
import { CurrentDataSource } from "./CurrentDataSource";
import { useCreateDatasource } from "ee/PluginActionEditor/hooks/useCreateDatasource";
interface CustomProps {
datasourceId: string;
datasourceName: string;
}
type Props = InjectedFormProps<Action, CustomProps> & CustomProps;
interface DATASOURCES_OPTIONS_TYPE {
label: string;
value: string;
image?: string;
icon?: string;
onSelect?: (value: string) => void;
}
const DatasourceSelector = ({ datasourceId, datasourceName }: Props) => {
const activeActionBaseId = useActiveActionBaseId();
const currentActionConfig = useSelector((state) =>
activeActionBaseId
? getActionByBaseId(state, activeActionBaseId)
: undefined,
);
const plugin = useSelector((state: AppState) =>
getPlugin(state, currentActionConfig?.pluginId || ""),
);
const dataSources = useSelector((state: AppState) =>
getDatasourceByPluginId(state, currentActionConfig?.pluginId || ""),
);
const isFeatureEnabled = useFeatureFlag(FEATURE_FLAG.license_gac_enabled);
const userWorkspacePermissions = useSelector(
(state: AppState) => getCurrentAppWorkspace(state).userPermissions ?? [],
);
const isChangePermitted = getHasManageActionPermission(
isFeatureEnabled,
currentActionConfig?.userPermissions,
);
const canCreateDatasource = getHasCreateDatasourcePermission(
isFeatureEnabled,
userWorkspacePermissions,
);
const showDatasourceSelector = doesPluginRequireDatasource(plugin);
const pluginImages = useSelector(getPluginImages);
const { onCreateDatasourceClick } = useCreateDatasource();
const DATASOURCES_OPTIONS: Array<DATASOURCES_OPTIONS_TYPE> =
dataSources.reduce(
(acc: Array<DATASOURCES_OPTIONS_TYPE>, dataSource: Datasource) => {
if (dataSource.pluginId === plugin?.id) {
acc.push({
label: dataSource.name,
value: dataSource.id,
image: pluginImages[dataSource.pluginId],
});
}
return acc;
},
[],
);
if (canCreateDatasource) {
DATASOURCES_OPTIONS.push({
label: createMessage(CREATE_NEW_DATASOURCE),
value: "create",
icon: "plus",
onSelect: () =>
onCreateDatasourceClick(
INTEGRATION_TABS.NEW,
currentActionConfig?.pageId,
),
});
}
if (!showDatasourceSelector || !isChangePermitted) {
return (
<CurrentDataSourceLink
datasourceId={datasourceId}
datasourceName={datasourceName}
/>
);
}
return (
<Flex>
<MenuField
className={"t--switch-datasource"}
formName={QUERY_EDITOR_FORM_NAME}
name="datasource.id"
options={DATASOURCES_OPTIONS}
>
<CurrentDataSource
datasourceId={datasourceId}
datasourceName={datasourceName}
/>
</MenuField>
</Flex>
);
};
export default reduxForm<Action, CustomProps>({
form: QUERY_EDITOR_FORM_NAME,
destroyOnUnmount: false,
enableReinitialize: true,
})(DatasourceSelector);

View File

@ -0,0 +1,197 @@
import { Flex } from "@appsmith/ads";
import React, { useEffect, useState } from "react";
import { DatasourceStructureContext } from "entities/Datasource";
import { useDispatch, useSelector } from "react-redux";
import {
getDatasourceStructureById,
getIsFetchingDatasourceStructure,
getPluginIdFromDatasourceId,
getPluginDatasourceComponentFromId,
} from "ee/selectors/entitiesSelector";
import type { AppState } from "ee/reducers";
import { fetchDatasourceStructure } from "actions/datasourceActions";
import history from "utils/history";
import { datasourcesEditorIdURL } from "ee/RouteBuilder";
import { DatasourceComponentTypes } from "api/PluginApi";
import { getPluginActionDebuggerState } from "PluginActionEditor/store";
import { SchemaDisplayStatus, StatusDisplay } from "./StatusDisplay";
import DatasourceSelector from "./DatasourceSelector";
import { SchemaTables } from "./SchemaTables";
import { DatasourceEditEntryPoints } from "constants/Datasource";
import AnalyticsUtil from "ee/utils/AnalyticsUtil";
import { isEmpty, omit } from "lodash";
import { getQueryParams } from "utils/URLUtils";
import { getCurrentPageId } from "selectors/editorSelectors";
import { TableColumns } from "./TableColumns";
import { BOTTOMBAR_HEIGHT } from "./constants";
interface Props {
datasourceId: string;
datasourceName: string;
currentActionId: string;
}
const Schema = (props: Props) => {
const dispatch = useDispatch();
const datasourceStructure = useSelector((state) =>
getDatasourceStructureById(state, props.datasourceId),
);
const { responseTabHeight } = useSelector(getPluginActionDebuggerState);
const pluginId = useSelector((state) =>
getPluginIdFromDatasourceId(state, props.datasourceId),
);
const currentPageId = useSelector(getCurrentPageId);
const [selectedTable, setSelectedTable] = useState<string>();
const isLoading = useSelector((state: AppState) =>
getIsFetchingDatasourceStructure(state, props.datasourceId),
);
const pluginDatasourceForm = useSelector((state) =>
getPluginDatasourceComponentFromId(state, pluginId || ""),
);
useEffect(
function resetSelectedTable() {
setSelectedTable(undefined);
},
[props.datasourceId],
);
useEffect(
function fetchDatasourceStructureEffect() {
function fetchStructure() {
if (
props.datasourceId &&
datasourceStructure === undefined &&
pluginDatasourceForm !==
DatasourceComponentTypes.RestAPIDatasourceForm
) {
dispatch(
fetchDatasourceStructure(
props.datasourceId,
true,
DatasourceStructureContext.QUERY_EDITOR,
),
);
}
}
fetchStructure();
},
[props.datasourceId, datasourceStructure, dispatch, pluginDatasourceForm],
);
useEffect(
function selectFirstTable() {
if (!selectedTable && datasourceStructure?.tables?.length && !isLoading) {
setSelectedTable(datasourceStructure.tables[0].name);
}
},
[selectedTable, props.datasourceId, isLoading, datasourceStructure],
);
// eslint-disable-next-line react-perf/jsx-no-new-function-as-prop
const editDatasource = () => {
const entryPoint = DatasourceEditEntryPoints.QUERY_EDITOR_DATASOURCE_SCHEMA;
AnalyticsUtil.logEvent("EDIT_DATASOURCE_CLICK", {
datasourceId: props.datasourceId,
pluginName: "",
entryPoint: entryPoint,
});
const url = datasourcesEditorIdURL({
basePageId: currentPageId,
datasourceId: props.datasourceId,
params: { ...omit(getQueryParams(), "viewMode"), viewMode: false },
generateEditorPath: true,
});
history.push(url);
};
const getStatusState = () => {
if (isLoading) return SchemaDisplayStatus.SCHEMA_LOADING;
if (!datasourceStructure) return SchemaDisplayStatus.NOSCHEMA;
if (datasourceStructure && "error" in datasourceStructure)
return SchemaDisplayStatus.FAILED;
if (isEmpty(datasourceStructure)) return SchemaDisplayStatus.CANTSHOW;
return null;
};
const statusState = getStatusState();
const renderStatus = () => {
if (!statusState) {
return null;
}
return (
<>
<Flex padding="spaces-3">
<DatasourceSelector
datasourceId={props.datasourceId}
datasourceName={props.datasourceName}
/>
</Flex>
<StatusDisplay
editDatasource={editDatasource}
errorMessage={
datasourceStructure?.error && "message" in datasourceStructure.error
? datasourceStructure.error.message
: ""
}
state={statusState}
/>
</>
);
};
const renderContent = () => {
if (statusState) {
return null;
}
return (
<Flex h="100%">
<SchemaTables
currentActionId={props.currentActionId}
datasourceId={props.datasourceId}
datasourceName={props.datasourceName}
datasourceStructure={datasourceStructure}
selectedTable={selectedTable}
setSelectedTable={setSelectedTable}
/>
<TableColumns
datasourceStructure={datasourceStructure}
isLoading={isLoading}
selectedTable={selectedTable}
/>
</Flex>
);
};
return (
<Flex
flexDirection="column"
gap="spaces-3"
height={`${responseTabHeight - BOTTOMBAR_HEIGHT}px`}
overflow="hidden"
>
{renderStatus()}
{renderContent()}
</Flex>
);
};
export { Schema };

View File

@ -0,0 +1,83 @@
import { Flex, Button } from "@appsmith/ads";
import {
DatasourceStructureContext,
type DatasourceStructure,
} from "entities/Datasource";
import { DatasourceStructureContainer as DatasourceStructureList } from "pages/Editor/DatasourceInfo/DatasourceStructureContainer";
import React, { useCallback } from "react";
import DatasourceSelector from "./DatasourceSelector";
import { refreshDatasourceStructure } from "actions/datasourceActions";
import { useDispatch } from "react-redux";
import { SchemaTableContainer } from "./styles";
interface Props {
datasourceId: string;
datasourceName: string;
currentActionId: string;
datasourceStructure: DatasourceStructure;
setSelectedTable: (table: string) => void;
selectedTable: string | undefined;
}
const SchemaTables = ({
currentActionId,
datasourceId,
datasourceName,
datasourceStructure,
selectedTable,
setSelectedTable,
}: Props) => {
const dispatch = useDispatch();
const refreshStructure = useCallback(() => {
dispatch(
refreshDatasourceStructure(
datasourceId,
DatasourceStructureContext.QUERY_EDITOR,
),
);
}, [dispatch, datasourceId]);
return (
<SchemaTableContainer
data-testid="t--datasource-schema-container"
flexDirection="column"
gap="spaces-3"
overflow="hidden"
padding="spaces-3"
paddingBottom="spaces-0"
w="400px"
>
<Flex
alignItems={"center"}
gap="spaces-2"
justifyContent={"space-between"}
>
<DatasourceSelector
datasourceId={datasourceId}
datasourceName={datasourceName}
/>
<Button
className="datasourceStructure-refresh"
isIconButton
kind="tertiary"
onClick={refreshStructure}
size="sm"
startIcon="refresh"
/>
</Flex>
<DatasourceStructureList
context={DatasourceStructureContext.QUERY_EDITOR}
currentActionId={currentActionId}
datasourceId={datasourceId}
datasourceName={datasourceName}
datasourceStructure={datasourceStructure}
onEntityTableClick={setSelectedTable}
step={0}
tableName={selectedTable}
/>
</SchemaTableContainer>
);
};
export { SchemaTables };

View File

@ -0,0 +1,126 @@
import React, { type ReactNode } from "react";
import { Button, Flex, Spinner, Text } from "@appsmith/ads";
import {
createMessage,
EMPTY_TABLE_MESSAGE_TEXT,
EMPTY_TABLE_TITLE_TEXT,
FAILED_RECORDS_MESSAGE_TEXT,
FAILED_RECORDS_TITLE_TEXT,
LOADING_RECORDS_MESSAGE_TEXT,
LOADING_SCHEMA_TITLE_TEXT,
NO_COLUMNS_MESSAGE_TEXT,
EMPTY_SCHEMA_TITLE_TEXT,
EMPTY_SCHEMA_MESSAGE_TEXT,
EDIT_DATASOURCE,
LOADING_RECORDS_TITLE_TEXT,
CANT_SHOW_SCHEMA,
} from "ee/constants/messages";
import { getAssetUrl } from "ee/utils/airgapHelpers";
import { ASSETS_CDN_URL } from "constants/ThirdPartyConstants";
enum SchemaDisplayStatus {
SCHEMA_LOADING = "SCHEMA_LOADING",
LOADING = "LOADING",
NOSCHEMA = "NOSCHEMA",
NODATA = "NODATA",
FAILED = "FAILED",
NOCOLUMNS = "NOCOLUMNS",
CANTSHOW = "CANTSHOW",
}
interface Props {
state: SchemaDisplayStatus;
editDatasource?: () => void;
errorMessage?: string;
}
const StateData: Record<
SchemaDisplayStatus,
{ title?: string; message?: string; image: string | ReactNode }
> = {
SCHEMA_LOADING: {
title: createMessage(LOADING_SCHEMA_TITLE_TEXT),
message: createMessage(LOADING_RECORDS_MESSAGE_TEXT),
image: <Spinner size="md" />,
},
LOADING: {
title: createMessage(LOADING_RECORDS_TITLE_TEXT),
message: createMessage(LOADING_RECORDS_MESSAGE_TEXT),
image: <Spinner size="md" />,
},
NOSCHEMA: {
title: createMessage(EMPTY_SCHEMA_TITLE_TEXT),
message: createMessage(EMPTY_SCHEMA_MESSAGE_TEXT),
image: getAssetUrl(`${ASSETS_CDN_URL}/empty-state.svg`),
},
NODATA: {
title: createMessage(EMPTY_TABLE_TITLE_TEXT),
message: createMessage(EMPTY_TABLE_MESSAGE_TEXT),
image: getAssetUrl(`${ASSETS_CDN_URL}/empty-state.svg`),
},
FAILED: {
title: createMessage(FAILED_RECORDS_TITLE_TEXT),
message: createMessage(FAILED_RECORDS_MESSAGE_TEXT),
image: getAssetUrl(`${ASSETS_CDN_URL}/failed-state.svg`),
},
NOCOLUMNS: {
title: createMessage(EMPTY_TABLE_TITLE_TEXT),
message: createMessage(NO_COLUMNS_MESSAGE_TEXT),
image: getAssetUrl(`${ASSETS_CDN_URL}/empty-state.svg`),
},
CANTSHOW: {
message: createMessage(CANT_SHOW_SCHEMA),
image: getAssetUrl(`${ASSETS_CDN_URL}/empty-state.svg`),
},
};
const StatusDisplay = ({ editDatasource, errorMessage, state }: Props) => {
const { image, message, title } = StateData[state];
return (
<Flex
alignItems={"center"}
flexDirection={"column"}
gap="spaces-7"
h="100%"
justifyContent={"start"}
overflowY={"scroll"}
p="spaces-3"
w="100%"
>
{typeof image === "string" ? (
<Flex alignItems={"center"} h="150px" justifyContent={"center"}>
<img alt={title} className="h-full" src={image} />
</Flex>
) : (
image
)}
<Flex
alignItems={"center"}
className="text-center"
flexDirection="column"
justifyContent={"center"}
maxWidth="400px"
>
<Text kind="heading-xs">{title}</Text>
<Text kind="body-m">
{state === "FAILED" && errorMessage ? errorMessage : message}
</Text>
{state === "FAILED" && (
<Button
className="mt-[16px]"
kind="secondary"
onClick={editDatasource}
size={"sm"}
>
{createMessage(EDIT_DATASOURCE)}
</Button>
)}
</Flex>
</Flex>
);
};
export { StatusDisplay, SchemaDisplayStatus };

View File

@ -0,0 +1,143 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Flex, type FlexProps, SearchInput, Text } from "@appsmith/ads";
import { find } from "lodash";
import type { DatasourceStructure } from "entities/Datasource";
import { StatusDisplay, SchemaDisplayStatus } from "./StatusDisplay";
import DatasourceField from "pages/Editor/DatasourceInfo/DatasourceField";
import {
COLUMNS_SEARCH_PLACEHOLDER,
COLUMNS_TITLE,
createMessage,
} from "ee/constants/messages";
import Fuse from "fuse.js";
import { TableColumn } from "./styles";
interface Props {
isLoading: boolean;
datasourceStructure: DatasourceStructure;
selectedTable: string | undefined;
}
const Wrapper: React.FC<FlexProps> = (props) => {
return (
<Flex
borderLeft="1px solid var(--ads-v2-color-border)"
flex="1"
flexDirection="column"
height="100%"
justifyContent="flex-start"
padding="spaces-3"
{...props}
>
{props.children}
</Flex>
);
};
const TableColumns: React.FC<Props> = ({
datasourceStructure,
isLoading,
selectedTable,
}) => {
// Find selected table items
const selectedTableItems = useMemo(
() =>
find(datasourceStructure?.tables, { name: selectedTable }) ?? {
columns: [],
keys: [],
},
[datasourceStructure, selectedTable],
);
// Combine columns and keys
const columns = useMemo(() => {
return selectedTableItems.columns.map((column) => ({
name: column.name,
type: column.type,
keys: selectedTableItems.keys
.filter(
(key) =>
key.columnNames?.includes(column.name) ||
key.fromColumns?.includes(column.name),
)
.map((key) => key.type),
}));
}, [selectedTableItems]);
// search
const columnsFuzy = useMemo(
() =>
new Fuse(columns, {
keys: ["name"],
shouldSort: true,
threshold: 0.5,
location: 0,
}),
[columns],
);
const [term, setTerm] = useState("");
const filteredColumns = useMemo(
() => (term ? columnsFuzy.search(term) : columns),
[term, columns, columnsFuzy],
);
const handleSearch = useCallback((value: string) => setTerm(value), []);
// Reset term whenever selectedTable changes
useEffect(
function clearTerm() {
setTerm("");
},
[selectedTable],
);
// loading status
if (isLoading) {
return (
<Wrapper>
<StatusDisplay state={SchemaDisplayStatus.LOADING} />
</Wrapper>
);
}
// no columns status
if (columns.length === 0) {
return (
<Wrapper>
<StatusDisplay state={SchemaDisplayStatus.NOCOLUMNS} />
</Wrapper>
);
}
return (
<Wrapper gap="spaces-3">
<Flex alignItems="center" minH="24px">
<Text>{createMessage(COLUMNS_TITLE)}</Text>
</Flex>
<Flex>
<SearchInput
className="datasourceStructure-search"
endIcon="close"
onChange={handleSearch}
placeholder={createMessage(COLUMNS_SEARCH_PLACEHOLDER, selectedTable)}
size={"sm"}
startIcon="search"
value={term}
/>
</Flex>
<TableColumn flexDirection="column" overflowY="scroll">
{filteredColumns.map((field, index) => (
<DatasourceField
field={field}
key={`${field.name}${index}`}
step={0}
/>
))}
</TableColumn>
</Wrapper>
);
};
export { TableColumns };

View File

@ -0,0 +1,2 @@
// This is bottom bar height + paddings
export const BOTTOMBAR_HEIGHT = 42;

View File

@ -0,0 +1 @@
export * from "./Schema";

View File

@ -0,0 +1,23 @@
import styled from "styled-components";
import { Flex } from "@appsmith/ads";
export const TableColumn = styled(Flex)`
& .t--datasource-column {
padding: 0;
& > div {
margin: 0;
}
}
`;
export const SchemaTableContainer = styled(Flex)`
& .t--entity-item {
height: 28px;
grid-template-columns: 0 auto 1fr auto auto auto auto auto;
.entity-icon > .ads-v2-icon {
display: none;
}
}
`;

View File

@ -0,0 +1,15 @@
import { useCallback } from "react";
import { datasourcesEditorIdURL } from "ee/RouteBuilder";
import history from "utils/history";
function useGoToDatasource() {
const goToDatasource = useCallback((datasourceId: string) => {
history.push(
datasourcesEditorIdURL({ datasourceId, generateEditorPath: true }),
);
}, []);
return { goToDatasource };
}
export { useGoToDatasource };

View File

@ -25,7 +25,7 @@ import {
} from "PluginActionEditor/store";
import { doesPluginRequireDatasource } from "ee/entities/Engine/actionHelpers";
import useShowSchema from "PluginActionEditor/components/PluginActionResponse/hooks/useShowSchema";
import Schema from "PluginActionEditor/components/PluginActionResponse/components/Schema";
import { Schema } from "PluginActionEditor/components/PluginActionResponse/components/Schema";
import QueryResponseTab from "PluginActionEditor/components/PluginActionResponse/components/QueryResponseTab";
import type { SourceEntity } from "entities/AppsmithConsole";
import { ENTITY_TYPE as SOURCE_ENTITY_TYPE } from "ee/entities/AppsmithConsole/utils";

View File

@ -0,0 +1,27 @@
import { useCallback } from "react";
import { integrationEditorURL } from "ee/RouteBuilder";
import AnalyticsUtil from "ee/utils/AnalyticsUtil";
import { DatasourceCreateEntryPoints } from "constants/Datasource";
import history from "utils/history";
function useCreateDatasource() {
const onCreateDatasourceClick = useCallback(
(selectedTab, pageId?: string) => {
history.push(
integrationEditorURL({
basePageId: pageId,
selectedTab,
}),
);
AnalyticsUtil.logEvent("NAVIGATE_TO_CREATE_NEW_DATASOURCE_PAGE", {
entryPoint: DatasourceCreateEntryPoints.QUERY_EDITOR,
});
},
[],
);
return { onCreateDatasourceClick };
}
export { useCreateDatasource };

View File

@ -365,7 +365,7 @@ export const DATASOURCE_UPDATE = (dsName: string) =>
`${dsName} datasource updated successfully`;
export const DATASOURCE_VALID = (dsName: string) =>
`${dsName} datasource is valid`;
export const EDIT_DATASOURCE = () => "Edit";
export const EDIT_DATASOURCE = () => "Edit configuration";
export const SAVE_DATASOURCE = () => "Save URL";
export const EDIT_DATASOURCE_TOOLTIP = () => "Edit datasource";
export const SAVE_DATASOURCE_TOOLTIP = () => "Save URL as a datasource";
@ -804,7 +804,7 @@ export const SCHEMA_NOT_AVAILABLE = () =>
"We can't show schema for this datasource";
export const TABLE_NOT_FOUND = () => "Table not found.";
export const DATASOURCE_STRUCTURE_INPUT_PLACEHOLDER_TEXT = (name: string) =>
`Tables in ${name}`;
`Search tables in ${name}`;
export const SCHEMA_LABEL = () => "Schema";
export const STRUCTURE_NOT_FETCHED = () =>
"We could not fetch the schema of the database.";
@ -2270,14 +2270,24 @@ export const COMMUNITY_TEMPLATES = {
// Interim data state info
export const EMPTY_TABLE_TITLE_TEXT = () => "Empty table";
export const EMPTY_SCHEMA_TITLE_TEXT = () => "Empty schema";
export const EMPTY_TABLE_MESSAGE_TEXT = () =>
"There are no data records to show";
export const EMPTY_SCHEMA_MESSAGE_TEXT = () =>
"There are no schema records to show";
export const NO_COLUMNS_MESSAGE_TEXT = () => "There are no columns to show";
export const LOADING_RECORDS_TITLE_TEXT = () => "Loading records";
export const LOADING_SCHEMA_TITLE_TEXT = () => "Loading schema";
export const LOADING_RECORDS_MESSAGE_TEXT = () => "This may take a few seconds";
export const FAILED_RECORDS_TITLE_TEXT = () => "Failed to load";
export const FAILED_RECORDS_MESSAGE_TEXT = () =>
"There was an error connecting to the datasource. Please check the datasource configuration and retry. If the issue persists, review the datasource settings.";
"There was an error connecting to the datasource. Please check the datasource configuration and retry.";
export const DATASOURCE_SWITCHER_MENU_GROUP_NAME = () => "Select a datasource";
export const CANT_SHOW_SCHEMA = () =>
"We cant show the schema for this datasource";
export const COLUMNS_TITLE = () => "Columns";
export const COLUMNS_SEARCH_PLACEHOLDER = (tableName: string) =>
`Search columns in ${tableName}`;
export const DATA_PANE_TITLE = () => "Datasources in your workspace";
export const DATASOURCE_LIST_BLANK_DESCRIPTION = () =>

View File

@ -0,0 +1,87 @@
import {
Button,
Menu,
MenuContent,
MenuGroupName,
MenuTrigger,
Text,
MenuGroup,
MenuItem,
Flex,
Icon,
} from "@appsmith/ads";
import { getAssetUrl } from "ee/utils/airgapHelpers";
import React from "react";
import { type WrappedFieldProps, type BaseFieldProps, Field } from "redux-form";
interface iOption {
value: string;
label: string;
icon?: string;
image?: string;
onSelect?: (value: string) => void;
}
interface iMenuFieldProps {
options: iOption[];
children: React.ReactNode;
className?: string;
groupName?: string;
}
const MenuFieldRender = (props: iMenuFieldProps & WrappedFieldProps) => {
const { children, groupName, input, options } = props;
const handleMenuSelect = (option: iOption) => {
if (option.onSelect) {
option.onSelect(option.value); // Trigger custom onSelect
} else {
input.onChange(option.value); // Default behavior
}
};
return (
<Menu>
<MenuTrigger>
<Button endIcon={"arrow-down-s-line"} kind="tertiary" size="sm">
{children}
</Button>
</MenuTrigger>
<MenuContent align="start" loop width="235px">
{groupName && (
<MenuGroupName asChild>
<Text kind="body-s">{groupName}</Text>
</MenuGroupName>
)}
<MenuGroup>
{options.map((option) => (
<MenuItem
key={option.value}
onSelect={() => handleMenuSelect(option)}
>
<Flex alignItems={"center"} gap="spaces-2">
{option.image && (
<img
alt="Datasource"
className="plugin-image h-[12px] w-[12px]"
src={getAssetUrl(option.image)}
/>
)}
{option.icon && <Icon name={option.icon} size="md" />}
{option.label}
</Flex>
</MenuItem>
))}
</MenuGroup>
</MenuContent>
</Menu>
);
};
const MenuField = (
props: BaseFieldProps & iMenuFieldProps & { formName: string },
) => (
<Field className={props.className} component={MenuFieldRender} {...props} />
);
export default MenuField;

View File

@ -0,0 +1 @@
export * from "ce/PluginActionEditor/hooks/useCreateDatasource";

View File

@ -66,6 +66,7 @@ export interface DatasourceKeys {
name: string;
type: string;
columnNames: string[];
fromColumns: string[];
}
export interface DatasourceStructure {

View File

@ -1,11 +1,7 @@
import React, { useRef } from "react";
import {
DATASOURCE_FIELD_ICONS_MAP,
datasourceColumnIcon,
} from "../Explorer/ExplorerIcons";
import { DATASOURCE_FIELD_ICONS_MAP } from "../Explorer/ExplorerIcons";
import styled from "styled-components";
import type { DatasourceColumns, DatasourceKeys } from "entities/Datasource";
import { Tooltip } from "@appsmith/ads";
import { Tooltip, Tag, Flex } from "@appsmith/ads";
import { isEllipsisActive } from "utils/helpers";
const Wrapper = styled.div<{ step: number }>`
@ -21,33 +17,41 @@ const Wrapper = styled.div<{ step: number }>`
const FieldName = styled.div`
color: var(--ads-v2-color-fg);
flex: 1;
font-size: 12px;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
line-height: 13px;
text-overflow: ellipsis;
padding-right: 30px;
`;
const FieldValue = styled.div`
color: var(--ads-v2-color-fg-subtle);
text-align: right;
font-size: 10px;
line-height: 12px;
font-size: 14px;
font-weight: 300;
`;
const Content = styled.div`
margin: 0px 4px;
flex: 1;
flex-direction: row;
min-width: 0;
display: flex;
justify-content: space-between;
gap: var(--ads-v2-spaces-2);
`;
const FieldKeyLabel = styled.span`
&:first-letter {
text-transform: capitalize;
}
`;
interface FieldProps {
name: string;
type: string;
keys?: string[];
}
interface DatabaseFieldProps {
field: DatasourceColumns | DatasourceKeys;
field: FieldProps;
step: number;
}
@ -55,12 +59,15 @@ export function DatabaseColumns(props: DatabaseFieldProps) {
const field = props.field;
const fieldName = field.name;
const fieldType = field.type;
const icon = DATASOURCE_FIELD_ICONS_MAP[fieldType] || datasourceColumnIcon;
const fieldKeys = field.keys;
const icon =
fieldKeys && fieldKeys.length > 0
? DATASOURCE_FIELD_ICONS_MAP[fieldKeys[0]]
: null;
const nameRef = useRef<HTMLDivElement | null>(null);
return (
<Wrapper className="t--datasource-column" step={props.step}>
{icon}
<Content>
<Tooltip
content={fieldName}
@ -68,9 +75,19 @@ export function DatabaseColumns(props: DatabaseFieldProps) {
mouseEnterDelay={2}
showArrow={false}
>
<FieldName ref={nameRef}>{fieldName}</FieldName>
<FieldName className="t--field-name" ref={nameRef}>
{fieldName}
</FieldName>
</Tooltip>
<FieldValue>{fieldType}</FieldValue>
{icon && fieldKeys && (
<Tag isClosable={false} size="md">
<Flex gap="spaces-1">
{icon}
<FieldKeyLabel>{fieldKeys[0]}</FieldKeyLabel>
</Flex>
</Tag>
)}
</Content>
</Wrapper>
);

View File

@ -1,4 +1,4 @@
import React from "react";
import React, { useCallback } from "react";
import { useSelector } from "react-redux";
import styled from "styled-components";
import { Text, Button } from "@appsmith/ads";
@ -42,7 +42,7 @@ const DatasourceStructureNotFound = (props: Props) => {
const basePageId = useSelector(getCurrentBasePageId);
const editDatasource = () => {
const editDatasource = useCallback(() => {
let entryPoint = DatasourceEditEntryPoints.QUERY_EDITOR_DATASOURCE_SCHEMA;
if (props.context === DatasourceStructureContext.DATASOURCE_VIEW_MODE) {
@ -69,7 +69,7 @@ const DatasourceStructureNotFound = (props: Props) => {
});
history.push(url);
};
}, [basePageId, datasourceId, pluginName, props]);
return (
<NotFoundContainer>

View File

@ -246,7 +246,7 @@ const DatasourceViewModeSchema = (props: Props) => {
return (
<ViewModeSchemaContainer>
<DataWrapperContainer data-testid="datasource-schema-container">
<DataWrapperContainer data-testid="t--datasource-schema-container">
<StructureContainer>
{props.datasource && (
<DatasourceStructureHeader

View File

@ -399,7 +399,7 @@ function GoogleSheetSchema(props: Props) {
return (
<ViewModeSchemaContainer>
<DataWrapperContainer>
<StructureContainer data-testid="datasource-schema-container">
<StructureContainer data-testid="t--datasource-schema-container">
{datasource && (
<DatasourceStructureHeader
datasource={datasource}

View File

@ -14,7 +14,7 @@ import {
} from "ee/constants/messages";
import DebuggerLogs from "components/editorComponents/Debugger/DebuggerLogs";
import ErrorLogs from "components/editorComponents/Debugger/Errors";
import Schema from "PluginActionEditor/components/PluginActionResponse/components/Schema";
import { Schema } from "PluginActionEditor/components/PluginActionResponse/components/Schema";
import type { ActionResponse } from "api/ActionAPI";
import { isString } from "lodash";
import type { SourceEntity } from "entities/AppsmithConsole";