Improve onboarding performance (#11682)
* Improve onboarding performance * Fix auto import issues * Fix minor issues * Regenerate index.html * Add JUnit tests
This commit is contained in:
@@ -1996,6 +1996,28 @@ paths:
|
||||
$ref: "#/components/schemas/WebBackendConnectionReadList"
|
||||
"422":
|
||||
$ref: "#/components/responses/InvalidInputResponse"
|
||||
/v1/web_backend/workspace/state:
|
||||
post:
|
||||
tags:
|
||||
- web_backend
|
||||
summary: Returns the current state of a workspace
|
||||
operationId: webBackendGetWorkspaceState
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/WebBackendWorkspaceState"
|
||||
responses:
|
||||
"200":
|
||||
description: Successful operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/WebBackendWorkspaceStateResult"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFoundResponse"
|
||||
"422":
|
||||
$ref: "#/components/responses/InvalidInputResponse"
|
||||
/v1/jobs/list:
|
||||
post:
|
||||
tags:
|
||||
@@ -2418,6 +2440,26 @@ components:
|
||||
properties:
|
||||
workspaceId:
|
||||
$ref: "#/components/schemas/WorkspaceId"
|
||||
WebBackendWorkspaceState:
|
||||
type: object
|
||||
required:
|
||||
- workspaceId
|
||||
properties:
|
||||
workspaceId:
|
||||
$ref: "#/components/schemas/WorkspaceId"
|
||||
WebBackendWorkspaceStateResult:
|
||||
type: object
|
||||
required:
|
||||
- hasConnections
|
||||
- hasSources
|
||||
- hasDestinations
|
||||
properties:
|
||||
hasConnections:
|
||||
type: boolean
|
||||
hasSources:
|
||||
type: boolean
|
||||
hasDestinations:
|
||||
type: boolean
|
||||
# SLUG
|
||||
SlugRequestBody:
|
||||
type: object
|
||||
|
||||
@@ -87,6 +87,8 @@ import io.airbyte.api.model.WebBackendConnectionReadList;
|
||||
import io.airbyte.api.model.WebBackendConnectionRequestBody;
|
||||
import io.airbyte.api.model.WebBackendConnectionSearch;
|
||||
import io.airbyte.api.model.WebBackendConnectionUpdate;
|
||||
import io.airbyte.api.model.WebBackendWorkspaceState;
|
||||
import io.airbyte.api.model.WebBackendWorkspaceStateResult;
|
||||
import io.airbyte.api.model.WorkspaceCreate;
|
||||
import io.airbyte.api.model.WorkspaceGiveFeedback;
|
||||
import io.airbyte.api.model.WorkspaceIdRequestBody;
|
||||
@@ -242,7 +244,8 @@ public class ConfigurationApi implements io.airbyte.api.V1Api {
|
||||
schedulerHandler,
|
||||
operationsHandler,
|
||||
featureFlags,
|
||||
eventRunner);
|
||||
eventRunner,
|
||||
configRepository);
|
||||
healthCheckHandler = new HealthCheckHandler();
|
||||
archiveHandler = new ArchiveHandler(
|
||||
airbyteVersion,
|
||||
@@ -817,6 +820,11 @@ public class ConfigurationApi implements io.airbyte.api.V1Api {
|
||||
return execute(() -> webBackendConnectionsHandler.webBackendGetConnection(webBackendConnectionRequestBody));
|
||||
}
|
||||
|
||||
@Override
|
||||
public WebBackendWorkspaceStateResult webBackendGetWorkspaceState(WebBackendWorkspaceState webBackendWorkspaceState) {
|
||||
return execute(() -> webBackendConnectionsHandler.getWorkspaceState(webBackendWorkspaceState));
|
||||
}
|
||||
|
||||
@Override
|
||||
public WebBackendConnectionRead webBackendCreateConnection(final WebBackendConnectionCreate webBackendConnectionCreate) {
|
||||
return execute(() -> webBackendConnectionsHandler.webBackendCreateConnection(webBackendConnectionCreate));
|
||||
|
||||
@@ -40,11 +40,14 @@ import io.airbyte.api.model.WebBackendConnectionRequestBody;
|
||||
import io.airbyte.api.model.WebBackendConnectionSearch;
|
||||
import io.airbyte.api.model.WebBackendConnectionUpdate;
|
||||
import io.airbyte.api.model.WebBackendOperationCreateOrUpdate;
|
||||
import io.airbyte.api.model.WebBackendWorkspaceState;
|
||||
import io.airbyte.api.model.WebBackendWorkspaceStateResult;
|
||||
import io.airbyte.api.model.WorkspaceIdRequestBody;
|
||||
import io.airbyte.commons.features.FeatureFlags;
|
||||
import io.airbyte.commons.json.Jsons;
|
||||
import io.airbyte.commons.lang.MoreBooleans;
|
||||
import io.airbyte.config.persistence.ConfigNotFoundException;
|
||||
import io.airbyte.config.persistence.ConfigRepository;
|
||||
import io.airbyte.scheduler.client.EventRunner;
|
||||
import io.airbyte.validation.json.JsonValidationException;
|
||||
import java.io.IOException;
|
||||
@@ -72,6 +75,19 @@ public class WebBackendConnectionsHandler {
|
||||
private final OperationsHandler operationsHandler;
|
||||
private final FeatureFlags featureFlags;
|
||||
private final EventRunner eventRunner;
|
||||
private final ConfigRepository configRepository;
|
||||
|
||||
public WebBackendWorkspaceStateResult getWorkspaceState(final WebBackendWorkspaceState webBackendWorkspaceState) throws IOException {
|
||||
final var workspaceId = webBackendWorkspaceState.getWorkspaceId();
|
||||
final var connectionCount = configRepository.countConnectionsForWorkspace(workspaceId);
|
||||
final var destinationCount = configRepository.countDestinationsForWorkspace(workspaceId);
|
||||
final var sourceCount = configRepository.countSourcesForWorkspace(workspaceId);
|
||||
|
||||
return new WebBackendWorkspaceStateResult()
|
||||
.hasConnections(connectionCount > 0)
|
||||
.hasDestinations(destinationCount > 0)
|
||||
.hasSources(sourceCount > 0);
|
||||
}
|
||||
|
||||
public WebBackendConnectionReadList webBackendListConnectionsForWorkspace(final WorkspaceIdRequestBody workspaceIdRequestBody)
|
||||
throws ConfigNotFoundException, IOException, JsonValidationException {
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
package io.airbyte.server.handlers;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.mock;
|
||||
@@ -55,6 +57,7 @@ import io.airbyte.api.model.WebBackendConnectionRequestBody;
|
||||
import io.airbyte.api.model.WebBackendConnectionSearch;
|
||||
import io.airbyte.api.model.WebBackendConnectionUpdate;
|
||||
import io.airbyte.api.model.WebBackendOperationCreateOrUpdate;
|
||||
import io.airbyte.api.model.WebBackendWorkspaceState;
|
||||
import io.airbyte.api.model.WorkspaceIdRequestBody;
|
||||
import io.airbyte.commons.enums.Enums;
|
||||
import io.airbyte.commons.features.FeatureFlags;
|
||||
@@ -64,6 +67,7 @@ import io.airbyte.config.StandardDestinationDefinition;
|
||||
import io.airbyte.config.StandardSourceDefinition;
|
||||
import io.airbyte.config.StandardSync;
|
||||
import io.airbyte.config.persistence.ConfigNotFoundException;
|
||||
import io.airbyte.config.persistence.ConfigRepository;
|
||||
import io.airbyte.protocol.models.CatalogHelpers;
|
||||
import io.airbyte.protocol.models.Field;
|
||||
import io.airbyte.protocol.models.JsonSchemaType;
|
||||
@@ -103,6 +107,7 @@ class WebBackendConnectionsHandlerTest {
|
||||
private FeatureFlags featureFlags;
|
||||
private EventRunner eventRunner;
|
||||
private ConnectionHelper connectionHelper;
|
||||
private ConfigRepository configRepository;
|
||||
|
||||
@BeforeEach
|
||||
public void setup() throws IOException, JsonValidationException, ConfigNotFoundException {
|
||||
@@ -111,6 +116,7 @@ class WebBackendConnectionsHandlerTest {
|
||||
final SourceHandler sourceHandler = mock(SourceHandler.class);
|
||||
final DestinationHandler destinationHandler = mock(DestinationHandler.class);
|
||||
final JobHistoryHandler jobHistoryHandler = mock(JobHistoryHandler.class);
|
||||
configRepository = mock(ConfigRepository.class);
|
||||
schedulerHandler = mock(SchedulerHandler.class);
|
||||
featureFlags = mock(FeatureFlags.class);
|
||||
eventRunner = mock(EventRunner.class);
|
||||
@@ -122,7 +128,8 @@ class WebBackendConnectionsHandlerTest {
|
||||
schedulerHandler,
|
||||
operationsHandler,
|
||||
featureFlags,
|
||||
eventRunner);
|
||||
eventRunner,
|
||||
configRepository);
|
||||
|
||||
final StandardSourceDefinition standardSourceDefinition = SourceDefinitionHelpers.generateSourceDefinition();
|
||||
final SourceConnection source = SourceHelpers.generateSource(UUID.randomUUID());
|
||||
@@ -234,6 +241,32 @@ class WebBackendConnectionsHandlerTest {
|
||||
.thenReturn(new JobInfoRead().job(new JobRead().status(JobStatus.SUCCEEDED)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetWorkspaceState() throws IOException {
|
||||
final UUID uuid = UUID.randomUUID();
|
||||
final WebBackendWorkspaceState request = new WebBackendWorkspaceState().workspaceId(uuid);
|
||||
when(configRepository.countSourcesForWorkspace(uuid)).thenReturn(5);
|
||||
when(configRepository.countDestinationsForWorkspace(uuid)).thenReturn(2);
|
||||
when(configRepository.countConnectionsForWorkspace(uuid)).thenReturn(8);
|
||||
final var actual = wbHandler.getWorkspaceState(request);
|
||||
assertTrue(actual.getHasConnections());
|
||||
assertTrue(actual.getHasDestinations());
|
||||
assertTrue((actual.getHasSources()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetWorkspaceStateEmpty() throws IOException {
|
||||
final UUID uuid = UUID.randomUUID();
|
||||
final WebBackendWorkspaceState request = new WebBackendWorkspaceState().workspaceId(uuid);
|
||||
when(configRepository.countSourcesForWorkspace(uuid)).thenReturn(0);
|
||||
when(configRepository.countDestinationsForWorkspace(uuid)).thenReturn(0);
|
||||
when(configRepository.countConnectionsForWorkspace(uuid)).thenReturn(0);
|
||||
final var actual = wbHandler.getWorkspaceState(request);
|
||||
assertFalse(actual.getHasConnections());
|
||||
assertFalse(actual.getHasDestinations());
|
||||
assertFalse(actual.getHasSources());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testWebBackendListConnectionsForWorkspace() throws ConfigNotFoundException, IOException, JsonValidationException {
|
||||
final WorkspaceIdRequestBody workspaceIdRequestBody = new WorkspaceIdRequestBody();
|
||||
|
||||
@@ -20,3 +20,9 @@ export interface Workspace {
|
||||
displaySetupWizard: boolean;
|
||||
notifications: Notification[];
|
||||
}
|
||||
|
||||
export interface WorkspaceState {
|
||||
hasSources: boolean;
|
||||
hasDestinations: boolean;
|
||||
hasConnections: boolean;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AirbyteRequestService } from "core/request/AirbyteRequestService";
|
||||
import { Workspace } from "./Workspace";
|
||||
import { Workspace, WorkspaceState } from "./Workspace";
|
||||
|
||||
class WorkspaceService extends AirbyteRequestService {
|
||||
get url() {
|
||||
@@ -19,6 +19,12 @@ class WorkspaceService extends AirbyteRequestService {
|
||||
public async update(payload: Record<string, unknown>): Promise<Workspace> {
|
||||
return await this.fetch<Workspace>(`${this.url}/update`, payload);
|
||||
}
|
||||
|
||||
public async getState(workspaceId: string): Promise<WorkspaceState> {
|
||||
return await this.fetch<WorkspaceState>(`web_backend/workspace/state`, {
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { WorkspaceService };
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
import React, { Suspense, useEffect, useState } from "react";
|
||||
import React, { Suspense, useState } from "react";
|
||||
import styled from "styled-components";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
import { Button } from "components";
|
||||
import HeadTitle from "components/HeadTitle";
|
||||
import { useCreateSource, useSourceList } from "hooks/services/useSourceHook";
|
||||
import {
|
||||
useCreateDestination,
|
||||
useDestinationList,
|
||||
} from "hooks/services/useDestinationHook";
|
||||
import {
|
||||
useConnectionList,
|
||||
useSyncConnection,
|
||||
} from "hooks/services/useConnectionHook";
|
||||
import { ConnectionConfiguration } from "core/domain/connection";
|
||||
import useGetStepsConfig from "./useStepsConfig";
|
||||
import SourceStep from "./components/SourceStep";
|
||||
import DestinationStep from "./components/DestinationStep";
|
||||
@@ -27,10 +17,9 @@ import StepsCounter from "./components/StepsCounter";
|
||||
import LoadingPage from "components/LoadingPage";
|
||||
import useWorkspace from "hooks/services/useWorkspace";
|
||||
import useRouterHook from "hooks/useRouter";
|
||||
import { JobInfo } from "core/domain/job";
|
||||
import { RoutePaths } from "../routePaths";
|
||||
import { useSourceDefinitionList } from "services/connector/SourceDefinitionService";
|
||||
import { useDestinationDefinitionList } from "services/connector/DestinationDefinitionService";
|
||||
import { useCurrentWorkspaceState } from "services/workspaces/WorkspacesService";
|
||||
import { useEffectOnce } from "react-use";
|
||||
|
||||
const Content = styled.div<{ big?: boolean; medium?: boolean }>`
|
||||
width: 100%;
|
||||
@@ -64,39 +53,27 @@ const OnboardingPage: React.FC = () => {
|
||||
const analyticsService = useAnalyticsService();
|
||||
const { push } = useRouterHook();
|
||||
|
||||
useEffect(() => {
|
||||
useEffectOnce(() => {
|
||||
analyticsService.page("Onboarding Page");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
});
|
||||
|
||||
const { sources } = useSourceList();
|
||||
const { destinations } = useDestinationList();
|
||||
const { connections } = useConnectionList();
|
||||
const { sourceDefinitions } = useSourceDefinitionList();
|
||||
const { destinationDefinitions } = useDestinationDefinitionList();
|
||||
|
||||
const { mutateAsync: syncConnection } = useSyncConnection();
|
||||
|
||||
const { mutateAsync: createSource } = useCreateSource();
|
||||
const { mutateAsync: createDestination } = useCreateDestination();
|
||||
const { finishOnboarding } = useWorkspace();
|
||||
const {
|
||||
hasConnections,
|
||||
hasDestinations,
|
||||
hasSources,
|
||||
} = useCurrentWorkspaceState();
|
||||
|
||||
const [successRequest, setSuccessRequest] = useState(false);
|
||||
const [errorStatusRequest, setErrorStatusRequest] = useState<{
|
||||
status: number;
|
||||
response: JobInfo;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const [animateExit, setAnimateExit] = useState(false);
|
||||
|
||||
const afterUpdateStep = () => {
|
||||
setSuccessRequest(false);
|
||||
setErrorStatusRequest(null);
|
||||
setAnimateExit(false);
|
||||
};
|
||||
|
||||
const { currentStep, setCurrentStep, steps } = useGetStepsConfig(
|
||||
!!sources.length,
|
||||
!!destinations.length,
|
||||
!!connections.length,
|
||||
hasSources,
|
||||
hasDestinations,
|
||||
hasConnections,
|
||||
afterUpdateStep
|
||||
);
|
||||
|
||||
@@ -105,114 +82,12 @@ const OnboardingPage: React.FC = () => {
|
||||
push(RoutePaths.Connections);
|
||||
};
|
||||
|
||||
const renderStep = () => {
|
||||
if (currentStep === StepType.INSTRUCTION) {
|
||||
const onStart = () => setCurrentStep(StepType.CREATE_SOURCE);
|
||||
//TODO: add username
|
||||
return <WelcomeStep onSubmit={onStart} userName="" />;
|
||||
}
|
||||
if (currentStep === StepType.CREATE_SOURCE) {
|
||||
const getSourceDefinitionById = (id: string) =>
|
||||
sourceDefinitions.find((item) => item.sourceDefinitionId === id);
|
||||
|
||||
const onSubmitSourceStep = async (values: {
|
||||
name: string;
|
||||
serviceType: string;
|
||||
sourceId?: string;
|
||||
connectionConfiguration?: ConnectionConfiguration;
|
||||
}) => {
|
||||
setErrorStatusRequest(null);
|
||||
const sourceConnector = getSourceDefinitionById(values.serviceType);
|
||||
|
||||
try {
|
||||
await createSource({ values, sourceConnector });
|
||||
|
||||
setSuccessRequest(true);
|
||||
setTimeout(() => {
|
||||
setSuccessRequest(false);
|
||||
setCurrentStep(StepType.CREATE_DESTINATION);
|
||||
}, 2000);
|
||||
} catch (e) {
|
||||
setErrorStatusRequest(e);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<SourceStep
|
||||
afterSelectConnector={() => setErrorStatusRequest(null)}
|
||||
onSubmit={onSubmitSourceStep}
|
||||
availableServices={sourceDefinitions}
|
||||
hasSuccess={successRequest}
|
||||
error={errorStatusRequest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (currentStep === StepType.CREATE_DESTINATION) {
|
||||
const getDestinationDefinitionById = (id: string) =>
|
||||
destinationDefinitions.find(
|
||||
(item) => item.destinationDefinitionId === id
|
||||
);
|
||||
|
||||
const onSubmitDestinationStep = async (values: {
|
||||
name: string;
|
||||
serviceType: string;
|
||||
destinationDefinitionId?: string;
|
||||
connectionConfiguration?: ConnectionConfiguration;
|
||||
}) => {
|
||||
setErrorStatusRequest(null);
|
||||
const destinationConnector = getDestinationDefinitionById(
|
||||
values.serviceType
|
||||
);
|
||||
|
||||
try {
|
||||
await createDestination({
|
||||
values,
|
||||
destinationConnector,
|
||||
});
|
||||
|
||||
setSuccessRequest(true);
|
||||
setTimeout(() => {
|
||||
setSuccessRequest(false);
|
||||
setCurrentStep(StepType.SET_UP_CONNECTION);
|
||||
}, 2000);
|
||||
} catch (e) {
|
||||
setErrorStatusRequest(e);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<DestinationStep
|
||||
afterSelectConnector={() => setErrorStatusRequest(null)}
|
||||
onSubmit={onSubmitDestinationStep}
|
||||
availableServices={destinationDefinitions}
|
||||
hasSuccess={successRequest}
|
||||
error={errorStatusRequest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentStep === StepType.SET_UP_CONNECTION) {
|
||||
return (
|
||||
<ConnectionStep
|
||||
errorStatus={errorStatusRequest?.status}
|
||||
source={sources[0]}
|
||||
destination={destinations[0]}
|
||||
afterSubmitConnection={() => setCurrentStep(StepType.FINAl)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const onSync = () => syncConnection(connections[0]);
|
||||
|
||||
return (
|
||||
<FinalStep connectionId={connections[0].connectionId} onSync={onSync} />
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenContent>
|
||||
{currentStep === StepType.CREATE_SOURCE ? (
|
||||
<LetterLine exit={successRequest} />
|
||||
<LetterLine exit={animateExit} />
|
||||
) : currentStep === StepType.CREATE_DESTINATION ? (
|
||||
<LetterLine onRight exit={successRequest} />
|
||||
<LetterLine onRight exit={animateExit} />
|
||||
) : null}
|
||||
<Content
|
||||
big={currentStep === StepType.SET_UP_CONNECTION}
|
||||
@@ -223,7 +98,29 @@ const OnboardingPage: React.FC = () => {
|
||||
<HeadTitle titles={[{ id: "onboarding.headTitle" }]} />
|
||||
<StepsCounter steps={steps} currentStep={currentStep} />
|
||||
|
||||
<Suspense fallback={<LoadingPage />}>{renderStep()}</Suspense>
|
||||
<Suspense fallback={<LoadingPage />}>
|
||||
{currentStep === StepType.INSTRUCTION && (
|
||||
<WelcomeStep
|
||||
onNextStep={() => setCurrentStep(StepType.CREATE_SOURCE)}
|
||||
/>
|
||||
)}
|
||||
{currentStep === StepType.CREATE_SOURCE && (
|
||||
<SourceStep
|
||||
onSuccess={() => setAnimateExit(true)}
|
||||
onNextStep={() => setCurrentStep(StepType.CREATE_DESTINATION)}
|
||||
/>
|
||||
)}
|
||||
{currentStep === StepType.CREATE_DESTINATION && (
|
||||
<DestinationStep
|
||||
onSuccess={() => setAnimateExit(true)}
|
||||
onNextStep={() => setCurrentStep(StepType.SET_UP_CONNECTION)}
|
||||
/>
|
||||
)}
|
||||
{currentStep === StepType.SET_UP_CONNECTION && (
|
||||
<ConnectionStep onNextStep={() => setCurrentStep(StepType.FINAl)} />
|
||||
)}
|
||||
{currentStep === StepType.FINAl && <FinalStep />}
|
||||
</Suspense>
|
||||
|
||||
<Footer>
|
||||
<Button secondary onClick={() => handleFinishOnboarding()}>
|
||||
|
||||
@@ -2,22 +2,21 @@ import React from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
import CreateConnectionContent from "components/CreateConnectionContent";
|
||||
import { Destination, Source } from "core/domain/connector";
|
||||
import TitlesBlock from "./TitlesBlock";
|
||||
import HighlightedText from "./HighlightedText";
|
||||
import { useSourceList } from "hooks/services/useSourceHook";
|
||||
import { useDestinationList } from "hooks/services/useDestinationHook";
|
||||
|
||||
type IProps = {
|
||||
errorStatus?: number;
|
||||
source: Source;
|
||||
destination: Destination;
|
||||
afterSubmitConnection: () => void;
|
||||
onNextStep: () => void;
|
||||
};
|
||||
|
||||
const ConnectionStep: React.FC<IProps> = ({
|
||||
source,
|
||||
destination,
|
||||
afterSubmitConnection,
|
||||
onNextStep: afterSubmitConnection,
|
||||
}) => {
|
||||
const { sources } = useSourceList();
|
||||
const { destinations } = useDestinationList();
|
||||
|
||||
return (
|
||||
<>
|
||||
<TitlesBlock
|
||||
@@ -36,8 +35,8 @@ const ConnectionStep: React.FC<IProps> = ({
|
||||
</TitlesBlock>
|
||||
<CreateConnectionContent
|
||||
noTitles
|
||||
source={source}
|
||||
destination={destination}
|
||||
source={sources[0]}
|
||||
destination={destinations[0]}
|
||||
afterSubmitConnection={afterSubmitConnection}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -3,34 +3,22 @@ import { FormattedMessage } from "react-intl";
|
||||
|
||||
import { createFormErrorMessage } from "utils/errorStatusMessage";
|
||||
import { ConnectionConfiguration } from "core/domain/connection";
|
||||
import { DestinationDefinition } from "core/domain/connector";
|
||||
|
||||
import { ConnectorCard } from "views/Connector/ConnectorCard";
|
||||
import TitlesBlock from "./TitlesBlock";
|
||||
import HighlightedText from "./HighlightedText";
|
||||
import { useAnalyticsService } from "hooks/services/Analytics/useAnalyticsService";
|
||||
import { useGetDestinationDefinitionSpecificationAsync } from "services/connector/DestinationDefinitionSpecificationService";
|
||||
import { useDestinationDefinitionList } from "services/connector/DestinationDefinitionService";
|
||||
import { useCreateDestination } from "hooks/services/useDestinationHook";
|
||||
import { JobInfo } from "core/domain/job";
|
||||
|
||||
type IProps = {
|
||||
availableServices: DestinationDefinition[];
|
||||
onSubmit: (values: {
|
||||
name: string;
|
||||
serviceType: string;
|
||||
destinationDefinitionId?: string;
|
||||
connectionConfiguration?: ConnectionConfiguration;
|
||||
}) => void;
|
||||
hasSuccess?: boolean;
|
||||
error?: null | { message?: string; status?: number };
|
||||
afterSelectConnector?: () => void;
|
||||
type Props = {
|
||||
onNextStep: () => void;
|
||||
onSuccess: () => void;
|
||||
};
|
||||
|
||||
const DestinationStep: React.FC<IProps> = ({
|
||||
onSubmit,
|
||||
availableServices,
|
||||
hasSuccess,
|
||||
error,
|
||||
afterSelectConnector,
|
||||
}) => {
|
||||
const DestinationStep: React.FC<Props> = ({ onNextStep, onSuccess }) => {
|
||||
const [destinationDefinitionId, setDestinationDefinitionId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
@@ -38,12 +26,51 @@ const DestinationStep: React.FC<IProps> = ({
|
||||
data: destinationDefinitionSpecification,
|
||||
isLoading,
|
||||
} = useGetDestinationDefinitionSpecificationAsync(destinationDefinitionId);
|
||||
const { destinationDefinitions } = useDestinationDefinitionList();
|
||||
const [successRequest, setSuccessRequest] = useState(false);
|
||||
const [error, setError] = useState<{
|
||||
status: number;
|
||||
response: JobInfo;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
|
||||
const { mutateAsync: createDestination } = useCreateDestination();
|
||||
const analyticsService = useAnalyticsService();
|
||||
|
||||
const onDropDownSelect = (destinationDefinition: string) => {
|
||||
const destinationConnector = availableServices.find(
|
||||
(s) => s.destinationDefinitionId === destinationDefinition
|
||||
const getDestinationDefinitionById = (id: string) =>
|
||||
destinationDefinitions.find((item) => item.destinationDefinitionId === id);
|
||||
|
||||
const onSubmitDestinationStep = async (values: {
|
||||
name: string;
|
||||
serviceType: string;
|
||||
destinationDefinitionId?: string;
|
||||
connectionConfiguration?: ConnectionConfiguration;
|
||||
}) => {
|
||||
setError(null);
|
||||
const destinationConnector = getDestinationDefinitionById(
|
||||
values.serviceType
|
||||
);
|
||||
|
||||
try {
|
||||
await createDestination({
|
||||
values,
|
||||
destinationConnector,
|
||||
});
|
||||
|
||||
setSuccessRequest(true);
|
||||
onSuccess();
|
||||
setTimeout(() => {
|
||||
setSuccessRequest(false);
|
||||
onNextStep();
|
||||
}, 2000);
|
||||
} catch (e) {
|
||||
setError(e);
|
||||
}
|
||||
};
|
||||
|
||||
const onDropDownSelect = (destinationDefinitionId: string) => {
|
||||
const destinationConnector = getDestinationDefinitionById(
|
||||
destinationDefinitionId
|
||||
);
|
||||
analyticsService.track("New Destination - Action", {
|
||||
action: "Select a connector",
|
||||
@@ -52,17 +79,14 @@ const DestinationStep: React.FC<IProps> = ({
|
||||
destinationConnector?.destinationDefinitionId,
|
||||
});
|
||||
|
||||
if (afterSelectConnector) {
|
||||
afterSelectConnector();
|
||||
}
|
||||
|
||||
setDestinationDefinitionId(destinationDefinition);
|
||||
setError(null);
|
||||
setDestinationDefinitionId(destinationDefinitionId);
|
||||
};
|
||||
const onSubmitForm = async (values: {
|
||||
name: string;
|
||||
serviceType: string;
|
||||
}) => {
|
||||
await onSubmit({
|
||||
await onSubmitDestinationStep({
|
||||
...values,
|
||||
destinationDefinitionId:
|
||||
destinationDefinitionSpecification?.destinationDefinitionId,
|
||||
@@ -92,8 +116,8 @@ const DestinationStep: React.FC<IProps> = ({
|
||||
formType="destination"
|
||||
onServiceSelect={onDropDownSelect}
|
||||
onSubmit={onSubmitForm}
|
||||
hasSuccess={hasSuccess}
|
||||
availableServices={availableServices}
|
||||
hasSuccess={successRequest}
|
||||
availableServices={destinationDefinitions}
|
||||
errorMessage={errorMessage}
|
||||
selectedConnectorDefinitionSpecification={
|
||||
destinationDefinitionSpecification
|
||||
|
||||
@@ -13,12 +13,11 @@ import SyncCompletedModal from "views/Feedback/SyncCompletedModal";
|
||||
import { useOnboardingService } from "hooks/services/Onboarding/OnboardingService";
|
||||
import Status from "core/statuses";
|
||||
import useWorkspace from "hooks/services/useWorkspace";
|
||||
import { useGetConnection } from "hooks/services/useConnectionHook";
|
||||
|
||||
type FinalStepProps = {
|
||||
connectionId: string;
|
||||
onSync: () => void;
|
||||
};
|
||||
import {
|
||||
useConnectionList,
|
||||
useGetConnection,
|
||||
useSyncConnection,
|
||||
} from "hooks/services/useConnectionHook";
|
||||
|
||||
const Title = styled(H1)`
|
||||
margin: 21px 0;
|
||||
@@ -35,7 +34,7 @@ const Videos = styled.div`
|
||||
padding: 0 27px;
|
||||
`;
|
||||
|
||||
const FinalStep: React.FC<FinalStepProps> = ({ connectionId, onSync }) => {
|
||||
const FinalStep: React.FC = () => {
|
||||
const config = useConfig();
|
||||
const { sendFeedback } = useWorkspace();
|
||||
const {
|
||||
@@ -45,7 +44,9 @@ const FinalStep: React.FC<FinalStepProps> = ({ connectionId, onSync }) => {
|
||||
useCaseLinks,
|
||||
skipCase,
|
||||
} = useOnboardingService();
|
||||
const connection = useGetConnection(connectionId, {
|
||||
const { mutateAsync: syncConnection } = useSyncConnection();
|
||||
const { connections } = useConnectionList();
|
||||
const connection = useGetConnection(connections[0].connectionId, {
|
||||
refetchInterval: 2500,
|
||||
});
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -74,6 +75,8 @@ const FinalStep: React.FC<FinalStepProps> = ({ connectionId, onSync }) => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const onSync = () => syncConnection(connections[0]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Videos>
|
||||
|
||||
@@ -39,12 +39,12 @@ export const ExitAnimation = keyframes`
|
||||
}
|
||||
`;
|
||||
|
||||
const Line = styled.div<{ onRight?: boolean }>`
|
||||
const Line = styled.div<{ $onRight?: boolean }>`
|
||||
position: absolute;
|
||||
width: calc(50% - 275px);
|
||||
z-index: 1;
|
||||
top: 382px;
|
||||
left: ${({ onRight }) => (onRight ? "calc(50% + 275px)" : 0)};
|
||||
left: ${({ $onRight }) => ($onRight ? "calc(50% + 275px)" : 0)};
|
||||
`;
|
||||
const Path = styled.div<{ exit?: boolean }>`
|
||||
width: 100%;
|
||||
@@ -70,7 +70,7 @@ type LetterLineProps = {
|
||||
|
||||
const LetterLine: React.FC<LetterLineProps> = ({ onRight, exit }) => {
|
||||
return (
|
||||
<Line onRight={onRight}>
|
||||
<Line $onRight={onRight}>
|
||||
<Path exit={exit} />
|
||||
<Img
|
||||
src="/newsletter.png"
|
||||
|
||||
@@ -8,43 +8,65 @@ import { createFormErrorMessage } from "utils/errorStatusMessage";
|
||||
import { useAnalyticsService } from "hooks/services/Analytics/useAnalyticsService";
|
||||
import HighlightedText from "./HighlightedText";
|
||||
import TitlesBlock from "./TitlesBlock";
|
||||
import { SourceDefinition } from "core/domain/connector";
|
||||
import { useGetSourceDefinitionSpecificationAsync } from "services/connector/SourceDefinitionSpecificationService";
|
||||
import { useSourceDefinitionList } from "services/connector/SourceDefinitionService";
|
||||
import { useCreateSource } from "hooks/services/useSourceHook";
|
||||
import { JobInfo } from "core/domain/job";
|
||||
|
||||
type IProps = {
|
||||
onSubmit: (values: {
|
||||
name: string;
|
||||
serviceType: string;
|
||||
sourceDefinitionId?: string;
|
||||
connectionConfiguration?: ConnectionConfiguration;
|
||||
}) => void;
|
||||
availableServices: SourceDefinition[];
|
||||
hasSuccess?: boolean;
|
||||
error?: null | { message?: string; status?: number };
|
||||
afterSelectConnector?: () => void;
|
||||
onSuccess: () => void;
|
||||
onNextStep: () => void;
|
||||
};
|
||||
|
||||
const SourceStep: React.FC<IProps> = ({
|
||||
onSubmit,
|
||||
availableServices,
|
||||
hasSuccess,
|
||||
error,
|
||||
afterSelectConnector,
|
||||
}) => {
|
||||
const SourceStep: React.FC<IProps> = ({ onNextStep, onSuccess }) => {
|
||||
const { sourceDefinitions } = useSourceDefinitionList();
|
||||
const [sourceDefinitionId, setSourceDefinitionId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [successRequest, setSuccessRequest] = useState(false);
|
||||
const [error, setError] = useState<{
|
||||
status: number;
|
||||
response: JobInfo;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
|
||||
const { mutateAsync: createSource } = useCreateSource();
|
||||
|
||||
const analyticsService = useAnalyticsService();
|
||||
|
||||
const getSourceDefinitionById = (id: string) =>
|
||||
sourceDefinitions.find((item) => item.sourceDefinitionId === id);
|
||||
|
||||
const {
|
||||
data: sourceDefinitionSpecification,
|
||||
isLoading,
|
||||
} = useGetSourceDefinitionSpecificationAsync(sourceDefinitionId);
|
||||
|
||||
const onSubmitSourceStep = async (values: {
|
||||
name: string;
|
||||
serviceType: string;
|
||||
sourceId?: string;
|
||||
connectionConfiguration?: ConnectionConfiguration;
|
||||
}) => {
|
||||
setError(null);
|
||||
const sourceConnector = getSourceDefinitionById(values.serviceType);
|
||||
|
||||
try {
|
||||
await createSource({ values, sourceConnector });
|
||||
|
||||
setSuccessRequest(true);
|
||||
onSuccess();
|
||||
setTimeout(() => {
|
||||
setSuccessRequest(false);
|
||||
onNextStep();
|
||||
}, 2000);
|
||||
} catch (e) {
|
||||
setError(e);
|
||||
}
|
||||
};
|
||||
|
||||
const onServiceSelect = (sourceId: string) => {
|
||||
const sourceDefinition = availableServices.find(
|
||||
(s) => s.sourceDefinitionId === sourceId
|
||||
);
|
||||
const sourceDefinition = getSourceDefinitionById(sourceId);
|
||||
|
||||
analyticsService.track("New Source - Action", {
|
||||
action: "Select a connector",
|
||||
@@ -52,17 +74,13 @@ const SourceStep: React.FC<IProps> = ({
|
||||
connector_source_id: sourceDefinition?.sourceDefinitionId,
|
||||
});
|
||||
|
||||
if (afterSelectConnector) {
|
||||
afterSelectConnector();
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setSourceDefinitionId(sourceId);
|
||||
};
|
||||
|
||||
const onSubmitForm = async (values: { name: string; serviceType: string }) =>
|
||||
onSubmit({
|
||||
onSubmitSourceStep({
|
||||
...values,
|
||||
sourceDefinitionId: sourceDefinitionSpecification?.sourceDefinitionId,
|
||||
});
|
||||
|
||||
const errorMessage = error ? createFormErrorMessage(error) : "";
|
||||
@@ -89,8 +107,8 @@ const SourceStep: React.FC<IProps> = ({
|
||||
onServiceSelect={onServiceSelect}
|
||||
onSubmit={onSubmitForm}
|
||||
formType="source"
|
||||
availableServices={availableServices}
|
||||
hasSuccess={hasSuccess}
|
||||
availableServices={sourceDefinitions}
|
||||
hasSuccess={successRequest}
|
||||
errorMessage={errorMessage}
|
||||
selectedConnectorDefinitionSpecification={sourceDefinitionSpecification}
|
||||
isLoading={isLoading}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { BigButton } from "components/CenteredPageComponents";
|
||||
import { useConfig } from "config";
|
||||
|
||||
type WelcomeStepProps = {
|
||||
onSubmit: () => void;
|
||||
onNextStep: () => void;
|
||||
userName?: string;
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ const Videos = styled.div`
|
||||
margin: 20px 0 67px;
|
||||
`;
|
||||
|
||||
const WelcomeStep: React.FC<WelcomeStepProps> = ({ userName, onSubmit }) => {
|
||||
const WelcomeStep: React.FC<WelcomeStepProps> = ({ userName, onNextStep }) => {
|
||||
const config = useConfig();
|
||||
|
||||
return (
|
||||
@@ -66,7 +66,7 @@ const WelcomeStep: React.FC<WelcomeStepProps> = ({ userName, onSubmit }) => {
|
||||
link={config.ui.demoLink}
|
||||
/>
|
||||
</Videos>
|
||||
<BigButton onClick={onSubmit} shadow>
|
||||
<BigButton onClick={onNextStep} shadow>
|
||||
<FormattedMessage id="onboarding.firstConnection" />
|
||||
</BigButton>
|
||||
</>
|
||||
|
||||
@@ -29,9 +29,7 @@ const useStepsConfig = (
|
||||
const updateStep = useCallback(
|
||||
(step: StepType) => {
|
||||
setCurrentStep(step);
|
||||
if (afterUpdateStep) {
|
||||
afterUpdateStep();
|
||||
}
|
||||
afterUpdateStep?.();
|
||||
},
|
||||
[setCurrentStep, afterUpdateStep]
|
||||
);
|
||||
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
} from "react-query";
|
||||
|
||||
import useRouter from "hooks/useRouter";
|
||||
import { Workspace, WorkspaceService } from "core/domain/workspace";
|
||||
import {
|
||||
Workspace,
|
||||
WorkspaceService,
|
||||
WorkspaceState,
|
||||
} from "core/domain/workspace";
|
||||
import { RoutePaths } from "pages/routePaths";
|
||||
import { useConfig } from "config";
|
||||
import { useDefaultRequestMiddlewares } from "../useDefaultRequestMiddlewares";
|
||||
@@ -20,6 +24,8 @@ export const workspaceKeys = {
|
||||
list: (filters: string) => [...workspaceKeys.lists(), { filters }] as const,
|
||||
detail: (workspaceId: string) =>
|
||||
[...workspaceKeys.all, "details", workspaceId] as const,
|
||||
state: (workspaceId: string) =>
|
||||
[...workspaceKeys.all, "state", workspaceId] as const,
|
||||
};
|
||||
|
||||
type Context = {
|
||||
@@ -106,6 +112,22 @@ export const useCurrentWorkspace = (): Workspace => {
|
||||
});
|
||||
};
|
||||
|
||||
export const useCurrentWorkspaceState = (): WorkspaceState => {
|
||||
const workspaceId = useCurrentWorkspaceId();
|
||||
const service = useWorkspaceApiService();
|
||||
|
||||
return (useQuery(
|
||||
workspaceKeys.state(workspaceId),
|
||||
() => service.getState(workspaceId),
|
||||
{
|
||||
// We want to keep this query only shortly in cache, so we refetch
|
||||
// the data whenever the user might have changed sources/destinations/connections
|
||||
// without requiring to manually invalidate that query on each change.
|
||||
cacheTime: 5 * 1000,
|
||||
}
|
||||
) as QueryObserverSuccessResult<WorkspaceState>).data;
|
||||
};
|
||||
|
||||
export const useListWorkspaces = (): Workspace[] => {
|
||||
const service = useWorkspaceApiService();
|
||||
|
||||
|
||||
@@ -363,6 +363,7 @@ font-style: italic;
|
||||
<ul>
|
||||
<li><a href="#webBackendCreateConnection"><code><span class="http-method">post</span> /v1/web_backend/connections/create</code></a></li>
|
||||
<li><a href="#webBackendGetConnection"><code><span class="http-method">post</span> /v1/web_backend/connections/get</code></a></li>
|
||||
<li><a href="#webBackendGetWorkspaceState"><code><span class="http-method">post</span> /v1/web_backend/workspace/state</code></a></li>
|
||||
<li><a href="#webBackendListAllConnectionsForWorkspace"><code><span class="http-method">post</span> /v1/web_backend/connections/list_all</code></a></li>
|
||||
<li><a href="#webBackendListConnectionsForWorkspace"><code><span class="http-method">post</span> /v1/web_backend/connections/list</code></a></li>
|
||||
<li><a href="#webBackendSearchConnections"><code><span class="http-method">post</span> /v1/web_backend/connections/search</code></a></li>
|
||||
@@ -8096,6 +8097,66 @@ font-style: italic;
|
||||
<a href="#InvalidInputExceptionInfo">InvalidInputExceptionInfo</a>
|
||||
</div> <!-- method -->
|
||||
<hr/>
|
||||
<div class="method"><a name="webBackendGetWorkspaceState"/>
|
||||
<div class="method-path">
|
||||
<a class="up" href="#__Methods">Up</a>
|
||||
<pre class="post"><code class="huge"><span class="http-method">post</span> /v1/web_backend/workspace/state</code></pre></div>
|
||||
<div class="method-summary">Returns the current state of a workspace (<span class="nickname">webBackendGetWorkspaceState</span>)</div>
|
||||
<div class="method-notes"></div>
|
||||
|
||||
|
||||
<h3 class="field-label">Consumes</h3>
|
||||
This API call consumes the following media types via the <span class="header">Content-Type</span> request header:
|
||||
<ul>
|
||||
<li><code>application/json</code></li>
|
||||
</ul>
|
||||
|
||||
<h3 class="field-label">Request body</h3>
|
||||
<div class="field-items">
|
||||
<div class="param">WebBackendWorkspaceState <a href="#WebBackendWorkspaceState">WebBackendWorkspaceState</a> (optional)</div>
|
||||
|
||||
<div class="param-desc"><span class="param-type">Body Parameter</span> — </div>
|
||||
|
||||
</div> <!-- field-items -->
|
||||
|
||||
|
||||
|
||||
|
||||
<h3 class="field-label">Return type</h3>
|
||||
<div class="return-type">
|
||||
<a href="#WebBackendWorkspaceStateResult">WebBackendWorkspaceStateResult</a>
|
||||
|
||||
</div>
|
||||
|
||||
<!--Todo: process Response Object and its headers, schema, examples -->
|
||||
|
||||
<h3 class="field-label">Example data</h3>
|
||||
<div class="example-data-content-type">Content-Type: application/json</div>
|
||||
<pre class="example"><code>{
|
||||
"hasDestinations" : true,
|
||||
"hasConnections" : true,
|
||||
"hasSources" : true
|
||||
}</code></pre>
|
||||
|
||||
<h3 class="field-label">Produces</h3>
|
||||
This API call produces the following media types according to the <span class="header">Accept</span> request header;
|
||||
the media type will be conveyed by the <span class="header">Content-Type</span> response header.
|
||||
<ul>
|
||||
<li><code>application/json</code></li>
|
||||
</ul>
|
||||
|
||||
<h3 class="field-label">Responses</h3>
|
||||
<h4 class="field-label">200</h4>
|
||||
Successful operation
|
||||
<a href="#WebBackendWorkspaceStateResult">WebBackendWorkspaceStateResult</a>
|
||||
<h4 class="field-label">404</h4>
|
||||
Object with given id was not found.
|
||||
<a href="#NotFoundKnownExceptionInfo">NotFoundKnownExceptionInfo</a>
|
||||
<h4 class="field-label">422</h4>
|
||||
Input failed validation
|
||||
<a href="#InvalidInputExceptionInfo">InvalidInputExceptionInfo</a>
|
||||
</div> <!-- method -->
|
||||
<hr/>
|
||||
<div class="method"><a name="webBackendListAllConnectionsForWorkspace"/>
|
||||
<div class="method-path">
|
||||
<a class="up" href="#__Methods">Up</a>
|
||||
@@ -9792,6 +9853,8 @@ font-style: italic;
|
||||
<li><a href="#WebBackendConnectionSearch"><code>WebBackendConnectionSearch</code> - </a></li>
|
||||
<li><a href="#WebBackendConnectionUpdate"><code>WebBackendConnectionUpdate</code> - </a></li>
|
||||
<li><a href="#WebBackendOperationCreateOrUpdate"><code>WebBackendOperationCreateOrUpdate</code> - </a></li>
|
||||
<li><a href="#WebBackendWorkspaceState"><code>WebBackendWorkspaceState</code> - </a></li>
|
||||
<li><a href="#WebBackendWorkspaceStateResult"><code>WebBackendWorkspaceStateResult</code> - </a></li>
|
||||
<li><a href="#WorkspaceCreate"><code>WorkspaceCreate</code> - </a></li>
|
||||
<li><a href="#WorkspaceGiveFeedback"><code>WorkspaceGiveFeedback</code> - </a></li>
|
||||
<li><a href="#WorkspaceIdRequestBody"><code>WorkspaceIdRequestBody</code> - </a></li>
|
||||
@@ -11032,6 +11095,22 @@ if oauth parameters were contained inside the top level, rootObject=[] If they w
|
||||
<div class="param">operatorConfiguration </div><div class="param-desc"><span class="param-type"><a href="#OperatorConfiguration">OperatorConfiguration</a></span> </div>
|
||||
</div> <!-- field-items -->
|
||||
</div>
|
||||
<div class="model">
|
||||
<h3><a name="WebBackendWorkspaceState"><code>WebBackendWorkspaceState</code> - </a> <a class="up" href="#__Models">Up</a></h3>
|
||||
<div class='model-description'></div>
|
||||
<div class="field-items">
|
||||
<div class="param">workspaceId </div><div class="param-desc"><span class="param-type"><a href="#UUID">UUID</a></span> format: uuid</div>
|
||||
</div> <!-- field-items -->
|
||||
</div>
|
||||
<div class="model">
|
||||
<h3><a name="WebBackendWorkspaceStateResult"><code>WebBackendWorkspaceStateResult</code> - </a> <a class="up" href="#__Models">Up</a></h3>
|
||||
<div class='model-description'></div>
|
||||
<div class="field-items">
|
||||
<div class="param">hasConnections </div><div class="param-desc"><span class="param-type"><a href="#boolean">Boolean</a></span> </div>
|
||||
<div class="param">hasSources </div><div class="param-desc"><span class="param-type"><a href="#boolean">Boolean</a></span> </div>
|
||||
<div class="param">hasDestinations </div><div class="param-desc"><span class="param-type"><a href="#boolean">Boolean</a></span> </div>
|
||||
</div> <!-- field-items -->
|
||||
</div>
|
||||
<div class="model">
|
||||
<h3><a name="WorkspaceCreate"><code>WorkspaceCreate</code> - </a> <a class="up" href="#__Models">Up</a></h3>
|
||||
<div class='model-description'></div>
|
||||
|
||||
Reference in New Issue
Block a user