feat(ui): implemented ui for env management table

This commit is contained in:
akhilmhdh
2023-01-10 19:39:19 +05:30
parent 3ad3e19bcf
commit 9116bf3344
7 changed files with 661 additions and 0 deletions

View File

@@ -0,0 +1,127 @@
import { FormEventHandler, Fragment, useEffect, useState } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import Button from '../buttons/Button';
import InputField from '../InputField';
type FormFields = { name: string; slug: string };
type Props = {
isOpen?: boolean;
isEditMode?: boolean;
// on edit mode load up initial values
initialValues?: FormFields;
onClose: () => void;
onSubmit: (envName: string, envSlug: string) => void;
};
/**
* The dialog modal for when the user wants to create a new workspace
* @param {*} param0
* @returns
*/
export const AddEnvironmentDialog = ({ isOpen, onClose, onSubmit, initialValues, isEditMode }: Props) => {
const [formInput, setFormInput] = useState<FormFields>({
name: '',
slug: '',
});
// This use effect can be removed when the unmount is happening from outside the component
// When unmount happens outside state gets unmounted also
useEffect(() => {
setFormInput(initialValues || { name: '', slug: '' });
}, [isOpen]);
// REFACTOR: Move to react-hook-form with yup for better form management
const onInputChange = (fieldName: string, fieldValue: string) => {
setFormInput((state) => ({ ...state, [fieldName]: fieldValue }));
};
const onFormSubmit: FormEventHandler = (e) => {
e.preventDefault();
console.log(formInput);
};
return (
<div>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as='div' className='relative z-20' onClose={onClose}>
<Transition.Child
as={Fragment}
enter='ease-out duration-300'
enterFrom='opacity-0'
enterTo='opacity-100'
leave='ease-out duration-150'
leaveFrom='opacity-100'
leaveTo='opacity-0'
>
<div className='fixed inset-0 bg-black bg-opacity-70' />
</Transition.Child>
<div className='fixed inset-0 overflow-y-auto z-50'>
<div className='flex min-h-full items-center justify-center p-4 text-center'>
<Transition.Child
as={Fragment}
enter='ease-out duration-300'
enterFrom='opacity-0 scale-95'
enterTo='opacity-100 scale-100'
leave='ease-in duration-200'
leaveFrom='opacity-100 scale-100'
leaveTo='opacity-0 scale-95'
>
<Dialog.Panel className='w-full max-w-md transform overflow-hidden rounded-2xl bg-bunker-800 border border-gray-700 p-6 text-left align-middle shadow-xl transition-all'>
<Dialog.Title
as='h3'
className='text-lg font-medium leading-6 text-gray-400'
>
{isEditMode
? 'Update environment'
: 'Create a new environment'}
</Dialog.Title>
<form onSubmit={onFormSubmit}>
<div className='max-h-28 mt-4'>
<InputField
label='Project Name'
onChangeHandler={(val) => onInputChange('name', val)}
type='varName'
value={formInput.name}
placeholder=''
isRequired
// error={error.length > 0}
// errorText={error}
/>
</div>
<div className='max-h-28 mt-4'>
<InputField
label='Environment Slug'
onChangeHandler={(val) => onInputChange('slug', val)}
type='varName'
value={formInput.slug}
placeholder=''
isRequired
// error={error.length > 0}
// errorText={error}
/>
</div>
<p className='text-xs text-gray-500 mt-2'>
Slugs are shorthands used in cli to access environment
</p>
<div className='mt-4 max-w-min'>
<Button
onButtonPressed={() => null}
type='submit'
color='mineshaft'
text={isEditMode ? 'Update' : 'Create'}
size='md'
/>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
</div>
);
};

View File

