diff --git a/readme.md b/readme.md index 8cbe1fd..0cdd62d 100644 --- a/readme.md +++ b/readme.md @@ -19,6 +19,8 @@ Backed with a Redis (or Redis compatible) key/value store this service lets you manage application configuration variables and serve them to the clients. Cache control headers provided by default and ready to be placed behind a CDN for delivery to many user applications at the same time. +![Screenshot](screenshot.png) + ## Requirements Redis compatible database diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000..8b1ce37 Binary files /dev/null and b/screenshot.png differ diff --git a/src/app/client.ts b/src/app/client.ts new file mode 100644 index 0000000..1a06ddf --- /dev/null +++ b/src/app/client.ts @@ -0,0 +1,82 @@ +import { ConfigObject, ConfigObjectList } from '@/api_config'; +import { ActionResponse } from './utils'; + +export async function getConfigObjectList({ + apiUrl, + offset, + limit +}: { + apiUrl: string; + offset: number; + limit: number; +}): ActionResponse { + const url = new URL(`${apiUrl}/config`); + url.searchParams.append('offset', offset.toString()); + url.searchParams.append('limit', limit.toString()); + const response = await fetch(url); + if (!response.ok) { + return [undefined, 'Failed to fetch config object list']; + } + return [await response.json()]; +} + +export async function updateConfigObject({ + apiUrl, + keyId, + value +}: { + apiUrl: string; + keyId: string; + value: string; +}): ActionResponse { + const url = new URL(`${apiUrl}/config`); + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ key: keyId, value }) + }); + if (!response.ok) { + return [undefined, 'Failed to update config object']; + } + return [await response.json()]; +} + +export async function createConfigObject({ + apiUrl, + obj +}: { + apiUrl: string; + obj: ConfigObject; +}): ActionResponse { + const url = new URL(`${apiUrl}/config`); + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(obj) + }); + if (!response.ok) { + return [undefined, 'Failed to create config object']; + } + return [await response.json()]; +} + +export async function deleteConfigObject({ + apiUrl, + key +}: { + apiUrl: string; + key: string; +}): ActionResponse { + const url = new URL(`${apiUrl}/config/${key}`); + const response = await fetch(url, { + method: 'DELETE' + }); + if (!response.ok) { + return [undefined, 'Failed to delete config object']; + } + return [undefined]; +} diff --git a/src/app/config/variables/_components/ConfigObjectTable.tsx b/src/app/config/variables/_components/ConfigObjectTable.tsx index 196473b..ed027ba 100644 --- a/src/app/config/variables/_components/ConfigObjectTable.tsx +++ b/src/app/config/variables/_components/ConfigObjectTable.tsx @@ -1,7 +1,6 @@ 'use client'; import { ConfigObject, ConfigObjectList } from '@/api_config'; -import { ActionResponse } from '@/app/utils'; import { useApiUrl } from '@/hooks/useApiUrl'; import { Button, @@ -15,8 +14,17 @@ import { TableHeader, TableRow } from '@nextui-org/react'; -import { IconCheck, IconPencil, IconTrash } from '@tabler/icons-react'; +import { IconCheck, IconPencil, IconTrash, IconX } from '@tabler/icons-react'; import { useEffect, useMemo, useState } from 'react'; +import NewConfigObjectModal from './NewConfigObjectModal'; +import { + createConfigObject, + deleteConfigObject, + getConfigObjectList, + updateConfigObject +} from '@/app/client'; +import { ConfirmationModal } from '@/components/modal/ConfirmationModal'; +import ClipBoardCopyButton from '@/components/button/ClipboardCopyButton'; export interface ConfigObjectTableProps { pageSize?: number; @@ -24,48 +32,6 @@ export interface ConfigObjectTableProps { const DEFAULT_PAGE_SIZE = 20; -async function getConfigObjectList({ - apiUrl, - offset, - limit -}: { - apiUrl: string; - offset: number; - limit: number; -}): ActionResponse { - const url = new URL(`${apiUrl}/config`); - url.searchParams.append('offset', offset.toString()); - url.searchParams.append('limit', limit.toString()); - const response = await fetch(url); - if (!response.ok) { - return [undefined, 'Failed to fetch config object list']; - } - return [await response.json()]; -} - -async function updateConfigObject({ - apiUrl, - keyId, - value -}: { - apiUrl: string; - keyId: string; - value: string; -}): ActionResponse { - const url = new URL(`${apiUrl}/config`); - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ key: keyId, value }) - }); - if (!response.ok) { - return [undefined, 'Failed to update config object']; - } - return [await response.json()]; -} - export default function ConfigObjectTable({ pageSize = DEFAULT_PAGE_SIZE }: ConfigObjectTableProps) { @@ -74,6 +40,7 @@ export default function ConfigObjectTable({ const [data, setData] = useState(undefined); const [editKeyId, setEditKeyId] = useState(undefined); const [editValue, setEditValue] = useState(undefined); + const [isConfirmModalOpen, setConfirmModalOpen] = useState(false); const apiUrl = useApiUrl(); @@ -122,6 +89,44 @@ export default function ConfigObjectTable({ }); }; + const handleCreate = (obj: ConfigObject) => { + if (!apiUrl) { + return; + } + createConfigObject({ + apiUrl, + obj + }) + .then(([data, error]) => { + if (error) { + console.error(error); + return; + } + }) + .finally(() => { + updateTableContents(); + }); + }; + + const handleDelete = (keyId: string) => { + if (!apiUrl) { + return; + } + deleteConfigObject({ + apiUrl, + key: keyId + }) + .then(([data, error]) => { + if (error) { + console.error(error); + return; + } + }) + .finally(() => { + updateTableContents(); + }); + }; + useEffect(() => { updateTableContents(); }, [page, pageSize, apiUrl]); @@ -134,78 +139,120 @@ export default function ConfigObjectTable({ isLoading || data?.items.length === 0 ? 'loading' : 'idle'; return ( - 0 ? ( -
- setPage(page)} - /> -
- ) : null - } - > - - KEY - VALUE - ACTIONS - - { - return { keyId: obj.key, value: obj.value }; - }) ?? [] +
+
+ +
+
0 ? ( +
+ setPage(page)} + /> +
+ ) : null } - loadingContent={} - loadingState={loadingState} > - {(item) => ( - - {item.keyId} - - {editKeyId === item.keyId ? ( - - ) : ( - item.value - )} - - - {editKeyId !== item.keyId && ( - - )} - {editKeyId === item.keyId && ( - - )} - - - - )} - -
+ + + KEY + + + VALUE + + + ACTIONS + + + { + return { keyId: obj.key, value: obj.value }; + }) ?? [] + } + loadingContent={} + loadingState={loadingState} + > + {(item) => ( + + {item.keyId} + + {editKeyId === item.keyId ? ( + + ) : ( + item.value + )} + + +
+ {editKeyId !== item.keyId && ( + + )} + {editKeyId === item.keyId && ( + <> + + + + )} + + + handleDelete(item.keyId)} + modalContent={`Are you sure you want to delete this configuration variable?`} + buttonContent="Yes, I am sure" + /> +
+
+
+ )} +
+ + ); } diff --git a/src/app/config/variables/_components/NewConfigObjectModal.tsx b/src/app/config/variables/_components/NewConfigObjectModal.tsx new file mode 100644 index 0000000..92d1381 --- /dev/null +++ b/src/app/config/variables/_components/NewConfigObjectModal.tsx @@ -0,0 +1,72 @@ +import { ConfigObject } from '@/api_config'; +import { + Button, + Input, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + useDisclosure +} from '@nextui-org/react'; +import { IconPlus } from '@tabler/icons-react'; +import { useState } from 'react'; + +export interface NewConfigObjectModalProps { + onSave: (obj: ConfigObject) => void; +} + +export default function NewConfigObjectModal({ + onSave +}: NewConfigObjectModalProps) { + const { isOpen, onOpen, onOpenChange } = useDisclosure(); + const [keyId, setKeyId] = useState(''); + const [value, setValue] = useState(''); + + return ( + <> + + + + {(onClose) => ( + <> + + Add new configuration variable + + +
+ + +
+
+ + + + + + )} +
+
+ + ); +} diff --git a/src/components/button/ClipboardCopyButton.tsx b/src/components/button/ClipboardCopyButton.tsx new file mode 100644 index 0000000..bfc44a5 --- /dev/null +++ b/src/components/button/ClipboardCopyButton.tsx @@ -0,0 +1,38 @@ +import { useState } from 'react'; +import { IconCheck, IconCopy } from '@tabler/icons-react'; +import { Button } from '@nextui-org/react'; + +type ClipBoardCopyButtonProps = { + className?: string; + text: string; +}; + +const ClipBoardCopyButton = ({ className, text }: ClipBoardCopyButtonProps) => { + const [isBodyCopied, setIsBodyCopied] = useState(false); + + const handleCopy = (text: string, setIsCopied: (value: boolean) => void) => { + copyTextToClipboard(text) + .then(() => { + setIsCopied(true); + setTimeout(() => { + setIsCopied(false); + }, 1500); + }) + .catch((error) => console.error(error)); + }; + + async function copyTextToClipboard(text: string) { + return await navigator.clipboard.writeText(text); + } + + return ( + + ); +}; +export default ClipBoardCopyButton; diff --git a/src/components/modal/ConfirmationModal.tsx b/src/components/modal/ConfirmationModal.tsx new file mode 100644 index 0000000..d4cfa40 --- /dev/null +++ b/src/components/modal/ConfirmationModal.tsx @@ -0,0 +1,58 @@ +import { useTransition } from 'react'; +import { + Button, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader +} from '@nextui-org/react'; + +type ConfirmationModalProps = { + isOpen: boolean; + setIsOpen: (param: boolean) => void; + handleConfirmClick: () => void; + modalContent: string; + buttonContent: string; +}; + +export const ConfirmationModal = ({ + isOpen, + setIsOpen, + handleConfirmClick, + modalContent, + buttonContent +}: ConfirmationModalProps) => { + const [isPending, startTransition] = useTransition(); + const handleConfirm = () => { + startTransition(() => { + handleConfirmClick(); + }); + setIsOpen(false); + }; + return ( + + + Are you sure? + {modalContent} + + + + + + + ); +};