Add vendor creation

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Jonathan
2025-05-22 21:29:42 +02:00
committed by Sacha Al Himdani
parent fe3128da2a
commit c2a69b5428
38 changed files with 1426 additions and 103 deletions

View File

@@ -0,0 +1,12 @@
import { describe, it, expect } from "vitest";
import { isEmpty } from "./array";
describe("array", () => {
it("should check if an array is empty", () => {
expect(isEmpty([])).toBe(true);
expect(isEmpty([1, 2, 3])).toBe(false);
expect(isEmpty(null)).toBe(true);
expect(isEmpty(undefined)).toBe(true);
expect(isEmpty([[], false])).toBe(true);
});
});

View File

@@ -18,3 +18,13 @@ export function groupBy<T>(
{} as Record<string, T[]>,
);
}
/**
* Check that a value is empty (null, undefined, empty string, empty array, empty object)
*/
export function isEmpty(v: unknown) {
if (Array.isArray(v)) {
return v.find((v) => !isEmpty(v)) === undefined;
}
return !v;
}

View File

@@ -1,10 +1,10 @@
export { objectKeys } from "./object";
export { sprintf } from "./string";
export { sprintf, faviconUrl } from "./string";
export {
getTreatment,
getRiskImpacts,
getRiskLikelihoods,
getSeverity,
} from "./risk";
export { times, groupBy } from "./array";
export { times, groupBy, isEmpty } from "./array";
export { randomInt } from "./number";

View File

@@ -7,3 +7,16 @@ export function sprintf(str: string, ...params: unknown[]): string {
return params[i++] as string;
});
}
export function faviconUrl(url?: string | null): string | null {
if (!url) {
return null;
}
try {
const parsedUrl = new URL(url);
return `https://www.google.com/s2/favicons?domain=${parsedUrl.hostname}&sz=64`;
} catch (e) {
return null;
}
}

View File

@@ -53,5 +53,33 @@ export function useTranslate() {
return {
lang,
__: translate,
dateFormat: (
date: Date | string | null | undefined,
options?: Intl.DateTimeFormatOptions
) => {
if (!date) {
return "";
}
if (typeof date === "string") {
return new Intl.DateTimeFormat(lang, options).format(parseDate(date));
}
return new Intl.DateTimeFormat(lang, options).format(date);
},
};
}
function parseDate(date: Date | string): Date {
if (typeof date === "string") {
if (date.includes("T")) {
return new Date(date);
}
const parts = date.split("-");
return new Date(
parseInt(parts[0], 10),
parts[1] ? parseInt(parts[1], 10) - 1 : 0,
parts[2] ? parseInt(parts[2], 10) : 1
);
}
return date;
}

View File

@@ -17,7 +17,9 @@ const preview: Preview = {
(Story, { parameters }) => {
return (
<BrowserRouter>
<Story />
<div className="text-txt-primary">
<Story />
</div>
</BrowserRouter>
);
},

View File

@@ -12,6 +12,7 @@
},
"prettier": "@probo/prettier",
"dependencies": {
"@ariakit/react": "^0.4.17",
"@probo/helpers": "1.0.0",
"@probo/i18n": "1.0.0",
"@radix-ui/react-dropdown-menu": "^2.1.14",

View File

@@ -31,7 +31,7 @@ const badge = tv({
},
},
defaultVariants: {
size: "md",
size: "sm",
},
});

View File