@@ -0,0 +1,100 @@
import { Fragment, useState } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import InputField from '../InputField';
// REFACTOR: Move all these modals into one reusable one
type Props = {
isOpen?: boolean;
onClose: ()=>void;
title: string;
onSubmit:()=>void;
deleteKey?:string;
}
const DeleteActionModal = ({
isOpen,
onClose,
title,
onSubmit,
deleteKey
}:Props) => {
const [deleteInputField, setDeleteInputField] = useState("")
return (
<div>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as='div' className='relative z-10' onClose={onClose}>
<Transition.Child
as={Fragment}
enter='ease-out duration-300'
enterFrom='opacity-0'
enterTo='opacity-100'
leave='ease-in duration-150'
leaveFrom='opacity-100'
leaveTo='opacity-0'
>
<div className='fixed inset-0 bg-black bg-opacity-25' />
</Transition.Child>
<div className='fixed inset-0 overflow-y-auto'>
<div className='flex min-h-full items-center justify-center p-4 text-center'>
<Transition.Child
as={Fragment}
enter='ease-out duration-300'
enterFrom='opacity-0 scale-95'
enterTo='opacity-100 scale-100'
leave='ease-in duration-200'
leaveFrom='opacity-100 scale-100'
leaveTo='opacity-0 scale-95'
>
<Dialog.Panel className='w-full max-w-md transform overflow-hidden rounded-2xl bg-grey border border-gray-700 p-6 text-left align-middle shadow-xl transition-all'>
<Dialog.Title
as='h3'
className='text-lg font-medium leading-6 text-gray-400'
>
{title}
</Dialog.Title>
<div className='mt-2'>
<p className='text-sm text-gray-500'>
This action is irrevertible.
</p>
</div>
<div className='mt-2'>
<InputField
isRequired
label={`Type ${deleteKey} to delete the resource`}
onChangeHandler={(val) => setDeleteInputField(val)}
value={deleteInputField}
type='text'
/>
</div>
<div className='mt-6'>
<button
type='button'
className='inline-flex justify-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-sm font-medium text-gray-400 hover:bg-alizarin hover:text-white hover:text-semibold duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2'
onClick={onSubmit}
disabled={
Boolean(deleteKey) && deleteInputField !== deleteKey
}
>
Delete
</button>
<button
type='button'
className='ml-2 inline-flex justify-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-sm font-medium text-gray-400 hover:border-white hover:text-white hover:text-semibold duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2'
onClick={onClose}
>
Cancel
</button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
</div>
);
};
export default DeleteActionModal;

View File

@@ -0,0 +1,118 @@
import { faPencil,faPlus,faX } from '@fortawesome/free-solid-svg-icons';
import { usePopUp } from '../../../hooks/usePopUp';
import Button from '../buttons/Button';
import {AddEnvironmentDialog} from '../dialog/AddEnvironmentDialog';
import DeleteActionModal from '../dialog/DeleteActionModal';
const EnvironmentTable = ({ data = [] }) => {
const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
'createUpdateEnv',
'deleteEnv',
] as const);
return (
<>
<div className='flex flex-row justify-between w-full'>
<div className='flex flex-col w-full'>
<p className='text-xl font-semibold mb-3'>Project Environments</p>
<p className='text-base text-gray-400 mb-4'>
Choose which environments will show up in your dashboard like
development, staging, production
</p>
<p className='text-sm mr-1 text-gray-500 self-start'>
Note: the text in slugs shows how these environmant should be
accessed in CLI.
</p>
</div>
<div className='w-48'>
<Button
text='Add New Env'
onButtonPressed={() => handlePopUpOpen('createUpdateEnv')}
color='mineshaft'
icon={faPlus}
size='md'
/>
</div>
</div>
<div className='table-container w-full bg-bunker rounded-md mb-6 border border-mineshaft-700 relative mt-1'>
<div className='absolute rounded-t-md w-full h-12 bg-white/5'></div>
<table className='w-full my-1'>
<thead className='text-bunker-300'>
<tr>
<th className='text-left pl-6 pt-2.5 pb-2'>Name</th>
<th className='text-left pl-6 pt-2.5 pb-2'>Slug</th>
<th></th>
</tr>
</thead>
<tbody>
{data?.length > 0 ? (
data.map(({ name, slug }) => {
return (
<tr
key={name}
className='bg-bunker-800 hover:bg-bunker-800/5 duration-100'
>
<td className='pl-6 py-2 border-mineshaft-700 border-t text-gray-300 capitalize'>
{name}
</td>
<td className='pl-6 py-2 border-mineshaft-700 border-t text-gray-300'>
{slug}
</td>
<td className='py-2 border-mineshaft-700 border-t flex'>
<div className='opacity-50 hover:opacity-100 duration-200 flex items-center mr-8'>
<Button
onButtonPressed={() => handlePopUpOpen("createUpdateEnv",{ name, slug })}
color='red'
size='icon-sm'
icon={faPencil}
/>
</div>
<div className='opacity-50 hover:opacity-100 duration-200 flex items-center'>
<Button
onButtonPressed={() =>
handlePopUpOpen('deleteEnv', { name, slug })
}
color='red'
size='icon-sm'
icon={faX}
/>
</div>
</td>
</tr>
);
})
) : (
<tr>
<td
colSpan={4}
className='text-center pt-7 pb-4 text-bunker-400'
>
No environmants found
</td>
</tr>
)}
</tbody>
</table>
<DeleteActionModal
isOpen={popUp['deleteEnv'].isOpen}
title={`Are you sure want to delete ${
(popUp?.deleteEnv?.data as { name: string })?.name || ' '
}?`}
deleteKey={(popUp?.deleteEnv?.data as { slug: string })?.slug || ''}
onClose={() => handlePopUpClose('deleteEnv')}
onSubmit={() => handlePopUpClose('deleteEnv')}
/>
<AddEnvironmentDialog
isOpen={popUp.createUpdateEnv.isOpen}
isEditMode={Boolean(popUp.createUpdateEnv?.data)}
initialValues={popUp?.createUpdateEnv?.data as any}
onClose={() => handlePopUpClose('createUpdateEnv')}
onSubmit={() => null}
/>
</div>
</>
);
};
export default EnvironmentTable;

