Add risks
Signed-off-by: Bryan Frimin <bryan@getprobo.com> Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
committed by
Sacha Al Himdani
parent
a2d1310780
commit
a86181c83d
@@ -60,7 +60,7 @@ help: ## Show this help
|
||||
|
||||
.PHONY:dev
|
||||
dev: ## Start the development server
|
||||
parallel -j 2 --line-buffer ::: "gow -r=false run cmd/probod/main.go" "cd apps/console && npm run dev"
|
||||
parallel -j 2 --line-buffer ::: "gow -r=false run cmd/probod/main.go" "cd apps/console2 && npm run dev"
|
||||
|
||||
.PHONY: fmt
|
||||
fmt: fmt-go ## Format Go code
|
||||
|
||||
@@ -11,8 +11,10 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@probo/hooks": "1.0.0",
|
||||
"@probo/i18n": "1.0.0",
|
||||
"@probo/ui": "1.0.0",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-error-boundary": "^6.0.0",
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { RouterProvider } from "react-router";
|
||||
import { routes } from "./routes";
|
||||
import { router } from "./routes";
|
||||
import { Toasts } from "@probo/ui";
|
||||
|
||||
export function App() {
|
||||
return <RouterProvider router={routes} />;
|
||||
return (
|
||||
<>
|
||||
<RouterProvider router={router} />
|
||||
<Toasts />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap");
|
||||
@import "tailwindcss";
|
||||
@import "@probo/ui/src/theme.css";
|
||||
@source '../../../packages/ui/src';
|
||||
@import "tw-animate-css";
|
||||
@source "../../../packages/ui/src";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Outlet, useParams } from "react-router";
|
||||
import { Link, Navigate, Outlet, useParams } from "react-router";
|
||||
import {
|
||||
DropdownSeparator,
|
||||
IconArrowBoxLeft,
|
||||
@@ -13,13 +13,21 @@ import {
|
||||
IconTodo,
|
||||
Layout,
|
||||
SidebarItem,
|
||||
UserDropdown,
|
||||
UserDropdown as UserDropdownRoot,
|
||||
UserDropdownItem,
|
||||
Skeleton,
|
||||
Dropdown,
|
||||
Button,
|
||||
DropdownItem,
|
||||
IconChevronGrabberVertical,
|
||||
IconPlusLarge,
|
||||
Avatar,
|
||||
} from "@probo/ui";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { graphql } from "relay-runtime";
|
||||
import { useLazyLoadQuery } from "react-relay";
|
||||
import type { MainLayoutQuery as MainLayoutQueryType } from "./__generated__/MainLayoutQuery.graphql";
|
||||
import { Suspense } from "react";
|
||||
|
||||
const MainLayoutQuery = graphql`
|
||||
query MainLayoutQuery {
|
||||
@@ -29,42 +37,45 @@ const MainLayoutQuery = graphql`
|
||||
fullName
|
||||
email
|
||||
}
|
||||
organizations(first: 25) @connection(key: "MainLayout_organizations") {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
logoUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Site layout with a header and a sidebar
|
||||
*/
|
||||
export function MainLayout() {
|
||||
const { organizationId } = useParams();
|
||||
const { __ } = useTranslate();
|
||||
const user = useLazyLoadQuery<MainLayoutQueryType>(MainLayoutQuery, {}).viewer
|
||||
.user;
|
||||
|
||||
const prefix = `/organizations/${organizationId}`;
|
||||
|
||||
if (!organizationId) {
|
||||
return <Navigate to="/" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<div className="mr-auto"></div>
|
||||
<UserDropdown fullName={user.fullName} email={user.email}>
|
||||
<UserDropdownItem
|
||||
to="/settings"
|
||||
icon={IconSettingsGear2}
|
||||
label={__("Settings")}
|
||||
/>
|
||||
<UserDropdownItem
|
||||
to="/settings"
|
||||
icon={IconCircleQuestionmark}
|
||||
label={__("Help")}
|
||||
/>
|
||||
<DropdownSeparator />
|
||||
<UserDropdownItem
|
||||
variant="danger"
|
||||
to="/logout"
|
||||
icon={IconArrowBoxLeft}
|
||||
label="Logout"
|
||||
/>
|
||||
</UserDropdown>
|
||||
<div className="mr-auto">
|
||||
<Suspense fallback={<Skeleton className="w-20 h-8" />}>
|
||||
<OrganizationSelector organizationId={organizationId} />
|
||||
</Suspense>
|
||||
</div>
|
||||
<Suspense fallback={<Skeleton className="w-32 h-8" />}>
|
||||
<UserDropdown />
|
||||
</Suspense>
|
||||
</>
|
||||
}
|
||||
sidebar={
|
||||
@@ -116,3 +127,75 @@ export function MainLayout() {
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
function UserDropdown() {
|
||||
const { __ } = useTranslate();
|
||||
const user = useLazyLoadQuery<MainLayoutQueryType>(MainLayoutQuery, {}).viewer
|
||||
.user;
|
||||
return (
|
||||
<UserDropdownRoot fullName={user.fullName} email={user.email}>
|
||||
<UserDropdownItem
|
||||
to="/settings"
|
||||
icon={IconSettingsGear2}
|
||||
label={__("Settings")}
|
||||
/>
|
||||
<UserDropdownItem
|
||||
to="/settings"
|
||||
icon={IconCircleQuestionmark}
|
||||
label={__("Help")}
|
||||
/>
|
||||
<DropdownSeparator />
|
||||
<UserDropdownItem
|
||||
variant="danger"
|
||||
to="/logout"
|
||||
icon={IconArrowBoxLeft}
|
||||
label="Logout"
|
||||
/>
|
||||
</UserDropdownRoot>
|
||||
);
|
||||
}
|
||||
|
||||
function OrganizationSelector({ organizationId }: { organizationId: string }) {
|
||||
const organizations = useLazyLoadQuery<MainLayoutQueryType>(
|
||||
MainLayoutQuery,
|
||||
{}
|
||||
).viewer.organizations.edges.map((edge) => edge.node);
|
||||
const currentOrganization = organizations.find(
|
||||
(organization) => organization.id === organizationId
|
||||
);
|
||||
const { __ } = useTranslate();
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
toggle={
|
||||
<Button
|
||||
className="-ml-3"
|
||||
variant="tertiary"
|
||||
iconAfter={IconChevronGrabberVertical}
|
||||
>
|
||||
{currentOrganization?.name}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{organizations.map((organization) => (
|
||||
<DropdownItem
|
||||
asChild
|
||||
key={organization.id}
|
||||
icon={IconCircleQuestionmark}
|
||||
>
|
||||
<Link to={`/organizations/${organization.id}`}>
|
||||
<Avatar src={organization.logoUrl} name={organization.name} />
|
||||
{organization.name}
|
||||
</Link>
|
||||
</DropdownItem>
|
||||
))}
|
||||
<DropdownSeparator />
|
||||
<DropdownItem asChild icon={IconPlusLarge}>
|
||||
<Link to="/organizations/new">
|
||||
<IconPlusLarge size={16} />
|
||||
{__("Add organization")}
|
||||
</Link>
|
||||
</DropdownItem>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @generated SignedSource<<998d88d8fcb28932ec470d6afdb49b8e>>
|
||||
* @generated SignedSource<<d411922830ba632f8c48c98caa435a22>>
|
||||
* @lightSyntaxTransform
|
||||
* @nogrep
|
||||
*/
|
||||
@@ -13,6 +13,16 @@ export type MainLayoutQuery$variables = Record<PropertyKey, never>;
|
||||
export type MainLayoutQuery$data = {
|
||||
readonly viewer: {
|
||||
readonly id: string;
|
||||
readonly organizations: {
|
||||
readonly __id: string;
|
||||
readonly edges: ReadonlyArray<{
|
||||
readonly node: {
|
||||
readonly id: string;
|
||||
readonly logoUrl: string | null | undefined;
|
||||
readonly name: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
readonly user: {
|
||||
readonly email: string;
|
||||
readonly fullName: string;
|
||||
@@ -45,7 +55,104 @@ v2 = {
|
||||
"kind": "ScalarField",
|
||||
"name": "email",
|
||||
"storageKey": null
|
||||
};
|
||||
},
|
||||
v3 = [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "OrganizationEdge",
|
||||
"kind": "LinkedField",
|
||||
"name": "edges",
|
||||
"plural": true,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "Organization",
|
||||
"kind": "LinkedField",
|
||||
"name": "node",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v0/*: any*/),
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "name",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "logoUrl",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "__typename",
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "cursor",
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "PageInfo",
|
||||
"kind": "LinkedField",
|
||||
"name": "pageInfo",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "endCursor",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "hasNextPage",
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"kind": "ClientExtension",
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "__id",
|
||||
"storageKey": null
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
v4 = [
|
||||
{
|
||||
"kind": "Literal",
|
||||
"name": "first",
|
||||
"value": 25
|
||||
}
|
||||
];
|
||||
return {
|
||||
"fragment": {
|
||||
"argumentDefinitions": [],
|
||||
@@ -74,6 +181,16 @@ return {
|
||||
(v2/*: any*/)
|
||||
],
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": "organizations",
|
||||
"args": null,
|
||||
"concreteType": "OrganizationConnection",
|
||||
"kind": "LinkedField",
|
||||
"name": "__MainLayout_organizations_connection",
|
||||
"plural": false,
|
||||
"selections": (v3/*: any*/),
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
@@ -110,6 +227,25 @@ return {
|
||||
(v0/*: any*/)
|
||||
],
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": (v4/*: any*/),
|
||||
"concreteType": "OrganizationConnection",
|
||||
"kind": "LinkedField",
|
||||
"name": "organizations",
|
||||
"plural": false,
|
||||
"selections": (v3/*: any*/),
|
||||
"storageKey": "organizations(first:25)"
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": (v4/*: any*/),
|
||||
"filters": null,
|
||||
"handle": "connection",
|
||||
"key": "MainLayout_organizations",
|
||||
"kind": "LinkedHandle",
|
||||
"name": "organizations"
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
@@ -117,16 +253,28 @@ return {
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"cacheID": "4b5a08e03bcb26d2edadb57cd94aa7d1",
|
||||
"cacheID": "9ab80df970b8fa7c4eabff75a6730ec5",
|
||||
"id": null,
|
||||
"metadata": {},
|
||||
"metadata": {
|
||||
"connection": [
|
||||
{
|
||||
"count": null,
|
||||
"cursor": null,
|
||||
"direction": "forward",
|
||||
"path": [
|
||||
"viewer",
|
||||
"organizations"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "MainLayoutQuery",
|
||||
"operationKind": "query",
|
||||
"text": "query MainLayoutQuery {\n viewer {\n id\n user {\n fullName\n email\n id\n }\n }\n}\n"
|
||||
"text": "query MainLayoutQuery {\n viewer {\n id\n user {\n fullName\n email\n id\n }\n organizations(first: 25) {\n edges {\n node {\n id\n name\n logoUrl\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n}\n"
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
(node as any).hash = "785f7a3ca19b673a157ef0f649de5c1b";
|
||||
(node as any).hash = "fc37d1b70f1a239abb4c959d47ee28fd";
|
||||
|
||||
export default node;
|
||||
|
||||
95
apps/console2/src/pages/OrganizationsPage.tsx
Normal file
95
apps/console2/src/pages/OrganizationsPage.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { useLazyLoadQuery } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
import type { OrganizationSelectionPageQuery as OrganizationSelectionPageQueryType } from "./__generated__/OrganizationSelectionPageQuery.graphql";
|
||||
import { useEffect } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { Avatar, Button, Card, IconPlusLarge } from "@probo/ui";
|
||||
|
||||
const OrganizationSelectionPageQuery = graphql`
|
||||
query OrganizationSelectionPageQuery {
|
||||
viewer {
|
||||
organizations(first: 25) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
logoUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default function OrganizationSelectionPage() {
|
||||
const { __ } = useTranslate();
|
||||
const navigate = useNavigate();
|
||||
const data = useLazyLoadQuery<OrganizationSelectionPageQueryType>(
|
||||
OrganizationSelectionPageQuery,
|
||||
{}
|
||||
);
|
||||
|
||||
const organizations = data.viewer.organizations.edges.map(
|
||||
(edge) => edge.node
|
||||
);
|
||||
|
||||
// Redirect to the first organization if only one exists
|
||||
useEffect(() => {
|
||||
if (organizations.length === 1) {
|
||||
// navigate(`/organizations/${organizations[0].id}`);
|
||||
}
|
||||
}, [organizations]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>{__("Select an organization")}</title>
|
||||
<div className="space-y-6 w-full">
|
||||
<h1 className="text-3xl font-bold text-center">
|
||||
{__("Select an organization")}
|
||||
</h1>
|
||||
<div className="space-y-4 w-full">
|
||||
{organizations.map((organization) => (
|
||||
<Card
|
||||
asChild
|
||||
padded
|
||||
key={organization.id}
|
||||
className="w-full hover:bg-tertiary-hover cursor-pointer"
|
||||
>
|
||||
<Link
|
||||
to={`/organizations/${organization.id}`}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar
|
||||
src={organization.logoUrl}
|
||||
name={organization.name}
|
||||
size="l"
|
||||
/>
|
||||
<h2 className="font-semibold text-xl">{organization.name}</h2>
|
||||
</div>
|
||||
<Button>{__("Select")}</Button>
|
||||
</Link>
|
||||
</Card>
|
||||
))}
|
||||
<Card padded>
|
||||
<h2 className="text-xl font-semibold mb-1">
|
||||
{__("Create an organization")}
|
||||
</h2>
|
||||
<p className="text-txt-tertiary mb-4">
|
||||
{__("Add a new organization to your account")}
|
||||
</p>
|
||||
<Button
|
||||
to="/organizations/new"
|
||||
variant="quaternary"
|
||||
icon={IconPlusLarge}
|
||||
className="w-full"
|
||||
>
|
||||
{__("Create organization")}
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
150
apps/console2/src/pages/__generated__/OrganizationSelectionPageQuery.graphql.ts
generated
Normal file
150
apps/console2/src/pages/__generated__/OrganizationSelectionPageQuery.graphql.ts
generated
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* @generated SignedSource<<282a94b76fe5e17bd40b45ced6445ef3>>
|
||||
* @lightSyntaxTransform
|
||||
* @nogrep
|
||||
*/
|
||||
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { ConcreteRequest } from 'relay-runtime';
|
||||
export type OrganizationSelectionPageQuery$variables = Record<PropertyKey, never>;
|
||||
export type OrganizationSelectionPageQuery$data = {
|
||||
readonly viewer: {
|
||||
readonly organizations: {
|
||||
readonly edges: ReadonlyArray<{
|
||||
readonly node: {
|
||||
readonly id: string;
|
||||
readonly logoUrl: string | null | undefined;
|
||||
readonly name: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
export type OrganizationSelectionPageQuery = {
|
||||
response: OrganizationSelectionPageQuery$data;
|
||||
variables: OrganizationSelectionPageQuery$variables;
|
||||
};
|
||||
|
||||
const node: ConcreteRequest = (function(){
|
||||
var v0 = {
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "id",
|
||||
"storageKey": null
|
||||
},
|
||||
v1 = {
|
||||
"alias": null,
|
||||
"args": [
|
||||
{
|
||||
"kind": "Literal",
|
||||
"name": "first",
|
||||
"value": 25
|
||||
}
|
||||
],
|
||||
"concreteType": "OrganizationConnection",
|
||||
"kind": "LinkedField",
|
||||
"name": "organizations",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "OrganizationEdge",
|
||||
"kind": "LinkedField",
|
||||
"name": "edges",
|
||||
"plural": true,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "Organization",
|
||||
"kind": "LinkedField",
|
||||
"name": "node",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v0/*: any*/),
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "name",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "logoUrl",
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": "organizations(first:25)"
|
||||
};
|
||||
return {
|
||||
"fragment": {
|
||||
"argumentDefinitions": [],
|
||||
"kind": "Fragment",
|
||||
"metadata": null,
|
||||
"name": "OrganizationSelectionPageQuery",
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "Viewer",
|
||||
"kind": "LinkedField",
|
||||
"name": "viewer",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v1/*: any*/)
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"type": "Query",
|
||||
"abstractKey": null
|
||||
},
|
||||
"kind": "Request",
|
||||
"operation": {
|
||||
"argumentDefinitions": [],
|
||||
"kind": "Operation",
|
||||
"name": "OrganizationSelectionPageQuery",
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "Viewer",
|
||||
"kind": "LinkedField",
|
||||
"name": "viewer",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v1/*: any*/),
|
||||
(v0/*: any*/)
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"cacheID": "2938cb693a84c22366862de424a84d4b",
|
||||
"id": null,
|
||||
"metadata": {},
|
||||
"name": "OrganizationSelectionPageQuery",
|
||||
"operationKind": "query",
|
||||
"text": "query OrganizationSelectionPageQuery {\n viewer {\n organizations(first: 25) {\n edges {\n node {\n id\n name\n logoUrl\n }\n }\n }\n id\n }\n}\n"
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
(node as any).hash = "c41774fd198ab5f0f4b866d805099baf";
|
||||
|
||||
export default node;
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Button, Input, useToast } from "@probo/ui";
|
||||
import type { FormEventHandler } from "react";
|
||||
import { Link, useLocation, useNavigate } from "react-router";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
|
||||
export function LoginPage() {
|
||||
export default function LoginPage() {
|
||||
const { __ } = useTranslate();
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
|
||||
118
apps/console2/src/pages/organizations/NewOrganizationPage.tsx
Normal file
118
apps/console2/src/pages/organizations/NewOrganizationPage.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Button, Card, Input, PageHeader, useToast } from "@probo/ui";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { ConnectionHandler, graphql } from "relay-runtime";
|
||||
import { useLazyLoadQuery, useMutation } from "react-relay";
|
||||
import type { NewOrganizationPageQuery as NewOrganizationPageQueryType } from "./__generated__/NewOrganizationPageQuery.graphql";
|
||||
import type { NewOrganizationPageMutation as NewOrganizationPageMutationType } from "./__generated__/NewOrganizationPageMutation.graphql";
|
||||
import { useState, type FormEventHandler } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
const createOrganizationMutation = graphql`
|
||||
mutation NewOrganizationPageMutation(
|
||||
$input: CreateOrganizationInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
createOrganization(input: $input) {
|
||||
organizationEdge @appendEdge(connections: $connections) {
|
||||
node {
|
||||
id
|
||||
name
|
||||
logoUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const viewerQuery = graphql`
|
||||
query NewOrganizationPageQuery {
|
||||
viewer {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default function NewOrganizationPage() {
|
||||
const { __ } = useTranslate();
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
|
||||
const data = useLazyLoadQuery<NewOrganizationPageQueryType>(viewerQuery, {});
|
||||
const [createOrganization] = useMutation<NewOrganizationPageMutationType>(
|
||||
createOrganizationMutation
|
||||
);
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const name = formData.get("name")?.toString()!;
|
||||
setIsFetching(true);
|
||||
|
||||
createOrganization({
|
||||
variables: {
|
||||
input: {
|
||||
name,
|
||||
},
|
||||
connections: [
|
||||
ConnectionHandler.getConnectionID(
|
||||
data.viewer.id,
|
||||
"NewOrganizationPageQuery"
|
||||
),
|
||||
],
|
||||
},
|
||||
onCompleted: (r) => {
|
||||
setIsFetching(false);
|
||||
const org = r.createOrganization.organizationEdge.node;
|
||||
navigate(`/organizations/${org.id}`);
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Organization has been created successfully"),
|
||||
variant: "success",
|
||||
});
|
||||
},
|
||||
onError: (e) => {
|
||||
setIsFetching(false);
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: e.message ?? __("Failed to create organization"),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={__("Create Organization")}
|
||||
description={__(
|
||||
"Create a new organization to manage your compliance and security needs."
|
||||
)}
|
||||
/>
|
||||
<Card padded asChild>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<h2 className="text-xl font-semibold mb-1">
|
||||
{__("Organization Details")}
|
||||
</h2>
|
||||
<p className="text-txt-tertiary text-sm mb-4">
|
||||
{__("Enter the basic information about your organization.")}
|
||||
</p>
|
||||
<Input
|
||||
required
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder={__("Organization name")}
|
||||
label={__("Organization name")}
|
||||
help={__(
|
||||
"The name of your organization as it will appear throughout the platform."
|
||||
)}
|
||||
/>
|
||||
<Button disabled={isFetching} type="submit" className="w-full">
|
||||
{__("Create Organization")}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
174
apps/console2/src/pages/organizations/__generated__/NewOrganizationPageMutation.graphql.ts
generated
Normal file
174
apps/console2/src/pages/organizations/__generated__/NewOrganizationPageMutation.graphql.ts
generated
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* @generated SignedSource<<b15b08fb2d1a0e4e289a9c3c5b9ec7ef>>
|
||||
* @lightSyntaxTransform
|
||||
* @nogrep
|
||||
*/
|
||||
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { ConcreteRequest } from 'relay-runtime';
|
||||
export type CreateOrganizationInput = {
|
||||
name: string;
|
||||
};
|
||||
export type NewOrganizationPageMutation$variables = {
|
||||
connections: ReadonlyArray<string>;
|
||||
input: CreateOrganizationInput;
|
||||
};
|
||||
export type NewOrganizationPageMutation$data = {
|
||||
readonly createOrganization: {
|
||||
readonly organizationEdge: {
|
||||
readonly node: {
|
||||
readonly id: string;
|
||||
readonly logoUrl: string | null | undefined;
|
||||
readonly name: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
export type NewOrganizationPageMutation = {
|
||||
response: NewOrganizationPageMutation$data;
|
||||
variables: NewOrganizationPageMutation$variables;
|
||||
};
|
||||
|
||||
const node: ConcreteRequest = (function(){
|
||||
var v0 = {
|
||||
"defaultValue": null,
|
||||
"kind": "LocalArgument",
|
||||
"name": "connections"
|
||||
},
|
||||
v1 = {
|
||||
"defaultValue": null,
|
||||
"kind": "LocalArgument",
|
||||
"name": "input"
|
||||
},
|
||||
v2 = [
|
||||
{
|
||||
"kind": "Variable",
|
||||
"name": "input",
|
||||
"variableName": "input"
|
||||
}
|
||||
],
|
||||
v3 = {
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "OrganizationEdge",
|
||||
"kind": "LinkedField",
|
||||
"name": "organizationEdge",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "Organization",
|
||||
"kind": "LinkedField",
|
||||
"name": "node",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "id",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "name",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "logoUrl",
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
};
|
||||
return {
|
||||
"fragment": {
|
||||
"argumentDefinitions": [
|
||||
(v0/*: any*/),
|
||||
(v1/*: any*/)
|
||||
],
|
||||
"kind": "Fragment",
|
||||
"metadata": null,
|
||||
"name": "NewOrganizationPageMutation",
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": (v2/*: any*/),
|
||||
"concreteType": "CreateOrganizationPayload",
|
||||
"kind": "LinkedField",
|
||||
"name": "createOrganization",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v3/*: any*/)
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"type": "Mutation",
|
||||
"abstractKey": null
|
||||
},
|
||||
"kind": "Request",
|
||||
"operation": {
|
||||
"argumentDefinitions": [
|
||||
(v1/*: any*/),
|
||||
(v0/*: any*/)
|
||||
],
|
||||
"kind": "Operation",
|
||||
"name": "NewOrganizationPageMutation",
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": (v2/*: any*/),
|
||||
"concreteType": "CreateOrganizationPayload",
|
||||
"kind": "LinkedField",
|
||||
"name": "createOrganization",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v3/*: any*/),
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"filters": null,
|
||||
"handle": "appendEdge",
|
||||
"key": "",
|
||||
"kind": "LinkedHandle",
|
||||
"name": "organizationEdge",
|
||||
"handleArgs": [
|
||||
{
|
||||
"kind": "Variable",
|
||||
"name": "connections",
|
||||
"variableName": "connections"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"cacheID": "917f8c27b3faacda07aa6d62e6123677",
|
||||
"id": null,
|
||||
"metadata": {},
|
||||
"name": "NewOrganizationPageMutation",
|
||||
"operationKind": "mutation",
|
||||
"text": "mutation NewOrganizationPageMutation(\n $input: CreateOrganizationInput!\n) {\n createOrganization(input: $input) {\n organizationEdge {\n node {\n id\n name\n logoUrl\n }\n }\n }\n}\n"
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
(node as any).hash = "b88831f98e1414c16a18381ab4aeaa38";
|
||||
|
||||
export default node;
|
||||
74
apps/console2/src/pages/organizations/__generated__/NewOrganizationPageQuery.graphql.ts
generated
Normal file
74
apps/console2/src/pages/organizations/__generated__/NewOrganizationPageQuery.graphql.ts
generated
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @generated SignedSource<<83489e54447230c6d654a6baa9fb3e9d>>
|
||||
* @lightSyntaxTransform
|
||||
* @nogrep
|
||||
*/
|
||||
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { ConcreteRequest } from 'relay-runtime';
|
||||
export type NewOrganizationPageQuery$variables = Record<PropertyKey, never>;
|
||||
export type NewOrganizationPageQuery$data = {
|
||||
readonly viewer: {
|
||||
readonly id: string;
|
||||
};
|
||||
};
|
||||
export type NewOrganizationPageQuery = {
|
||||
response: NewOrganizationPageQuery$data;
|
||||
variables: NewOrganizationPageQuery$variables;
|
||||
};
|
||||
|
||||
const node: ConcreteRequest = (function(){
|
||||
var v0 = [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "Viewer",
|
||||
"kind": "LinkedField",
|
||||
"name": "viewer",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "id",
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
];
|
||||
return {
|
||||
"fragment": {
|
||||
"argumentDefinitions": [],
|
||||
"kind": "Fragment",
|
||||
"metadata": null,
|
||||
"name": "NewOrganizationPageQuery",
|
||||
"selections": (v0/*: any*/),
|
||||
"type": "Query",
|
||||
"abstractKey": null
|
||||
},
|
||||
"kind": "Request",
|
||||
"operation": {
|
||||
"argumentDefinitions": [],
|
||||
"kind": "Operation",
|
||||
"name": "NewOrganizationPageQuery",
|
||||
"selections": (v0/*: any*/)
|
||||
},
|
||||
"params": {
|
||||
"cacheID": "3a624ea532d0ad916c5e4bc883867f5e",
|
||||
"id": null,
|
||||
"metadata": {},
|
||||
"name": "NewOrganizationPageQuery",
|
||||
"operationKind": "query",
|
||||
"text": "query NewOrganizationPageQuery {\n viewer {\n id\n }\n}\n"
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
(node as any).hash = "c4d28895f85836c0698352ac737720ad";
|
||||
|
||||
export default node;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Breadcrumb,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
} from "@probo/ui";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type Props = {
|
||||
trigger: ReactNode;
|
||||
};
|
||||
|
||||
export default function NewRiskDialog({ trigger }: Props) {
|
||||
const { __ } = useTranslate();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onClose={() => {}}
|
||||
trigger={trigger}
|
||||
title={<Breadcrumb items={[__("Risks"), __("New Risk")]}></Breadcrumb>}
|
||||
>
|
||||
<form>
|
||||
<DialogContent>
|
||||
Lorem ipsum dolor, sit amet consectetur adipisicing elit. Velit harum
|
||||
maiores dolor hic nisi repudiandae autem quod rem eveniet iure
|
||||
quibusdam sint iste numquam, suscipit ad delectus aspernatur. Quo,
|
||||
amet.
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button type="submit">{__("Create risk")}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
17
apps/console2/src/pages/organizations/risks/RisksPage.tsx
Normal file
17
apps/console2/src/pages/organizations/risks/RisksPage.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Button, PageHeader } from "@probo/ui";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { IconPlusLarge } from "@probo/ui";
|
||||
import NewRiskDialog from "./NewRiskDialog";
|
||||
|
||||
export default function RisksPage() {
|
||||
const { __ } = useTranslate();
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title={__("Risks")}>
|
||||
<NewRiskDialog
|
||||
trigger={<Button icon={IconPlusLarge}>{__("New Risk")}</Button>}
|
||||
/>
|
||||
</PageHeader>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import { createBrowserRouter, useRouteError } from "react-router";
|
||||
import {
|
||||
createBrowserRouter,
|
||||
useRouteError,
|
||||
type RouteObject,
|
||||
} from "react-router";
|
||||
import { MainLayout } from "./layouts/MainLayout";
|
||||
import { AuthLayout, CenteredLayout, CenteredLayoutSkeleton } from "@probo/ui";
|
||||
import { lazy, Suspense, type FC } from "react";
|
||||
import { UnAuthenticatedError } from "./providers/RelayProviders";
|
||||
import { AuthLayout } from "@probo/ui";
|
||||
import { LoginPage } from "./pages/auth/LoginPage";
|
||||
|
||||
function Demo() {
|
||||
function ErrorBoundary() {
|
||||
const error = useRouteError();
|
||||
if (error instanceof UnAuthenticatedError) {
|
||||
return <div></div>;
|
||||
@@ -13,26 +17,71 @@ function Demo() {
|
||||
return <div>error</div>;
|
||||
}
|
||||
|
||||
export const routes = createBrowserRouter([
|
||||
type Route = {
|
||||
path: string;
|
||||
Component: FC;
|
||||
children?: Route[];
|
||||
ErrorBoundary?: FC;
|
||||
fallback?: FC;
|
||||
};
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: "/auth",
|
||||
Component: AuthLayout,
|
||||
children: [
|
||||
{
|
||||
path: "login",
|
||||
element: <LoginPage />,
|
||||
Component: lazy(() => import("./pages/auth/LoginPage")),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
Component: MainLayout,
|
||||
ErrorBoundary: Demo,
|
||||
Component: CenteredLayout,
|
||||
fallback: CenteredLayoutSkeleton,
|
||||
children: [
|
||||
{
|
||||
path: "/",
|
||||
element: <div>Home</div>,
|
||||
path: "",
|
||||
Component: lazy(() => import("./pages/OrganizationsPage")),
|
||||
},
|
||||
{
|
||||
path: "organizations/new",
|
||||
Component: lazy(
|
||||
() => import("./pages/organizations/NewOrganizationPage")
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
{
|
||||
path: "/organizations/:organizationId",
|
||||
Component: MainLayout,
|
||||
ErrorBoundary: ErrorBoundary,
|
||||
children: [
|
||||
{
|
||||
path: "risks",
|
||||
Component: lazy(() => import("./pages/organizations/risks/RisksPage")),
|
||||
},
|
||||
],
|
||||
},
|
||||
] satisfies Route[];
|
||||
|
||||
/**
|
||||
* Wrap component with a suspense to handle lazy loading & relay loading states
|
||||
*/
|
||||
function routeTransformer(route: Route): RouteObject {
|
||||
if ("fallback" in route && route.fallback) {
|
||||
return {
|
||||
...route,
|
||||
children: route.children?.map(routeTransformer),
|
||||
Component: () => (
|
||||
<Suspense fallback={<route.fallback />}>
|
||||
<route.Component />
|
||||
</Suspense>
|
||||
),
|
||||
} as RouteObject;
|
||||
}
|
||||
return route as RouteObject;
|
||||
}
|
||||
|
||||
export const router = createBrowserRouter(routes.map(routeTransformer));
|
||||
|
||||
4334
package-lock.json
generated
4334
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,6 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"volta": {
|
||||
"node": "22.15.0"
|
||||
"node": "22.15.1"
|
||||
}
|
||||
}
|
||||
|
||||
21
packages/hooks/package.json
Normal file
21
packages/hooks/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@probo/hooks",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "./src/index.ts",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"format": "prettier --write ./src"
|
||||
},
|
||||
"prettier": "@probo/prettier",
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@probo/prettier": "1.0.0",
|
||||
"prettier": "^3.5.3",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.1.3"
|
||||
}
|
||||
}
|
||||
1
packages/hooks/src/index.ts
Normal file
1
packages/hooks/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { usePageTitle } from "./usePageTitle";
|
||||
10
packages/hooks/src/usePageTitle.tsx
Normal file
10
packages/hooks/src/usePageTitle.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function usePageTitle(title: string) {
|
||||
useEffect(() => {
|
||||
document.title = title + " - Probo";
|
||||
return () => {
|
||||
document.title = "Probo";
|
||||
};
|
||||
}, [title]);
|
||||
}
|
||||
5
packages/hooks/tsconfig.json
Normal file
5
packages/hooks/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2021", "dom"]
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap");
|
||||
@import 'tailwindcss';
|
||||
@import '../src/theme.css';
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "../src/theme.css";
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { tv } from "tailwind-variants";
|
||||
|
||||
type Props = {
|
||||
fullName: string;
|
||||
name: string;
|
||||
src?: string | null;
|
||||
size?: "s" | "m" | "l" | "xl";
|
||||
};
|
||||
|
||||
const avatar = tv({
|
||||
base: "bg-txt-success text-sm text-invert rounded-full text-xxs font-semibold flex items-center justify-center",
|
||||
base: "bg-txt-success text-txt-invert rounded-full font-semibold flex items-center justify-center",
|
||||
variants: {
|
||||
size: {
|
||||
s: "size-5",
|
||||
m: "size-6",
|
||||
l: "size-8",
|
||||
xl: "size-16",
|
||||
s: "size-5 text-xss",
|
||||
m: "size-6 text-xxs",
|
||||
l: "size-8 text-sm",
|
||||
xl: "size-16 text-3xl",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -20,8 +21,13 @@ const avatar = tv({
|
||||
},
|
||||
});
|
||||
|
||||
export function Avatar({ fullName, size = "m" }: Props) {
|
||||
return <div className={avatar({ size })}>{extractInitials(fullName)}</div>;
|
||||
export function Avatar({ name, src, size = "m" }: Props) {
|
||||
if (src) {
|
||||
return <img className={avatar({ size })} src={src} alt={name} />;
|
||||
}
|
||||
return (
|
||||
<div className={avatar({ size })}>{extractInitials(name ?? "")}</div>
|
||||
);
|
||||
}
|
||||
|
||||
function extractInitials(name: string) {
|
||||
|
||||
29
packages/ui/src/Atoms/Breadcrumb/Breadcrumb.stories.tsx
Normal file
29
packages/ui/src/Atoms/Breadcrumb/Breadcrumb.stories.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Breadcrumb } from "./Breadcrumb";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
|
||||
export default {
|
||||
title: "Atoms/Breadcrumb",
|
||||
component: Breadcrumb,
|
||||
argTypes: {},
|
||||
} satisfies Meta<typeof Breadcrumb>;
|
||||
|
||||
type Story = StoryObj<typeof Breadcrumb>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
items: [
|
||||
{
|
||||
label: "Home",
|
||||
to: "/",
|
||||
},
|
||||
{
|
||||
label: "Library",
|
||||
to: "/library",
|
||||
},
|
||||
{
|
||||
label: "Data",
|
||||
to: "/data",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
51
packages/ui/src/Atoms/Breadcrumb/Breadcrumb.tsx
Normal file
51
packages/ui/src/Atoms/Breadcrumb/Breadcrumb.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import clsx from "clsx";
|
||||
import { Link } from "react-router";
|
||||
import { IconChevronRight } from "../Icons";
|
||||
|
||||
type Props = {
|
||||
items: (ItemProps | string)[];
|
||||
};
|
||||
|
||||
type ItemProps = {
|
||||
label: string;
|
||||
to?: string;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
export function Breadcrumb({ items }: Props) {
|
||||
return (
|
||||
<div className="flex items-center gap-[6px] text-txt-tertiary">
|
||||
{items.map((item, index) => (
|
||||
<>
|
||||
{index > 0 && <IconChevronRight size={12} />}
|
||||
<BreadcrumbItem
|
||||
key={index}
|
||||
{...(typeof item === "string" ? { label: item } : item)}
|
||||
active={index === items.length - 1}
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ label, to, active }: ItemProps) {
|
||||
const className = clsx(
|
||||
"text-sm px-1 rounded-sm h-5",
|
||||
active && "text-txt-primary font-medium",
|
||||
);
|
||||
if (to) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className={clsx(
|
||||
className,
|
||||
"hover:bg-tertiary-hover active:bg-tertiary-pressed",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return <span className={className}>{label}</span>;
|
||||
}
|
||||
17
packages/ui/src/Atoms/Card/Card.stories.tsx
Normal file
17
packages/ui/src/Atoms/Card/Card.stories.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Card } from "./Card";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
|
||||
export default {
|
||||
title: "Atoms/Card",
|
||||
component: Card,
|
||||
argTypes: {},
|
||||
} satisfies Meta<typeof Card>;
|
||||
|
||||
type Story = StoryObj<typeof Card>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
padded: true,
|
||||
children: "lorem ipsum",
|
||||
},
|
||||
};
|
||||
27
packages/ui/src/Atoms/Card/Card.tsx
Normal file
27
packages/ui/src/Atoms/Card/Card.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { tv } from "tailwind-variants";
|
||||
import { Slot } from "../Slot";
|
||||
|
||||
type Props = PropsWithChildren<{
|
||||
padded?: boolean;
|
||||
className?: string;
|
||||
asChild?: boolean;
|
||||
}>;
|
||||
|
||||
const card = tv({
|
||||
base: "border border-border-low surface-1 rounded-2xl",
|
||||
variants: {
|
||||
padded: {
|
||||
true: "p-6",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function Card({ padded = false, children, className, asChild }: Props) {
|
||||
const Component = asChild ? Slot : "div";
|
||||
return (
|
||||
<Component className={card({ padded, className })}>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export function Dropdown({ children, toggle, className }: Props) {
|
||||
<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-surface-1 overflow-y-auto overflow-x-hidden rounded-2xl border-border-low",
|
||||
"z-50 p-2 shadow-mid min-w-[8rem] bg-level-1 overflow-y-auto overflow-x-hidden rounded-2xl border-border-low",
|
||||
className,
|
||||
)}
|
||||
sideOffset={5}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "react";
|
||||
import { clsx } from "clsx";
|
||||
import { IconCollapse, IconExpand } from "../Icons";
|
||||
import { Button } from "../Button/Button";
|
||||
|
||||
const sidebarContext = createContext({ open: true });
|
||||
|
||||
@@ -15,21 +16,17 @@ export function Sidebar({ children }: PropsWithChildren) {
|
||||
<sidebarContext.Provider value={{ open }}>
|
||||
<aside
|
||||
className={clsx(
|
||||
"border-r border-border-solid relative pt-12 flex-none",
|
||||
"border-r border-border-solid relative pt-16 flex-none",
|
||||
open ? "px-4 w-[260px]" : "px-2",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<button
|
||||
<Button
|
||||
variant="tertiary"
|
||||
icon={open ? IconCollapse : IconExpand}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="cursor-pointer absolute bottom-4 left-4"
|
||||
>
|
||||
{open ? (
|
||||
<IconCollapse size={16} />
|
||||
) : (
|
||||
<IconExpand size={16} />
|
||||
)}
|
||||
</button>
|
||||
className="absolute bottom-4 left-4"
|
||||
/>
|
||||
</aside>
|
||||
</sidebarContext.Provider>
|
||||
);
|
||||
|
||||
16
packages/ui/src/Atoms/Skeleton/Skeleton.stories.tsx
Normal file
16
packages/ui/src/Atoms/Skeleton/Skeleton.stories.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Skeleton } from "./Skeleton";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
|
||||
export default {
|
||||
title: "Atoms/Skeleton",
|
||||
component: Skeleton,
|
||||
argTypes: {},
|
||||
} satisfies Meta<typeof Skeleton>;
|
||||
|
||||
type Story = StoryObj<typeof Skeleton>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
className: "w-50 h-4",
|
||||
},
|
||||
};
|
||||
16
packages/ui/src/Atoms/Skeleton/Skeleton.tsx
Normal file
16
packages/ui/src/Atoms/Skeleton/Skeleton.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { clsx } from "clsx";
|
||||
|
||||
export function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"animate-pulse rounded-md bg-border-low",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
29
packages/ui/src/Atoms/Slot.tsx
Normal file
29
packages/ui/src/Atoms/Slot.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
import { cloneElement, isValidElement, Children } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type AsChildProps<DefaultElementProps> =
|
||||
| ({ asChild?: false } & DefaultElementProps)
|
||||
| { asChild: true; children: ReactNode };
|
||||
|
||||
export function Slot({
|
||||
children,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLElement> & {
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
if (isValidElement(children)) {
|
||||
return cloneElement(children, {
|
||||
...props,
|
||||
// @ts-expect-error children.props can be undefined it's ok
|
||||
...children.props,
|
||||
// @ts-expect-error children.props.className can be undefined it's ok
|
||||
className: clsx(props.className, children.props.className),
|
||||
});
|
||||
}
|
||||
if (Children.count(children) > 1) {
|
||||
Children.only(null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import logo from "../assets/android-chrome-512x512.png";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Outlet } from "react-router";
|
||||
import { Toasts } from "../Atoms/Toasts/Toasts";
|
||||
|
||||
export function AuthLayout() {
|
||||
const { __ } = useTranslate();
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 min-h-screen">
|
||||
<div className="bg-surface-0 flex flex-col items-center justify-center">
|
||||
<div className="bg-level-0 flex flex-col items-center justify-center">
|
||||
<div className="max-w-112">
|
||||
<Outlet />
|
||||
</div>
|
||||
@@ -21,7 +20,6 @@ export function AuthLayout() {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Toasts />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
16
packages/ui/src/Layouts/CenteredLayout.stories.tsx
Normal file
16
packages/ui/src/Layouts/CenteredLayout.stories.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { CenteredLayout } from "./CenteredLayout";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
|
||||
export default {
|
||||
title: "Layouts/CenteredLayout",
|
||||
component: CenteredLayout,
|
||||
argTypes: {},
|
||||
} satisfies Meta<typeof CenteredLayout>;
|
||||
|
||||
type Story = StoryObj<typeof CenteredLayout>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: "lorem ipsum",
|
||||
},
|
||||
};
|
||||
24
packages/ui/src/Layouts/CenteredLayout.tsx
Normal file
24
packages/ui/src/Layouts/CenteredLayout.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { Outlet } from "react-router";
|
||||
import { Skeleton } from "../Atoms/Skeleton/Skeleton";
|
||||
|
||||
export function CenteredLayout({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<div className="grid place-items-center h-screen">
|
||||
<div className="w-full max-w-2xl flex flex-col items-center">
|
||||
{children ?? <Outlet />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CenteredLayoutSkeleton() {
|
||||
return (
|
||||
<CenteredLayout>
|
||||
<div className="w-full max-w-2xl flex flex-col items-center space-y-6">
|
||||
<Skeleton className="w-77 h-9" />
|
||||
<Skeleton className="w-full h-20" />
|
||||
</div>
|
||||
</CenteredLayout>
|
||||
);
|
||||
}
|
||||
@@ -11,8 +11,18 @@ type Props = PropsWithChildren<{
|
||||
export function Layout({ header, sidebar, children }: Props) {
|
||||
return (
|
||||
<div>
|
||||
<header className="absolute left-0 right-0 px-4 flex items-center border-b border-border-solid h-12">
|
||||
<header className="absolute z-2 left-0 right-0 px-4 flex items-center border-b border-border-solid h-12 bg-level-1">
|
||||
<Logo className="w-12 h-5" />
|
||||
<svg
|
||||
className="mx-3"
|
||||
width="8"
|
||||
height="18"
|
||||
viewBox="0 0 8 18"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M1 17L7 1" stroke="#C3C8C2" />
|
||||
</svg>
|
||||
{header}
|
||||
</header>
|
||||
<div className="flex h-screen">
|
||||
|
||||
37
packages/ui/src/Molecules/Dialog/Dialog.stories.tsx
Normal file
37
packages/ui/src/Molecules/Dialog/Dialog.stories.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Button } from "../../Atoms/Button/Button";
|
||||
import { Dialog, DialogContent, DialogFooter } from "./Dialog";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
|
||||
export default {
|
||||
title: "Atoms/Dialog",
|
||||
component: Dialog,
|
||||
argTypes: {},
|
||||
} satisfies Meta<typeof Dialog>;
|
||||
|
||||
type Story = StoryObj<typeof Dialog>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => {
|
||||
return (
|
||||
<Dialog
|
||||
{...args}
|
||||
onClose={() => {}}
|
||||
trigger={<Button>Open dialog</Button>}
|
||||
title="Edit profile"
|
||||
>
|
||||
<DialogContent>
|
||||
<div>
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit.
|
||||
Voluptate, voluptas. Doloribus incidunt cum laboriosam
|
||||
nulla magni soluta voluptatum omnis, sapiente minus
|
||||
corporis impedit explicabo vero praesentium, fugit
|
||||
possimus facilis rem.
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button>Save</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
);
|
||||
},
|
||||
};
|
||||
72
packages/ui/src/Molecules/Dialog/Dialog.tsx
Normal file
72
packages/ui/src/Molecules/Dialog/Dialog.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
Root,
|
||||
Trigger,
|
||||
Portal,
|
||||
Overlay,
|
||||
Content,
|
||||
Title,
|
||||
Close,
|
||||
} from "@radix-ui/react-dialog";
|
||||
import { IconCrossLargeX } from "../../Atoms/Icons";
|
||||
import type { ReactNode } from "react";
|
||||
import { Button } from "../../Atoms/Button/Button";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
trigger: ReactNode;
|
||||
title: ReactNode;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export function Dialog({ onClose, trigger, title, children }: Props) {
|
||||
return (
|
||||
<Root>
|
||||
<Trigger asChild>{trigger}</Trigger>
|
||||
<Portal>
|
||||
<Overlay className="fixed inset-0 z-50 bg-dialog/40 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
|
||||
<div className="fixed grid place-items-center inset-0 z-50">
|
||||
<Content className="bg-level-2 rounded-2xl max-w-5xl w-[95%]">
|
||||
<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}
|
||||
/>
|
||||
</Close>
|
||||
</div>
|
||||
{children}
|
||||
</Content>
|
||||
</div>
|
||||
</Portal>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function DialogFooter({ children }: { children?: ReactNode }) {
|
||||
const { __ } = useTranslate();
|
||||
return (
|
||||
<footer className="flex justify-end items-center p-3 border-t border-t-border-low gap-2">
|
||||
<Close asChild>
|
||||
<Button variant="secondary">{__("Cancel")}</Button>
|
||||
</Close>
|
||||
{children}
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
export function DialogContent({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="overflow-y-auto"
|
||||
style={{
|
||||
maxHeight: "min(640px, calc(100vh - 140px))",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,12 +2,18 @@
|
||||
export { Layout } from "./Layouts/Layout";
|
||||
export { AuthLayout } from "./Layouts/AuthLayout";
|
||||
export { ErrorLayout } from "./Layouts/ErrorLayout";
|
||||
export {
|
||||
CenteredLayout,
|
||||
CenteredLayoutSkeleton,
|
||||
} from "./Layouts/CenteredLayout";
|
||||
|
||||
// Atoms
|
||||
export * from "./Atoms/Icons";
|
||||
export { Logo } from "./Atoms/Logo/Logo";
|
||||
export { SidebarItem } from "./Atoms/Sidebar/SidebarItem";
|
||||
export { Button } from "./Atoms/Button/Button";
|
||||
export { Card } from "./Atoms/Card/Card";
|
||||
export { Breadcrumb } from "./Atoms/Breadcrumb/Breadcrumb";
|
||||
export {
|
||||
Dropdown,
|
||||
DropdownSeparator,
|
||||
@@ -22,6 +28,8 @@ export {
|
||||
UserDropdownItem,
|
||||
} from "./Molecules/UserDropdown/UserDropdown";
|
||||
export { PageHeader } from "./Molecules/PageHeader/PageHeader";
|
||||
export { Skeleton } from "./Atoms/Skeleton/Skeleton";
|
||||
export { Dialog, DialogContent, DialogFooter } from "./Molecules/Dialog/Dialog";
|
||||
|
||||
// Hooks
|
||||
export { useToast } from "./Atoms/Toasts/Toasts";
|
||||
export { useToast, Toasts } from "./Atoms/Toasts/Toasts";
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
/* Text colors */
|
||||
--color-txt-primary: #141e12;
|
||||
--color-invert: #fcfdfc;
|
||||
--color-txt-invert: var(--color-invert);
|
||||
--color-txt-secondary: #6b716a;
|
||||
--color-txt-tertiary: #818780;
|
||||
--color-txt-quaternary: #d8dcd8;
|
||||
@@ -162,5 +163,3 @@
|
||||
0px 2px 4px 0px rgba(0, 0, 0, 0.08),
|
||||
0px 2px 12px 0px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
@import "tw-animate-css";
|
||||
|
||||
6
relay.config.json
Normal file
6
relay.config.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"src": "./apps/console2/src",
|
||||
"schema": "./pkg/server/api/console/v1/schema.graphql",
|
||||
"language": "typescript",
|
||||
"eagerEsModules": true
|
||||
}
|
||||
Reference in New Issue
Block a user