From d576fe57ada1e7d6e6c9ac01010890734a89714c Mon Sep 17 00:00:00 2001 From: HagerDakroury Date: Mon, 23 Feb 2026 20:46:25 +0100 Subject: [PATCH 01/10] Merge ui-library source into src/ui-library/ (with history squashed) --- src/ui-library/_util/colors.ts | 5 + src/ui-library/_util/helpers.tsx | 77 ++ src/ui-library/_util/lagoonColors.ts | 1 + .../components/Accordion/Accordion.tsx | 44 ++ src/ui-library/components/Accordion/index.tsx | 3 + .../components/ActionButton/ActionButton.tsx | 40 ++ .../components/ActionButton/index.tsx | 1 + .../AnnouncementCard/AnnouncementCard.tsx | 72 ++ .../components/AnnouncementCard/index.tsx | 1 + .../components/AvatarBubble/AvatarBubble.tsx | 18 + .../components/Breadcrumb/Breadcrumb.tsx | 104 +++ .../components/Breadcrumb/index.tsx | 3 + .../components/ChangeFeed/ChangeFeed.tsx | 114 +++ .../ChangeFeed/ChangeFeedContainer.tsx | 93 +++ .../components/ChangeFeed/ChangeFeedItem.tsx | 48 ++ .../components/ChangeFeed/index.tsx | 4 + .../components/ChangeFeed/sampleData.ts | 224 ++++++ .../components/Checkbox/Checkbox.tsx | 21 + src/ui-library/components/Checkbox/index.tsx | 3 + .../CopyToClipboard/CopyToClipboard.tsx | 145 ++++ .../components/CopyToClipboard/index.tsx | 3 + .../components/DataTable/DataTable.tsx | 336 +++++++++ .../components/DataTable/HighlightText.tsx | 24 + src/ui-library/components/DataTable/index.tsx | 5 + .../components/DateRangePicker/date-input.tsx | 244 +++++++ .../DateRangePicker/date-range-picker.tsx | 523 ++++++++++++++ .../components/DateRangePicker/index.tsx | 3 + .../components/DetailStat/DetailStat.tsx | 56 ++ .../components/DetailStat/index.tsx | 3 + src/ui-library/components/Input/Input.tsx | 97 +++ src/ui-library/components/Input/index.tsx | 5 + .../components/KeyFactCard/KeyFactCard.tsx | 17 + .../components/KeyFactCard/index.tsx | 3 + .../components/Notification/Notification.tsx | 77 ++ .../components/Notification/index.tsx | 3 + .../components/Pagination/Pagination.tsx | 112 +++ .../components/Pagination/index.tsx | 3 + .../ProblemsOverview/ProblemsOverview.tsx | 72 ++ .../components/ProblemsOverview/index.tsx | 3 + .../components/RootLayout/RootLayout.tsx | 61 ++ .../components/RootLayout/index.tsx | 3 + src/ui-library/components/Select/Select.tsx | 57 ++ src/ui-library/components/Select/index.tsx | 3 + src/ui-library/components/Sheet/Sheet.tsx | 277 +++++++ src/ui-library/components/Sheet/index.tsx | 3 + src/ui-library/components/Sidenav/Sidenav.tsx | 289 ++++++++ .../components/Sidenav/SidenavFooterMenu.tsx | 72 ++ .../components/Sidenav/SidenavLogo.tsx | 46 ++ src/ui-library/components/Sidenav/index.tsx | 3 + .../components/Sidenav/useActivePaths.tsx | 70 ++ .../components/StatCard/StatCard.tsx | 46 ++ src/ui-library/components/StatCard/index.tsx | 3 + src/ui-library/components/Switch/Switch.tsx | 25 + src/ui-library/components/Switch/index.tsx | 3 + .../TabNavigation/TabNavigation.tsx | 45 ++ .../components/TabNavigation/index.tsx | 3 + src/ui-library/components/Table/Table.tsx | 98 +++ src/ui-library/components/Table/index.tsx | 3 + .../components/TextArea/TextArea.tsx | 98 +++ src/ui-library/components/TextArea/index.tsx | 5 + .../components/ThemeSwitch/ThemeSwitch.tsx | 34 + .../components/ThemeSwitch/index.tsx | 3 + src/ui-library/components/ui/accordion.tsx | 51 ++ src/ui-library/components/ui/alert-dialog.tsx | 113 +++ src/ui-library/components/ui/alert.tsx | 49 ++ src/ui-library/components/ui/aspect-ratio.tsx | 7 + src/ui-library/components/ui/avatar.tsx | 34 + src/ui-library/components/ui/badge.tsx | 66 ++ src/ui-library/components/ui/breadcrumb.tsx | 92 +++ src/ui-library/components/ui/button.tsx | 50 ++ src/ui-library/components/ui/calendar.tsx | 156 ++++ src/ui-library/components/ui/card.tsx | 56 ++ src/ui-library/components/ui/carousel.tsx | 216 ++++++ src/ui-library/components/ui/chart.tsx | 289 ++++++++ src/ui-library/components/ui/checkbox.tsx | 29 + src/ui-library/components/ui/collapsible.tsx | 15 + src/ui-library/components/ui/command.tsx | 137 ++++ src/ui-library/components/ui/context-menu.tsx | 211 ++++++ src/ui-library/components/ui/dialog.tsx | 121 ++++ src/ui-library/components/ui/drawer.tsx | 106 +++ .../components/ui/dropdown-menu.tsx | 219 ++++++ src/ui-library/components/ui/form.tsx | 136 ++++ src/ui-library/components/ui/hover-card.tsx | 36 + src/ui-library/components/ui/input-otp.tsx | 68 ++ src/ui-library/components/ui/input.tsx | 21 + src/ui-library/components/ui/label.tsx | 21 + src/ui-library/components/ui/menubar.tsx | 234 ++++++ .../components/ui/navigation-menu.tsx | 142 ++++ src/ui-library/components/ui/pagination.tsx | 100 +++ src/ui-library/components/ui/popover.tsx | 42 ++ src/ui-library/components/ui/progress.tsx | 22 + src/ui-library/components/ui/radio-group.tsx | 33 + src/ui-library/components/ui/resizable.tsx | 46 ++ src/ui-library/components/ui/scroll-area.tsx | 48 ++ src/ui-library/components/ui/select.tsx | 158 ++++ src/ui-library/components/ui/separator.tsx | 28 + src/ui-library/components/ui/sheet.tsx | 101 +++ src/ui-library/components/ui/sidebar.tsx | 677 ++++++++++++++++++ src/ui-library/components/ui/skeleton.tsx | 7 + src/ui-library/components/ui/slider.tsx | 56 ++ src/ui-library/components/ui/sonner.tsx | 23 + src/ui-library/components/ui/switch.tsx | 28 + src/ui-library/components/ui/table.tsx | 70 ++ src/ui-library/components/ui/tabs.tsx | 42 ++ src/ui-library/components/ui/textarea.tsx | 18 + src/ui-library/components/ui/toggle-group.tsx | 67 ++ src/ui-library/components/ui/toggle.tsx | 39 + src/ui-library/components/ui/tooltip.tsx | 46 ++ src/ui-library/hooks/use-mobile.ts | 19 + src/ui-library/hooks/useSyncTheme.ts | 15 + src/ui-library/icons/amazee_dark.svg | 5 + src/ui-library/icons/amazee_light.svg | 5 + src/ui-library/icons/sidebar/lagoon.svg | 24 + src/ui-library/icons/sidebar/logo-dark.svg | 29 + src/ui-library/index.css | 228 ++++++ src/ui-library/index.ts | 362 ++++++++++ src/ui-library/lib/utils.ts | 6 + src/ui-library/providers/NextLinkProvider.tsx | 21 + src/ui-library/providers/ThemeProvider.tsx | 9 + src/ui-library/schemas.ts | 89 +++ src/ui-library/schemas/announcementCard.ts | 12 + src/ui-library/schemas/changeFeed.ts | 24 + src/ui-library/schemas/sidenavFooterMenu.ts | 10 + src/ui-library/stories/Accordion.stories.tsx | 56 ++ .../stories/ActionButton.stories.tsx | 75 ++ .../stories/AnnouncementCard.stories.tsx | 61 ++ src/ui-library/stories/Badge.stories.tsx | 67 ++ src/ui-library/stories/Breadcrumb.stories.tsx | 122 ++++ src/ui-library/stories/Button.stories.tsx | 89 +++ src/ui-library/stories/ChangeFeed.stories.tsx | 50 ++ src/ui-library/stories/Checkbox.stories.tsx | 36 + .../stories/CopyToClipboard.stories.tsx | 91 +++ src/ui-library/stories/DataTable.stories.tsx | 255 +++++++ .../stories/DateRangePicker.stories.tsx | 25 + src/ui-library/stories/DetailStat.stories.tsx | 50 ++ src/ui-library/stories/Input.stories.tsx | 58 ++ .../stories/KeyFactCard.stories.tsx | 42 ++ .../stories/Notification.stories.tsx | 42 ++ src/ui-library/stories/Pagination.stories.tsx | 21 + .../stories/ProblemsOverview.stories.tsx | 94 +++ src/ui-library/stories/RootLayout.stories.tsx | 119 +++ src/ui-library/stories/Select.stories.tsx | 80 +++ src/ui-library/stories/Sheet.stories.tsx | 105 +++ src/ui-library/stories/Sidenav.stories.tsx | 143 ++++ src/ui-library/stories/StatCard.stories.tsx | 92 +++ src/ui-library/stories/Switch.stories.tsx | 38 + src/ui-library/stories/Table.stories.tsx | 51 ++ src/ui-library/stories/TextArea.stories.tsx | 46 ++ .../stories/ThemeSwitch.stories.tsx | 16 + src/ui-library/typings/fonts.d.ts | 2 + src/ui-library/typings/jpgs.d.ts | 1 + src/ui-library/typings/nextLink.d.ts | 38 + src/ui-library/typings/styles.d.ts | 4 + src/ui-library/typings/vectors.d.ts | 1 + 154 files changed, 11014 insertions(+) create mode 100644 src/ui-library/_util/colors.ts create mode 100644 src/ui-library/_util/helpers.tsx create mode 100644 src/ui-library/_util/lagoonColors.ts create mode 100644 src/ui-library/components/Accordion/Accordion.tsx create mode 100644 src/ui-library/components/Accordion/index.tsx create mode 100644 src/ui-library/components/ActionButton/ActionButton.tsx create mode 100644 src/ui-library/components/ActionButton/index.tsx create mode 100644 src/ui-library/components/AnnouncementCard/AnnouncementCard.tsx create mode 100644 src/ui-library/components/AnnouncementCard/index.tsx create mode 100644 src/ui-library/components/AvatarBubble/AvatarBubble.tsx create mode 100644 src/ui-library/components/Breadcrumb/Breadcrumb.tsx create mode 100644 src/ui-library/components/Breadcrumb/index.tsx create mode 100644 src/ui-library/components/ChangeFeed/ChangeFeed.tsx create mode 100644 src/ui-library/components/ChangeFeed/ChangeFeedContainer.tsx create mode 100644 src/ui-library/components/ChangeFeed/ChangeFeedItem.tsx create mode 100644 src/ui-library/components/ChangeFeed/index.tsx create mode 100644 src/ui-library/components/ChangeFeed/sampleData.ts create mode 100644 src/ui-library/components/Checkbox/Checkbox.tsx create mode 100644 src/ui-library/components/Checkbox/index.tsx create mode 100644 src/ui-library/components/CopyToClipboard/CopyToClipboard.tsx create mode 100644 src/ui-library/components/CopyToClipboard/index.tsx create mode 100644 src/ui-library/components/DataTable/DataTable.tsx create mode 100644 src/ui-library/components/DataTable/HighlightText.tsx create mode 100644 src/ui-library/components/DataTable/index.tsx create mode 100644 src/ui-library/components/DateRangePicker/date-input.tsx create mode 100644 src/ui-library/components/DateRangePicker/date-range-picker.tsx create mode 100644 src/ui-library/components/DateRangePicker/index.tsx create mode 100644 src/ui-library/components/DetailStat/DetailStat.tsx create mode 100644 src/ui-library/components/DetailStat/index.tsx create mode 100644 src/ui-library/components/Input/Input.tsx create mode 100644 src/ui-library/components/Input/index.tsx create mode 100644 src/ui-library/components/KeyFactCard/KeyFactCard.tsx create mode 100644 src/ui-library/components/KeyFactCard/index.tsx create mode 100644 src/ui-library/components/Notification/Notification.tsx create mode 100644 src/ui-library/components/Notification/index.tsx create mode 100644 src/ui-library/components/Pagination/Pagination.tsx create mode 100644 src/ui-library/components/Pagination/index.tsx create mode 100644 src/ui-library/components/ProblemsOverview/ProblemsOverview.tsx create mode 100644 src/ui-library/components/ProblemsOverview/index.tsx create mode 100644 src/ui-library/components/RootLayout/RootLayout.tsx create mode 100644 src/ui-library/components/RootLayout/index.tsx create mode 100644 src/ui-library/components/Select/Select.tsx create mode 100644 src/ui-library/components/Select/index.tsx create mode 100644 src/ui-library/components/Sheet/Sheet.tsx create mode 100644 src/ui-library/components/Sheet/index.tsx create mode 100644 src/ui-library/components/Sidenav/Sidenav.tsx create mode 100644 src/ui-library/components/Sidenav/SidenavFooterMenu.tsx create mode 100644 src/ui-library/components/Sidenav/SidenavLogo.tsx create mode 100644 src/ui-library/components/Sidenav/index.tsx create mode 100644 src/ui-library/components/Sidenav/useActivePaths.tsx create mode 100644 src/ui-library/components/StatCard/StatCard.tsx create mode 100644 src/ui-library/components/StatCard/index.tsx create mode 100644 src/ui-library/components/Switch/Switch.tsx create mode 100644 src/ui-library/components/Switch/index.tsx create mode 100644 src/ui-library/components/TabNavigation/TabNavigation.tsx create mode 100644 src/ui-library/components/TabNavigation/index.tsx create mode 100644 src/ui-library/components/Table/Table.tsx create mode 100644 src/ui-library/components/Table/index.tsx create mode 100644 src/ui-library/components/TextArea/TextArea.tsx create mode 100644 src/ui-library/components/TextArea/index.tsx create mode 100644 src/ui-library/components/ThemeSwitch/ThemeSwitch.tsx create mode 100644 src/ui-library/components/ThemeSwitch/index.tsx create mode 100644 src/ui-library/components/ui/accordion.tsx create mode 100644 src/ui-library/components/ui/alert-dialog.tsx create mode 100644 src/ui-library/components/ui/alert.tsx create mode 100644 src/ui-library/components/ui/aspect-ratio.tsx create mode 100644 src/ui-library/components/ui/avatar.tsx create mode 100644 src/ui-library/components/ui/badge.tsx create mode 100644 src/ui-library/components/ui/breadcrumb.tsx create mode 100644 src/ui-library/components/ui/button.tsx create mode 100644 src/ui-library/components/ui/calendar.tsx create mode 100644 src/ui-library/components/ui/card.tsx create mode 100644 src/ui-library/components/ui/carousel.tsx create mode 100644 src/ui-library/components/ui/chart.tsx create mode 100644 src/ui-library/components/ui/checkbox.tsx create mode 100644 src/ui-library/components/ui/collapsible.tsx create mode 100644 src/ui-library/components/ui/command.tsx create mode 100644 src/ui-library/components/ui/context-menu.tsx create mode 100644 src/ui-library/components/ui/dialog.tsx create mode 100644 src/ui-library/components/ui/drawer.tsx create mode 100644 src/ui-library/components/ui/dropdown-menu.tsx create mode 100644 src/ui-library/components/ui/form.tsx create mode 100644 src/ui-library/components/ui/hover-card.tsx create mode 100644 src/ui-library/components/ui/input-otp.tsx create mode 100644 src/ui-library/components/ui/input.tsx create mode 100644 src/ui-library/components/ui/label.tsx create mode 100644 src/ui-library/components/ui/menubar.tsx create mode 100644 src/ui-library/components/ui/navigation-menu.tsx create mode 100644 src/ui-library/components/ui/pagination.tsx create mode 100644 src/ui-library/components/ui/popover.tsx create mode 100644 src/ui-library/components/ui/progress.tsx create mode 100644 src/ui-library/components/ui/radio-group.tsx create mode 100644 src/ui-library/components/ui/resizable.tsx create mode 100644 src/ui-library/components/ui/scroll-area.tsx create mode 100644 src/ui-library/components/ui/select.tsx create mode 100644 src/ui-library/components/ui/separator.tsx create mode 100644 src/ui-library/components/ui/sheet.tsx create mode 100644 src/ui-library/components/ui/sidebar.tsx create mode 100644 src/ui-library/components/ui/skeleton.tsx create mode 100644 src/ui-library/components/ui/slider.tsx create mode 100644 src/ui-library/components/ui/sonner.tsx create mode 100644 src/ui-library/components/ui/switch.tsx create mode 100644 src/ui-library/components/ui/table.tsx create mode 100644 src/ui-library/components/ui/tabs.tsx create mode 100644 src/ui-library/components/ui/textarea.tsx create mode 100644 src/ui-library/components/ui/toggle-group.tsx create mode 100644 src/ui-library/components/ui/toggle.tsx create mode 100644 src/ui-library/components/ui/tooltip.tsx create mode 100644 src/ui-library/hooks/use-mobile.ts create mode 100644 src/ui-library/hooks/useSyncTheme.ts create mode 100644 src/ui-library/icons/amazee_dark.svg create mode 100644 src/ui-library/icons/amazee_light.svg create mode 100644 src/ui-library/icons/sidebar/lagoon.svg create mode 100644 src/ui-library/icons/sidebar/logo-dark.svg create mode 100644 src/ui-library/index.css create mode 100644 src/ui-library/index.ts create mode 100644 src/ui-library/lib/utils.ts create mode 100644 src/ui-library/providers/NextLinkProvider.tsx create mode 100644 src/ui-library/providers/ThemeProvider.tsx create mode 100644 src/ui-library/schemas.ts create mode 100644 src/ui-library/schemas/announcementCard.ts create mode 100644 src/ui-library/schemas/changeFeed.ts create mode 100644 src/ui-library/schemas/sidenavFooterMenu.ts create mode 100644 src/ui-library/stories/Accordion.stories.tsx create mode 100644 src/ui-library/stories/ActionButton.stories.tsx create mode 100644 src/ui-library/stories/AnnouncementCard.stories.tsx create mode 100644 src/ui-library/stories/Badge.stories.tsx create mode 100644 src/ui-library/stories/Breadcrumb.stories.tsx create mode 100644 src/ui-library/stories/Button.stories.tsx create mode 100644 src/ui-library/stories/ChangeFeed.stories.tsx create mode 100644 src/ui-library/stories/Checkbox.stories.tsx create mode 100644 src/ui-library/stories/CopyToClipboard.stories.tsx create mode 100644 src/ui-library/stories/DataTable.stories.tsx create mode 100644 src/ui-library/stories/DateRangePicker.stories.tsx create mode 100644 src/ui-library/stories/DetailStat.stories.tsx create mode 100644 src/ui-library/stories/Input.stories.tsx create mode 100644 src/ui-library/stories/KeyFactCard.stories.tsx create mode 100644 src/ui-library/stories/Notification.stories.tsx create mode 100644 src/ui-library/stories/Pagination.stories.tsx create mode 100644 src/ui-library/stories/ProblemsOverview.stories.tsx create mode 100644 src/ui-library/stories/RootLayout.stories.tsx create mode 100644 src/ui-library/stories/Select.stories.tsx create mode 100644 src/ui-library/stories/Sheet.stories.tsx create mode 100644 src/ui-library/stories/Sidenav.stories.tsx create mode 100644 src/ui-library/stories/StatCard.stories.tsx create mode 100644 src/ui-library/stories/Switch.stories.tsx create mode 100644 src/ui-library/stories/Table.stories.tsx create mode 100644 src/ui-library/stories/TextArea.stories.tsx create mode 100644 src/ui-library/stories/ThemeSwitch.stories.tsx create mode 100644 src/ui-library/typings/fonts.d.ts create mode 100644 src/ui-library/typings/jpgs.d.ts create mode 100644 src/ui-library/typings/nextLink.d.ts create mode 100644 src/ui-library/typings/styles.d.ts create mode 100644 src/ui-library/typings/vectors.d.ts diff --git a/src/ui-library/_util/colors.ts b/src/ui-library/_util/colors.ts new file mode 100644 index 00000000..476df462 --- /dev/null +++ b/src/ui-library/_util/colors.ts @@ -0,0 +1,5 @@ +import { lagoonColors } from './lagoonColors'; + +const colors = Object.freeze({} as const); + +export default colors; diff --git a/src/ui-library/_util/helpers.tsx b/src/ui-library/_util/helpers.tsx new file mode 100644 index 00000000..6d86509e --- /dev/null +++ b/src/ui-library/_util/helpers.tsx @@ -0,0 +1,77 @@ +import React, { ReactNode } from 'react'; +import Highlighter from 'react-highlight-words'; + +export const highlightTextInElement = (element: ReactNode, searchString: string, key: string | number): any => { + // recursively apply highlighting to all text nodes + if (typeof element === 'string') { + return ( + + ); + } + + if (React.isValidElement(element)) { + return React.cloneElement( + element, + //@ts-ignore + { ...element.props, key: `item-${key}` }, + //@ts-ignore + React.Children.map(element.props.children, (child, index) => + highlightTextInElement(child, searchString, `${index}-${key}`), + ), + ); + } + + return element; +}; + +export const genAvatarBackground = ( + firstLetter: string, + secondLetter: string, +): { bgColor: string; textColor: string } => { + const alphaPosition = (letter: string): number => letter.charCodeAt(0) - 64; + const getColorIndex = (letter: string): number => Math.round(alphaPosition(letter) * 11); + + let red = getColorIndex(firstLetter) % 256; + let green = getColorIndex(secondLetter) % 256; + let blue = Math.round(((alphaPosition(firstLetter) + alphaPosition(secondLetter)) / 2) * 11) % 256; + + return { + bgColor: `rgb(${red}, ${green}, ${blue})`, + textColor: getLuminance(red, green, blue) > 0.5 ? '#000000' : '#FFFFFF', + }; +}; + +function getLuminance(r: number, g: number, b: number) { + // normalize the RGB values to the range [0, 1] + const a = [r, g, b].map((v) => { + v /= 255; + return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); + }); + + // calculate the luminance. reference: https://www.w3.org/WAI/GL/wiki/Relative_luminance + return 0.2126 * a[0] + 0.7152 * a[1] + 0.0722 * a[2]; +} + +const daysToMilliseconds = (days: number) => days * 24 * 60 * 60 * 1000; + +export const setLocalStorage = (key: string, value: string | boolean, expiryDays: number) => { + const now = new Date(); + const lsItem = { + value: value, + expiry: now.getTime() + daysToMilliseconds(expiryDays), + }; + localStorage.setItem(key, JSON.stringify(lsItem)); +}; + +export const getLocalStorage = (key: string) => { + const lsItem = localStorage.getItem(key); + if (!lsItem) { + return null; + } + const result = JSON.parse(lsItem); + if (result.expiry < Date.now()) { + localStorage.removeItem(key); + return null; + } + return result.value; +}; \ No newline at end of file diff --git a/src/ui-library/_util/lagoonColors.ts b/src/ui-library/_util/lagoonColors.ts new file mode 100644 index 00000000..bc8a7d1c --- /dev/null +++ b/src/ui-library/_util/lagoonColors.ts @@ -0,0 +1 @@ +export const lagoonColors = {}; diff --git a/src/ui-library/components/Accordion/Accordion.tsx b/src/ui-library/components/Accordion/Accordion.tsx new file mode 100644 index 00000000..075b8ed4 --- /dev/null +++ b/src/ui-library/components/Accordion/Accordion.tsx @@ -0,0 +1,44 @@ +import React from 'react'; + +import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '../ui/accordion'; +import { cn } from '@/lib/utils'; + +type AccordionProps = React.ComponentProps & { + items: { + id: string; + trigger: React.ReactNode; + content: React.ReactNode; + }[]; + showArrows?: boolean; + showSeparators?: boolean; + secondaryText?: boolean; +}; + +export default function UIAccordion({ + items, + showArrows = true, + secondaryText = false, + showSeparators = true, + ...rest +}: AccordionProps) { + return ( + + {items.map((item) => ( + + svg]:hidden underline hover:no-underline', + secondaryText && 'text-[#737373] font-normal', + )} + > + {item.trigger} + + + {item.content} + + + ))} + + ); +} diff --git a/src/ui-library/components/Accordion/index.tsx b/src/ui-library/components/Accordion/index.tsx new file mode 100644 index 00000000..089579b8 --- /dev/null +++ b/src/ui-library/components/Accordion/index.tsx @@ -0,0 +1,3 @@ +import { default as Accordion } from './Accordion'; + +export default Accordion; diff --git a/src/ui-library/components/ActionButton/ActionButton.tsx b/src/ui-library/components/ActionButton/ActionButton.tsx new file mode 100644 index 00000000..77e01286 --- /dev/null +++ b/src/ui-library/components/ActionButton/ActionButton.tsx @@ -0,0 +1,40 @@ +import {Button} from '@/components/ui/button' + +interface ActionButtonProps { + selected: boolean; + onSelect: () => void; + icon: React.ReactNode; + title: string; + description: string; + disabled?: boolean; + type?: string +} + +export default function ActionButton({ + selected, + onSelect, + icon, + title, + description, + disabled, + type +}:ActionButtonProps) { + return ( + + ) + +}; \ No newline at end of file diff --git a/src/ui-library/components/ActionButton/index.tsx b/src/ui-library/components/ActionButton/index.tsx new file mode 100644 index 00000000..a0b26dc8 --- /dev/null +++ b/src/ui-library/components/ActionButton/index.tsx @@ -0,0 +1 @@ +export { default } from './ActionButton'; \ No newline at end of file diff --git a/src/ui-library/components/AnnouncementCard/AnnouncementCard.tsx b/src/ui-library/components/AnnouncementCard/AnnouncementCard.tsx new file mode 100644 index 00000000..f0ab26b4 --- /dev/null +++ b/src/ui-library/components/AnnouncementCard/AnnouncementCard.tsx @@ -0,0 +1,72 @@ +import React, { useState } from 'react'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { BotMessageSquare, X } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { getLocalStorage, setLocalStorage } from '@/_util/helpers'; + +export type AnnouncementCardProps = { + title?: string; + description?: string; + ctaText?: string; + ctaUrl?: string; + openInNewTab?: boolean; + onClose?: () => void; + className?: string; + defaultLogo?: boolean; + disabled?: boolean; +}; + +export default function AnnouncementCard({ + title = "Latest Changes", + description, + ctaText = "See What's New", + ctaUrl = 'https://docs.lagoon.sh/releases/2.30.0/', // hardcoded for now, need a way to make this dynamic in the future + openInNewTab = true, + onClose, + className = '[background:#dae8fd] ![color:#387eda] [box-shadow:var(--badge-ring)]', + defaultLogo = false, + disabled = false +}: AnnouncementCardProps) { + const [isVisible, setIsVisible] = useState(getLocalStorage('promo-card-dismissed') !== true); + + const handleClose = () => { + setIsVisible(false); + onClose?.(); + setLocalStorage('promo-card-dismissed', true, 30); + }; + + if (!isVisible || disabled) { + return null; + } + + return ( + + + +
+ {defaultLogo && } + {title &&

{title}

} +
+
+ +

+ {description} +

+ + {ctaText} + +
+
+ ); +} diff --git a/src/ui-library/components/AnnouncementCard/index.tsx b/src/ui-library/components/AnnouncementCard/index.tsx new file mode 100644 index 00000000..64b42f02 --- /dev/null +++ b/src/ui-library/components/AnnouncementCard/index.tsx @@ -0,0 +1 @@ +export { default } from './AnnouncementCard'; diff --git a/src/ui-library/components/AvatarBubble/AvatarBubble.tsx b/src/ui-library/components/AvatarBubble/AvatarBubble.tsx new file mode 100644 index 00000000..8997c70f --- /dev/null +++ b/src/ui-library/components/AvatarBubble/AvatarBubble.tsx @@ -0,0 +1,18 @@ +import { cva } from 'class-variance-authority'; +import { HTMLAttributes } from 'react'; +import { cn } from '@/lib/utils'; + +const avatarBubble = cva('rounded-full h-6 w-6 min-w-[24px] flex justify-center items-center mr-2 text-xs'); + +type AvatarBubbleProps = HTMLAttributes & { + bgColor: string; + textColor: string; +}; + +export default function AvatarBubble({ bgColor, textColor, className, children, ...props }: AvatarBubbleProps) { + return ( +
+ {children} +
+ ); +} diff --git a/src/ui-library/components/Breadcrumb/Breadcrumb.tsx b/src/ui-library/components/Breadcrumb/Breadcrumb.tsx new file mode 100644 index 00000000..f73f57ad --- /dev/null +++ b/src/ui-library/components/Breadcrumb/Breadcrumb.tsx @@ -0,0 +1,104 @@ +'use client'; + +import React, { FC, Fragment, MouseEventHandler, ReactElement, ReactNode } from 'react'; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbList, + BreadcrumbLink, + BreadcrumbSeparator, +} from '@/components/ui/breadcrumb'; +import CopyToClipboard from '../CopyToClipboard'; +import { ChevronRight } from 'lucide-react'; + +type Component> = ReactElement; +type LinkComponent = Component<'a'>; + +const decorators = { + default: ['', 'project', 'environment'], + orgs: ['', 'organization', 'project'], +}; + +export interface BasicProps { + items: ( + | { + title: string | ReactNode; + navOnClick?: MouseEventHandler; + key?: string | number; + copyText?: string; + } + | { + title: LinkComponent; + key?: string | number; + copyText?: string; + } + )[]; + activeKey?: string | number; +} + +export type UIBreadcrumbProps = BasicProps & + ({ type: 'default'; currentSlug?: never } | { type: 'orgs'; currentSlug?: 'project' | 'user' | 'group' }); + +const UIBreadcrumb: FC = ({ activeKey, items, type, currentSlug }) => { + const decoratorList = type && ['default', 'orgs'].includes(type) ? [...decorators[type]] : null; + if (currentSlug && decoratorList) { + decoratorList[2] = currentSlug; + } + + return ( + + + {items.map((item, idx) => { + const key = 'key' in item ? item.key : idx; + const isActive = activeKey && activeKey === key; + const titleDecorator = decoratorList?.[idx] ?? null; + const shouldCopy = 'copyText' in item && item.copyText && titleDecorator; + const isSmall = item.copyText && item.copyText.length < 15; + const decorator = isSmall && titleDecorator === 'organization' ? 'Org' : titleDecorator; + + const content = ( +
+ {decorator && ( + + {decorator} + + )} +
+ + {'navOnClick' in item && item.navOnClick ? ( + + {item.title} + + ) : ( + item.title + )} + + {shouldCopy && ( +
+ +
+ )} +
+
+ ); + + return ( + + + {content} + + {idx !== items.length - 1 && ( + + + + )} + + ); + })} +
+
+ ); +}; + +UIBreadcrumb.displayName = 'Breadcrumb'; +export default UIBreadcrumb; diff --git a/src/ui-library/components/Breadcrumb/index.tsx b/src/ui-library/components/Breadcrumb/index.tsx new file mode 100644 index 00000000..38c6daf7 --- /dev/null +++ b/src/ui-library/components/Breadcrumb/index.tsx @@ -0,0 +1,3 @@ +import { default as Breadcrumb } from './Breadcrumb'; + +export default Breadcrumb; diff --git a/src/ui-library/components/ChangeFeed/ChangeFeed.tsx b/src/ui-library/components/ChangeFeed/ChangeFeed.tsx new file mode 100644 index 00000000..07b8caf3 --- /dev/null +++ b/src/ui-library/components/ChangeFeed/ChangeFeed.tsx @@ -0,0 +1,114 @@ +import { ChangeFeedItemProps, default as ChangeFeedItem } from './ChangeFeedItem'; +import Checkbox from "@/components/Checkbox"; +import { useState, useMemo, useRef, useEffect } from "react"; + +type ChangeFeedProps = { + changeFeedItems?: ChangeFeedItemProps[]; +} + +function ChangeFeed({ changeFeedItems = [] }: ChangeFeedProps) { + const [filteredItems, setFilteredItems] = useState(changeFeedItems); + const [activeFilters, setActiveFilters] = useState>(new Set(['All'])); + const [visibleCount, setVisibleCount] = useState(10); + const observerTarget = useRef(null); + + if (!filteredItems || filteredItems.length === 0) { + return ( +
+
+

No activity to display

+

Check back later for updates and new features.

+
+
+ ); + } + + const filters = useMemo(() => { + const filterSet = new Set(['All']); + changeFeedItems?.forEach(activityData => { + activityData.tags?.forEach(tag => { + filterSet.add(tag); + }); + }); + + return Array.from(filterSet); + }, [changeFeedItems]); + + useEffect(() => { + const observer = new IntersectionObserver( + entries => { + if (entries[0].isIntersecting) { + setVisibleCount(prev => prev + 10); + } + }, + { threshold: 1.0 } + ); + + if (observerTarget.current) { + observer.observe(observerTarget.current); + } + + return () => observer.disconnect(); + }, [filteredItems]); + + useEffect(() => { + setVisibleCount(10); + }, [activeFilters]); + + const filterList = () => ( + filters.map(filter => { + return ( + handleFilter(filter, checked)} /> + ) + })); + + const handleFilter = (filter: string, checked: boolean | string) => { + const newActiveFilters = new Set(activeFilters); + + if (filter === 'All') { + if (checked) { + setActiveFilters(new Set(['All'])); + setFilteredItems(changeFeedItems); + } + return; + } + + newActiveFilters.delete('All') + if (checked) { + newActiveFilters.add(filter); + } else { + newActiveFilters.delete(filter); + } + + if (newActiveFilters.size === 0) { + setActiveFilters(new Set(['All'])); + setFilteredItems(changeFeedItems); + return; + } + + setActiveFilters(newActiveFilters); + const newFilteredItems = changeFeedItems?.filter(item => item.tags?.some(tag => newActiveFilters.has(tag))); + setFilteredItems(newFilteredItems); + } + + const visibleItems = filteredItems.slice(0, visibleCount); + + return ( +
+
+ {visibleItems.map(activityData => ( + + ))} + {visibleCount < filteredItems.length && ( +
+ )} +
+ +
+ ) +} + +export default ChangeFeed; diff --git a/src/ui-library/components/ChangeFeed/ChangeFeedContainer.tsx b/src/ui-library/components/ChangeFeed/ChangeFeedContainer.tsx new file mode 100644 index 00000000..e7ca2754 --- /dev/null +++ b/src/ui-library/components/ChangeFeed/ChangeFeedContainer.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import ChangeFeed from './ChangeFeed'; +import { ChangeFeedItemProps } from './ChangeFeedItem'; +import { ChangeFeedDataSchema } from '@/schemas/changeFeed'; + +export type ChangeFeedContainerProps = { + sourceData?: string; + refetchInterval?: number; + fallbackData?: ChangeFeedItemProps[]; + onError?: (error: Error) => void; + showLoading?: boolean; +}; + +export default function ChangeFeedContainer({ + sourceData = 'https://raw.githubusercontent.com/amazeeio/lagoon-changefeed-data/refs/heads/main/changefeed.json', + refetchInterval = 600000, + fallbackData = [], + onError, + showLoading = true, +}: ChangeFeedContainerProps) { + const [data, setData] = useState(fallbackData); + const [isLoading, setIsLoading] = useState(!!sourceData); + const [error, setError] = useState(null); + + useEffect(() => { + if (!sourceData) { + setIsLoading(false); + return; + } + + const fetchData = async () => { + try { + setIsLoading(true); + setError(null); + + const response = await fetch(sourceData, { + cache: 'no-cache', + }); + + if (!response.ok) { + throw new Error(`Failed to fetch change feed data: ${response.statusText}`); + } + + const json = await response.json(); + + const validated = ChangeFeedDataSchema.safeParse(json); + + if (!validated.success) { + console.error('Changefeed validation failed:', validated.error.format()); + throw new Error('Invalid changefeed data format'); + } + + setData(validated.data.changes); + } catch (err) { + const error = err instanceof Error ? err : new Error('Unknown error'); + setError(error); + onError?.(error); + setData(fallbackData); + } finally { + setIsLoading(false); + } + }; + + fetchData(); + + if (refetchInterval > 0) { + const intervalId = setInterval(fetchData, refetchInterval); + return () => clearInterval(intervalId); + } + }, [sourceData, refetchInterval]); + + if (isLoading && showLoading) { + return ( +
+
Loading changefeed...
+
+ ); + } + + if (error && data.length === 0) { + return ( +
+
+ Failed to load changefeed. {error.message} +
+
+ ); + } + + return ; +} diff --git a/src/ui-library/components/ChangeFeed/ChangeFeedItem.tsx b/src/ui-library/components/ChangeFeed/ChangeFeedItem.tsx new file mode 100644 index 00000000..6ae4475c --- /dev/null +++ b/src/ui-library/components/ChangeFeed/ChangeFeedItem.tsx @@ -0,0 +1,48 @@ +import { Badge } from "@/components/ui/badge"; +import Accordion from "@/components/Accordion"; +import { useMemo } from "react"; + +export type ChangeFeedItemProps = { + id: string; + date: string; + title: string; + description: string; + tags?: string[]; +} + +export default function ChangeFeedItem({ date, id, tags, description, title }: ChangeFeedItemProps) { + + const renderTags = useMemo(() => { + if (!tags || tags.length === 0) { + return null; + } + return tags.map((tag) => ( + {tag} + )); + }, [tags]) + + const connector = ( +
+
+
+
+ ) + + return ( +
+ {connector} +
+

{date}

+ {title}, + content: <> + {description && ( +

{description}

+ )} + {renderTags} + + }]} /> +
+
+ ) +} \ No newline at end of file diff --git a/src/ui-library/components/ChangeFeed/index.tsx b/src/ui-library/components/ChangeFeed/index.tsx new file mode 100644 index 00000000..40e829e1 --- /dev/null +++ b/src/ui-library/components/ChangeFeed/index.tsx @@ -0,0 +1,4 @@ +import { default as ChangeFeedContainer } from './ChangeFeedContainer'; + +export type { ChangeFeedItemProps } from './ChangeFeedItem'; +export default ChangeFeedContainer; \ No newline at end of file diff --git a/src/ui-library/components/ChangeFeed/sampleData.ts b/src/ui-library/components/ChangeFeed/sampleData.ts new file mode 100644 index 00000000..f1fbe560 --- /dev/null +++ b/src/ui-library/components/ChangeFeed/sampleData.ts @@ -0,0 +1,224 @@ +import { ChangeFeedItemProps } from "@/components/ChangeFeed/ChangeFeedItem"; + +export const sampleActivityData: ChangeFeedItemProps[] = [ + { + "id": "1", + "date": "JANUARY 23 2026", + "title": "New Autoscaling Features for Production Workloads", + "description": "We've enhanced our autoscaling capabilities with predictive scaling based on historical traffic patterns. This allows your applications to scale proactively before traffic spikes occur, ensuring consistent performance during high-demand periods.", + "tags": [ + "New", + "Infrastructure", + "Scaling", + "Performance" + ] + }, + { + "id": "2", + "date": "OCTOBER 10", + "title": "MongoDB Atlas Integration Now Available", + "description": "You can now connect your applications directly to MongoDB Atlas with our new first-party integration. This provides seamless authentication, metrics collection, and simplified connection management for your database needs.", + "tags": [ + "Feature", + "Databases", + "Integrations" + ] + }, + { + "id": "3", + "date": "OCTOBER 5", + "title": "Enhanced Security with IP Allow-listing", + "description": "We've added IP allow-listing capabilities to all deployment environments. You can now restrict access to your applications and services based on specific IP addresses or ranges, providing an additional layer of security.", + "tags": [ + "Security", + "Networking" + ] + }, + { + "id": "4", + "date": "SEPTEMBER 28", + "title": "Deployment Pipeline Improvements", + "description": "", + "tags": [ + "Improvement", + "Deployments", + "CI/CD" + ] + }, + { + "id": "5", + "date": "SEPTEMBER 20", + "title": "New Metrics Dashboard for Application Performance", + "description": "", + "tags": [ + "Feature", + "Monitoring", + "UI/UX" + ] + }, + { + "id": "6", + "date": "SEPTEMBER 20", + "title": "New Metrics Dashboard for Application Performance", + "description": "", + "tags": [ + "Feature", + "Monitoring", + "UI/UX" + ] + }, + { + "id": "7", + "date": "SEPTEMBER 20", + "title": "New Metrics Dashboard for Application Performance", + "description": "", + "tags": [ + "Feature", + "Monitoring", + "UI/UX" + ] + }, + { + "id": "8", + "date": "SEPTEMBER 20", + "title": "New Metrics Dashboard for Application Performance", + "description": "", + "tags": [ + "Feature", + "Monitoring", + "UI/UX" + ] + }, + { + "id": "9", + "date": "SEPTEMBER 20", + "title": "New Metrics Dashboard for Application Performance", + "description": "", + "tags": [ + "Feature", + "Monitoring", + "UI/UX" + ] + }, + { + "id": "10", + "date": "SEPTEMBER 20", + "title": "New Metrics Dashboard for Application Performance", + "description": "", + "tags": [ + "Feature", + "Monitoring", + "UI/UX" + ] + }, + { + "id": "11", + "date": "SEPTEMBER 20", + "title": "New Metrics Dashboard for Application Performance", + "description": "", + "tags": [ + "Feature", + "Monitoring", + "UI/UX" + ] + }, + { + "id": "12", + "date": "SEPTEMBER 20", + "title": "New Metrics Dashboard for Application Performance", + "description": "", + "tags": [ + "Feature", + "Monitoring", + "UI/UX" + ] + }, + { + "id": "13", + "date": "SEPTEMBER 20", + "title": "New Metrics Dashboard for Application Performance", + "description": "", + "tags": [ + "Feature", + "Monitoring", + "UI/UX" + ] + }, + { + "id": "14", + "date": "SEPTEMBER 20", + "title": "New Metrics Dashboard for Application Performance", + "description": "", + "tags": [ + "Feature", + "Monitoring", + "UI/UX" + ] + }, + { + "id": "15", + "date": "SEPTEMBER 20", + "title": "New Metrics Dashboard for Application Performance", + "description": "", + "tags": [ + "Feature", + "Monitoring", + "UI/UX" + ] + }, + { + "id": "16", + "date": "SEPTEMBER 20", + "title": "New Metrics Dashboard for Application Performance", + "description": "", + "tags": [ + "Feature", + "Monitoring", + "UI/UX" + ] + }, + { + "id": "17", + "date": "SEPTEMBER 20", + "title": "New Metrics Dashboard for Application Performance", + "description": "", + "tags": [ + "Feature", + "Monitoring", + "UI/UX" + ] + }, + { + "id": "18", + "date": "SEPTEMBER 20", + "title": "New Metrics Dashboard for Application Performance", + "description": "", + "tags": [ + "Feature", + "Monitoring", + "UI/UX" + ] + }, + { + "id": "19", + "date": "SEPTEMBER 20", + "title": "New Metrics Dashboard for Application Performance", + "description": "", + "tags": [ + "Feature", + "Monitoring", + "UI/UX" + ] + }, + { + "id": "20", + "date": "SEPTEMBER 20", + "title": "New Metrics Dashboard for Application Performance", + "description": "", + "tags": [ + "Feature", + "Monitoring", + "UI/UX" + ] + }, +]; \ No newline at end of file diff --git a/src/ui-library/components/Checkbox/Checkbox.tsx b/src/ui-library/components/Checkbox/Checkbox.tsx new file mode 100644 index 00000000..56a33aba --- /dev/null +++ b/src/ui-library/components/Checkbox/Checkbox.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { Checkbox } from '../ui/checkbox'; +import { Label } from '../ui/label'; + +type CheckboxProps = React.ComponentProps & { + label: string; + id: string; +}; + +export default function CheckboxWithLabel({ label, id, ...rest }: CheckboxProps) { + return ( + <> +
+ + +
+ + ); +} + +CheckboxWithLabel.displayName = 'CheckboxWithLabel'; diff --git a/src/ui-library/components/Checkbox/index.tsx b/src/ui-library/components/Checkbox/index.tsx new file mode 100644 index 00000000..44d64872 --- /dev/null +++ b/src/ui-library/components/Checkbox/index.tsx @@ -0,0 +1,3 @@ +import { default as Checkbox } from './Checkbox'; + +export default Checkbox; diff --git a/src/ui-library/components/CopyToClipboard/CopyToClipboard.tsx b/src/ui-library/components/CopyToClipboard/CopyToClipboard.tsx new file mode 100644 index 00000000..c1512542 --- /dev/null +++ b/src/ui-library/components/CopyToClipboard/CopyToClipboard.tsx @@ -0,0 +1,145 @@ +import React, { useState } from 'react'; +import { Copy, Check, Eye, EyeOff } from 'lucide-react'; +import { cva } from 'class-variance-authority'; +import { Tooltip, TooltipTrigger, TooltipContent } from '../ui/tooltip'; + +export interface ClipboardProps { + text: string | number; + type?: 'visible' | 'hidden' | 'hiddenWithIcon' | 'alwaysHidden'; + width?: number | string; + fontSize?: string; + withToolTip?: boolean; + iconOnly?: boolean; +} + +const textVariants = cva('truncate transition-all duration-300', { + variants: { + type: { + visible: '', + hidden: 'blur-sm select-none hover:blur-0 hover:select-text', + hiddenWithIcon: '', + alwaysHidden: 'blur-sm select-none', + }, + unblur: { + true: '!blur-0 !select-text', + false: '', + }, + }, + compoundVariants: [ + { + type: 'hiddenWithIcon', + unblur: false, + className: 'blur-sm select-none', + }, + { + type: 'hiddenWithIcon', + unblur: true, + className: '!blur-0 !select-text', + }, + ], + defaultVariants: { + type: 'visible', + unblur: false, + }, +}); + +const CopyToClipboard: React.FC = ({ + text, + width, + fontSize = '14px', + type = 'visible', + withToolTip = false, + iconOnly = false, +}) => { + const [copied, setCopied] = useState(false); + const [manualUnblur, setManualUnblur] = useState(false); + + const copyFn = () => { + navigator.clipboard.writeText(text.toString()); + }; + + const handleCopy = () => { + copyFn(); + setCopied(true); + setTimeout(() => setCopied(false), 3500); + }; + + const handleBlurToggle = () => setManualUnblur(!manualUnblur); + + const containerStyle: React.CSSProperties = { + maxWidth: width ? (typeof width === 'number' ? `${width}px` : width) : undefined, + fontSize, + width: iconOnly ? 'max-content' : undefined, + }; + + return ( +
+ {!iconOnly && ( + + {withToolTip && manualUnblur ? ( + + + {text} + + +

{text}

+
+
+ ) : ( + text + )} +
+ )} + +
+ {!copied ? ( + <> + + {type === 'hiddenWithIcon' && + (manualUnblur ? ( + + ) : ( + + ))} + + ) : ( +
+ + + + + + Copied! + + + + {type === 'hiddenWithIcon' && + (manualUnblur ? ( + + ) : ( + + ))} +
+ )} +
+
+ ); +}; + +export default CopyToClipboard; diff --git a/src/ui-library/components/CopyToClipboard/index.tsx b/src/ui-library/components/CopyToClipboard/index.tsx new file mode 100644 index 00000000..8bd50eac --- /dev/null +++ b/src/ui-library/components/CopyToClipboard/index.tsx @@ -0,0 +1,3 @@ +import { default as CopyToClipboard } from './CopyToClipboard'; + +export default CopyToClipboard; diff --git a/src/ui-library/components/DataTable/DataTable.tsx b/src/ui-library/components/DataTable/DataTable.tsx new file mode 100644 index 00000000..4d849931 --- /dev/null +++ b/src/ui-library/components/DataTable/DataTable.tsx @@ -0,0 +1,336 @@ +'use client'; +import React, { ReactNode, useEffect, useMemo, useRef } from 'react'; +import { Button } from '@/components/ui/button'; +import { + ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + useReactTable, + SortingState, + getSortedRowModel, + ColumnFiltersState, + getFilteredRowModel, + VisibilityState, + FilterFnOption, + Cell, + Table as TableType, + Row, +} from '@tanstack/react-table'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table'; +import { DebouncedInput } from '../Input'; + +import { highlightTextInElement } from './HighlightText'; +import { cn } from '@/lib/utils'; +import { Skeleton } from '../ui/skeleton'; +import SelectWithOptions from '../Select'; + +type LibColumnDef = ColumnDef & { + width?: string; +}; +export interface DataTableProps { + columns: LibColumnDef[]; + data: TData[]; + /** Either accessorKeys, or ids from the column definition */ + searchableColumns?: string[]; + searchPlaceholder?: string; + loading?: boolean; + /** Pass in custom filters - datatime/pagination/status dropdowns etc */ + renderFilters?: (table: TableType) => ReactNode; + /** Do not render the top filter section, nor the bottom pagination section */ + disableExtra?: boolean; + disablePagination?: boolean; + onSearch?: (searchString: string) => void; + initialSearch?: string; + initialPageSize?: number; + /** Called on each row (empty space) click - ignored if cell item is clicked */ + onRowClick?: (row: Row) => void; +} + +export default function DataTable({ + columns, + data, + searchableColumns, + searchPlaceholder, + onSearch, + loading, + renderFilters, + disableExtra, + disablePagination, + initialSearch, + initialPageSize, + onRowClick, +}: DataTableProps) { + const [sorting, setSorting] = React.useState([]); + + const [columnFilters, setColumnFilters] = React.useState([]); + + const [columnVisibility, setColumnVisibility] = React.useState({}); + + const [globalFilter, setGlobalFilter] = React.useState(initialSearch ?? ''); + + // auto-focus if search was filled during loading state + const searchInputRef = useRef(null); + + useEffect(() => { + if (initialSearch && searchInputRef.current) { + searchInputRef.current.focus(); + } + }, []); + + const customGlobalFilter = (row: any, columnId: string, value: string) => { + // globally search everything if searchableCols isnt provided + if (searchableColumns?.length === 0) { + return String(row.getValue(columnId)).toLowerCase().includes(value.toLowerCase()); + } + return searchableColumns?.some((colId) => { + const cellValue = row.getValue(colId); + return String(cellValue).toLowerCase().includes(value.toLowerCase()); + }); + }; + + const renderCellWithHighlight = (cell: Cell) => { + const cellValue = cell.getValue(); + + const rendered = flexRender(cell.column.columnDef.cell, cell.getContext()); + + if (cellValue && globalFilter) { + const shouldHighlight = !searchableColumns?.length || searchableColumns?.includes(cell.column.id); + + if (shouldHighlight && typeof cell.column.columnDef.cell === 'function') { + return highlightTextInElement( + cell?.column?.columnDef?.cell(cell.getContext()), + globalFilter, + cellValue as string, + ); + } + } + + return rendered; + }; + + // loading state skeletons + const tableData = React.useMemo(() => (loading ? Array(10).fill({}) : data), [loading, data]); + const tableColumns = React.useMemo( + () => + loading + ? columns.map((column) => ({ + ...column, + cell: () => , + })) + : columns, + [loading, columns], + ); + + const table = useReactTable({ + data: tableData, + columns: tableColumns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getSortedRowModel: getSortedRowModel(), + onColumnFiltersChange: setColumnFilters, + onSortingChange: setSorting, + onColumnVisibilityChange: setColumnVisibility, + autoResetPageIndex: false, + ...(searchableColumns ? { globalFilterFn: customGlobalFilter as FilterFnOption } : {}), + state: { + sorting, + columnFilters, + columnVisibility, + globalFilter, + }, + }); + + const sortedColumnId = sorting.length > 0 ? sorting[0].id : null; + + // show every row if controls are disabled + useEffect(() => { + if (disableExtra || disablePagination) { + table.setPageSize(data.length); + } + }, [disableExtra, disablePagination, data.length]); + + useEffect(() => { + if (initialPageSize && !disablePagination) { + table.setPageSize(initialPageSize); + } + }, [initialPageSize, disablePagination]); + + // 7.26% of data, never higher than a second, nor lower than 40ms + const timerLengthPercentage = useMemo( + () => Math.min(1000, Math.max(40, Math.floor(tableData.length * 0.0725))), + [tableData.length], + ); + + const pageCount = table.getPageCount(); + const currentPage = table.getState().pagination.pageIndex + 1; + + const pageSelectOptions = Array.from({ length: pageCount || 1 }, (_, idx) => idx + 1).map((pageIndex) => { + return { label: `Page ${String(pageIndex)}`, value: pageIndex }; + }); + + const handlePageSelect = (pageIndex: string) => { + table.setPageIndex(Number(pageIndex) - 1); + }; + + const handleRowClick = (e: React.MouseEvent, row: Row) => { + if (typeof onRowClick !== 'function') return; + + const clickedElement = e.target as HTMLElement; + const parent = clickedElement.closest('[data-slot="table-cell"]'); + const tableRow = e.currentTarget; + + if (tableRow.contains(clickedElement)) { + if (!parent?.firstElementChild?.contains(clickedElement)) { + onRowClick(row); + } + } + }; + + return ( +
+ {/* filter/searchbar*/} + + {disableExtra ? null : ( +
+ { + onSearch && onSearch(value); + // don't trigger filtering with empty data + if (loading) return; + setGlobalFilter(value); + }} + className="max-w-sm" + /> + {/** render out custom filters */} + {renderFilters && renderFilters(table)} +
+ )} + +
+ + + {table.getHeaderGroups().map((headerGroup) => { + return ( + + {headerGroup.headers.map((header) => { + const isSorted = header.column.id === sortedColumnId; + const thInitialWidth = header.column.getSize(); + const thWidth = (header.column.columnDef as LibColumnDef)?.width || thInitialWidth; + + return ( + +
+ {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} +
+
+ ); + })} +
+ ); + })} +
+ + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => { + return ( + handleRowClick(e, row)} + className="py-6" + key={row.id} + data-state={row.getIsSelected() && 'selected'} + > + {row.getVisibleCells().map((visibleCell) => { + const isSorted = visibleCell.column.id === sortedColumnId; + const tdInitialWidth = visibleCell.column.getSize(); + const tdWidth = + (visibleCell.column.columnDef as LibColumnDef)?.width || tdInitialWidth; + + return ( + +
+ {renderCellWithHighlight(visibleCell)} +
+
+ ); + })} +
+ ); + }) + ) : ( + + + No entries + + + )} +
+
+
+ + {disableExtra ? null : ( +
+
+ Showing {table.getRowModel().rows.length} of {table.getPrePaginationRowModel().rows.length} entries +
+ {!disablePagination && ( + <> +
+ Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount() || 1} +
+ + + + + + + + + + )} +
+ )} +
+ ); +} + +export type { LibColumnDef as DataTableColumnDef }; \ No newline at end of file diff --git a/src/ui-library/components/DataTable/HighlightText.tsx b/src/ui-library/components/DataTable/HighlightText.tsx new file mode 100644 index 00000000..56cd7c7d --- /dev/null +++ b/src/ui-library/components/DataTable/HighlightText.tsx @@ -0,0 +1,24 @@ +import Highlighter from 'react-highlight-words'; + +import React, { type ReactNode } from 'react'; + +export const highlightTextInElement = (element: ReactNode, searchString: string, key: string | number): any => { + // recursively apply highlighting to all text nodes + if (typeof element === 'string') { + return ( + + ); + } + + if (React.isValidElement(element)) { + return React.cloneElement( + element, + { ...element.props, key: `item-${key}` }, + React.Children.map(element.props.children, (child, index) => + highlightTextInElement(child, searchString, `${index}-${key}`), + ), + ); + } + + return element; +}; diff --git a/src/ui-library/components/DataTable/index.tsx b/src/ui-library/components/DataTable/index.tsx new file mode 100644 index 00000000..f15125f1 --- /dev/null +++ b/src/ui-library/components/DataTable/index.tsx @@ -0,0 +1,5 @@ +import { default as DataTable, DataTableColumnDef } from './DataTable'; + +export { type DataTableColumnDef }; + +export default DataTable; diff --git a/src/ui-library/components/DateRangePicker/date-input.tsx b/src/ui-library/components/DateRangePicker/date-input.tsx new file mode 100644 index 00000000..db6d4675 --- /dev/null +++ b/src/ui-library/components/DateRangePicker/date-input.tsx @@ -0,0 +1,244 @@ +import React, { useEffect, useRef } from 'react'; + +interface DateInputProps { + value?: Date; + onChange: (date: Date) => void; +} + +interface DateParts { + day: number; + month: number; + year: number; +} + +const DateInput: React.FC = ({ value, onChange }) => { + const [date, setDate] = React.useState(() => { + const d = value ? new Date(value) : new Date(); + return { + day: d.getDate(), + month: d.getMonth() + 1, // JavaScript months are 0-indexed + year: d.getFullYear(), + }; + }); + + const monthRef = useRef(null); + const dayRef = useRef(null); + const yearRef = useRef(null); + + useEffect(() => { + const d = value ? new Date(value) : new Date(); + setDate({ + day: d.getDate(), + month: d.getMonth() + 1, + year: d.getFullYear(), + }); + }, [value]); + + const validateDate = (field: keyof DateParts, value: number): boolean => { + if ( + (field === 'day' && (value < 1 || value > 31)) || + (field === 'month' && (value < 1 || value > 12)) || + (field === 'year' && (value < 1000 || value > 9999)) + ) { + return false; + } + + // Validate the day of the month + const newDate = { ...date, [field]: value }; + const d = new Date(newDate.year, newDate.month - 1, newDate.day); + return d.getFullYear() === newDate.year && d.getMonth() + 1 === newDate.month && d.getDate() === newDate.day; + }; + + const handleInputChange = (field: keyof DateParts) => (e: React.ChangeEvent) => { + const newValue = e.target.value ? Number(e.target.value) : ''; + const isValid = typeof newValue === 'number' && validateDate(field, newValue); + + // If the new value is valid, update the date + const newDate = { ...date, [field]: newValue }; + setDate(newDate); + + // only call onChange when the entry is valid + if (isValid) { + onChange(new Date(newDate.year, newDate.month - 1, newDate.day)); + } + }; + + const initialDate = useRef(date); + + const handleBlur = + (field: keyof DateParts) => + (e: React.FocusEvent): void => { + if (!e.target.value) { + setDate(initialDate.current); + return; + } + + const newValue = Number(e.target.value); + const isValid = validateDate(field, newValue); + + if (!isValid) { + setDate(initialDate.current); + } else { + // If the new value is valid, update the initial value + initialDate.current = { ...date, [field]: newValue }; + } + }; + + const handleKeyDown = (field: keyof DateParts) => (e: React.KeyboardEvent) => { + // Allow command (or control) combinations + if (e.metaKey || e.ctrlKey) { + return; + } + + // Prevent non-numeric characters, excluding allowed keys + if ( + !/^[0-9]$/.test(e.key) && + !['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Delete', 'Tab', 'Backspace', 'Enter'].includes(e.key) + ) { + e.preventDefault(); + return; + } + + if (e.key === 'ArrowUp') { + e.preventDefault(); + let newDate = { ...date }; + + if (field === 'day') { + if (date[field] === new Date(date.year, date.month, 0).getDate()) { + newDate = { ...newDate, day: 1, month: (date.month % 12) + 1 }; + if (newDate.month === 1) newDate.year += 1; + } else { + newDate.day += 1; + } + } + + if (field === 'month') { + if (date[field] === 12) { + newDate = { ...newDate, month: 1, year: date.year + 1 }; + } else { + newDate.month += 1; + } + } + + if (field === 'year') { + newDate.year += 1; + } + + setDate(newDate); + onChange(new Date(newDate.year, newDate.month - 1, newDate.day)); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + let newDate = { ...date }; + + if (field === 'day') { + if (date[field] === 1) { + newDate.month -= 1; + if (newDate.month === 0) { + newDate.month = 12; + newDate.year -= 1; + } + newDate.day = new Date(newDate.year, newDate.month, 0).getDate(); + } else { + newDate.day -= 1; + } + } + + if (field === 'month') { + if (date[field] === 1) { + newDate = { ...newDate, month: 12, year: date.year - 1 }; + } else { + newDate.month -= 1; + } + } + + if (field === 'year') { + newDate.year -= 1; + } + + setDate(newDate); + onChange(new Date(newDate.year, newDate.month - 1, newDate.day)); + } + + if (e.key === 'ArrowRight') { + if ( + e.currentTarget.selectionStart === e.currentTarget.value.length || + (e.currentTarget.selectionStart === 0 && e.currentTarget.selectionEnd === e.currentTarget.value.length) + ) { + e.preventDefault(); + if (field === 'month') dayRef.current?.focus(); + if (field === 'day') yearRef.current?.focus(); + } + } else if (e.key === 'ArrowLeft') { + if ( + e.currentTarget.selectionStart === 0 || + (e.currentTarget.selectionStart === 0 && e.currentTarget.selectionEnd === e.currentTarget.value.length) + ) { + e.preventDefault(); + if (field === 'day') monthRef.current?.focus(); + if (field === 'year') dayRef.current?.focus(); + } + } + }; + + return ( +
+ { + if (window.innerWidth > 1024) { + e.target.select(); + } + }} + onBlur={handleBlur('month')} + className="p-0 outline-none w-6 border-none text-center" + placeholder="M" + /> + / + { + if (window.innerWidth > 1024) { + e.target.select(); + } + }} + onBlur={handleBlur('day')} + className="p-0 outline-none w-7 border-none text-center" + placeholder="D" + /> + / + { + if (window.innerWidth > 1024) { + e.target.select(); + } + }} + onBlur={handleBlur('year')} + className="p-0 outline-none w-12 border-none text-center" + placeholder="YYYY" + /> +
+ ); +}; + +DateInput.displayName = 'DateInput'; + +export { DateInput }; diff --git a/src/ui-library/components/DateRangePicker/date-range-picker.tsx b/src/ui-library/components/DateRangePicker/date-range-picker.tsx new file mode 100644 index 00000000..3075cb16 --- /dev/null +++ b/src/ui-library/components/DateRangePicker/date-range-picker.tsx @@ -0,0 +1,523 @@ +/* eslint-disable max-lines */ +'use client'; + +import { type FC, useState, useEffect, useRef } from 'react'; +import { Button } from '../ui/button'; +import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; +import { Calendar } from '../ui/calendar'; +import { DateInput } from './date-input'; +import { Label } from '../ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; +import { Switch } from '../ui/switch'; +import { cn } from '@/lib/utils'; +import { Check, ChevronDown, ChevronUp } from 'lucide-react'; +import type { JSX } from 'react/jsx-runtime'; // Import JSX to fix the undeclared variable error + +export interface DateRangePickerProps { + /** Click handler for applying the updates from DateRangePicker. */ + onUpdate?: (values: { range: DateRange; rangeCompare?: DateRange }) => void; + /** Initial value for start date */ + initialDateFrom?: Date | string; + /** Initial value for end date */ + initialDateTo?: Date | string; + /** Initial value for start date for compare */ + initialCompareFrom?: Date | string; + /** Initial value for end date for compare */ + initialCompareTo?: Date | string; + /** Alignment of popover */ + align?: 'start' | 'center' | 'end'; + /** Option for locale */ + locale?: string; + /** Option for showing compare feature */ + showCompare?: boolean; + + rangeText?: string; +} + +const formatDate = (date: Date, locale = 'en-us'): string => { + return date.toLocaleDateString(locale, { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +}; + +const getDateAdjustedForTimezone = (dateInput: Date | string): Date => { + if (typeof dateInput === 'string') { + // Split the date string to get year, month, and day parts + const parts = dateInput.split('-').map((part) => Number.parseInt(part, 10)); + // Create a new Date object using the local timezone + // Note: Month is 0-indexed, so subtract 1 from the month part + const date = new Date(parts[0], parts[1] - 1, parts[2]); + return date; + } else { + // If dateInput is already a Date object, return it directly + return dateInput; + } +}; + +interface DateRange { + from: Date; + to: Date | undefined; +} + +interface Preset { + name: string; + label: string; +} + +// Define presets +const PRESETS: Preset[] = [ + { name: 'today', label: 'Today' }, + { name: 'yesterday', label: 'Yesterday' }, + { name: 'last7', label: 'Last 7 days' }, + { name: 'last14', label: 'Last 14 days' }, + { name: 'last30', label: 'Last 30 days' }, + { name: 'thisWeek', label: 'This Week' }, + { name: 'lastWeek', label: 'Last Week' }, + { name: 'thisMonth', label: 'This Month' }, + { name: 'lastMonth', label: 'Last Month' }, +]; + +/** The DateRangePicker component allows a user to select a range of dates */ +export const DateRangePicker: FC = ({ + initialDateFrom, // No default here, allow it to be undefined + initialDateTo, // No default here, allow it to be undefined + initialCompareFrom, + initialCompareTo, + onUpdate, + align = 'end', + locale = 'en-US', + showCompare = true, + rangeText = undefined, + ...rest +}): JSX.Element => { + // Determine if initialDateFrom was explicitly provided + const wasInitialDateFromProvided = initialDateFrom !== undefined; + + const [isOpen, setIsOpen] = useState(false); + + const [range, setRange] = useState(() => { + if (wasInitialDateFromProvided) { + const from = getDateAdjustedForTimezone(initialDateFrom as Date | string); + const to = initialDateTo ? getDateAdjustedForTimezone(initialDateTo) : from; + return { from, to }; + } else { + // If no initialDateFrom, default to today internally for calendar component, + // but the display will be "Select a date range" controlled by isDateRangeSelected. + const today = new Date(new Date().setHours(0, 0, 0, 0)); + return { from: today, to: today }; + } + }); + + const [rangeCompare, setRangeCompare] = useState(() => { + if (initialCompareFrom) { + const from = new Date(new Date(initialCompareFrom).setHours(0, 0, 0, 0)); + const to = initialCompareTo ? new Date(new Date(initialCompareTo).setHours(0, 0, 0, 0)) : from; + return { from, to }; + } + return undefined; + }); + + // This state controls the display text in the trigger + const [isDateRangeSelected, setIsDateRangeSelected] = useState(wasInitialDateFromProvided); + + // Refs to store the values when the date picker is opened + const openedRangeRef = useRef(); + const openedRangeCompareRef = useRef(); + const openedIsDateRangeSelectedRef = useRef(false); + const hasAppliedChangesRef = useRef(false); // To track if "Update" was clicked + + const [selectedPreset, setSelectedPreset] = useState(undefined); + const [isSmallScreen, setIsSmallScreen] = useState(typeof window !== 'undefined' ? window.innerWidth < 960 : false); + + useEffect(() => { + const handleResize = (): void => { + setIsSmallScreen(window.innerWidth < 960); + }; + window.addEventListener('resize', handleResize); + // Clean up event listener on unmount + return () => { + window.removeEventListener('resize', handleResize); + }; + }, []); + + const getPresetRange = (presetName: string): DateRange => { + const preset = PRESETS.find(({ name }) => name === presetName); + if (!preset) throw new Error(`Unknown date range preset: ${presetName}`); + + const from = new Date(); + const to = new Date(); + const first = from.getDate() - from.getDay(); + + switch (preset.name) { + case 'today': + from.setHours(0, 0, 0, 0); + to.setHours(23, 59, 59, 999); + break; + case 'yesterday': + from.setDate(from.getDate() - 1); + from.setHours(0, 0, 0, 0); + to.setDate(to.getDate() - 1); + to.setHours(23, 59, 59, 999); + break; + case 'last7': + from.setDate(from.getDate() - 6); + from.setHours(0, 0, 0, 0); + to.setHours(23, 59, 59, 999); + break; + case 'last14': + from.setDate(from.getDate() - 13); + from.setHours(0, 0, 0, 0); + to.setHours(23, 59, 59, 999); + break; + case 'last30': + from.setDate(from.getDate() - 29); + from.setHours(0, 0, 0, 0); + to.setHours(23, 59, 59, 999); + break; + case 'thisWeek': + from.setDate(first); + from.setHours(0, 0, 0, 0); + to.setHours(23, 59, 59, 999); + break; + case 'lastWeek': + from.setDate(from.getDate() - 7 - from.getDay()); + to.setDate(to.getDate() - to.getDay() - 1); + from.setHours(0, 0, 0, 0); + to.setHours(23, 59, 59, 999); + break; + case 'thisMonth': + from.setDate(1); + from.setHours(0, 0, 0, 0); + to.setHours(23, 59, 59, 999); + break; + case 'lastMonth': + from.setMonth(from.getMonth() - 1); + from.setDate(1); + from.setHours(0, 0, 0, 0); + to.setDate(0); + to.setHours(23, 59, 59, 999); + break; + } + return { from, to }; + }; + + const setPreset = (preset: string): void => { + const newRange = getPresetRange(preset); + setRange(newRange); + setIsDateRangeSelected(true); // A preset selection means a date range is selected + if (rangeCompare) { + const newRangeCompare = { + from: new Date(newRange.from.getFullYear() - 1, newRange.from.getMonth(), newRange.from.getDate()), + to: newRange.to + ? new Date(newRange.to.getFullYear() - 1, newRange.to.getMonth(), newRange.to.getDate()) + : undefined, + }; + setRangeCompare(newRangeCompare); + } + }; + + const checkPreset = (): void => { + for (const preset of PRESETS) { + const presetRange = getPresetRange(preset.name); + const normalizedRangeFrom = new Date(range.from); + normalizedRangeFrom.setHours(0, 0, 0, 0); + const normalizedPresetFrom = new Date(presetRange.from.setHours(0, 0, 0, 0)); + const normalizedRangeTo = new Date(range.to ?? 0); + normalizedRangeTo.setHours(0, 0, 0, 0); + const normalizedPresetTo = new Date(presetRange.to?.setHours(0, 0, 0, 0) ?? 0); + + if ( + normalizedRangeFrom.getTime() === normalizedPresetFrom.getTime() && + normalizedRangeTo.getTime() === normalizedPresetTo.getTime() + ) { + setSelectedPreset(preset.name); + return; + } + } + setSelectedPreset(undefined); + }; + + useEffect(() => { + checkPreset(); + }, [range]); + + const PresetButton = ({ + preset, + label, + isSelected, + }: { + preset: string; + label: string; + isSelected: boolean; + }): JSX.Element => ( + + ); + + // Helper function to check if two date ranges are equal + const areRangesEqual = (a?: DateRange, b?: DateRange): boolean => { + if (!a || !b) return a === b; // If either is undefined, return true if both are undefined + return a.from.getTime() === b.from.getTime() && (!a.to || !b.to || a.to.getTime() === b.to.getTime()); + }; + + const handleClear = (): void => { + // Reset range to a default internal state (e.g., today), but set isDateRangeSelected to false + //@ts-ignore + setRange({ from: undefined, to: undefined }); + setRangeCompare(undefined); + setIsDateRangeSelected(false); + setSelectedPreset(undefined); // Clear any selected preset + hasAppliedChangesRef.current = true; // Mark that changes were applied (cleared) + setIsOpen(false); // Close the popover + //@ts-ignore + onUpdate?.({ range: { from: undefined, to: undefined }, rangeCompare: undefined }); // Notify parent of clear + }; + + return ( + { + if (!open) { + // Popover is closing + if (!hasAppliedChangesRef.current) { + // If changes were not applied via "Update" button, revert to stored state + setRange(openedRangeRef.current || range); + setRangeCompare(openedRangeCompareRef.current); + setIsDateRangeSelected(openedIsDateRangeSelectedRef.current); + } + hasAppliedChangesRef.current = false; // Reset for next open + } else { + // Popover is opening, store the current state + openedRangeRef.current = range; + openedRangeCompareRef.current = rangeCompare; + openedIsDateRangeSelectedRef.current = isDateRangeSelected; + hasAppliedChangesRef.current = false; // Ensure it's false when opening + } + setIsOpen(open); + }} + {...rest} + > + + + + +
+
+
+
+ {showCompare && ( +
+ { + if (checked) { + if (!range.to) { + setRange((prevRange) => ({ + ...prevRange, + to: prevRange.from, + })); + } + setRangeCompare({ + from: new Date(range.from.getFullYear(), range.from.getMonth(), range.from.getDate() - 365), + to: range.to + ? new Date(range.to.getFullYear() - 1, range.to.getMonth(), range.to.getDate()) + : new Date(range.from.getFullYear() - 1, range.from.getMonth(), range.from.getDate()), + }); + } else { + setRangeCompare(undefined); + } + }} + id="compare-mode" + /> + +
+ )} +
+
+ { + const toDate = range.to == null || date > range.to ? date : range.to; + setRange((prevRange) => ({ + ...prevRange, + from: date, + to: toDate, + })); + setIsDateRangeSelected(true); // User manually picked a date + }} + /> +
-
+ { + const fromDate = date < range.from ? date : range.from; + setRange((prevRange) => ({ + ...prevRange, + from: fromDate, + to: date, + })); + setIsDateRangeSelected(true); // User manually picked a date + }} + /> +
+ {rangeCompare != null && ( +
+ { + if (rangeCompare) { + const compareToDate = + rangeCompare.to == null || date > rangeCompare.to ? date : rangeCompare.to; + setRangeCompare((prevRangeCompare) => ({ + ...prevRangeCompare, + from: date, + to: compareToDate, + })); + } else { + setRangeCompare({ + from: date, + to: new Date(), + }); + } + }} + /> +
-
+ { + if (rangeCompare && rangeCompare.from) { + const compareFromDate = date < rangeCompare.from ? date : rangeCompare.from; + setRangeCompare({ + ...rangeCompare, + from: compareFromDate, + to: date, + }); + } + }} + /> +
+ )} +
+
+ {isSmallScreen && ( + + )} +
+ { + if (value?.from != null) { + setRange({ from: value.from, to: value?.to }); + setIsDateRangeSelected(true); // User selected dates from calendar + } else { + setIsDateRangeSelected(false); + } + }} + selected={range} + numberOfMonths={isSmallScreen ? 1 : 2} + defaultMonth={new Date(new Date().setMonth(new Date().getMonth() - (isSmallScreen ? 0 : 1)))} + /> +
+
+
+ {!isSmallScreen && ( +
+
+ {PRESETS.map((preset) => ( + + ))} +
+
+ )} +
+
+ + + +
+
+
+ ); +}; + +DateRangePicker.displayName = 'DateRangePicker'; diff --git a/src/ui-library/components/DateRangePicker/index.tsx b/src/ui-library/components/DateRangePicker/index.tsx new file mode 100644 index 00000000..cbe55100 --- /dev/null +++ b/src/ui-library/components/DateRangePicker/index.tsx @@ -0,0 +1,3 @@ +import { DateRangePicker } from './date-range-picker'; + +export default DateRangePicker; diff --git a/src/ui-library/components/DetailStat/DetailStat.tsx b/src/ui-library/components/DetailStat/DetailStat.tsx new file mode 100644 index 00000000..f162d04d --- /dev/null +++ b/src/ui-library/components/DetailStat/DetailStat.tsx @@ -0,0 +1,56 @@ +import React, { isValidElement, ReactNode } from 'react'; +import StatCard from '../StatCard'; +import { cva } from 'class-variance-authority'; + +type StatProps = { + title: string; + value: ReactNode; + fullWidth?: boolean; + lowercaseValue?: boolean; + capitalizeValue?: boolean; +}; + +const valueText = cva('font-sans font-normal text-lg leading-normal tracking-normal text-right', { + variants: { + transform: { + lowercase: 'lowercase', + capitalize: 'capitalize', + none: '', + }, + }, + defaultVariants: { + transform: 'none', + }, +}); + +function formatToCypressString(input: string) { + return input.toLowerCase().replace(/\s+/g, '-'); +} + +const DetailStat: React.FC = ({ title, value, lowercaseValue, capitalizeValue }) => { + const isElement = isValidElement(value); + + let textTransform = ''; + if (lowercaseValue) textTransform = 'lowercase'; + if (capitalizeValue) textTransform = 'capitalize'; + + const content = isElement ? ( +
+ {value} +
+ ) : ( + + {value} + + ); + + return ; +}; + +export default DetailStat; +export type { StatProps }; diff --git a/src/ui-library/components/DetailStat/index.tsx b/src/ui-library/components/DetailStat/index.tsx new file mode 100644 index 00000000..f6fe30b0 --- /dev/null +++ b/src/ui-library/components/DetailStat/index.tsx @@ -0,0 +1,3 @@ +import { default as DetailStat } from './DetailStat'; + +export default DetailStat; diff --git a/src/ui-library/components/Input/Input.tsx b/src/ui-library/components/Input/Input.tsx new file mode 100644 index 00000000..23bc1c8a --- /dev/null +++ b/src/ui-library/components/Input/Input.tsx @@ -0,0 +1,97 @@ +import { Label } from '@/components/ui/label'; +import { Input as ShadInput } from '@/components/ui/input'; +import React, { ComponentProps, forwardRef, ReactNode } from 'react'; +import { cva } from 'class-variance-authority'; +import { Loader2 } from 'lucide-react'; + +type InputProps = ComponentProps & { + label?: string; + placeholder?: string; + description?: string; + icon?: ReactNode; +}; +const inputVariants = cva('w-full rounded-lg bg-background', { + variants: { + hasIcon: { + true: 'pl-8', + false: '', + }, + }, +}); + +export default function Input({ label = '', placeholder = '', icon, description, ...rest }: InputProps) { + return ( +
+ +
+ {icon &&
{icon}
} + + {description && ( +

+ {description} +

+ )} +
+
+ ); +} + +type DebouncedInputProps = { + value: string; + onChange: (value: string) => void; + debounce?: number; + label: string; + placeholder?: string; + description?: string; + icon?: React.ReactNode; +} & Omit, 'onChange'>; + +export const DebouncedInput = forwardRef( + ({ value: initialValue, onChange, debounce = 500, label, placeholder = '', icon, description, ...rest }, ref) => { + const [value, setValue] = React.useState(initialValue); + const [loading, setLoading] = React.useState(false); + + React.useEffect(() => { + setValue(initialValue); + }, [initialValue]); + + React.useEffect(() => { + setLoading(true); + const timeout = setTimeout(() => { + onChange(String(value)); + setLoading(false); + }, debounce); + + return () => clearTimeout(timeout); + }, [value]); + + return ( +
+ +
+ {icon &&
{icon}
} + {loading && } + setValue(e.target.value)} + /> + {description && ( +

+ {description} +

+ )} +
+
+ ); + }, +); diff --git a/src/ui-library/components/Input/index.tsx b/src/ui-library/components/Input/index.tsx new file mode 100644 index 00000000..ea5ccde9 --- /dev/null +++ b/src/ui-library/components/Input/index.tsx @@ -0,0 +1,5 @@ +import { default as Input, DebouncedInput } from './Input'; + +export { DebouncedInput }; + +export default Input; diff --git a/src/ui-library/components/KeyFactCard/KeyFactCard.tsx b/src/ui-library/components/KeyFactCard/KeyFactCard.tsx new file mode 100644 index 00000000..a734f0a7 --- /dev/null +++ b/src/ui-library/components/KeyFactCard/KeyFactCard.tsx @@ -0,0 +1,17 @@ +import React, { ReactElement } from 'react'; +import StatCard from '../StatCard'; + +type KeyFactProps = { name: string; category: string; value: string; img: ReactElement }; + +export default function KeyFactCard({ name, category, value, img }: KeyFactProps) { + const KeyFactContent = ( +
+ {img} + {name} +
+ {value} +
+ ); + + return ; +} diff --git a/src/ui-library/components/KeyFactCard/index.tsx b/src/ui-library/components/KeyFactCard/index.tsx new file mode 100644 index 00000000..a5016038 --- /dev/null +++ b/src/ui-library/components/KeyFactCard/index.tsx @@ -0,0 +1,3 @@ +import { default as KeyFactCard } from './KeyFactCard'; + +export default KeyFactCard; diff --git a/src/ui-library/components/Notification/Notification.tsx b/src/ui-library/components/Notification/Notification.tsx new file mode 100644 index 00000000..17a60ce3 --- /dev/null +++ b/src/ui-library/components/Notification/Notification.tsx @@ -0,0 +1,77 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; +import { ReactNode } from 'react'; + +type NotificationProps = { + title: string; + message: ReactNode; + cancelText?: string; + onCancel?: () => void; + confirmText?: ReactNode; + confirmDisabled?: boolean; + onConfirm?: () => void; +} & ( + | { children?: ReactNode } + | { + open?: boolean; + onOpenChange?: (open: boolean) => void; + } +); + +export default function Notification({ + title, + message, + cancelText, + onCancel, + confirmText, + confirmDisabled = false, + onConfirm, + ...rest +}: NotificationProps) { + const alertDialogProps = + 'open' in rest && 'onOpenChange' in rest + ? { open: rest.open, onOpenChange: rest.onOpenChange } + : {}; + + return ( + + {'children' in rest ? rest.children : null} + + + + {title} + {message} + + + { + onCancel && onCancel(); + }} + > + {cancelText ?? 'Cancel'} + + { + if ('onOpenChange' in rest) { + e.preventDefault(); + } + onConfirm && onConfirm(); + }} + > + {confirmText ?? 'Continue'} + + + + + ); +} diff --git a/src/ui-library/components/Notification/index.tsx b/src/ui-library/components/Notification/index.tsx new file mode 100644 index 00000000..8da32913 --- /dev/null +++ b/src/ui-library/components/Notification/index.tsx @@ -0,0 +1,3 @@ +import { default as Notification } from './Notification'; + +export default Notification; diff --git a/src/ui-library/components/Pagination/Pagination.tsx b/src/ui-library/components/Pagination/Pagination.tsx new file mode 100644 index 00000000..0f8385a3 --- /dev/null +++ b/src/ui-library/components/Pagination/Pagination.tsx @@ -0,0 +1,112 @@ +import React, { useState } from 'react'; +import { + Pagination, + PaginationEllipsis, + PaginationContent, + PaginationLink, + PaginationNext, + PaginationPrevious, + PaginationItem, +} from '../ui/pagination'; + +type PaginationProps = React.ComponentProps & { + allItems: number; + itemsPerPage: number; + initialPage?: number; + onClickPrevious?: () => void; + onClickNext?: () => void; + onClickPageNumber?: React.Dispatch< + React.SetStateAction<{ + pageIndex: number; + pageSize: number; + }> + >; +}; + +export default function UIPagination({ + allItems, + itemsPerPage, + initialPage = 1, + onClickPrevious, + onClickNext, + onClickPageNumber, + ...rest +}: PaginationProps) { + const totalPages = Math.ceil(allItems / itemsPerPage); + const [currentPage, setCurrentPage] = useState(initialPage); + + const handlePrevious = () => { + onClickPrevious && onClickPrevious(); + setCurrentPage((prev) => Math.max(1, prev - 1)); + }; + + const handleNext = () => { + onClickNext && onClickNext(); + setCurrentPage((prev) => Math.min(totalPages, prev + 1)); + }; + + const renderPageNumbers = () => { + const pageNumbers = []; + + let startPage = Math.max(1, currentPage - 1); + let endPage = startPage + 2; + + if (endPage > totalPages) { + endPage = totalPages; + startPage = Math.max(1, endPage - 2); + } + + if (startPage > 1) { + pageNumbers.push( + + + , + ); + } + + for (let i = startPage; i <= endPage; i++) { + pageNumbers.push( + + { + e.preventDefault(); + setCurrentPage(i); + + onClickPageNumber && onClickPageNumber((p) => ({ ...p, pageIndex: i - 1 })); + }} + isActive={i === currentPage} + > + {i} + + , + ); + } + + if (endPage < totalPages) { + pageNumbers.push( + + + , + ); + } + + return pageNumbers; + }; + + return ( + + + + + + + {renderPageNumbers()} + + + + + + + ); +} diff --git a/src/ui-library/components/Pagination/index.tsx b/src/ui-library/components/Pagination/index.tsx new file mode 100644 index 00000000..16a58726 --- /dev/null +++ b/src/ui-library/components/Pagination/index.tsx @@ -0,0 +1,3 @@ +import { default as Pagination } from './Pagination'; + +export default Pagination; diff --git a/src/ui-library/components/ProblemsOverview/ProblemsOverview.tsx b/src/ui-library/components/ProblemsOverview/ProblemsOverview.tsx new file mode 100644 index 00000000..2a740999 --- /dev/null +++ b/src/ui-library/components/ProblemsOverview/ProblemsOverview.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { Frown, Meh, Smile, HelpCircle } from 'lucide-react'; +import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'; + +import StatCard from '../StatCard'; + +type LagoonProblemsOverviewProps = { + problems: number; + critical: number; + high: number; + medium: number; + low: number; + skeleton?: false; +}; +type Props = + | LagoonProblemsOverviewProps + | { + skeleton: true; + }; + +const LagoonProblemsOverview: React.FC = (props) => { + if (props.skeleton) { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ ); + } + + const { problems, critical, high, medium, low } = props; + + const getStatusIcon = () => { + if (critical >= 1) return ; + if (high >= 1) return ; + if (medium >= 1 || low >= 1) return ; + + return ; + }; + + return ( +
+
+
{getStatusIcon()}
+
+

