Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add connect button #1197

Merged
merged 1 commit into from
Apr 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/renderer/api/cadt/v1/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ const baseQueryWithDynamicHost = async (args, api, extraOptions) => {
// Check if currentHost is equal to the initialState's apiHost
const effectiveHost = (currentHost === initialState.apiHost && import.meta.env.VITE_API_HOST) ? import.meta.env.VITE_API_HOST : currentHost;

if (!args.url.startsWith('/')) {
return await baseQuery(args, api, extraOptions);
}

// Modify the URL based on the effectiveHost
if (typeof args === 'string') {
modifiedArgs = `${effectiveHost}${args}`;
Expand Down
1 change: 1 addition & 0 deletions src/renderer/api/cadt/v1/system/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './system.api';
40 changes: 40 additions & 0 deletions src/renderer/api/cadt/v1/system/system.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { cadtApi } from '../';
// @ts-ignore
import { BaseQueryResult } from '@reduxjs/toolkit/dist/query/baseQueryTypes';

export interface Health {
message: string;
timestamp: string;
}

interface GetHealthParams {
apiHost?: string;
apiKey?: string;
}

const systemApi = cadtApi.injectEndpoints({
endpoints: (builder) => ({
getHealth: builder.query<boolean, GetHealthParams>({
query: ({ apiHost = '', apiKey }) => ({
url: `${apiHost}/health`,
method: 'GET',
headers: apiKey ? { 'X-Api-Key': apiKey } : {},
}),
transformResponse(baseQueryReturnValue: BaseQueryResult<Health>): boolean {
return baseQueryReturnValue?.message === 'OK';
},
}),
getHealthImmediate: builder.mutation<boolean, GetHealthParams>({
query: ({ apiHost = '', apiKey }) => ({
url: `${apiHost}/health`,
method: 'GET',
headers: apiKey ? { 'X-Api-Key': apiKey } : {},
}),
transformResponse(baseQueryReturnValue: BaseQueryResult<Health>): boolean {
return baseQueryReturnValue?.message === 'OK';
},
}),
}),
});

export const { useGetHealthQuery, useGetHealthImmediateMutation } = systemApi;
53 changes: 53 additions & 0 deletions src/renderer/components/blocks/buttons/ConnectButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import React, { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { Button, ConnectModal } from '@/components';
import { FormattedMessage } from 'react-intl';
import { useUrlHash } from '@/hooks';
import { useGetHealthQuery } from '@/api/cadt/v1/system';
import { useSelector } from 'react-redux';
import { RootState } from '@/store';
import initialAppState from '@/store/slices/app/app.initialstate';
import { resetApiHost } from '@/store/slices/app';
import { useDispatch } from 'react-redux';

const ConnectButton: React.FC = () => {
const location = useLocation();
const dispatch = useDispatch();
const [isActive, setActive] = useUrlHash('connect');
const { apiHost } = useSelector((state: RootState) => state.app);

const { data: serverFound, isLoading, refetch } = useGetHealthQuery({});

// Activte the connect modal when the service is not found
useEffect(() => {
if (!serverFound && !isLoading) {
setActive(true);
}
}, [serverFound, setActive, isLoading]);

// Recheck the health when the location changes
useEffect(() => {
refetch();
}, [location, refetch]);

const handleDisconnect = () => {
dispatch(resetApiHost());
setTimeout(() => window.location.reload(), 0);
};

return (
<>
<Button
color="none"
onClick={() => {
apiHost === initialAppState.apiHost ? setActive(true) : handleDisconnect();
}}
>
{apiHost == initialAppState.apiHost ? <FormattedMessage id="connect" /> : <FormattedMessage id="disconnect" />}
</Button>
{isActive && <ConnectModal onClose={() => setActive(false)} />}
</>
);
};

export { ConnectButton };
18 changes: 18 additions & 0 deletions src/renderer/components/blocks/buttons/FormButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import React from 'react';
import { Button, Spinner } from '@/components';

interface FormButtonProps {
isSubmitting: boolean;
formikErrors: any;
children?: React.ReactNode
}

const FormButton: React.FC<FormButtonProps> = ({ isSubmitting, formikErrors, children }) => {
return (
<Button type="submit" disabled={isSubmitting || Boolean(Object.keys(formikErrors).length)}>
{isSubmitting ? <Spinner size="sm" light={true} /> : children}
</Button>
);
};

export { FormButton };
4 changes: 3 additions & 1 deletion src/renderer/components/blocks/buttons/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export * from './QueryRefetchButton';
export * from './QueryRefetchButton';
export * from './ConnectButton';
export * from './FormButton';
105 changes: 105 additions & 0 deletions src/renderer/components/blocks/forms/ConnectForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import React, { useCallback } from 'react';
import { noop } from 'lodash';
import { ErrorMessage, Field, Form, Formik } from 'formik';
import * as yup from 'yup';
import { FloatingLabel, HelperText, Spacer, FormButton } from '@/components';
import { Alert } from 'flowbite-react';
import { IntlShape, useIntl, FormattedMessage } from 'react-intl';

const validationSchema = yup.object({
apiHost: yup
.string()
.required('Server Address is required')
.matches(
// eslint-disable-next-line no-useless-escape
/^(https?:\/\/(www\.)?)?([a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}|localhost|[a-z0-9]+)(:[0-9]{1,5})?(\/.*)?$/,
'Please enter a valid server address',
),
apiKey: yup.string(),
});

interface FormProps {
onSubmit: (apiHost: string, apiKey?: string) => Promise<any>;
hasServerError: boolean;
onClearError: () => void;
}

const ConnectForm: React.FC<FormProps> = ({ onSubmit, hasServerError, onClearError = noop }) => {
const intl: IntlShape = useIntl();

const handleSubmit = useCallback(
async (values: { apiHost: string; apiKey?: string }, { setSubmitting }) => {
await onSubmit(values.apiHost, values.apiKey);
setSubmitting(false);
},
[onSubmit],
);

const handleChange = useCallback(
(event, field) => {
onClearError();
field.onChange(event); // Call Formik's original onChange
},
[onClearError],
);

return (
<Formik initialValues={{ apiHost: '', apiKey: '' }} validationSchema={validationSchema} onSubmit={handleSubmit}>
{({ errors, touched, isSubmitting }) => (
<Form>
{hasServerError && (
<>
<Alert color="failure">
<FormattedMessage id="server-not-found" />
</Alert>
<Spacer size={15} />
</>
)}
<div className="mb-4">
<HelperText className="text-gray-400">
<FormattedMessage id="server-address-helper" />
</HelperText>
<Spacer size={5} />
<Field name="apiHost">
{({ field }) => (
<FloatingLabel
id="apiHost"
label={intl.formatMessage({ id: 'server-address' })}
color={errors.apiHost && touched.apiHost && 'error'}
variant="outlined"
required
type="text"
{...field}
onChange={(event) => handleChange(event, field)}
/>
)}
</Field>
{touched.apiHost && <ErrorMessage name="apiHost" component="div" className="text-red-600" />}
</div>
<div className="mb-4">
<Field name="apiKey">
{({ field }) => (
<FloatingLabel
id="apiKey"
label={intl.formatMessage({ id: 'api-key' })}
color={errors.apiKey && touched.apiKey && 'error'}
variant="outlined"
type="text"
{...field}
onChange={(event) => handleChange(event, field)}
ccccccrjflurktedcrhvrbtgldlgnjicvleikebbgnju
/>
)}
</Field>
{touched.apiKey && <ErrorMessage name="apiKey" component="div" className="text-red-600" />}
</div>
<FormButton isSubmitting={isSubmitting} formikErrors={errors}>
<FormattedMessage id="submit" />
</FormButton>
</Form>
)}
</Formik>
);
};

export { ConnectForm };
1 change: 1 addition & 0 deletions src/renderer/components/blocks/forms/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './ConnectForm';
3 changes: 2 additions & 1 deletion src/renderer/components/blocks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export * from './layout';
export * from './buttons';
export * from './modals';
export * from './tables';
export * from './widgets';
export * from './widgets';
export * from './forms';
52 changes: 52 additions & 0 deletions src/renderer/components/blocks/modals/ConnectModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import React, { useState } from 'react';
import { Modal, ConnectForm } from '@/components';
import { FormattedMessage } from 'react-intl';
import { useDispatch } from 'react-redux';
import { setHost } from '@/store/slices/app';
import { useGetHealthImmediateMutation } from '@/api/cadt/v1/system';
import { useUrlHash } from '@/hooks';

// @ts-ignore
import { BaseQueryResult, FetchBaseQueryError, SerializedError } from '@reduxjs/toolkit/dist/query/baseQueryTypes';

interface ConnectModalProps {
onClose: () => void;
}

const ConnectModal: React.FC<ConnectModalProps> = ({ onClose }: ConnectModalProps) => {
const dispatch = useDispatch();
const [getHealth] = useGetHealthImmediateMutation();
const [serverNotFound, setServerNotFound] = useState(false);
const [, setActive] = useUrlHash('connect');

const handleSubmit = async (apiHost: string, apiKey?: string) => {
const response: BaseQueryResult | FetchBaseQueryError | SerializedError = await getHealth({ apiHost });

if (!response.data) {
setServerNotFound(true);
return;
}

dispatch(setHost({ apiHost, apiKey }));
setActive(false);
setTimeout(() => window.location.reload(), 0);
onClose();
};

return (
<Modal onClose={onClose}>
<Modal.Header>
<FormattedMessage id="connect-to-remote-server" />
</Modal.Header>
<Modal.Body>
<ConnectForm
onSubmit={handleSubmit}
hasServerError={serverNotFound}
onClearError={() => setServerNotFound(false)}
/>
</Modal.Body>
</Modal>
);
};

export { ConnectModal };
3 changes: 2 additions & 1 deletion src/renderer/components/blocks/modals/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './ProjectModal';
export * from './ProjectModal';
export * from './ConnectModal';
4 changes: 2 additions & 2 deletions src/renderer/components/layout/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { AppLogo, ThemeSelector } from "@/components";
import { AppLogo, ThemeSelector, ConnectButton } from "@/components";

const Header: React.FC = () => {
return (
Expand All @@ -12,7 +12,7 @@ const Header: React.FC = () => {
</div>
{/* Right-aligned elements with explicit right margin on larger breakpoints */}
<div className="flex items-center gap-5 text-white">
<div>todo: connect button and locale selector</div>
<ConnectButton />
<ThemeSelector />
</div>
</div>
Expand Down
7 changes: 7 additions & 0 deletions src/renderer/components/proxy/FloatingLabel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { FloatingLabel as FlowbiteFloatingLabel, FloatingLabelProps } from 'flowbite-react';

function FloatingLabel({ children, ...props }: FloatingLabelProps) {
return <FlowbiteFloatingLabel {...props}>{children}</FlowbiteFloatingLabel>;
}

export { FloatingLabel };
16 changes: 13 additions & 3 deletions src/renderer/components/proxy/Modal.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import { Modal as FlowbiteModal, ModalBodyProps, ModalFooterProps, ModalHeaderProps, ModalProps } from 'flowbite-react';
import {
Modal as FlowbiteModal,
ModalBodyProps,
ModalFooterProps,
ModalHeaderProps,
ModalProps as _ModalProps,
} from 'flowbite-react';

function Modal({ children, ...props }: ModalProps) {
return <FlowbiteModal {...props}>{children}</FlowbiteModal>;
interface ModalProps extends _ModalProps {
show?: boolean;
}

function Modal({ children, show = true, ...props }: ModalProps) {
return <FlowbiteModal show={show} {...props}>{children}</FlowbiteModal>;
}

function Body({ children, ...props }: ModalBodyProps) {
Expand Down
1 change: 1 addition & 0 deletions src/renderer/components/proxy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ export * from './Card';
export * from './Toast';
export * from './HelperText';
export * from './Label';
export * from './FloatingLabel';
2 changes: 2 additions & 0 deletions src/renderer/store/slices/app/app.initialstate.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
export interface AppState {
locale?: string | null,
apiHost: string,
apiKey?: string | null,
isDarkTheme: boolean
}

const initialState: AppState = {
locale: null,
apiHost: 'http://localhost:31310',
apiKey: null,
isDarkTheme: false
};

Expand Down
Loading