1
frontend/hooks/index.ts Normal file
View File

@@ -0,0 +1 @@
export { usePopUp } from './usePopUp';

View File

@@ -0,0 +1,69 @@
import { useCallback, useState } from 'react';
interface usePopUpProps {
name: Readonly<string>;
isOpen: boolean;
}
/**
* to provide better intellisense
* checks which type of inputProps were given and converts them into key-names
* SIDENOTE: On inputting give it as const and not string with (as const)
*/
type usePopUpState<T extends Readonly<string[]> | usePopUpProps[]> = {
[P in T extends usePopUpProps[] ? T[number]['name'] : T[number]]: {
isOpen: boolean;
data?: unknown;
};
};
interface usePopUpReturn<T extends Readonly<string[]> | usePopUpProps[]> {
popUp: usePopUpState<T>;
handlePopUpOpen: (popUpName: keyof usePopUpState<T>, data?: unknown) => void;
handlePopUpClose: (popUpName: keyof usePopUpState<T>) => void;
handlePopUpToggle: (popUpName: keyof usePopUpState<T>) => void;
}
/**
* This hook is used to manage multiple popUps/modal/dialog in a page
* Provides api to open,close,toggle and also store temporary data for the popUp
* @param popUpNames: the names of popUp containers eg: ["popUp1","second"] or [{name:"popUp2",isOpen:bool}]
*/
export const usePopUp = <T extends Readonly<string[]> | usePopUpProps[]>(
popUpNames: T
): usePopUpReturn<T> => {
const [popUp, setPopUp] = useState<usePopUpState<T>>(
Object.fromEntries(
popUpNames.map((popUpName) =>
typeof popUpName === 'string'
? [popUpName, { isOpen: false }]
: [popUpName.name, { isOpen: popUpName.isOpen }]
) // convert into an array of [[popUpName,state]] then into Object
) as usePopUpState<T> // to override generic string return type of the function
);
const handlePopUpOpen = useCallback(
(popUpName: keyof usePopUpState<T>, data?: unknown) => {
setPopUp((popUp) => ({ ...popUp, [popUpName]: { isOpen: true, data } }));
},
[]
);
const handlePopUpClose = useCallback((popUpName: keyof usePopUpState<T>) => {
setPopUp((popUp) => ({ ...popUp, [popUpName]: { isOpen: false } }));
}, []);
const handlePopUpToggle = useCallback((popUpName: keyof usePopUpState<T>) => {
setPopUp((popUp) => ({
...popUp,
[popUpName]: { isOpen: !popUp[popUpName].isOpen },
}));
}, []);
return {
popUp,
handlePopUpOpen,
handlePopUpClose,
handlePopUpToggle,
};
};

View File