At a glance

+ + + + + + The summary of all the problems is shown here. + + +
+
+ +
+ + + + + +
+
+ ); +}; + +LagoonProblemsOverview.displayName = 'LagoonProblemsOverview'; +export default LagoonProblemsOverview; +export type { LagoonProblemsOverviewProps }; diff --git a/src/ui-library/components/ProblemsOverview/index.tsx b/src/ui-library/components/ProblemsOverview/index.tsx new file mode 100644 index 00000000..b986d08c --- /dev/null +++ b/src/ui-library/components/ProblemsOverview/index.tsx @@ -0,0 +1,3 @@ +import { default as ProblemsOverview } from './ProblemsOverview'; + +export default ProblemsOverview; diff --git a/src/ui-library/components/RootLayout/RootLayout.tsx b/src/ui-library/components/RootLayout/RootLayout.tsx new file mode 100644 index 00000000..d36727c2 --- /dev/null +++ b/src/ui-library/components/RootLayout/RootLayout.tsx @@ -0,0 +1,61 @@ +import React, { ReactNode } from 'react'; +import { SidebarProvider } from '../ui/sidebar'; +import Sidenav from '../Sidenav'; +import ThemeProvider from '@/providers/ThemeProvider'; +import { AppInfo, SidebarItem, SidebarSection, UserInfo } from '@/components/Sidenav/Sidenav'; +import { AnnouncementCardProps } from '@/components/AnnouncementCard/AnnouncementCard'; + +export type EnvNavFn = (projectSlug: string, environmentSlug: string) => Promise; + +export type OrgNavFn = (orgSlug: string) => Promise; + +export type ProjectNavFn = ( + projectSlug: string, + environmentSlug?: string, + getEnvironmentNav?: EnvNavFn, +) => Promise; + +interface RootLayoutProps { + userInfo: UserInfo; + appInfo: AppInfo; + sidenavItems: SidebarSection[]; + children: ReactNode; + signOutFn: () => Promise; + currentPath: string; + documentationUrl?: string; + cardProps?: AnnouncementCardProps; + disableAccountLink?: boolean; + disableChangeFeedLink?: boolean; +} + +//** +// Root layout wrapping the whole app with the side navigation +// */ + +export default function RootLayout({ + userInfo, + appInfo, + signOutFn, + currentPath, + children, + sidenavItems, + documentationUrl, + cardProps, + disableAccountLink, + disableChangeFeedLink, +}: RootLayoutProps) { + return ( + + +
+ +
+
+ {children} +
+
+
+
+
+ ); +} diff --git a/src/ui-library/components/RootLayout/index.tsx b/src/ui-library/components/RootLayout/index.tsx new file mode 100644 index 00000000..cc2f7719 --- /dev/null +++ b/src/ui-library/components/RootLayout/index.tsx @@ -0,0 +1,3 @@ +import { default as RootLayout } from './RootLayout'; + +export default RootLayout; diff --git a/src/ui-library/components/Select/Select.tsx b/src/ui-library/components/Select/Select.tsx new file mode 100644 index 00000000..71c8869c --- /dev/null +++ b/src/ui-library/components/Select/Select.tsx @@ -0,0 +1,57 @@ +import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '../ui/select'; +import { ReactNode } from 'react'; + +type Option = { label: string; value: string | number; icon?: ReactNode }; +type OptionGroup = { label: string; options: Option[]; icon?: ReactNode }; + +export type SelectProps = Omit, 'disabled'> & { + placeholder: string; + options?: Option[] | OptionGroup[]; + disabled?: boolean; + width?: number; +}; + +function isOptionGroupArray(options: Option[] | OptionGroup[] | undefined): options is OptionGroup[] { + return Array.isArray(options) && options.length > 0 && 'options' in options[0]!; +} + +export default function SelectWithOptions({ placeholder, options, disabled, width, ...rest }: SelectProps) { + return ( + + ); +} diff --git a/src/ui-library/components/Select/index.tsx b/src/ui-library/components/Select/index.tsx new file mode 100644 index 00000000..06f33052 --- /dev/null +++ b/src/ui-library/components/Select/index.tsx @@ -0,0 +1,3 @@ +import { default as SelectWithOptions } from './Select'; + +export default SelectWithOptions; diff --git a/src/ui-library/components/Sheet/Sheet.tsx b/src/ui-library/components/Sheet/Sheet.tsx new file mode 100644 index 00000000..d2605b45 --- /dev/null +++ b/src/ui-library/components/Sheet/Sheet.tsx @@ -0,0 +1,277 @@ +import React, { ReactNode, useEffect, useMemo, useRef, useState } from 'react'; +import { Label } from '../ui/label'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetTrigger, + SheetFooter, + SheetTitle, + SheetHeader, + SheetClose, +} from '../ui/sheet'; +import { Button } from '../ui/button'; +import { Input } from '../ui/input'; +import Checkbox from '../Checkbox'; +import SelectWithOptions from '../Select'; +import { SelectProps } from '../Select/Select'; +import { Loader2 } from 'lucide-react'; + +type SheetProps = React.ComponentProps & { + sheetTitle?: ReactNode; + sheetTrigger?: ReactNode; + sheetDescription?: string; + sheetFooterButton?: string; + loading?: boolean; + buttonAction?: ( + e: React.MouseEvent, + values: any, + ) => Promise | boolean | void; + additionalContent: ReactNode; + error: boolean; + sheetFields: { + id: string; + label: string; + inputDefault?: string | boolean; + type?: string; + placeholder?: string; + required?: boolean; + options?: SelectProps['options']; + readOnly?: boolean; + triggerFieldUpdate?: boolean; + validate?: (value: string | boolean) => string | null; + }[]; + onFieldChange?: (fieldId: string, value: string | boolean, currentValues: Record) => void; +}; + +export default function UISheet({ + sheetTrigger = 'Open', + sheetTitle = '', + sheetDescription = '', + sheetFooterButton = 'Save changes', + buttonAction = () => {}, + sheetFields, + loading = false, + additionalContent = null, + error = false, + onFieldChange, + ...rest +}: SheetProps) { + const [sheetOpen, setSheetOpen] = useState(false); + const [fieldErrors, setFieldErrors] = useState>({}); + + const prevLoadingRef = useRef(loading); + + const getInitialFieldValues = useMemo(() => { + return () => { + const initialValues: Record = {}; + sheetFields.forEach((field) => { + if (field.inputDefault !== undefined) { + initialValues[field.id] = field.inputDefault; + } + }); + return initialValues; + }; + }, [sheetFields]); + + const [fieldValues, setFieldValues] = useState>(getInitialFieldValues); + + const validateAllFields = (values: Record) => { + const errors: Record = {}; + + sheetFields.forEach((field) => { + if (field.validate) { + const error = field.validate(values[field.id]); + if (error) errors[field.id] = error; + } + }); + + return errors; + }; + + const buttonDisabled = useMemo(() => { + const requiredFieldsNotFilled = sheetFields.some((field) => { + if (field.required) { + switch (field.type ?? 'text') { + case 'checkbox': + return fieldValues[field.id] !== true; + case 'select': + case 'textarea': + case 'text': + case 'email': + case 'number': + case 'password': + case 'tel': + return !fieldValues[field.id]; + default: + return false; + } + } + return false; + }); + + return requiredFieldsNotFilled || loading; + }, [sheetFields, fieldValues, loading]); + + const handleInputChange = (id: string, value: string | boolean) => { + const newValues = { + ...fieldValues, + [id]: value, + }; + + setFieldValues(newValues); + + const changedField = sheetFields.find((field) => field.id === id); + + if (changedField?.triggerFieldUpdate && onFieldChange) { + onFieldChange(id, value, newValues); + } + }; + + const handleSubmit = async (e: React.MouseEvent) => { + if (buttonDisabled) return; + + const allErrors = validateAllFields(fieldValues); + setFieldErrors(allErrors); + if (Object.keys(allErrors).length > 0) return; + + try { + const result = await buttonAction(e, fieldValues); + // if loading was never passed/default false, then close + if (result !== false && !loading && !error) { + setTimeout(() => setSheetOpen(false)); + } + } catch (err) { + console.error('Error in button action:', err); + } + }; + + useEffect(() => { + if (sheetOpen) { + setFieldValues(getInitialFieldValues()); + setFieldErrors({}); + } + }, [sheetOpen, getInitialFieldValues]); + + useEffect(() => { + if (prevLoadingRef.current === true && loading === false && sheetOpen && !error) { + setSheetOpen(false); + } + prevLoadingRef.current = loading; + }, [loading, sheetOpen, error]); + + useEffect(() => { + const currentFieldIds = sheetFields.map((field) => field.id); + const currentValueIds = Object.keys(fieldValues); + + const updatedValues = { ...fieldValues }; + currentValueIds.forEach((id) => { + if (!currentFieldIds.includes(id)) { + delete updatedValues[id]; + } + }); + + sheetFields.forEach((field) => { + if (field.inputDefault !== undefined && !(field.id in updatedValues)) { + updatedValues[field.id] = field.inputDefault; + } + }); + + setFieldValues(updatedValues); + setFieldErrors((prev) => { + const newErrors = { ...prev }; + Object.keys(newErrors).forEach((id) => { + if (!currentFieldIds.includes(id)) { + delete newErrors[id]; + } + }); + return newErrors; + }); + }, [sheetFields]); + + return ( + + + + + + + {sheetTitle} + {sheetDescription} + +
+ {sheetFields.map((field) => ( +
+
+ {field.type !== 'checkbox' && ( + + )} + + {(() => { + switch (field.type) { + case 'checkbox': + return ( +
+ handleInputChange(field.id, checked)} + /> +
+ ); + case 'textarea': + return ( +