feat(ui): implemented the new dashboard page

This commit is contained in:
akhilmhdh
2023-03-19 00:40:13 +05:30
parent b42c33107e
commit 4cce75d128
24 changed files with 2405 additions and 1246 deletions
@@ -0,0 +1,64 @@
import { useCallback } from 'react';
import { FormControl, Input, Spinner } from '@app/components/v2';
import { useGetProjectSecrets, useGetUserWsKey } from '@app/hooks/api';
type SecretValueProps = {
workspaceId: string;
envName: string;
env: string;
secretKey: string;
};
const SecretValue = ({ workspaceId, env, envName, secretKey }: SecretValueProps) => {
const { data: latestFileKey } = useGetUserWsKey(workspaceId);
const { data: secret, isLoading: isSecretsLoading } = useGetProjectSecrets({
workspaceId,
env,
decryptFileKey: latestFileKey!
});
const getValue = useCallback(
(data: typeof secret) => {
const sec = data?.secrets?.find(({ key: secKey }) => secKey === secretKey);
return sec?.value || 'Not found';
},
[secretKey]
);
return (
<FormControl label={envName}>
<Input
className="w-full text-ellipsis font-mono focus:text-bunker-100 focus:ring-transparent"
value={getValue(secret)}
isReadOnly
rightIcon={isSecretsLoading ? <Spinner /> : undefined}
/>
</FormControl>
);
};
type Props = {
workspaceId: string;
secretKey: string;
envs: Array<{ name: string; slug: string }>;
};
export const CompareSecret = ({ workspaceId, secretKey, envs }: Props): JSX.Element => {
// should not do anything until secretKey is available
if (!secretKey) return <div />;
return (
<div className="flex flex-col">
{envs.map(({ name, slug }) => (
<SecretValue
workspaceId={workspaceId}
key={`secret-comparison-${slug}`}
envName={name}
env={slug}
secretKey={secretKey}
/>
))}
</div>
);
};
@@ -0,0 +1 @@
export { CompareSecret } from './CompareSecret';
@@ -0,0 +1,55 @@
import { Controller, useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
import { Button, FormControl, Input, ModalClose } from '@app/components/v2';
type Props = {
onCreateTag: (tagName: string) => Promise<void>;
};
const createTagSchema = yup.object({
name: yup.string().required().trim().label('Tag Name')
});
type FormData = yup.InferType<typeof createTagSchema>;
export const CreateTagModal = ({ onCreateTag }: Props): JSX.Element => {
const {
control,
reset,
formState: { isSubmitting },
handleSubmit
} = useForm<FormData>({
resolver: yupResolver(createTagSchema)
});
const onFormSubmit = async ({ name }: FormData) => {
await onCreateTag(name);
reset();
};
return (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="name"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl label="Tag Name" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} placeholder="Type your tag name" />
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button className="mr-4" type="submit" isDisabled={isSubmitting} isLoading={isSubmitting}>
Create
</Button>
<ModalClose asChild>
<Button variant="plain" colorSchema="secondary">
Cancel
</Button>
</ModalClose>
</div>
</form>
);
};
@@ -0,0 +1 @@
export {CreateTagModal} from './CreateTagModal'
@@ -0,0 +1,76 @@
import { Fragment, useCallback } from 'react';
import { InfiniteData } from '@tanstack/react-query';
import { Button, Drawer, DrawerContent } from '@app/components/v2';
import timeSince from '@app/ee/utilities/timeSince';
import { TWorkspaceSecretSnapshot } from '@app/hooks/api/secretSnapshots/types';
type Props = {
isDrawerOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
secretSnaphots?: InfiniteData<TWorkspaceSecretSnapshot[]>;
onSelectSnapshot: (id: string) => void;
snapshotId: string | null;
isFetchingNextPage?: boolean;
hasNextPage?: boolean;
fetchNextPage: () => void;
};
export const PitDrawer = ({
isDrawerOpen,
onOpenChange,
secretSnaphots,
onSelectSnapshot,
snapshotId,
isFetchingNextPage,
hasNextPage,
fetchNextPage
}: Props): JSX.Element => {
const getButtonLabel = useCallback((isFirstChild: boolean, isSelectedSnapshot: boolean) => {
if (isFirstChild) return 'Current Version';
if (isSelectedSnapshot) return 'Currently Viewing';
return 'Explore';
}, []);
return (
<Drawer isOpen={isDrawerOpen} onOpenChange={onOpenChange}>
<DrawerContent
title="Point-in-time Recovery"
subTitle="Note: This will recover secrets for all environments in this project"
>
<div className="flex flex-col space-y-2">
{secretSnaphots?.pages?.map((group, i) => (
<Fragment key={`snapshot-item-${i + 1}`}>
{group.map(({ _id, createdAt }, index) => (
<Button
key={_id}
className="py-3 px-4 text-sm"
variant={
(i === 0 && index === 0 && snapshotId === null) || snapshotId === _id
? 'solid'
: 'star'
}
onClick={() => onSelectSnapshot(_id)}
>
<div className="flex w-full justify-between">
<div>{timeSince(new Date(createdAt))}</div>
<div>{getButtonLabel(i === 0 && index === 0, snapshotId === _id)}</div>
</div>
</Button>
))}
</Fragment>
))}
</div>
<Button
className="mt-8 py-3 px-4 text-sm"
isFullWidth
isLoading={isFetchingNextPage}
isDisabled={isFetchingNextPage || !hasNextPage}
onClick={fetchNextPage}
>
{hasNextPage ? 'Load More' : 'End of history'}
</Button>
</DrawerContent>
</Drawer>
);
};
@@ -0,0 +1 @@
export { PitDrawer } from './PitDrawer';
@@ -0,0 +1,52 @@
import crypto from 'crypto';
import { useState } from 'react';
import { useTranslation } from 'next-i18next';
import { faMinus, faPlus, faShuffle } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Button, Input } from '@app/components/v2';
type Props = {
onGenerate: (val: string) => void;
};
export const GenRandomNumber = ({ onGenerate }: Props) => {
const { t } = useTranslation();
const [value, setValue] = useState(32);
const onGenerateRandomHex = () => {
const rand = crypto.randomBytes(value).toString('hex');
onGenerate(rand);
};
return (
<div className="flex">
<Button
className="rounded-r-none"
colorSchema="secondary"
leftIcon={<FontAwesomeIcon icon={faShuffle} />}
onClick={onGenerateRandomHex}
>
{t('dashboard:sidebar.generate-random-hex')}
</Button>
<div className="rounded-r-md bg-mineshaft p-2">
<div className="flex rounded-md border-mineshaft-400 bg-bunker-800 p-1 px-2">
<button type="button" onClick={() => setValue((val) => val - 1)}>
<FontAwesomeIcon icon={faMinus} size="xs" />
</button>
<Input
size="sm"
className="w-8 p-0 text-center"
value={value}
variant="plain"
onChange={(e) => setValue(parseInt(e.target.value, 10))}
/>
<button type="button" onClick={() => setValue((val) => val + 1)}>
<FontAwesomeIcon icon={faPlus} size="xs" />
</button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,211 @@
import { useFormContext } from 'react-hook-form';
import { faCircle, faCircleDot, faShuffle } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
Button,
Drawer,
DrawerContent,
FormControl,
Input,
Popover,
PopoverContent,
PopoverTrigger,
Switch,
TextArea
} from '@app/components/v2';
import { useToggle } from '@app/hooks';
import { FormData, SecretActionType } from '../../DashboardPage.utils';
import { GenRandomNumber } from './GenRandomNumber';
type Props = {
isDrawerOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
index: number;
isReadOnly?: boolean;
onEnvCompare: (secretKey: string) => void;
secretVersion?: Array<{ id: string; createdAt: string; value: string }>;
// to record the ids of deleted ones
onSecretDelete: (index: number, id?: string, overrideId?: string) => void;
onSave: () => void;
};
export const SecretDetailDrawer = ({
isDrawerOpen,
onOpenChange,
index,
secretVersion = [],
isReadOnly,
onSecretDelete,
onSave,
onEnvCompare
}: Props): JSX.Element => {
const [canRevealSecVal, setCanRevealSecVal] = useToggle();
const [canRevealSecOverride, setCanRevealSecOverride] = useToggle();
const { register, setValue, watch } = useFormContext<FormData>();
const secret = watch(`secrets.${index}`);
const isOverridden =
secret?.overrideAction === SecretActionType.Created ||
secret?.overrideAction === SecretActionType.Modified;
const onSecretOverride = () => {
if (isOverridden) {
// when user created a new override but then removes
if (SecretActionType.Created) {
setValue(`secrets.${index}.valueOverride`, '', { shouldDirty: true });
}
setValue(`secrets.${index}.overrideAction`, SecretActionType.Deleted, { shouldDirty: true });
} else {
setValue(
`secrets.${index}.overrideAction`,
secret?.idOverride ? SecretActionType.Modified : SecretActionType.Created,
{ shouldDirty: true }
);
}
};
if (!secret) {
return <div />;
}
return (
<Drawer onOpenChange={onOpenChange} isOpen={isDrawerOpen}>
<DrawerContent
className="border-l border-mineshaft-500 bg-bunker"
title="Secret"
footerContent={
<div className="flex flex-col space-y-4 pt-4 shadow-md">
<div>
<Button
variant="star"
onClick={() => onEnvCompare(secret?.key)}
isFullWidth
isDisabled={isReadOnly}
>
Compare secret across environments
</Button>
</div>
<div className="flex w-full space-x-2">
<Button isFullWidth onClick={onSave} isDisabled={isReadOnly}>
Save Changes
</Button>
<Button
colorSchema="danger"
isDisabled={isReadOnly}
onClick={() => onSecretDelete(index, secret._id, secret.idOverride)}
>
Delete
</Button>
</div>
</div>
}
>
<div className="">
<FormControl label="Key">
<Input isDisabled {...register(`secrets.${index}.key`)} />
</FormControl>
<FormControl label="Value">
<Popover>
<Input
isReadOnly={isOverridden || isReadOnly}
{...register(`secrets.${index}.value`)}
placeholder="EMPTY"
onBlur={setCanRevealSecVal.off}
onFocus={setCanRevealSecVal.on}
type={canRevealSecVal ? 'text' : 'password'}
rightIcon={
<PopoverTrigger disabled={isOverridden || isReadOnly}>
<FontAwesomeIcon icon={faShuffle} />
</PopoverTrigger>
}
/>
<PopoverContent
hideCloseBtn
className="w-auto border-mineshaft-500 bg-bunker p-0"
align="end"
>
<GenRandomNumber
onGenerate={(val) =>
setValue(`secrets.${index}.value`, val, { shouldDirty: true })
}
/>
</PopoverContent>
</Popover>
</FormControl>
<div className="mb-2 border-t border-mineshaft-600 pt-4">
<Switch
id="personal-override"
onCheckedChange={onSecretOverride}
isChecked={isOverridden}
isDisabled={isReadOnly}
>
Override with a personal value
</Switch>
</div>
<FormControl>
<Popover>
<Input
isReadOnly={!isOverridden || isReadOnly}
{...register(`secrets.${index}.valueOverride`)}
placeholder="EMPTY"
type={canRevealSecOverride ? 'text' : 'password'}
onBlur={setCanRevealSecOverride.off}
onFocus={setCanRevealSecOverride.on}
rightIcon={
<PopoverTrigger disabled={!isOverridden || isReadOnly}>
<FontAwesomeIcon icon={faShuffle} />
</PopoverTrigger>
}
/>
<PopoverContent
hideCloseBtn
className="w-auto border-mineshaft-500 bg-bunker p-0"
align="end"
>
<GenRandomNumber
onGenerate={(val) =>
setValue(`secrets.${index}.valueOverride`, val, { shouldDirty: true })
}
/>
</PopoverContent>
</Popover>
</FormControl>
<div className="mb-4 text-sm text-bunker-300">
<div className="mb-2">Version History</div>
<div className="flex h-52 flex-col space-y-4 overflow-y-auto overflow-x-hidden rounded-md bg-bunker-800 p-2">
{secretVersion?.map(({ createdAt, value, id }, i) => (
<div key={id} className="flex flex-col space-y-2">
<div className="flex items-center space-x-2">
<div>
<FontAwesomeIcon icon={i === 0 ? faCircleDot : faCircle} size="sm" />
</div>
<div>
{new Date(createdAt).toLocaleDateString('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})}
</div>
</div>
<div className="ml-1 flex items-center space-x-2 border-l border-bunker-300 pl-4">
<div className="rounded-sm bg-primary-500/30 px-1">Value:</div>
<div className="font-mono">{value}</div>
</div>
</div>
))}
</div>
</div>
<FormControl label="Comments & Notes">
<TextArea isDisabled={isReadOnly} {...register(`secrets.${index}.comment`)} rows={5} />
</FormControl>
</div>
</DrawerContent>
</Drawer>
);
};
@@ -0,0 +1 @@
export {SecretDetailDrawer} from './SecretDetailDrawer'
@@ -0,0 +1,130 @@
import { ChangeEvent, DragEvent } from 'react';
import { useTranslation } from 'next-i18next';
import { faUpload } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { twMerge } from 'tailwind-merge';
import { useNotificationContext } from '@app/components/context/Notifications/NotificationProvider';
// TODO:(akhilmhdh) convert all the util functions like this into a lib folder grouped by functionalityj
import { parseDotEnv } from '@app/components/utilities/parseDotEnv';
import { Button } from '@app/components/v2';
import { useToggle } from '@app/hooks/useToggle';
type Props = {
isSmaller: boolean;
onParsedEnv: (env: Record<string, { value: string; comments: string[] }>) => void;
onAddNewSecret?: () => void;
};
export const SecretDropzone = ({ isSmaller, onParsedEnv, onAddNewSecret }: Props): JSX.Element => {
const { t } = useTranslation();
const [isDragActive, setDragActive] = useToggle();
const [isLoading, setIsLoading] = useToggle();
const { createNotification } = useNotificationContext();
const handleDrag = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive.on();
} else if (e.type === 'dragleave') {
setDragActive.off();
}
};
const parseFile = (file?: File) => {
const reader = new FileReader();
if (!file) {
createNotification({
text: `You can't inject files from VS Code. Click 'Reveal in finder', and drag your file directly from the directory where it's located.`,
type: 'error',
timeoutMs: 10000
});
return;
}
// const fileType = file.name.split('.')[1];
setIsLoading.on();
reader.onload = (event) => {
if (!event?.target?.result) return;
// parse function's argument looks like to be ArrayBuffer
const env = parseDotEnv(event.target.result as ArrayBuffer);
setIsLoading.off();
onParsedEnv(env);
};
// If something is wrong show an error
try {
reader.readAsText(file);
} catch (error) {
console.log(error);
}
};
const handleDrop = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!e.dataTransfer) {
return;
}
e.dataTransfer.dropEffect = 'copy';
setDragActive.off();
parseFile(e.dataTransfer.files[0]);
};
const handleFileUpload = (e: ChangeEvent<HTMLInputElement>) => {
e.preventDefault();
parseFile(e.target?.files?.[0]);
};
return (
<div
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
className={twMerge(
'relative mb-4 mt-4 flex w-full cursor-pointer items-center justify-center space-x-2 rounded-md bg-mineshaft-900 py-8 px-2 opacity-60 outline-dashed outline-2 outline-chicago-600 duration-200 hover:opacity-100',
isDragActive && 'opacity-100',
!isSmaller && 'flex-col space-y-4',
isLoading && 'bg-bunker-800'
)}
>
{isLoading ? (
<div className="mb-16 flex items-center justify-center pt-16">
<img src="/images/loading/loading.gif" height={70} width={120} alt="google logo" />
</div>
) : (
<>
<div>
<FontAwesomeIcon icon={faUpload} size={isSmaller ? '2x' : '5x'} />
</div>
<div>
<p className="">{t(isSmaller ? 'common:drop-zone-keys' : 'common:drop-zone')}</p>
</div>
<input
id="fileSelect"
type="file"
className="absolute h-full w-full cursor-pointer opacity-0"
accept=".txt,.env,.yml,.yaml"
onChange={handleFileUpload}
/>
{!isSmaller && (
<>
<div className="flex w-full flex-row items-center justify-center py-4">
<div className="w-1/5 border-t border-gray-700" />
<p className="mx-4 text-xs text-gray-400">OR</p>
<div className="w-1/5 border-t border-gray-700" />
</div>
<div>
<Button variant="star" onClick={onAddNewSecret}>
Add a new secret
</Button>
</div>
</>
)}{' '}
</>
)}
</div>
);
};
@@ -0,0 +1 @@
export { SecretDropzone } from './SecretDropzone';
@@ -0,0 +1,343 @@
/* eslint-disable react/jsx-no-useless-fragment */
import { Controller, useFieldArray, useFormContext, useWatch } from 'react-hook-form';
import {
faCodeBranch,
faEllipsis,
faInfoCircle,
faPlus,
faSquare,
faSquareCheck,
faTrash
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { cx } from 'cva';
import { twMerge } from 'tailwind-merge';
import {
Button,
FormControl,
HoverCard,
HoverCardContent,
HoverCardTrigger,
IconButton,
Input,
Popover,
PopoverContent,
PopoverTrigger,
Tag,
TextArea,
Tooltip
} from '@app/components/v2';
import { useToggle } from '@app/hooks';
import { WsTag } from '@app/hooks/api/types';
import { FormData, SecretActionType } from '../../DashboardPage.utils';
type Props = {
index: number;
// permission and external state's that decided to hide or show
isReadOnly?: boolean;
isAddOnly?: boolean;
isRollbackMode?: boolean;
isCommentColumnCollapsed: boolean;
isSecretValueHidden: boolean;
searchTerm: string;
// to record the ids of deleted ones
onSecretDelete: (index: number, id?: string, overrideId?: string) => void;
// sidebar control props
onRowExpand: () => void;
// tag props
wsTags?: WsTag[];
onCreateTagOpen: () => void;
};
const tagColors = [
{ bg: 'bg-[#f1c40f]/40', text: 'text-[#fcf0c3]/70' },
{ bg: 'bg-[#cb1c8d]/40', text: 'text-[#f2c6e3]/70' },
{ bg: 'bg-[#badc58]/40', text: 'text-[#eef6d5]/70' },
{ bg: 'bg-[#ff5400]/40', text: 'text-[#ffddcc]/70' },
{ bg: 'bg-[#3AB0FF]/40', text: 'text-[#f0fffd]/70' },
{ bg: 'bg-[#6F1AB6]/40', text: 'text-[#FFE5F1]/70' },
{ bg: 'bg-[#C40B13]/40', text: 'text-[#FFDEDE]/70' },
{ bg: 'bg-[#332FD0]/40', text: 'text-[#DFF6FF]/70' }
];
export const SecretInputRow = ({
index,
isCommentColumnCollapsed,
isSecretValueHidden,
onRowExpand,
isReadOnly,
isRollbackMode,
isAddOnly,
wsTags,
onCreateTagOpen,
onSecretDelete,
searchTerm
}: Props): JSX.Element => {
const { register, setValue, control } = useFormContext<FormData>();
const [canRevealSecret, setCanRevealSecret] = useToggle();
// comment management in a row
const {
fields: secretTags,
remove,
append
} = useFieldArray({ control, name: `secrets.${index}.tags` });
// to get details on a secret
const secret = useWatch({ name: `secrets.${index}`, control });
const hasComment = Boolean(secret.comment);
const tags = secret.tags || [];
const selectedTagIds = tags.reduce<Record<string, boolean>>(
(prev, curr) => ({ ...prev, [curr.slug]: true }),
{}
);
// when secret is override by personal values
const isOverridden =
secret.overrideAction === SecretActionType.Created ||
secret.overrideAction === SecretActionType.Modified;
const onSecretOverride = () => {
if (isOverridden) {
// when user created a new override but then removes
if (secret?.overrideAction === SecretActionType.Created)
setValue(`secrets.${index}.valueOverride`, '');
setValue(`secrets.${index}.overrideAction`, SecretActionType.Deleted, { shouldDirty: true });
} else {
setValue(
`secrets.${index}.overrideAction`,
secret?.idOverride ? SecretActionType.Modified : SecretActionType.Created,
{ shouldDirty: true }
);
}
};
const onSelectTag = (selectedTag: WsTag) => {
const shouldAppend = !selectedTagIds[selectedTag.slug];
if (shouldAppend) {
append(selectedTag);
} else {
const pos = tags.findIndex(({ slug }) => selectedTag.slug === slug);
remove(pos);
}
};
const isCreatedSecret = !secret?._id;
const shouldBeBlockedInAddOnly = !isCreatedSecret && isAddOnly;
// Why this instead of filter in parent
// Because rhf field.map has default values so basically
// keys are not updated there and index needs to kept so that we can monitor
// values individually here
if (
!(
secret.key?.toUpperCase().includes(searchTerm?.toUpperCase()) ||
tags
?.map((tag) => tag.name)
.join(' ')
?.toUpperCase()
.includes(searchTerm?.toUpperCase()) ||
secret.comment?.toUpperCase().includes(searchTerm?.toUpperCase())
)
) {
return <></>;
}
return (
<tr className="group">
<td className="px-4 text-center text-xs">{index + 1}</td>
<Controller
control={control}
defaultValue=""
name={`secrets.${index}.key`}
render={({ fieldState: { error }, field }) => (
<HoverCard openDelay={0} open={error?.message ? undefined : false}>
<HoverCardTrigger asChild>
<td className={cx(error?.message ? 'rounded ring ring-red/50' : null)}>
<div className="relative flex items-center">
<Input
autoComplete="off"
variant="plain"
isDisabled={isReadOnly || shouldBeBlockedInAddOnly || isRollbackMode}
className="w-full text-ellipsis font-mono focus:text-bunker-100 focus:ring-transparent"
{...field}
/>
{!isAddOnly && (
<IconButton
variant="plain"
className={twMerge(
'w-0 overflow-hidden p-0 group-hover:w-6',
isOverridden && 'w-6 text-primary'
)}
onClick={onSecretOverride}
isDisabled={isRollbackMode || isReadOnly}
ariaLabel="info"
>
<div className="flex items-center space-x-1">
<FontAwesomeIcon icon={faCodeBranch} className="text-base" />
</div>
</IconButton>
)}
</div>
</td>
</HoverCardTrigger>
<HoverCardContent className="w-auto py-2 pt-2">
<div className="flex items-center space-x-2">
<div>
<FontAwesomeIcon icon={faInfoCircle} className="text-red" />
</div>
<div className="text-sm">{error?.message}</div>
</div>
</HoverCardContent>
</HoverCard>
)}
/>
<Popover>
<PopoverTrigger asChild>
<td className="w-auto max-w-[180px] cursor-pointer text-sm hover:outline hover:outline-mineshaft-400">
{isCommentColumnCollapsed ? (
<div className="flex justify-center">
{hasComment && <FontAwesomeIcon icon={faInfoCircle} />}
</div>
) : (
<div className="overflow-hidden overflow-ellipsis whitespace-nowrap px-2">
{secret.comment || '-'}
</div>
)}
</td>
</PopoverTrigger>
<PopoverContent className="w-auto">
<FormControl label="Comment" className="mb-0">
<TextArea
isDisabled={isReadOnly || isRollbackMode || shouldBeBlockedInAddOnly}
{...register(`secrets.${index}.comment`)}
rows={8}
cols={30}
/>
</FormControl>
</PopoverContent>
</Popover>
<td className="flex items-center">
<div className="flex-grow">
{!isOverridden && (
<Input
variant="plain"
isReadOnly={isReadOnly || shouldBeBlockedInAddOnly || isRollbackMode}
className="w-full text-ellipsis font-mono focus:text-bunker-100 focus:ring-transparent"
type={canRevealSecret || isSecretValueHidden ? 'text' : 'password'}
{...register(`secrets.${index}.value`)}
placeholder="EMPTY"
onBlur={setCanRevealSecret.off}
onFocus={setCanRevealSecret.on}
autoComplete="off"
/>
)}
{isOverridden && (
<Input
variant="plain"
isReadOnly={isReadOnly || isAddOnly || isRollbackMode}
className="w-full text-ellipsis font-mono focus:text-bunker-100 focus:ring-transparent"
type={canRevealSecret || isSecretValueHidden ? 'text' : 'password'}
placeholder="EMPTY"
{...register(`secrets.${index}.valueOverride`)}
onBlur={setCanRevealSecret.off}
onFocus={setCanRevealSecret.on}
autoComplete="off"
/>
)}
</div>
<div className="flex items-center group-hover:mr-2 group-hover:border-r group-hover:border-mineshaft-600">
{secretTags.map(({ id, slug }, i) => (
<Tag
className={cx(
tagColors[i % tagColors.length].bg,
tagColors[i % tagColors.length].text
)}
isDisabled={isReadOnly || isAddOnly || isRollbackMode}
onClose={() => remove(i)}
key={id}
>
{slug}
</Tag>
))}
{!(isReadOnly || isAddOnly || isRollbackMode) && (
<div className="w-0 overflow-hidden group-hover:w-8">
<Popover>
<PopoverTrigger asChild>
<div>
<Tooltip content="Add tag">
<IconButton variant="star" size="xs" ariaLabel="add-tag">
<FontAwesomeIcon icon={faPlus} />
</IconButton>
</Tooltip>
</div>
</PopoverTrigger>
<PopoverContent
side="left"
className="max-h-96 w-auto min-w-[240px] overflow-y-auto overflow-x-hidden p-2 text-bunker-100"
hideCloseBtn
>
<div className="mb-2 text-sm font-medium">Add tags to this secret</div>
<div className="flex flex-col space-y-1">
{wsTags?.map((wsTag) => (
<Button
variant="plain"
size="sm"
isFullWidth
className={twMerge(
'justify-start bg-mineshaft-800 text-bunker-100',
selectedTagIds?.[wsTag.slug] && 'text-primary'
)}
onClick={() => onSelectTag(wsTag)}
leftIcon={
<FontAwesomeIcon
icon={selectedTagIds?.[wsTag.slug] ? faSquareCheck : faSquare}
/>
}
key={wsTag._id}
>
{wsTag.slug}
</Button>
))}
<Button
variant="plain"
size="sm"
isFullWidth
className="mt-4 justify-start bg-mineshaft-800 text-bunker-400 hover:text-primary"
onClick={onCreateTagOpen}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Add new tag
</Button>
</div>
</PopoverContent>
</Popover>
</div>
)}
</div>
<div className="flex w-0 items-center justify-end space-x-2 overflow-hidden transition-all group-hover:w-16">
<Tooltip content="delete">
<IconButton
size="xs"
colorSchema="danger"
ariaLabel="delete"
isDisabled={isReadOnly || isRollbackMode}
onClick={() => onSecretDelete(index, secret._id, secret?.idOverride)}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
{!isAddOnly && (
<Tooltip content="more">
<IconButton size="xs" variant="solid" onClick={onRowExpand} ariaLabel="expand">
<FontAwesomeIcon icon={faEllipsis} />
</IconButton>
</Tooltip>
)}
</div>
</td>
</tr>
);
};
@@ -0,0 +1 @@
export { SecretInputRow } from './SecretInputRow';
@@ -0,0 +1,49 @@
import { faArrowDown, faArrowUp, faComments } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { twMerge } from 'tailwind-merge';
import { IconButton, Tooltip } from '@app/components/v2';
type Props = {
isCommentColumnCollapsed?: boolean;
sortDir: 'asc' | 'desc';
onSort: () => void;
onCommentColToggle: () => void;
};
export const SecretTableHeader = ({
sortDir,
isCommentColumnCollapsed,
onSort,
onCommentColToggle
}: Props): JSX.Element => (
<thead>
<tr className="">
<th className="w-16 text-center">#</th>
<th className={twMerge('w-1/5 min-w-[220px]', isCommentColumnCollapsed && 'w-1/4')}>
<div className="inline-flex items-end">
Key
<IconButton variant="plain" className="ml-2" ariaLabel="sort" onClick={onSort}>
<FontAwesomeIcon icon={sortDir === 'asc' ? faArrowDown : faArrowUp} />
</IconButton>
</div>
</th>
<th
className={twMerge(
'w-1/5 cursor-pointer py-2 px-4 font-medium hover:outline hover:outline-mineshaft-400',
isCommentColumnCollapsed && 'w-12'
)}
onClick={onCommentColToggle}
>
<Tooltip content={isCommentColumnCollapsed ? 'Expand' : 'Minimise'}>
<div
className={twMerge('flex items-center', isCommentColumnCollapsed && 'justify-center')}
>
{isCommentColumnCollapsed ? <FontAwesomeIcon icon={faComments} /> : 'Comment'}
</div>
</Tooltip>
</th>
<th className={isCommentColumnCollapsed ? 'w-3/4' : '3/5'}>Value</th>
</tr>
</thead>
);
@@ -0,0 +1 @@
export { SecretTableHeader } from './SecretTableHeader';