@@ -0,0 +1,245 @@
import { useEffect, useRef, useState } from "react";
import Head from "next/head";
import { useRouter } from "next/router";
import { useTranslation } from "next-i18next";
import { faCheck, faPlus } from "@fortawesome/free-solid-svg-icons";
import Button from "~/components/basic/buttons/Button";
import AddServiceTokenDialog from "~/components/basic/dialog/AddServiceTokenDialog";
import InputField from "~/components/basic/InputField";
import EnvironmentTable from '~/components/basic/table/EnvironmentsTable';
import ServiceTokenTable from "~/components/basic/table/ServiceTokenTable";
import NavHeader from "~/components/navigation/NavHeader";
import { getTranslatedServerSideProps } from "~/utilities/withTranslateProps";
import getServiceTokens from "../../api/serviceToken/getServiceTokens";
import deleteWorkspace from "../../api/workspace/deleteWorkspace";
import getWorkspaces from "../../api/workspace/getWorkspaces";
import renameWorkspace from "../../api/workspace/renameWorkspace";
export default function SettingsBasic() {
const [buttonReady, setButtonReady] = useState(false);
const router = useRouter();
const [workspaceName, setWorkspaceName] = useState("");
const [serviceTokens, setServiceTokens] = useState([]);
const [environments,setEnvironments] = useState([]);
const [workspaceToBeDeletedName, setWorkspaceToBeDeletedName] = useState("");
const [isAddOpen, setIsAddOpen] = useState(false);
const [isAddServiceTokenDialogOpen, setIsAddServiceTokenDialogOpen] = useState(false);
const { t } = useTranslation();
useEffect(async () => {
const userWorkspaces = await getWorkspaces();
userWorkspaces.forEach((userWorkspace) => {
if (userWorkspace._id == router.query.id) {
setWorkspaceName(userWorkspace.name);
setEnvironments(userWorkspace.environments);
}
});
const tempServiceTokens = await getServiceTokens({
workspaceId: router.query.id,
});
setServiceTokens(tempServiceTokens);
}, []);
const modifyWorkspaceName = (newName) => {
setButtonReady(true);
setWorkspaceName(newName);
};
const submitChanges = (newWorkspaceName) => {
renameWorkspace(router.query.id, newWorkspaceName);
setButtonReady(false);
};
const closeAddServiceTokenModal = () => {
setIsAddServiceTokenDialogOpen(false);
};
/**
* This function deleted a workspace.
* It first checks if there is more than one workspace aviable. Otherwise, it doesn't delete
* It then checks if the name of the workspace to be deleted is correct. Otherwise, it doesn't delete.
* It then deletes the workspace and forwards the user to another aviable workspace.
*/
const executeDeletingWorkspace = async () => {
const userWorkspaces = await getWorkspaces();
if (userWorkspaces.length > 1) {
if (
userWorkspaces.filter(
(workspace) => workspace._id == router.query.id
)[0].name == workspaceToBeDeletedName
) {
await deleteWorkspace(router.query.id);
const userWorkspaces = await getWorkspaces();
router.push("/dashboard/" + userWorkspaces[0]._id);
}
}
};
return (
<div className='bg-bunker-800 max-h-screen flex flex-col justify-between text-white'>
<Head>
<title>
{t('common:head-title', { title: t('settings-project:title') })}
</title>
<link rel='icon' href='/infisical.ico' />
</Head>
<AddServiceTokenDialog
isOpen={isAddServiceTokenDialogOpen}
workspaceId={router.query.id}
closeModal={closeAddServiceTokenModal}
workspaceName={workspaceName}
/>
<div className='flex flex-row mr-6 max-w-5xl'>
<div className='w-full max-h-screen pb-2 overflow-y-auto'>
<NavHeader
pageName={t('settings-project:title')}
isProjectRelated={true}
/>
<div className='flex flex-row justify-between items-center ml-6 my-8 text-xl max-w-5xl'>
<div className='flex flex-col justify-start items-start text-3xl'>
<p className='font-semibold mr-4 text-gray-200'>
{t('settings-project:title')}
</p>
<p className='font-normal mr-4 text-gray-400 text-base'>
{t('settings-project:description')}
</p>
</div>
</div>
<div className='flex flex-col ml-6 text-mineshaft-50'>
<div className='flex flex-col'>
<div className='min-w-md mt-2 flex flex-col items-start'>
<div className='bg-white/5 rounded-md px-6 pt-6 pb-4 flex flex-col items-start w-full mb-6'>
<p className='text-xl font-semibold mb-4'>
{t('common:display-name')}
</p>
<div className='max-h-28 w-full max-w-md mr-auto'>
<InputField
onChangeHandler={modifyWorkspaceName}
type='varName'
value={workspaceName}
placeholder=''
isRequired
/>
</div>
<div className='flex justify-start w-full'>
<div className={`flex justify-start max-w-sm mt-4 mb-2`}>
<Button
text={t('common:save-changes')}
onButtonPressed={() => submitChanges(workspaceName)}
color='mineshaft'
size='md'
active={buttonReady}
iconDisabled={faCheck}
textDisabled='Saved'
/>
</div>
</div>
</div>
<div className='bg-white/5 rounded-md px-6 pt-6 pb-2 flex flex-col items-start w-full mb-6 mt-4'>
<p className='text-xl font-semibold self-start'>
{t('common:project-id')}
</p>
<p className='text-base text-gray-400 font-normal self-start mt-4'>
{t('settings-project:project-id-description')}
</p>
<p className='text-base text-gray-400 font-normal self-start'>
{t('settings-project:project-id-description2')}
{/* eslint-disable-next-line react/jsx-no-target-blank */}
<a
href='https://infisical.com/docs/getting-started/introduction'
target='_blank'
rel='noopener'
className='text-primary hover:opacity-80 duration-200'
>
{t('settings-project:docs')}
</a>
</p>
<div className='max-h-28 w-ful'>
<InputField
type='varName'
value={router.query.id}
placeholder=''
isRequired
static
text={t('settings-project:auto-generated')}
/>
</div>
</div>
<div className='bg-white/5 rounded-md px-6 pt-6 flex flex-col items-start w-full mt-4 mb-4'>
<div className='flex flex-row justify-between w-full'>
<div className='flex flex-col w-full'>
<p className='text-xl font-semibold mb-3'>
{t('section-token:service-tokens')}
</p>
<p className='text-base text-gray-400 mb-4'>
{t('section-token:service-tokens-description')}
</p>
</div>
<div className='w-48'>
<Button
text={t('section-token:add-new')}
onButtonPressed={() => {
setIsAddServiceTokenDialogOpen(true);
}}
color='mineshaft'
icon={faPlus}
size='md'
/>
</div>
</div>
<ServiceTokenTable
data={serviceTokens}
workspaceName={workspaceName}
/>
</div>
<div className='bg-white/5 rounded-md px-6 pt-6 flex flex-col items-start w-full mt-4 mb-4'>
<EnvironmentTable data={environments} />
</div>
</div>
</div>
<div className='bg-white/5 rounded-md px-6 pt-6 pb-6 border-l border-red pl-6 flex flex-col items-start w-full mb-6 mt-4'>
<p className='text-xl font-bold text-red'>
{t('settings-project:danger-zone')}
</p>
<p className='mt-2 text-md text-gray-400'>
{t('settings-project:danger-zone-note')}
</p>
<div className='max-h-28 w-full max-w-md mr-auto mt-4'>
<InputField
label={t('settings-project:project-to-delete')}
onChangeHandler={setWorkspaceToBeDeletedName}
type='varName'
value={workspaceToBeDeletedName}
placeholder=''
isRequired
/>
</div>
<button
type='button'
className='max-w-md mt-6 w-full inline-flex justify-center rounded-md border border-transparent bg-gray-800 px-4 py-2.5 text-sm font-medium text-gray-400 hover:bg-red hover:text-white hover:font-semibold hover:text-semibold duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2'
onClick={executeDeletingWorkspace}
>
{t('settings-project:delete-project')}
</button>
<p className='mt-0.5 ml-1 text-xs text-gray-500'>
{t('settings-project:delete-project-note')}
</p>
</div>
</div>
</div>
</div>
</div>
);
}
SettingsBasic.requireAuth = true;
export const getServerSideProps = getTranslatedServerSideProps([
"settings",
"settings-project",
"section-token",
]);

View File

@@ -3,6 +3,7 @@
"baseUrl": ".",
"paths": {
"~/components/*": ["components/*"],
"~/hooks/*": ["hooks/*"],
"~/utilities/*": ["components/utilities/*"],
"~/*": ["const"],
"~/pages/*": ["pages/*"]