@@ -8,7 +8,7 @@ import { Link } from "react-router";
import { tv, type VariantProps } from "tailwind-variants";
const button = tv({
base: "flex items-center justify-center gap-[6px] px-3 py-2 rounded-full cursor-pointer text-sm font-medium h-8",
base: "flex items-center justify-center gap-[6px] px-3 py-2 rounded-full cursor-pointer text-sm font-medium h-8 focus:outline-none",
variants: {
variant: {
primary:

View File

@@ -7,21 +7,24 @@ import { IconDotGrid1x3Horizontal } from "../Icons/IconDotGrid1x3Horizontal.tsx"
import { Button } from "../Button/Button.tsx";
type Props = PropsWithChildren<{
toggle: ReactNode;
toggle?: ReactNode;
className?: string;
open?: boolean;
}>;
export function Dropdown({ children, toggle, className }: Props) {
export const dropdown = tv({
base: "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin] z-50 p-2 shadow-mid min-w-[8rem] bg-level-1 overflow-y-auto overflow-x-hidden rounded-2xl border-border-low",
});
export function Dropdown({ children, toggle, className, open }: Props) {
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>{toggle}</DropdownMenu.Trigger>
<DropdownMenu.Root open={open}>
{toggle && (
<DropdownMenu.Trigger asChild>{toggle}</DropdownMenu.Trigger>
)}
<DropdownMenu.Portal>
<DropdownMenu.Content
className={clsx(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
"z-50 p-2 shadow-mid min-w-[8rem] bg-level-1 overflow-y-auto overflow-x-hidden rounded-2xl border-border-low",
className,
)}
className={dropdown({ className })}
sideOffset={5}
>
{children}
@@ -47,8 +50,8 @@ type DropdownItemProps = PropsWithChildren<{
}> &
ComponentProps<typeof DropdownMenu.Item>;
const dropdownItem = tv({
base: "text-txt-primary flex items-center gap-2 hover:bg-tertiary-hover active:bg-tertiary-pressed cursor-pointer p-2",
export const dropdownItem = tv({
base: "text-txt-primary flex items-center gap-2 hover:bg-tertiary-hover active:bg-tertiary-pressed data-active-item:bg-tertiary-pressed cursor-pointer p-2",
variants: {
variant: {
primary: "text-txt-primary",

View File

@@ -7,7 +7,7 @@ type Props = {
} & InputHTMLAttributes<HTMLInputElement>;
export const input = tv({
base: "py-[6px] bg-secondary border border-border-mid rounded-[10px] hover:border-border-strong focus:shadow-focus text-sm px-3 w-full bg-secondary disabled:bg-transparent",
base: "py-[6px] bg-secondary border border-border-mid rounded-[10px] hover:border-border-strong focus:shadow-focus text-sm px-3 w-full bg-secondary disabled:bg-transparent focus:outline-none",
variants: {
invalid: {
true: "border-border-danger",

View File

@@ -85,9 +85,6 @@ const findSelectedOption = (
children: unknown[],
condition: (c: { props: Record<string, unknown> }) => boolean,
): ReactNode => {
if (children.length === 1) {
debugger;
}
const selectedOptions = children.find(
// @ts-expect-error We know that the children are ReactElements with props
(c) => isValidElement(c) && condition(c),

View File

@@ -0,0 +1,12 @@
import { Spinner } from "./Spinner";
import type { Meta, StoryObj } from "@storybook/react";
export default {
title: "Atoms/Spinner",
component: Spinner,
argTypes: {},
} satisfies Meta<typeof Spinner>;
type Story = StoryObj<typeof Spinner>;
export const Default: Story = {};

View File

@@ -0,0 +1,12 @@
import clsx from "clsx";
type Props = { size: number; className?: string };
export function Spinner({ size = 16, className }: Props) {
return (
<div
className={clsx("animate-spin rounded-full border-b-2", className)}
style={{ width: size, height: size, borderColor: "currentColor" }}
/>
);
}

View File

@@ -12,9 +12,9 @@ type Story = StoryObj<typeof RiskBadge>;
export const Default: Story = {
render: () => (
<div className="space-y-2">
<RiskBadge score={10} />
<RiskBadge score={8} />
<RiskBadge score={0} />
<RiskBadge level={10} />
<RiskBadge level={8} />
<RiskBadge level={0} />
</div>
),
};

View File

@@ -2,29 +2,59 @@ import { useTranslate } from "@probo/i18n";
import { Badge } from "../../Atoms/Badge/Badge";
type Props = {
score: number;
level: number | string;
};
const badgeVariant = (score: number) => {
if (score >= 15) {
return "danger";
const badgeVariant = (level: string | number) => {
if (typeof level === "number") {
if (level >= 15) {
return "CRITICAL";
}
if (level >= 8) {
return "HIGH";
}
return "LOW";
}
if (score >= 8) {
return "warning";
switch (level) {
case "CRITICAL":
return "danger";
case "HIGH":
return "warning";
case "LOW":
return "success";
case "MEDIUM":
return "info";
default:
return "neutral";
}
return "success";
};
export function RiskBadge({ score }: Props) {
export function RiskBadge({ level }: Props) {
const { __ } = useTranslate();
const label = () => {
if (score >= 15) {
return __("High");
if (typeof level === "number") {
if (level >= 15) {
return __("High");
}
if (level >= 8) {
return __("Medium");
}
return __("Low");
}
if (score >= 8) {
return __("Medium");
switch (level) {
case "CRITICAL":
return __("Critical");
case "HIGH":
return __("High");
case "LOW":
return __("Low");
case "MEDIUM":
return __("Medium");
case "NONE":
return __("None");
default:
return __("Low");
}
return __("Low");
};
return <Badge variant={badgeVariant(score)}>{label()}</Badge>;
return <Badge variant={badgeVariant(level)}>{label()}</Badge>;
}

View File

@@ -0,0 +1,37 @@
import { useState } from "react";
import { Combobox, ComboboxItem } from "./Combobox";
import type { Meta, StoryObj } from "@storybook/react";
import { times } from "@probo/helpers";
export default {
title: "Atoms/Combobox",
component: Combobox,
argTypes: {},
} satisfies Meta<typeof Combobox>;
type Story = StoryObj<typeof Combobox>;
export const Default: Story = {
args: {
open: true,
},
render: () => {
const [items, setItems] = useState(["a", "b", "c"] as string[]);
const onSearch = (query: string) => {
setItems(times(10, (i) => `${query} ${i}`));
};
return (
<>
Lorem ipsum dolor sit, amet consectetur adipisicing elit.
Nostrum labore repellat facere voluptatum, in voluptatem eaque
quidem nam quod nesciunt repudiandae a illo non placeat nulla.
Ratione ipsa at sint?
<Combobox onSearch={onSearch}>
{items.map((item) => (
<ComboboxItem key={item}>{item}</ComboboxItem>
))}
</Combobox>
</>
);
},
};

View File

@@ -0,0 +1,46 @@
import {
Combobox as AriaKitCombobox,
ComboboxItem as AriaKitComboboxItem,
ComboboxPopover,
ComboboxProvider,
} from "@ariakit/react";
import {
type ComponentProps,
type PropsWithChildren,
type ReactNode,
} from "react";
import { input } from "../../Atoms/Input/Input";
import { dropdown, dropdownItem } from "../../Atoms/Dropdown/Dropdown";
import { isEmpty } from "@probo/helpers";
type Props = {
children: ReactNode;
loading?: boolean;
onSearch: (query: string) => void;
placeholder?: string;
};
export function Combobox({ children, onSearch, placeholder }: Props) {
const showDropdown = !isEmpty(children);
return (
<ComboboxProvider setValue={onSearch}>
<AriaKitCombobox placeholder={placeholder} className={input()} />
{showDropdown && (
<ComboboxPopover gutter={4} sameWidth className={dropdown()}>
{children}
</ComboboxPopover>
)}
</ComboboxProvider>
);
}
export function ComboboxItem({
children,
...props
}: PropsWithChildren<ComponentProps<typeof AriaKitComboboxItem>>) {
return (
<AriaKitComboboxItem className={dropdownItem()} {...props}>
{children}
</AriaKitComboboxItem>
);
}

View File

@@ -48,7 +48,12 @@ export function ConfirmDialog(props: Props) {
<>
{children}
<Portal>
<Dialog open={open} onOpenChange={setOpen} title={title}>
<Dialog
open={open}
onOpenChange={setOpen}
title={title}
className="max-w-[500px]"
>
<DialogContent>
<p className="text-sm text-txt-secondary p-4">
{props.message}

View File

@@ -19,6 +19,7 @@ type Props = {
children?: ReactNode;
onOpenChange?: (open: boolean) => void;
open?: boolean;
className?: string;
};
export function Dialog({
@@ -27,6 +28,7 @@ export function Dialog({
children,
onOpenChange,
open,
className,
}: Props) {
return (
<Root open={open} onOpenChange={onOpenChange}>
@@ -42,6 +44,7 @@ export function Dialog({
aria-describedby={undefined}
className={clsx(
"fixed inset-0 m-auto z-50 w-full h-max bg-level-2 rounded-2xl max-w-5xl w-[95%]",
className,
`duration-200
data-[state=open]:animate-in data-[state=closed]:animate-out
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0
@@ -53,7 +56,11 @@ export function Dialog({
<div className="flex justify-between items-center p-3 border-b border-b-border-low">
<Title className="text-sm font-medium">{title}</Title>
<Close asChild>
<Button variant="tertiary" icon={IconCrossLargeX} />
<Button
tabIndex={-1}
variant="tertiary"
icon={IconCrossLargeX}
/>
</Close>
</div>
{children}

View File

@@ -15,6 +15,7 @@ export { Button } from "./Atoms/Button/Button";
export { Card } from "./Atoms/Card/Card";
export { Breadcrumb } from "./Atoms/Breadcrumb/Breadcrumb";
export { Badge } from "./Atoms/Badge/Badge";
export { Spinner } from "./Atoms/Spinner/Spinner";
export {
Dropdown,
ActionDropdown,
@@ -43,6 +44,7 @@ export { SeverityBadge } from "./Molecules/Badge/SeverityBadge.tsx";
export { ConfirmDialog } from "./Molecules/Dialog/ConfirmDialog.tsx";
export { RisksChart } from "./Molecules/Risks/RisksChart";
export { RiskOverview } from "./Molecules/Risks/RiskOverview";
export { Combobox, ComboboxItem } from "./Molecules/Combobox/Combobox";
// Hooks
export { useToast, Toasts } from "./Atoms/Toasts/Toasts";