Remove old frontend

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2025-06-13 13:38:48 -07:00
parent 2adfe50054
commit d6ba8e1072
646 changed files with 671 additions and 78623 deletions

1
apps/console/.env Normal file
View File

@@ -0,0 +1 @@
VITE_API_URL=http://localhost:8080

View File

@@ -1,8 +0,0 @@
LIVE_RELOAD=true
POSTHOG_HOST=
POSTHOG_KEY=
FARO_PUSH_URL=
API_SERVER_HOST="http://localhost:8080"

View File

@@ -1,7 +0,0 @@
LIVE_RELOAD=
POSTHOG_HOST=
POSTHOG_KEY=
FARO_PUSH_URL=
HTTP_ENDPOINT="http://example.com"

View File

@@ -1,6 +0,0 @@
POSTHOG_HOST=
POSTHOG_KEY=
FARO_PUSH_URL=
API_SERVER_HOST=

View File

@@ -1,2 +1,25 @@
!dist/.keep
dist/
# Logs
old
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

54
apps/console/README.md Normal file
View File

@@ -0,0 +1,54 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config({
extends: [
// Remove ...tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config({
plugins: {
// Add the react-x and react-dom plugins
'react-x': reactX,
'react-dom': reactDom,
},
rules: {
// other rules...
// Enable its recommended typescript rules
...reactX.configs['recommended-typescript'].rules,
...reactDom.configs.recommended.rules,
},
})
```

View File

@@ -1,21 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/App.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@@ -1,32 +1,28 @@
import globals from "globals";
import pluginJs from "@eslint/js";
import tseslint from "typescript-eslint";
import pluginReact from "eslint-plugin-react";
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
/** @type {import('eslint').Linter.Config[]} */
export default [
{ files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"] },
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
},
pluginJs.configs.recommended,
...tseslint.configs.recommended,
pluginReact.configs.flat.recommended,
{
settings: {
react: {
version: "detect",
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
"react/react-in-jsx-scope": "off",
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
];
)

View File

@@ -1,47 +1,37 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Probo - Console</title>
<link rel="icon shortcut" href="/assets/favicon.ico" sizes="32x32" />
<link rel="icon" href="/assets/favicon.svg" type="image/svg+xml" />
<link
rel="icon"
href="/assets/favicon-dark.svg"
type="image/svg+xml"
media="(prefers-color-scheme: dark)"
/>
<link
rel="icon"
href="/assets/favicon-16x16.png"
type="image/png"
sizes="16x16"
/>
<link
rel="icon"
href="/assets/favicon-32x32.png"
type="image/png"
sizes="32x32"
/>
<link
rel="apple-touch-icon"
href="/assets/apple-touch-icon.png"
sizes="180x180"
/>
<head>
<meta charset="UTF-8" />
<meta name="description" content="The open-source compliance platform" />
<link rel="apple-touch-icon-precomposed" sizes="57x57" href="/favicons/apple-touch-icon-57x57.png" />
<link rel="apple-touch-icon-precomposed" sizes="114x114" href="/favicons/apple-touch-icon-114x114.png" />
<link rel="apple-touch-icon-precomposed" sizes="72x72" href="/favicons/apple-touch-icon-72x72.png" />
<link rel="apple-touch-icon-precomposed" sizes="144x144" href="/favicons/apple-touch-icon-144x144.png" />
<link rel="apple-touch-icon-precomposed" sizes="60x60" href="/favicons/apple-touch-icon-60x60.png" />
<link rel="apple-touch-icon-precomposed" sizes="120x120" href="/favicons/apple-touch-icon-120x120.png" />
<link rel="apple-touch-icon-precomposed" sizes="76x76" href="/favicons/apple-touch-icon-76x76.png" />
<link rel="apple-touch-icon-precomposed" sizes="152x152" href="/favicons/apple-touch-icon-152x152.png" />
<link rel="icon" type="image/png" href="/favicons/favicon-196x196.png" sizes="196x196" />
<link rel="icon" type="image/png" href="/favicons/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/png" href="/favicons/favicon-32x32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="/favicons/favicon-16x16.png" sizes="16x16" />
<link rel="icon" type="image/png" href="/favicons/favicon-128.png" sizes="128x128" />
<meta name="application-name" content="&nbsp;" />
<meta name="msapplication-TileColor" content="#FFFFFF" />
<meta name="msapplication-TileImage" content="/favicons/mstile-144x144.png" />
<meta name="msapplication-square70x70logo" content="/favicons/mstile-70x70.png" />
<meta name="msapplication-square150x150logo" content="/favicons/mstile-150x150.png" />
<meta name="msapplication-wide310x150logo" content="/favicons/mstile-310x150.png" />
<meta name="msapplication-square310x310logo" content="/favicons/mstile-310x310.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Probo Console</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body class="font-sans">
<div id="root"></div>
<script type="module" src="/src/App.tsx"></script>
</body>
</html>

View File

@@ -1,93 +1,61 @@
{
"name": "@probo/console",
"version": "0.0.1",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "npx node --env-file .env.development scripts/esbuild.mjs watch",
"build": "npx node --env-file .env.production scripts/esbuild.mjs",
"relay": "npx relay-compiler --output quiet-with-errors",
"relay:watch": "npx relay-compiler --output quiet-with-errors --watch",
"lint": "eslint src --ext .ts,.tsx",
"typecheck": "npx tsc --noEmit"
"dev": "vite",
"relay": "find src -type d -name \"__generated__\" -exec rm -rf {} + && npx relay-compiler ./relay.config.json",
"build": "tsc -b && vite build",
"lint": "eslint .",
"check": "tsc --noEmit -p tsconfig.app.json",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^5.0.1",
"@probo/helpers": "1.0.0",
"@probo/hooks": "1.0.0",
"@probo/i18n": "1.0.0",
"@probo/react-lazy": "1.0.0",
"@probo/vendors": "^0.0.1",
"@radix-ui/colors": "^3.0.0",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-progress": "^1.1.2",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.4",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-tooltip": "^1.1.8",
"@tailwindcss/typography": "^0.5.16",
"class-variance-authority": "^0.7.1",
"@probo/ui": "1.0.0",
"@probo/vendors": "0.0.1",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.5",
"@tanstack/react-query": "^5.76.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.4",
"date-fns": "^4.1.0",
"fuse.js": "^7.1.0",
"lucide-react": "^0.475.0",
"posthog-js": "^1.215.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-helmet-async": "^2.0.5",
"react-markdown": "^10.1.0",
"react-relay": "^18.2.0",
"react-router": "^7.1.5",
"rehype-raw": "^7.0.0",
"relay-runtime": "^18.2.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7"
"minisearch": "^7.1.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-error-boundary": "^6.0.0",
"react-hook-form": "^7.56.4",
"react-relay": "^19.0.0",
"react-router": "^7.6.0",
"relay-runtime": "^19.0.0",
"use-debounce": "^10.0.5",
"usehooks-ts": "^3.1.1",
"zod": "^3.25.17"
},
"devDependencies": {
"@babel/core": "^7.26.7",
"@babel/preset-env": "^7.26.7",
"@babel/preset-typescript": "^7.26.0",
"@eslint/js": "^9.21.0",
"@probo/esbuild-plugin-postcss": "^0.0.1",
"@probo/tsconfig": "^0.0.1",
"@tailwindcss/postcss": "^4.0.9",
"@types/babel-core": "^6.25.10",
"@types/node": "^22.13.0",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/react-relay": "^18.2.0",
"@types/relay-runtime": "^18.2.5",
"autoprefixer": "^10.4.20",
"babel-plugin-relay": "^18.2.0",
"esbuild": "^0.25.0",
"eslint": "^9.21.0",
"eslint-plugin-react": "^7.37.4",
"@eslint/js": "^9.25.0",
"@tailwindcss/vite": "^4.1.7",
"@types/node": "^22.15.21",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@types/react-relay": "^18.2.1",
"@types/relay-runtime": "^19.0.1",
"@vitejs/plugin-react": "^4.4.1",
"babel-plugin-relay": "^19.0.0",
"eslint": "^9.25.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"relay-compiler": "^18.2.0",
"tailwindcss": "^4.0.9",
"typescript": "^5.8.3",
"typescript-eslint": "^8.25.0"
},
"relay": {
"src": "src",
"language": "typescript",
"schema": "../../pkg/server/api/console/v1/schema.graphql",
"noFutureProofEnums": true,
"customScalarTypes": {
"Datetime": "string",
"CursorKey": "string"
},
"excludes": [
"**/node_modules/**",
"**/__mocks__/**",
"**/__generated__/**"
]
"graphql": "^16.11.0",
"prettier": "^3.5.3",
"relay-compiler": "^19.0.0",
"tailwindcss": "^4.1.7",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.5"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -1,3 +0,0 @@
<svg width="9" height="9" viewBox="0 0 9 9" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.239641 0.239641C0.559163 -0.0798804 1.0772 -0.0798804 1.39672 0.239641L4.5 3.34292L7.60326 0.239641C7.92278 -0.0798804 8.44086 -0.0798804 8.76038 0.239641C9.07985 0.559163 9.07985 1.0772 8.76038 1.39672L5.65707 4.5L8.76038 7.60326C9.07985 7.92278 9.07985 8.44086 8.76038 8.76038C8.44086 9.07985 7.92278 9.07985 7.60326 8.76038L4.5 5.65707L1.39672 8.76038C1.0772 9.07985 0.559163 9.07985 0.239641 8.76038C-0.0798804 8.44086 -0.0798804 7.92278 0.239641 7.60326L3.34292 4.5L0.239641 1.39672C-0.0798804 1.0772 -0.0798804 0.559163 0.239641 0.239641Z" fill="#6B716A"/>
</svg>

Before

Width:  |  Height:  |  Size: 714 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 625 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 945 B

View File

@@ -1,3 +0,0 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6 0C6.36819 0 6.66667 0.298477 6.66667 0.666667V6.05719L7.86193 4.86193C8.12228 4.60158 8.54439 4.60158 8.80474 4.86193C9.06509 5.12228 9.06509 5.54439 8.80474 5.80474L6.4714 8.13807C6.21106 8.39842 5.78895 8.39842 5.5286 8.13807L3.19526 5.80474C2.93491 5.54439 2.93491 5.12228 3.19526 4.86193C3.45561 4.60158 3.87772 4.60158 4.13807 4.86193L5.33333 6.05719V0.666667C5.33333 0.298477 5.63181 0 6 0ZM0.666667 7.33333C1.03486 7.33333 1.33333 7.63181 1.33333 8V9.33333C1.33333 10.0697 1.93028 10.6667 2.66667 10.6667H9.33333C10.0697 10.6667 10.6667 10.0697 10.6667 9.33333V8C10.6667 7.63181 10.9651 7.33333 11.3333 7.33333C11.7015 7.33333 12 7.63181 12 8V9.33333C12 10.8061 10.8061 12 9.33333 12H2.66667C1.19392 12 0 10.8061 0 9.33333V8C0 7.63181 0.298477 7.33333 0.666667 7.33333Z" fill="#141E12"/>
</svg>

Before

Width:  |  Height:  |  Size: 950 B

View File

@@ -1,3 +0,0 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.99966 1.39993C3.90702 1.39993 1.39993 3.90702 1.39993 6.99966C1.39993 10.0923 3.90701 12.5994 6.99966 12.5994C10.0923 12.5994 12.5994 10.0923 12.5994 6.99966C12.5994 3.90701 10.0923 1.39993 6.99966 1.39993ZM0 6.99966C0 3.13386 3.13386 0 6.99966 0C10.8655 0 13.9993 3.13386 13.9993 6.99966C13.9993 10.8655 10.8655 13.9993 6.99966 13.9993C3.13386 13.9993 0 10.8655 0 6.99966ZM7.69962 3.49983V6.70972L9.73947 8.74957L8.74957 9.73947L6.29969 7.28959V3.49983H7.69962Z" fill="#141E12"/>
</svg>

Before

Width:  |  Height:  |  Size: 636 B

View File

@@ -1,5 +0,0 @@
<svg width="14" height="3" viewBox="0 0 14 3" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.59993 1.39998C5.59993 0.626783 6.22671 0 6.99991 0C7.77311 0 8.3999 0.626783 8.3999 1.39998C8.3999 2.17318 7.77311 2.79997 6.99991 2.79997C6.22671 2.79997 5.59993 2.17318 5.59993 1.39998Z" fill="#141E12"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.1999 1.39998C11.1999 0.626783 11.8266 0 12.5998 0C13.373 0 13.9998 0.626783 13.9998 1.39998C13.9998 2.17318 13.373 2.79997 12.5998 2.79997C11.8266 2.79997 11.1999 2.17318 11.1999 1.39998Z" fill="#141E12"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 1.39998C0 0.626774 0.626806 0 1.39998 0C2.17316 0 2.79997 0.626774 2.79997 1.39998C2.79997 2.17319 2.17316 2.79997 1.39998 2.79997C0.626806 2.79997 0 2.17319 0 1.39998Z" fill="#141E12"/>
</svg>

Before

Width:  |  Height:  |  Size: 857 B

View File

@@ -1,5 +0,0 @@
<svg width="14" height="3" viewBox="0 0 14 3" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.59993 1.39998C5.59993 0.626783 6.22671 0 6.99991 0C7.77311 0 8.3999 0.626783 8.3999 1.39998C8.3999 2.17318 7.77311 2.79997 6.99991 2.79997C6.22671 2.79997 5.59993 2.17318 5.59993 1.39998Z" fill="#141E12"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.1999 1.39998C11.1999 0.626783 11.8266 0 12.5998 0C13.373 0 13.9998 0.626783 13.9998 1.39998C13.9998 2.17318 13.373 2.79997 12.5998 2.79997C11.8266 2.79997 11.1999 2.17318 11.1999 1.39998Z" fill="#141E12"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 1.39998C0 0.626774 0.626806 0 1.39998 0C2.17316 0 2.79997 0.626774 2.79997 1.39998C2.79997 2.17319 2.17316 2.79997 1.39998 2.79997C0.626806 2.79997 0 2.17319 0 1.39998Z" fill="#141E12"/>
</svg>

Before

Width:  |  Height:  |  Size: 857 B

View File

@@ -1,49 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Probo - Console</title>
<link rel="icon shortcut" href="/assets/favicon.ico" sizes="32x32" />
<link rel="icon" href="/assets/favicon.svg" type="image/svg+xml" />
<link
rel="icon"
href="/assets/favicon-dark.svg"
type="image/svg+xml"
media="(prefers-color-scheme: dark)"
/>
<link
rel="icon"
href="/assets/favicon-16x16.png"
type="image/png"
sizes="16x16"
/>
<link
rel="icon"
href="/assets/favicon-32x32.png"
type="image/png"
sizes="32x32"
/>
<link
rel="apple-touch-icon"
href="/assets/apple-touch-icon.png"
sizes="180x180"
/>
<meta name="description" content="The open-source compliance platform" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/App.css" />
</head>
<body class="font-sans">
<div id="root"></div>
<script type="module" src="/App.js" defer></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,6 +1,7 @@
{
"src": "./src",
"schema": "./schema.graphql",
"schema": "../../pkg/server/api/console/v1/schema.graphql",
"language": "typescript",
"eagerEsModules": true
"eagerEsModules": true,
"noFutureProofEnums": true
}

View File

@@ -1,278 +0,0 @@
import babel from "@babel/core";
import postCssPlugin from "@probo/esbuild-plugin-postcss";
import autoprefixer from "autoprefixer";
import { execSync, spawn } from "child_process";
import * as esbuild from "esbuild";
import fs from "node:fs/promises";
import http from "node:http";
import path from "node:path";
import process from "node:process";
import tailwindcssPlugin from "@tailwindcss/postcss";
async function copyRecursive(src, dest) {
await fs.mkdir(dest, { recursive: true });
const files = await fs.readdir(src);
for (const file of files) {
const srcPath = path.join(src, file);
const destPath = path.join(dest, file);
const fileInfo = await fs.lstat(srcPath);
if (fileInfo.isDirectory()) {
await copyRecursive(srcPath, destPath);
} else {
await fs.copyFile(srcPath, destPath);
}
}
}
function runRelayCompilerInWatchMode() {
const relayProcess = spawn("npm", ["run", "relay:watch"]);
relayProcess.stdout.on("data", (data) => {
console.log(`[relay] ${data}`);
});
relayProcess.stderr.on("data", (data) => {
console.error(`[relay] ${data}`);
});
relayProcess.on("error", (error) => {
console.error("Failed to start Relay compiler:", error);
});
console.log("Relay compiler started in watch mode.");
}
const envVars = Object.fromEntries(
Object.entries(process.env).map(([key, value]) => [
`process.env.${key}`,
JSON.stringify(value),
])
);
const hotReloading = {
name: "hot-reloading",
async setup(build) {
if (!process.env.LIVE_RELOAD) {
return;
}
build.initialOptions.banner = build.initialOptions.banner || {};
build.initialOptions.banner.js = `
${build.initialOptions.banner.js || ""};
if (!window.__esbuild_event_source__) {
window.__esbuild_event_source__ = true;
new EventSource('/esbuild').addEventListener('change', (e) => {
const { added, removed, updated } = JSON.parse(e.data);
if (!added.length && !removed.length && updated.length === 1) {
for (const link of document.getElementsByTagName("link")) {
const url = new URL(link.href);
if (url.host === location.host && url.pathname === updated[0]) {
const next = link.cloneNode();
next.href = updated[0] + '?' + Math.random().toString(36).slice(2);
next.onload = () => link.remove();
link.parentNode.insertBefore(next, link.nextSibling);
return;
}
}
}
location.reload();
});
}
`;
return;
},
};
const relayPlugin = {
name: "relay-compiler",
setup(build) {
build.initialOptions.loader = {
...build.initialOptions.loader,
".graphql": "text",
};
build.onLoad({ filter: /\.(ts|tsx)$/ }, async (args) => {
const source = await fs.readFile(args.path, "utf8");
const result = await babel.transformAsync(source, {
filename: args.path,
presets: [
[
"@babel/preset-env",
{
modules: false,
},
],
"@babel/preset-typescript",
],
plugins: ["relay"],
sourceMaps: true,
});
return {
contents: result.code,
loader: "default",
};
});
},
};
const htmlPlugin = {
name: 'html-plugin',
setup(build) {
build.onEnd(async (result) => {
// do not run this in watch mode
if (process.argv[2] === "watch") {
return;
}
if (result.errors.length > 0) {
return;
}
if (!result.metafile) {
console.error('Metafile not available');
return;
}
const outputs = Object.keys(result.metafile.outputs);
const jsOutputs = outputs.filter(file => file.endsWith('.js') && file.includes('/App-'));
const cssOutputs = outputs.filter(file => file.endsWith('.css') && file.includes('/App-'));
if (jsOutputs.length === 0 || cssOutputs.length === 0) {
console.error('Could not find entry point outputs');
return;
}
const jsFile = path.basename(jsOutputs[0]);
const cssFile = path.basename(cssOutputs[0]);
console.log('Found JS file:', jsFile);
console.log('Found CSS file:', cssFile);
const htmlPath = path.join(process.cwd(), 'dist', 'index.html');
let html = await fs.readFile(htmlPath, "utf8");
html = html.replace('"/App.css"', `"/${cssFile}"`);
html = html.replace('"/App.js"', `"/${jsFile}"`);
await fs.writeFile(htmlPath, html);
console.log("Successfully updated index.html with hashed filenames");
});
}
};
const defaultOptions = {
logLevel: "info",
entryPoints: ["src/App.tsx"],
bundle: true,
minify: true,
publicPath: "/",
outdir: "dist",
assetNames: "assets/[name]-[hash]",
chunkNames: "chunks/[name]-[hash]",
entryNames: "[dir]/[name]-[hash]",
splitting: true,
format: "esm",
sourcemap: "external",
metafile: true,
loader: {
".png": "file",
".jpg": "file",
".jpeg": "file",
".svg": "file",
".gif": "file",
},
plugins: [
relayPlugin,
hotReloading,
htmlPlugin,
postCssPlugin({
plugins: [tailwindcssPlugin(), autoprefixer()],
}),
],
define: {
...envVars,
},
};
await copyRecursive("public", "dist");
const [, , command] = process.argv;
if (command === "watch") {
runRelayCompilerInWatchMode();
const ctx = await esbuild.context({
...defaultOptions,
minify: false,
entryNames: "[dir]/[name]"
});
await ctx.watch();
let { host, port } = await ctx.serve({ servedir: "dist" });
http
.createServer((req, res) => {
const options = {
hostname: host,
port: port,
path: req.url,
method: req.method,
headers: req.headers,
};
const urlObj = new URL(req.url, `http://${req.headers.host}`);
const extensionPattern = /\.[^/]+$/;
const proxyReq = http.request(options, (proxyRes) => {
if (
proxyRes.statusCode === 404 &&
extensionPattern.test(urlObj.pathname)
) {
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("404 - Not Found");
return;
}
if (proxyRes.statusCode === 404) {
const indexPath = path.join(
import.meta.dirname,
"../dist",
"index.html"
);
fs.readFile(indexPath, "utf8")
.then((data) => {
res.writeHead(200, { "Content-Type": "text/html" });
res.end(data);
})
.catch((err) => {
console.error("Error reading index.html:", err);
res.writeHead(500, { "Content-Type": "text/plain" });
res.end("500 - Internal Server Error");
});
proxyRes.resume();
return;
}
res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res, { end: true });
});
req.pipe(proxyReq, { end: true });
})
.listen(3000, () => {
console.log(`listening on http://localhost:3000`);
});
} else {
execSync("npm run relay", { stdio: "inherit" });
await esbuild.build(defaultOptions);
}

View File

@@ -1,323 +0,0 @@
@import "tailwindcss";
@import "@radix-ui/colors/blue.css";
@import "@radix-ui/colors/blue-dark.css";
@import "@radix-ui/colors/olive.css";
@import "@radix-ui/colors/olive-alpha.css";
@import "@radix-ui/colors/olive-dark.css";
@import "@radix-ui/colors/olive-dark-alpha.css";
@import "@radix-ui/colors/lime.css";
@import "@radix-ui/colors/lime-dark.css";
@import "@radix-ui/colors/red.css";
@import "@radix-ui/colors/red-dark.css";
@import "@radix-ui/colors/red-dark-alpha.css";
@import "@radix-ui/colors/orange.css";
@import "@radix-ui/colors/orange-dark.css";
@import "./styles/document-content.css";
@plugin 'tailwindcss-animate';
@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *));
@theme {
/* Legacy */
/* --color-border: hsl(var(--border)); -> solid-b
--color-input: hsl(var(--input)); -> solid-b
--color-ring: hsl(var(--ring)); -> active-b
--color-background: hsl(var(--background)); -> level-0
--color-foreground: hsl(var(--foreground)); -> primary
--color-primary: hsl(var(--primary)); -> primary
--color-primary-foreground: hsl(var(--primary-foreground)); -> invert
--color-secondary: hsl(var(--secondary)); -> invert
--color-secondary-foreground: hsl(var(--secondary-foreground)); -> secondary
--color-destructive: hsl(var(--destructive)); -> danger-bg
--color-destructive-foreground: hsl(var(--destructive-foreground)); -> danger
--color-muted: hsl(var(--muted)); -> invert-bg
--color-muted-foreground: hsl(var(--muted-foreground)); -> tertiary
--color-accent: hsl(var(--accent)); -> accent-bg
--color-accent-foreground: hsl(var(--accent-foreground)); -> accent
--color-popover: hsl(var(--popover)); -> level-0
--color-popover-foreground: hsl(var(--popover-foreground)); -> primary
--color-card: hsl(var(--card)); -> level-0
--color-card-foreground: hsl(var(--card-foreground)); -> primary
--color-sidebar: hsl(var(--sidebar-background)); -> level-0
--color-sidebar-foreground: hsl(var(--sidebar-foreground)); -> secondary
--color-sidebar-primary: hsl(var(--sidebar-primary)); -> not used
--color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground));
--color-sidebar-accent: hsl(var(--sidebar-accent));
--color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground));
--color-sidebar-border: hsl(var(--sidebar-border)); -> solid-b
--color-sidebar-ring: hsl(var(--sidebar-ring)); -> active-b */
/* New */
/* Surfaces */
--color-level-0: var(--level-0);
--color-level-1: var(--level-1);
--color-level-2: var(--level-2);
--color-level-3: var(--level-3);
/* Text colors */
--color-primary: var(--primary);
--color-invert: var(--invert);
--color-secondary: var(--secondary);
--color-tertiary: var(--tertiary);
--color-quaternary: var(--quaternary);
--color-disabled: var(--disabled);
--color-accent: var(--accent);
--color-danger: var(--danger);
--color-success: var(--success);
--color-info: var(--info);
--color-warning: var(--warning);
/* Background colors */
--color-primary-bg: var(--primary-bg);
--color-invert-bg: var(--invert-bg);
--color-secondary-bg: var(--secondary-bg);
--color-tertiary-bg: var(--tertiary-bg);
--color-subtle-bg: var(--subtle-bg);
--color-highlight-bg: var(--highlight-bg);
--color-accent-bg: var(--accent-bg);
--color-active-bg: var(--active-bg);
--color-danger-bg: var(--danger-bg);
--color-danger-plain-bg: var(--danger-plain-bg);
--color-success-bg: var(--success-bg);
--color-info-bg: var(--info-bg);
--color-warning-bg: var(--warning-bg);
/* Hover */
--color-h-primary-bg: var(--h-primary-bg);
--color-h-invert-bg: var(--h-invert-bg);
--color-h-secondary-bg: var(--h-secondary-bg);
--color-h-tertiary-bg: var(--h-tertiary-bg);
--color-h-subtle-bg: var(--h-subtle-bg);
--color-h-highlight-bg: var(--h-highlight-bg);
--color-h-accent-bg: var(--h-accent-bg);
--color-h-active-bg: var(--h-active-bg);
/* Pressed */
--color-p-primary-bg: var(--p-primary-bg);
--color-p-invert-bg: var(--p-invert-bg);
--color-p-secondary-bg: var(--p-secondary-bg);
--color-p-tertiary-bg: var(--p-tertiary-bg);
--color-p-subtle-bg: var(--p-subtle-bg);
--color-p-highlight-bg: var(--p-highlight-bg);
--color-p-accent-bg: var(--p-accent-bg);
--color-p-active-bg: var(--p-active-bg);
/* Borders */
--color-solid-b: var(--solid-b);
--color-low-b: var(--low-b);
--color-mid-b: var(--mid-b);
--color-strong-b: var(--strong-b);
--color-active-b: var(--active-b);
--color-accent-b: var(--accent-b);
--color-danger-b: var(--danger-b);
--color-success-b: var(--success-b);
--color-warning-b: var(--warning-b);
--color-info-b: var(--info-b);
}
/*
The default border color has changed to `currentColor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-mid-b, currentColor);
}
}
@layer base {
/* Legacy */
/* :root {
--background: 0 0% 100%;
--foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 47.4% 11.2%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--card: 0 0% 100%;
--card-foreground: 222.2 47.4% 11.2%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 100% 50%;
--destructive-foreground: 210 40% 98%;
--ring: 215 20.2% 65.1%;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--background: 224 71% 4%;
--foreground: 213 31% 91%;
--muted: 223 47% 11%;
--muted-foreground: 215.4 16.3% 56.9%;
--accent: 216 34% 17%;
--accent-foreground: 210 40% 98%;
--popover: 224 71% 4%;
--popover-foreground: 215 20.2% 65.1%;
--border: 216 34% 17%;
--input: 216 34% 17%;
--card: 224 71% 4%;
--card-foreground: 213 31% 91%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 1.2%;
--secondary: 222.2 47.4% 11.2%;
--secondary-foreground: 210 40% 98%;
--destructive: 0 63% 31%;
--destructive-foreground: 210 40% 98%;
--ring: 216 34% 17%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
} */
/* New */
:root {
/* Surfaces */
--level-0: #FAFAF5;
--level-1: #FFFFFF;
--level-2: #FFFFFF;
--level-3: var(--olive-12);
/* Text colors */
--primary: var(--olive-12);
--invert: var(--olive-1);
--secondary: var(--olive-11);
--tertiary: var(--olive-10);
--quaternary: var(--olive-8);
--disabled: var(--olive-7);
--accent: var(--lime-10);
--danger: var(--red-11);
--success: var(--lime-11);
--info: var(--blue-11);
--warning: var(--orange-11);
/* Background colors */
--primary-bg: var(--olive-12);
--invert-bg: #ffffff;
--secondary-bg: var(--olive-1);
--tertiary-bg: transparent;
--subtle-bg: var(--olive-a2);
--highlight-bg: var(--olive-a3);
--accent-bg: var(--olive-12);
--active-bg: var(--lime-4);
--danger-bg: var(--red-3);
--danger-plain-bg: var(--red-10);
--success-bg: var(--lime-3);
--info-bg: var(--blue-3);
--warning-bg: var(--orange-3);
/* Hover */
--h-primary-bg: #273025;
--h-invert-bg: var(--olive-2);
--h-secondary-bg: var(--olive-2);
--h-tertiary-bg: var(--olive-a2);
--h-subtle-bg: var(--olive-a3);
--h-highlight-bg: var(--olive-a4);
--h-accent-bg: #1E2C1C;
--h-active-bg: var(--lime-5);
/* Pressed */
--p-primary-bg: #30392E;
--p-invert-bg: var(--olive-3);
--p-secondary-bg: var(--olive-3);
--p-tertiary-bg: var(--olive-a3);
--p-subtle-bg: var(--olive-a4);
--p-highlight-bg: var(--olive-a5);
--p-accent-bg: #293A25;
--p-active-bg: var(--lime-6);
/* Borders */
--solid-b: var(--olive-4);
--low-b: var(--olive-a4);
--mid-b: var(--olive-a7);
--strong-b: var(--olive-a8);
--active-b: var(--olive-12);
--accent-b: var(--lime-11);
--danger-b: var(--red-10);
--success-b: var(--lime-10);
--warning-b: var(--orange-10);
--info-b: var(--blue-10);
}
.dark {
/* Surfaces */
--level-0: var(--olive-1);
--level-1: var(--olive-2);
--level-2: var(--olive-3);
--level-3: var(--olive-7);
/* Text colors */
--success: var(--lime-a11);
/* Background colors */
--primary-bg: var(--lime-10);
--invert-bg: var(--olive-1);
--secondary-bg: var(--olive-a3);
--active-bg: var(--olive-a4);
--accent-bg: var(--lime-10);
--danger-bg: var(--red-a3);
--danger-plain-bg: var(--red-a10);
--success-bg: var(--lime-a3);
--info-bg: var(--blue-a3);
--warning-bg: var(--orange-a3);
/* Hover */
--h-primary-bg: #C4F042E1;
--h-secondary-bg: var(--olive-a3);
--h-highlight-bg: var(--olive-a5);
--h-accent-bg: var(--lime-a9);
--h-active-bg: var(--olive-a5);
/* Pressed */
--p-primary-bg: #C4F042D7;
--p-secondary-bg: var(--olive-a4);
--p-highlight-bg: var(--olive-a6);
--p-accent-bg: var(--lime-a10);
--p-active-bg: var(--olive-a6);
/* Borders */
--solid-b: var(--olive-5);
--mid-b: var(--olive-a5);
--active-b: var(--olive-a12);
--warning-b: var(--orange-a10);
}
}
@layer base {
* {
@apply border-solid-b;
}
body {
@apply font-sans antialiased bg-level-0 text-primary;
}
}

View File

@@ -1,91 +1,12 @@
import posthog from "posthog-js";
import { PostHogProvider } from "posthog-js/react";
import { lazy } from "@probo/react-lazy";
import { StrictMode, Suspense } from "react";
import { createRoot } from "react-dom/client";
import { HelmetProvider } from "react-helmet-async";
import { RelayEnvironmentProvider } from "react-relay";
import { BrowserRouter, Route, Routes, useLocation } from "react-router";
import "./App.css";
import "./styles/document-content.css";
import ErrorBoundary from "./components/ErrorBoundary";
import AuthLayout from "./layouts/AuthLayout";
import { RelayEnvironment } from "./RelayEnvironment";
import { AuthenticationRoutes } from "./pages/authentication/Routes";
import { OrganizationsRoutes } from "./pages/organizations/Routes";
import SigningRequestsPage from "./pages/SigningRequestsPage";
import { RouterProvider } from "react-router";
import { router } from "./routes";
import { Toasts } from "@probo/ui";
posthog.init(process.env.POSTHOG_KEY!, {
api_host: process.env.POSTHOG_HOST,
session_recording: {
maskAllInputs: true,
},
loaded: (posthog) => {
if (!process.env.POSTHOG_KEY) posthog.debug();
},
});
const OrganizationSelectionPage = lazy(
() => import("./pages/OrganizationSelectionPage"),
);
function App() {
export function App() {
return (
<StrictMode>
<PostHogProvider client={posthog}>
<RelayEnvironmentProvider environment={RelayEnvironment}>
<HelmetProvider>
<BrowserRouter>
<Routes>
<Route path="/*">
<Route
index
element={
<Suspense>
<ErrorBoundaryWithLocation>
<OrganizationSelectionPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="organizations/*"
element={<OrganizationsRoutes />}
/>
<Route
path="documents/signing-requests"
element={<SigningRequestsPage />}
/>
<Route
path="*"
element={
<AuthLayout>
<AuthenticationRoutes />
</AuthLayout>
}
/>
</Route>
</Routes>
</BrowserRouter>
</HelmetProvider>
</RelayEnvironmentProvider>
</PostHogProvider>
</StrictMode>
<>
<RouterProvider router={router} />
<Toasts />
</>
);
}
function ErrorBoundaryWithLocation({
children,
}: {
children: React.ReactNode;
}) {
const location = useLocation();
return <ErrorBoundary key={location.pathname}>{children}</ErrorBoundary>;
}
const container = document.getElementById("root");
const root = createRoot(container!);
root.render(<App />);

View File

@@ -1,65 +0,0 @@
import * as React from "react";
import { Suspense, useEffect } from "react";
import {
graphql,
PreloadedQuery,
usePreloadedQuery,
useQueryLoader,
} from "react-relay";
import { NavUser } from "@/components/NavUser";
import { Sidebar } from "@/components/ui/sidebar";
import type { AppSidebarQuery as AppSidebarQueryType } from "./__generated__/AppSidebarQuery.graphql";
import { OrganizationSwitcher } from "@/components/OrganizationSwitcher";
import { AppSidebarShell } from "./AppSidebarShell";
import { AppSidebarSkeleton } from "./AppSidebarSkeleton";
import { NavMain } from "./NavMain";
const AppSidebarQuery = graphql`
query AppSidebarQuery {
viewer {
id
...OrganizationSwitcher_organizations
...NavUser_viewer
}
}
`;
function AppSidebarContent({
queryRef,
...props
}: React.ComponentProps<typeof Sidebar> & {
queryRef: PreloadedQuery<AppSidebarQueryType>;
}) {
const data = usePreloadedQuery(AppSidebarQuery, queryRef);
return (
<AppSidebarShell
organizationSwitcher={
<OrganizationSwitcher organizations={data.viewer} />
}
navMain={<NavMain />}
navUser={<NavUser viewer={data.viewer} />}
{...props}
/>
);
}
export function AppSidebar(props: React.ComponentProps<typeof Sidebar>) {
const [queryRef, loadQuery] =
useQueryLoader<AppSidebarQueryType>(AppSidebarQuery);
useEffect(() => {
loadQuery({});
}, [loadQuery]);
if (!queryRef) {
return <AppSidebarSkeleton {...props} />;
}
return (
<Suspense fallback={<AppSidebarSkeleton {...props} />}>
<AppSidebarContent queryRef={queryRef} {...props} />
</Suspense>
);
}

View File

@@ -1,32 +0,0 @@
import { NavSecondary } from "./NavSecondary";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
} from "./ui/sidebar";
import { ReactNode } from "react";
interface AppSidebarShellProps extends React.ComponentProps<typeof Sidebar> {
navUser: ReactNode;
navMain: ReactNode;
organizationSwitcher: ReactNode;
}
export function AppSidebarShell({
navUser,
navMain,
organizationSwitcher,
...props
}: AppSidebarShellProps) {
return (
<Sidebar variant="sidebar" {...props}>
<SidebarHeader>{organizationSwitcher}</SidebarHeader>
<SidebarContent>
{navMain}
<NavSecondary className="mt-auto" />
</SidebarContent>
<SidebarFooter>{navUser}</SidebarFooter>
</Sidebar>
);
}

View File

@@ -1,18 +0,0 @@
import { AppSidebarShell } from "./AppSidebarShell";
import { NavMainSkeleton } from "./NavMainSkeleton";
import { NavUserSkeleton } from "./NavUserSkeleton";
import { OrganizationSwitcherSkeleton } from "./OrganizationSwitcherSkeleton";
import { Sidebar } from "./ui/sidebar";
export function AppSidebarSkeleton(
props: React.ComponentProps<typeof Sidebar>,
) {
return (
<AppSidebarShell
organizationSwitcher={<OrganizationSwitcherSkeleton />}
navMain={<NavMainSkeleton />}
navUser={<NavUserSkeleton />}
{...props}
/>
);
}

View File

@@ -1,284 +0,0 @@
.document-editor-container {
border-radius: 0.5rem;
position: relative;
line-height: 1.5;
font-weight: 400;
margin-top: 0.5rem;
border: 1px solid var(--solid-b);
display: block;
width: 100%;
}
.document-editor {
background: var(--bg-invert-bg);
position: relative;
border-radius: 0.5rem;
display: flex;
flex-direction: column;
width: 100%;
}
.document-editor-inner {
background: #fff;
position: relative;
border-radius: 0 0 0.5rem 0.5rem;
flex: 1;
min-height: 250px;
}
.document-editor-input {
min-height: 250px;
max-height: 500px;
resize: none;
font-size: 15px;
position: relative;
tab-size: 1;
outline: 0;
padding: 15px 10px;
overflow-y: auto;
width: 100%;
display: block;
color: #000;
}
.document-editor-placeholder {
color: #999;
overflow: hidden;
position: absolute;
text-overflow: ellipsis;
top: 15px;
left: 10px;
font-size: 15px;
user-select: none;
display: inline-block;
pointer-events: none;
}
.document-editor-paragraph {
margin: 0 0 15px 0;
position: relative;
}
.document-editor .toolbar {
display: flex;
flex-wrap: wrap;
padding: 8px;
border-bottom: 1px solid hsl(var(--solid-b));
background-color: hsl(var(--invert-bg));
border-radius: 0.5rem 0.5rem 0 0;
}
.document-editor .toolbar button {
border: 0;
display: flex;
background: none;
border-radius: 4px;
padding: 8px;
cursor: pointer;
vertical-align: middle;
align-items: center;
justify-content: center;
}
.document-editor .toolbar button:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.document-editor .toolbar button.active {
background-color: rgba(0, 0, 0, 0.1);
}
.document-editor .toolbar .divider {
width: 1px;
background-color: hsl(var(--solid-b));
margin: 0 8px;
height: 24px;
}
/* Typography */
.editor-heading-h1 {
font-size: 24px;
font-weight: 700;
margin: 0 0 12px 0;
}
.editor-heading-h2 {
font-size: 20px;
font-weight: 700;
margin: 10px 0;
}
.editor-heading-h3 {
font-size: 18px;
font-weight: 600;
margin: 8px 0;
}
.editor-heading-h4 {
font-size: 16px;
font-weight: 600;
margin: 8px 0;
}
.editor-heading-h5 {
font-size: 14px;
font-weight: 600;
margin: 8px 0;
}
.editor-quote {
margin: 0;
margin-left: 20px;
padding-left: 16px;
border-left: 4px solid hsl(var(--solid-b));
color: hsl(var(--tertiary));
}
.editor-list-ol {
padding: 0;
margin: 0 0 0 16px;
list-style-type: decimal;
display: block;
counter-reset: li;
}
.editor-list-ul {
padding: 0;
margin: 0 0 0 16px;
list-style-type: disc;
display: block;
}
.editor-listitem {
margin: 8px 0 0 0;
display: list-item;
position: relative;
padding-left: 8px;
}
/* Ensure list items display consistently across browsers */
li.editor-listitem::marker {
font-weight: normal;
color: inherit;
}
/* Specifically for ordered lists */
ol.editor-list-ol > .editor-listitem {
list-style-type: decimal;
list-style-position: outside;
}
/* For nested ordered lists */
ol.editor-list-ol ol.editor-list-ol > .editor-listitem {
list-style-type: lower-alpha;
}
.editor-listitem--checked,
.editor-listitem--unchecked {
position: relative;
margin-left: 0;
padding-left: 24px;
list-style-type: none;
}
.editor-listitem--checked:before,
.editor-listitem--unchecked:before {
content: '';
width: 16px;
height: 16px;
position: absolute;
left: 0;
top: 2px;
background-size: 16px;
background-position: center;
background-repeat: no-repeat;
}
.editor-listitem--checked:before {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 11 12 14 22 4"></polyline><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path></svg>');
}
.editor-listitem--unchecked:before {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect></svg>');
}
.editor-nested-listitem {
list-style-type: none;
}
/* Text formatting */
.editor-text-bold {
font-weight: bold;
}
.editor-text-italic {
font-style: italic;
}
.editor-text-underline {
text-decoration: underline;
}
.editor-text-strikethrough {
text-decoration: line-through;
}
.editor-text-underlineStrikethrough {
text-decoration: underline line-through;
}
.editor-text-code {
background-color: hsl(var(--invert-bg));
padding: 1px 4px;
border-radius: 4px;
font-family: monospace;
}
.editor-link {
color: hsl(var(--primary));
text-decoration: underline;
}
.editor-code {
background-color: hsl(var(--invert-bg));
font-family: monospace;
display: block;
padding: 8px 16px;
line-height: 1.6;
font-size: 13px;
border-radius: 0.5rem;
margin: 8px 0;
}
/* Code highlight */
.editor-tokenComment {
color: slategray;
}
.editor-tokenPunctuation {
color: #999;
}
.editor-tokenProperty {
color: #905;
}
.editor-tokenSelector {
color: #690;
}
.editor-tokenOperator {
color: #9a6e3a;
}
.editor-tokenAttr {
color: #07a;
}
.editor-tokenVariable {
color: #e90;
}
.editor-tokenFunction {
color: #dd4a68;
}

View File

@@ -1,66 +0,0 @@
import { HelpCircle } from "lucide-react";
import { Label } from "./ui/label";
import { Textarea } from "./ui/textarea";
import { Input } from "./ui/input";
import { cn } from "@/lib/utils";
export function EditableField({
label,
value,
onChange,
type = "text",
helpText,
required,
multiline = false,
}: {
label: string;
value: string;
onChange: (value: string) => void;
type?: string;
helpText?: string;
required?: boolean;
multiline?: boolean;
}) {
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label htmlFor={label} className="text-sm font-medium">
{label}
{required && <span className="text-red-500">*</span>}
</Label>
{helpText && (
<div className="relative flex items-center">
<HelpCircle className="h-4 w-4 text-tertiary" />
<span className="sr-only">{helpText}</span>
</div>
)}
</div>
{multiline ? (
<Textarea
id={label}
value={value}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
onChange(e.target.value)
}
className={cn(
"w-full resize-none",
required && !value && "border-red-500",
)}
placeholder={`Enter ${label.toLowerCase()}`}
rows={4}
/>
) : (
<Input
id={label}
type={type}
value={value}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange(e.target.value)
}
className={cn("w-full", required && !value && "border-red-500")}
placeholder={`Enter ${label.toLowerCase()}`}
/>
)}
</div>
);
}

View File

@@ -1,51 +0,0 @@
import { Component, ErrorInfo, ReactNode } from "react";
import { ErrorPage } from "@/pages/ErrorPage";
import { UnAuthenticatedError } from "@/RelayEnvironment";
import { Navigate } from "react-router";
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
isUnAuthenticated: boolean;
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, isUnAuthenticated: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
if (error instanceof UnAuthenticatedError) {
return { hasError: true, error, isUnAuthenticated: true };
}
return { hasError: true, error, isUnAuthenticated: false };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error("ErrorBoundary caught an error:", error);
console.error("Error info:", info.componentStack);
}
render(): ReactNode {
if (this.state.isUnAuthenticated) {
return <Navigate to="/login" replace state={{ authRequired: true }} />;
}
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return <ErrorPage error={this.state.error} />;
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@@ -0,0 +1,15 @@
import { availableFrameworks } from "@probo/helpers";
import { Avatar } from "@probo/ui";
const availableLogos = new Map(
availableFrameworks.map((framework) => [framework.name, framework.logo])
);
export function FrameworkLogo({ name }: { name: string }) {
const logo = availableLogos.get(name);
return logo ? (
<img src={logo} alt="" className="size-12" />
) : (
<Avatar name={name} size="l" className="size-12" />
);
}

View File

@@ -1,210 +0,0 @@
"use client";
import {
ChevronRight,
SquareCheck,
Inbox,
Store,
type LucideIcon,
Flame,
BookOpen,
FileText,
Settings,
Users,
Box,
Database
} from "lucide-react";
import { Link, useLocation, useParams } from "react-router";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
SidebarGroup,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from "@/components/ui/sidebar";
interface NavItem {
title: string;
url?: string;
icon: LucideIcon;
isActive?: boolean;
items?: {
title: string;
url: string;
icon: LucideIcon;
}[];
}
export function NavMain() {
const location = useLocation();
const { organizationId } = useParams();
const items: NavItem[] = getNavItems(organizationId);
if (!organizationId) {
return null;
}
const isItemActive = (item: { url?: string; items?: { url: string }[] }) => {
if (item.url && location.pathname.startsWith(item.url)) {
return true;
}
if (item.items?.length) {
return item.items.some((subItem) =>
location.pathname.startsWith(subItem.url),
);
}
return false;
};
return (
<SidebarGroup>
<SidebarMenu>
{items.map((item) => {
const active = isItemActive(item) || item.isActive;
return (
<Collapsible key={item.title} asChild defaultOpen={active}>
<SidebarMenuItem>
<SidebarMenuButton
asChild
tooltip={item.title}
data-active={active ? "true" : undefined}
>
<Link to={item.url ?? "#"}>
<item.icon
className={active ? "text-lime-9" : "text-lime-6"}
/>
<span className="font-medium">{item.title}</span>
</Link>
</SidebarMenuButton>
{item.items?.length ? (
<>
<CollapsibleTrigger asChild>
<SidebarMenuAction className="data-[state=open]:rotate-90">
<ChevronRight />
<span className="sr-only">Toggle</span>
</SidebarMenuAction>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items?.map((subItem) => {
const subItemActive = location.pathname.startsWith(
subItem.url,
);
return (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton
asChild
data-active={subItemActive ? "true" : undefined}
>
<Link to={subItem.url}>
<subItem.icon
className={
subItemActive
? "text-lime-9"
: "text-lime-6"
}
/>
<span className="font-medium">
{subItem.title}
</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
);
})}
</SidebarMenuSub>
</CollapsibleContent>
</>
) : null}
</SidebarMenuItem>
</Collapsible>
);
})}
</SidebarMenu>
</SidebarGroup>
);
}
function getNavItems(organizationId?: string): NavItem[] {
return [
{
title: "Tasks",
icon: Inbox,
url: organizationId
? `/organizations/${organizationId}/tasks`
: undefined,
},
{
title: "Measures",
icon: SquareCheck,
url: organizationId
? `/organizations/${organizationId}/measures`
: undefined,
},
{
title: "Risks",
icon: Flame,
url: organizationId
? `/organizations/${organizationId}/risks`
: undefined,
},
{
title: "Frameworks",
url: organizationId
? `/organizations/${organizationId}/frameworks`
: undefined,
icon: BookOpen,
},
{
title: "People",
url: `/organizations/${organizationId}/people`,
icon: Users,
},
{
title: "Vendors",
url: `/organizations/${organizationId}/vendors`,
icon: Store,
},
{
title: "Documents",
url: organizationId
? `/organizations/${organizationId}/documents`
: undefined,
icon: FileText,
},
{
title: "Assets",
url: organizationId
? `/organizations/${organizationId}/assets`
: undefined,
icon: Box,
},
{
title: "Data",
url: organizationId
? `/organizations/${organizationId}/data`
: undefined,
icon: Database,
},
{
title: "Settings",
url: organizationId
? `/organizations/${organizationId}/settings`
: undefined,
icon: Settings,
},
];
}

View File

@@ -1,55 +0,0 @@
"use client";
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
export function NavMainSkeleton() {
return (
<SidebarGroup>
<SidebarGroupLabel className="pl-3">
<div className="py-0.5">
<div className="h-3 w-24 rounded-sm bg-subtle-bg animate-pulse" />
</div>
</SidebarGroupLabel>
<SidebarMenu className="space-y-1.5">
<SidebarMenuItem>
<SidebarMenuButton className="animate-pulse">
<div className="h-4 w-4 rounded-md bg-lime-5" />
<div className="py-[3px]">
<div className="h-3.5 w-32 rounded-md bg-subtle-bg" />
</div>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton className="animate-pulse">
<div className="h-4 w-4 rounded-md bg-lime-6" />
<div className="py-[3px]">
<div className="h-3.5 w-32 rounded-md bg-subtle-bg" />
</div>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton className="animate-pulse">
<div className="h-4 w-4 rounded-md bg-lime-6" />
<div className="py-[3px]">
<div className="h-3.5 w-32 rounded-md bg-subtle-bg" />
</div>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton className="animate-pulse">
<div className="h-4 w-4 rounded-md bg-lime-6" />
<div className="py-[3px]">
<div className="h-3.5 w-32 rounded-md bg-subtle-bg" />
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
);
}

View File

@@ -1,60 +0,0 @@
import * as React from "react";
import { LifeBuoy, Send } from "lucide-react";
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
const navItems = [
{
title: "Support",
url: "mailto:support@getprobo.com",
icon: LifeBuoy,
},
{
title: "Feedback",
url: "#",
icon: Send,
},
];
export function NavSecondary(
props: React.ComponentPropsWithoutRef<typeof SidebarGroup>,
) {
return (
<SidebarGroup {...props}>
<SidebarGroupContent>
<SidebarMenu>
{navItems.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild={item.url !== "#"}
size="sm"
tooltip={item.title}
className={
item.url === "#" ? "opacity-50 cursor-not-allowed" : ""
}
>
{item.url !== "#" ? (
<a href={item.url}>
<item.icon />
<span className="font-medium">{item.title}</span>
</a>
) : (
<>
<item.icon />
<span className="font-medium">{item.title}</span>
</>
)}
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}

View File

@@ -1,100 +0,0 @@
"use client";
import { ChevronsUpDown, LogOut } from "lucide-react";
import { graphql, useFragment } from "react-relay";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import { NavUser_viewer$key } from "./__generated__/NavUser_viewer.graphql";
import { buildEndpoint } from "@/utils";
export const navUserFragment = graphql`
fragment NavUser_viewer on Viewer {
user {
id
fullName
email
}
}
`;
export function NavUser({ viewer }: { viewer: NavUser_viewer$key }) {
const { isMobile } = useSidebar();
const currentUser = useFragment(navUserFragment, viewer).user;
const handleLogout = async () => {
fetch(buildEndpoint("/api/console/v1/auth/logout"), {
method: "DELETE",
credentials: "include",
}).then(() => {
window.location.href = "https://www.getprobo.com";
});
};
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton size="lg" variant="outline">
<Avatar className="h-9 w-9">
<AvatarFallback className="bg-highlight-bg">
{currentUser.fullName.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{currentUser.fullName}
</span>
<span className="truncate text-xs text-secondary/70">
{currentUser.email}
</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 bg-highlight-bg">
<AvatarFallback>
{currentUser.fullName.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{currentUser.fullName}
</span>
<span className="truncate text-xs">{currentUser.email}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOut />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}

View File

@@ -1,29 +0,0 @@
"use client";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
export function NavUserSkeleton() {
return (
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
isActive
disabled
size="lg"
className="data-[state=open]:bg-accent data-[state=open]:text-accent animate-pulse"
>
<div className="bg-subtle-bg size-9 rounded-full animate-pulse" />
<div className="flex-1 space-y-[3px] animate-pulse">
<div className="h-3.5 w-20 rounded-sm bg-subtle-bg" />
<div className="h-3.5 w-30 rounded-sm bg-subtle-bg" />
</div>
<div className="ml-auto size-4 rounded-lg bg-subtle-bg" />
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
);
}

View File

@@ -1,180 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { ChevronsUpDown, Plus } from "lucide-react";
import { graphql, useFragment } from "react-relay";
import { Link, useNavigate, useParams } from "react-router";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import {
OrganizationSwitcher_organizations$key,
OrganizationSwitcher_organizations$data,
} from "./__generated__/OrganizationSwitcher_organizations.graphql";
import { cn } from "@/lib/utils";
export const organizationSwitcherFragment = graphql`
fragment OrganizationSwitcher_organizations on Viewer {
organizations(first: 25)
@connection(key: "OrganizationSwitcher_organizations") {
__id
edges {
node {
id
name
logoUrl
}
}
}
}
`;
type Organization =
OrganizationSwitcher_organizations$data["organizations"]["edges"][0]["node"];
const LogoComponent = ({
org,
className,
}: {
org: Organization;
className?: string;
}) => {
if (org.logoUrl) {
return (
<img
src={org.logoUrl}
alt={org.name}
className={cn("rounded-md", className)}
/>
);
}
return org.name.substring(0, 2).toUpperCase();
};
export function OrganizationSwitcher({
organizations,
}: {
organizations: OrganizationSwitcher_organizations$key;
}) {
const { isMobile } = useSidebar();
const data = useFragment(organizationSwitcherFragment, organizations);
const navigate = useNavigate();
const { organizationId } = useParams();
const [currentOrganization, setCurrentOrganization] =
useState<Organization | null>(null);
const hasOrganizations =
data.organizations && data.organizations.edges.length > 0;
useEffect(() => {
if (hasOrganizations) {
const org = data.organizations.edges.find(
(edge) => edge.node.id === organizationId,
);
if (org) {
setCurrentOrganization(org.node);
}
}
}, [data.organizations, organizationId, hasOrganizations]);
const handleOrganizationSwitch = (org: Organization) => {
navigate(`/organizations/${org.id}`);
};
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
variant="ghost"
className={cn(
"gap-2.5",
!currentOrganization && "border border-dashed",
)}
>
<div className="flex aspect-square size-8 items-center justify-center rounded-sm bg-highlight-bg">
{currentOrganization ? (
<LogoComponent org={currentOrganization} className="size-8" />
) : (
<div className="size-8 bg-highlight-bg rounded-sm" />
)}
</div>
<div className="grid text-left leading-tight text-primary">
{currentOrganization ? (
<>
<span className="truncate font-medium text-lg leading-5">
{currentOrganization.name}
</span>
</>
) : (
<span className="truncate text-secondary font-medium">
Select Organization
</span>
)}
</div>
<ChevronsUpDown className="ml-auto" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
align="start"
side={isMobile ? "bottom" : "right"}
sideOffset={4}
>
<DropdownMenuLabel className="text-xs text-tertiary">
Organizations
</DropdownMenuLabel>
{hasOrganizations &&
data.organizations.edges.map((edge, index) => (
<DropdownMenuItem
key={edge.node.id}
onClick={() => handleOrganizationSwitch(edge.node)}
className={`gap-2 p-2 ${
edge.node.id === organizationId ? "bg-subtle-bg" : ""
}`}
>
<div className="flex size-6 items-center justify-center rounded-sm bg-highlight-bg">
<LogoComponent
org={edge.node}
className="size-4 shrink-0"
/>
</div>
{edge.node.name}
<DropdownMenuShortcut>⌘{index + 1}</DropdownMenuShortcut>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
to="/organizations/new"
className="gap-2 p-2 cursor-pointer"
>
<div className="flex size-6 items-center justify-center rounded-md border bg-secondary-bg">
<Plus className="size-4" />
</div>
<div className="font-medium text-tertiary">
Add organization
</div>
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}

View File

@@ -1,31 +0,0 @@
"use client";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
export function OrganizationSwitcherSkeleton() {
return (
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-accent-bg data-[state=open]:text-accent gap-2.5"
>
<div className="size-8 items-center justify-center rounded-md bg-subtle-bg animate-pulse" />
<div className="flex flex-col items-start">
<div className="py-[1px]">
<div className="h-4.5 animate-pulse w-16 rounded-sm bg-subtle-bg" />
</div>
<div className="py-0.5">
<div className="h-3 animate-pulse w-8 rounded-sm bg-subtle-bg" />
</div>
</div>
<div className="ml-auto size-4 rounded-lg bg-subtle-bg animate-pulse" />
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
);
}

View File

@@ -1,26 +0,0 @@
import { FC, ReactNode } from "react";
import { Helmet } from "react-helmet-async";
export interface PageContainerProps {
children: ReactNode;
title: string;
}
export const PageContainer: FC<PageContainerProps> = ({ children, title }) => {
return (
<>
<Helmet>{`${title} - Probo Console`}</Helmet>
<div className="container">{children}</div>
</>
);
};
export const PageContainerSkeleton: FC<{
title?: string;
children: ReactNode;
}> = ({ children, title }) =>
title ? (
<PageContainer title={title}>{children}</PageContainer>
) : (
<div className="container">{children}</div>
);

View File

@@ -0,0 +1,60 @@
import { useLocation, useRouteError } from "react-router";
import { IconPageCross } from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { useEffect, useRef } from "react";
const classNames = {
wrapper: "py-10 text-center space-y-2 ",
title: "text-2xl flex gap-2 font-semibold items-center justify-center",
description: "text-base text-txt-tertiary",
detail:
"text-sm text-txt-tertiary font-mono text-start border border-border-low p-2 rounded bg-level-1 mt-2",
};
type Props = {
resetErrorBoundary?: () => void;
error?: string;
};
export function PageError({ resetErrorBoundary, error: propsError }: Props) {
const error = useRouteError() ?? propsError;
const { __ } = useTranslate();
const location = useLocation();
const baseLocation = useRef(location);
// Reset error boundary on page change
useEffect(() => {
if (
location.pathname !== baseLocation.current.pathname &&
resetErrorBoundary
) {
resetErrorBoundary();
}
}, [location, resetErrorBoundary]);
if (!error) {
return (
<div className={classNames.wrapper}>
<h1 className={classNames.title}>
<IconPageCross size={26} />
{__("Page not found")}
</h1>
<p className={classNames.description}>
{__("The page you are looking for does not exist")}
</p>
</div>
);
}
return (
<div className={classNames.wrapper}>
<h1 className={classNames.title}>{__("Unexpected error :(")}</h1>
<details>
<summary className={classNames.description}>
{__("Something went wrong")}
</summary>
<p className={classNames.detail}>{error.toString()}</p>
</details>
</div>
);
}

View File

@@ -1,110 +0,0 @@
import { cn } from "@/lib/utils";
import { FC, ReactNode } from "react";
export interface PageHeaderProps {
title: ReactNode;
actions?: ReactNode;
description?: ReactNode;
}
export function PageHeaderShell({
className,
title,
actions,
description,
}: PageHeaderProps & { className?: string }) {
return (
<div className={cn("space-y-6", className)}>
{actions ? (
<div className="flex items-center justify-between">
{title}
{actions}
</div>
) : (
title
)}
{description}
</div>
);
}
export function PageHeader({
className,
title,
actions,
description,
}: PageHeaderProps & { className?: string }) {
return (
<PageHeaderShell
className={className}
title={<PageHeading>{title}</PageHeading>}
description={<PageDescription>{description}</PageDescription>}
actions={actions}
/>
);
}
export function PageHeading({
children,
className,
}: {
className?: string;
children: ReactNode;
}) {
return <h1 className={cn("text-3xl font-medium", className)}>{children}</h1>;
}
const PageHeadingSkeleton: FC = () => {
return (
<div className="py-[3px] w-2/5">
<div className="bg-subtle-bg animate rounded-lg h-7.5" />
</div>
);
};
export function PageDescription({
children,
className,
}: {
className?: string;
children: ReactNode;
}) {
return (
<div className={cn("text-md text-tertiary text-left", className)}>
{children}
</div>
);
}
export function PageDescriptionSkeleton() {
return (
<div className="py-[5px]">
<div className="bg-subtle-bg animate rounded-md w-100 h-4.5" />
</div>
);
}
export const PageHeaderSkeleton: FC<{
className?: string;
title?: string;
actions?: ReactNode;
description?: ReactNode;
withDescription?: boolean;
}> = ({ className, title, actions, description, withDescription = false }) => {
return (
<PageHeaderShell
className={className}
title={
title ? <PageHeading>{title}</PageHeading> : <PageHeadingSkeleton />
}
description={
description ? (
<PageDescription>{description}</PageDescription>
) : withDescription ? (
<PageDescriptionSkeleton />
) : null
}
actions={actions}
/>
);
};

View File

@@ -1,47 +0,0 @@
import { FC, ReactNode } from "react";
import {
PageContainer,
PageContainerProps,
PageContainerSkeleton,
} from "./PageContainer";
import { PageHeader, PageHeaderProps, PageHeaderSkeleton } from "./PageHeader";
export const PageTemplate: FC<PageContainerProps & PageHeaderProps> = ({
actions,
title,
description,
children,
}) => {
return (
<PageContainer title={title}>
<PageHeader
className="mb-12"
title={title}
description={description}
actions={actions}
/>
{children}
</PageContainer>
);
};
export const PageTemplateSkeleton: FC<{
title?: string;
description?: ReactNode;
withDescription?: boolean;
actions?: ReactNode;
children: ReactNode;
}> = ({ actions, children, description, withDescription = false, title }) => {
return (
<PageContainerSkeleton title={title}>
<PageHeaderSkeleton
className="mb-17"
title={title}
description={description}
withDescription={withDescription}
actions={actions}
/>
{children}
</PageContainerSkeleton>
);
};

View File

@@ -1,74 +0,0 @@
import { useState, useEffect } from "react";
import { graphql, useFragment } from "react-relay";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { PeopleSelector_organization$key } from "./__generated__/PeopleSelector_organization.graphql";
const peopleSelectorFragment = graphql`
fragment PeopleSelector_organization on Organization {
id
peoples(first: 100, orderBy: { direction: ASC, field: FULL_NAME })
@connection(key: "PeopleSelector_organization_peoples") {
edges {
node {
id
fullName
primaryEmailAddress
}
}
}
}
`;
interface PeopleSelectorProps {
organizationRef: PeopleSelector_organization$key;
selectedPersonId: string | null;
onSelect: (personId: string) => void;
placeholder?: string;
required?: boolean;
}
export default function PeopleSelector({
organizationRef,
selectedPersonId,
onSelect,
placeholder = "Select a person",
required = false,
}: PeopleSelectorProps) {
const organization = useFragment(peopleSelectorFragment, organizationRef);
const [value, setValue] = useState<string>(selectedPersonId || "");
useEffect(() => {
if (selectedPersonId) {
setValue(selectedPersonId);
}
}, [selectedPersonId]);
const handleValueChange = (newValue: string) => {
setValue(newValue);
onSelect(newValue);
};
return (
<Select value={value} onValueChange={handleValueChange} required={required}>
<SelectTrigger>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{organization.peoples?.edges?.map(
(edge) =>
edge?.node && (
<SelectItem key={edge.node.id} value={edge.node.id}>
{edge.node.fullName} ({edge.node.primaryEmailAddress})
</SelectItem>
),
)}
</SelectContent>
</Select>
);
}

View File

@@ -0,0 +1,80 @@
import { IconChevronTriangleDownSmall, Table, Th } from "@probo/ui";
import clsx from "clsx";
import {
createContext,
startTransition,
useContext,
useState,
type ComponentProps,
} from "react";
type Order = {
direction: string;
field: string;
};
const SortableContext = createContext({
order: {
direction: "DESC",
field: "CREATED_AT",
},
onOrderChange: (() => {}) as (order: Order) => void,
});
const defaultOrder = {
direction: "DESC",
field: "CREATED_AT",
} as Order;
export function SortableTable({
refetch,
...props
}: ComponentProps<typeof Table> & {
refetch: (o: { order: Order }) => void;
}) {
const [order, setOrder] = useState(defaultOrder);
const onOrderChange = (o: Order) => {
startTransition(() => {
setOrder(o);
refetch({ order: o });
});
};
return (
<SortableContext value={{ order, onOrderChange }}>
<Table {...props} />
</SortableContext>
);
}
export function SortableTh({
children,
field,
...props
}: ComponentProps<typeof Th> & { field: string }) {
const { order, onOrderChange } = useContext(SortableContext);
const isCurrentField = order.field === field;
const isDesc = order.direction === "DESC";
const changeOrder = () => {
onOrderChange({
direction: isDesc && isCurrentField ? "ASC" : "DESC",
field,
});
};
return (
<Th {...props}>
<button
className="flex items-center cursor-pointer hover:text-txt-primary"
onClick={changeOrder}
>
{children}
<IconChevronTriangleDownSmall
size={16}
className={clsx(
isCurrentField && "text-txt-primary",
isCurrentField && !isDesc && "rotate-180"
)}
/>
</button>
</Th>
);
}

View File

@@ -1,48 +0,0 @@
import { Component, ErrorInfo, ReactNode } from "react";
import { ErrorPage } from "@/pages/ErrorPage";
import { UnAuthenticatedError } from "@/RelayEnvironment";
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
}
class VisitorErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
if (error instanceof UnAuthenticatedError) {
return { hasError: false, error: undefined };
}
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error("ErrorBoundary caught an error:", error);
console.error("Error info:", info.componentStack);
}
render(): ReactNode {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return <ErrorPage error={this.state.error} />;
}
return this.props.children;
}
}
export default VisitorErrorBoundary;

View File

@@ -1,240 +0,0 @@
/**
* @generated SignedSource<<72d01b98680bc82c2d5bdc8c2fa2d49d>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type AppSidebarQuery$variables = Record<PropertyKey, never>;
export type AppSidebarQuery$data = {
readonly viewer: {
readonly id: string;
readonly " $fragmentSpreads": FragmentRefs<"NavUser_viewer" | "OrganizationSwitcher_organizations">;
};
};
export type AppSidebarQuery = {
response: AppSidebarQuery$data;
variables: AppSidebarQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v1 = [
{
"kind": "Literal",
"name": "first",
"value": 25
}
];
return {
"fragment": {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "AppSidebarQuery",
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
"selections": [
(v0/*: any*/),
{
"args": null,
"kind": "FragmentSpread",
"name": "OrganizationSwitcher_organizations"
},
{
"args": null,
"kind": "FragmentSpread",
"name": "NavUser_viewer"
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [],
"kind": "Operation",
"name": "AppSidebarQuery",
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
"selections": [
(v0/*: any*/),
{
"alias": null,
"args": (v1/*: any*/),
"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
},
{
"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
}
]
}
],
"storageKey": "organizations(first:25)"
},
{
"alias": null,
"args": (v1/*: any*/),
"filters": null,
"handle": "connection",
"key": "OrganizationSwitcher_organizations",
"kind": "LinkedHandle",
"name": "organizations"
},
{
"alias": null,
"args": null,
"concreteType": "User",
"kind": "LinkedField",
"name": "user",
"plural": false,
"selections": [
(v0/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "email",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "51c72d5d6b9e1d4e5e7f45e7280a0534",
"id": null,
"metadata": {},
"name": "AppSidebarQuery",
"operationKind": "query",
"text": "query AppSidebarQuery {\n viewer {\n id\n ...OrganizationSwitcher_organizations\n ...NavUser_viewer\n }\n}\n\nfragment NavUser_viewer on Viewer {\n user {\n id\n fullName\n email\n }\n}\n\nfragment OrganizationSwitcher_organizations on Viewer {\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"
}
};
})();
(node as any).hash = "ebe1f481a1454380e017fb976b8bb8a5";
export default node;

View File

@@ -1,71 +0,0 @@
/**
* @generated SignedSource<<0d5ada0f912fcf3a8f2f3328b0943b28>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type NavUser_viewer$data = {
readonly user: {
readonly email: string;
readonly fullName: string;
readonly id: string;
};
readonly " $fragmentType": "NavUser_viewer";
};
export type NavUser_viewer$key = {
readonly " $data"?: NavUser_viewer$data;
readonly " $fragmentSpreads": FragmentRefs<"NavUser_viewer">;
};
const node: ReaderFragment = {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "NavUser_viewer",
"selections": [
{
"alias": null,
"args": null,
"concreteType": "User",
"kind": "LinkedField",
"name": "user",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "email",
"storageKey": null
}
],
"storageKey": null
}
],
"type": "Viewer",
"abstractKey": null
};
(node as any).hash = "05e26e238976738b4cff26f37c5f29e7";
export default node;

View File

@@ -0,0 +1,163 @@
import { graphql } from "relay-runtime";
import {
Button,
Tr,
Td,
Thead,
Tbody,
Th,
IconTrashCan,
Badge,
TrButton,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import type { LinkedControlsCardFragment$key } from "./__generated__/LinkedControlsCardFragment.graphql";
import { useFragment } from "react-relay";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { LinkedControlsDialog } from "./LinkedControlsDialog";
import { SortableTable, SortableTh } from "../SortableTable";
import type { ComponentProps } from "react";
const linkedControlFragment = graphql`
fragment LinkedControlsCardFragment on Control {
id
name
sectionTitle
framework {
id
name
}
}
`;
type Mutation<Params> = (p: {
variables: {
input: {
controlId: string;
} & Params;
connections: string[];
};
}) => void;
type Props<Params> = {
// Controls linked to the element
controls: (LinkedControlsCardFragment$key & { id: string })[];
// Extra params to send to the mutation
params: Params;
// Disable (action when loading for instance)
disabled?: boolean;
// ID of the connection to update
connectionId: string;
// Mutation to detach a control (will receive {controlId, ...params})
onDetach: Mutation<Params>;
// Mutation to attach a control (will receive {controlId, ...params})
onAttach?: Mutation<Params>;
// Allow sorting in the table
refetch: ComponentProps<typeof SortableTable>["refetch"];
};
/**
* Reusable component that displays a list of linked controls
*/
export function LinkedControlsCard<Params>(props: Props<Params>) {
const { __ } = useTranslate();
const controls = props.controls;
const onDetach = (controlId: string) => {
props.onDetach({
variables: {
input: {
controlId,
...props.params,
},
connections: [props.connectionId],
},
});
};
const onAttach = (controlId: string) => {
if (!props.onAttach) {
return;
}
props.onAttach({
variables: {
input: {
controlId,
...props.params,
},
connections: [props.connectionId],
},
});
};
return (
<SortableTable refetch={props.refetch as any}>
<Thead>
<Tr>
<SortableTh field="SECTION_TITLE">{__("Reference")}</SortableTh>
<Th>{__("Name")}</Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{controls.length === 0 && (
<Tr>
<Td colSpan={4} className="text-center text-txt-secondary">
{__("No controls linked")}
</Td>
</Tr>
)}
{controls.map((control) => (
<ControlRow
key={control.id}
control={control}
onClick={onDetach}
onAttach={onAttach}
/>
))}
<LinkedControlsDialog
connectionId={props.connectionId}
disabled={props.disabled}
linkedControls={controls}
onLink={onAttach}
onUnlink={onDetach}
>
<TrButton colspan={3}>{__("Link control")}</TrButton>
</LinkedControlsDialog>
</Tbody>
</SortableTable>
);
}
function ControlRow(props: {
control: LinkedControlsCardFragment$key & { id: string };
onClick: (controlId: string) => void;
onAttach?: (controlId: string) => void;
}) {
const control = useFragment(linkedControlFragment, props.control);
const organizationId = useOrganizationId();
const { __ } = useTranslate();
return (
<Tr
to={`/organizations/${organizationId}/frameworks/${control.framework.id}/controls/${control.id}`}
>
<Td>
<span className="inline-flex gap-2 items-center">
{control.framework.name}{" "}
<Badge size="md">{control.sectionTitle}</Badge>
</span>
</Td>
<Td>{control.name}</Td>
<Td noLink width={50} className="text-end">
<Button
variant="secondary"
onClick={() => props.onClick(control.id)}
icon={IconTrashCan}
>
{__("Unlink")}
</Button>
</Td>
</Tr>
);
}

View File

@@ -0,0 +1,199 @@
import {
Badge,
Button,
Dialog,
DialogContent,
IconMagnifyingGlass,
IconPlusLarge,
IconTrashCan,
InfiniteScrollTrigger,
Input,
Spinner,
} from "@probo/ui";
import {
Suspense,
useMemo,
useRef,
useState,
type ReactNode,
type RefObject,
} from "react";
import { useTranslate } from "@probo/i18n";
import { graphql } from "relay-runtime";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { useLazyLoadQuery, usePaginationFragment } from "react-relay";
import type { LinkedControlsDialogQuery } from "./__generated__/LinkedControlsDialogQuery.graphql";
import type {
LinkedControlsDialogFragment$data,
LinkedControlsDialogFragment$key,
} from "./__generated__/LinkedControlsDialogFragment.graphql";
import type { NodeOf } from "/types";
import { useDebounceCallback } from "usehooks-ts";
const query = graphql`
query LinkedControlsDialogQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
id
...LinkedControlsDialogFragment
}
}
`;
const controlsFragment = graphql`
fragment LinkedControlsDialogFragment on Organization
@argumentDefinitions(
first: { type: "Int", defaultValue: 1 }
after: { type: "CursorKey" }
last: { type: "Int", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
order: { type: "ControlOrder", defaultValue: null }
filter: { type: "ControlFilter", defaultValue: null }
)
@refetchable(queryName: "LinkedControlsDialogControlsQuery") {
controls(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
filter: $filter
) @connection(key: "LinkedControlsDialogControlsQuery_controls") {
edges {
node {
id
name
sectionTitle
framework {
name
}
}
}
}
}
`;
type Props = {
children: ReactNode;
connectionId: string;
disabled?: boolean;
linkedControls?: { id: string }[];
onLink: (controlId: string) => void;
onUnlink: (controlId: string) => void;
};
type SearchRef = RefObject<{ search: (v: string) => void } | null>;
export function LinkedControlsDialog(props: Props) {
const { __ } = useTranslate();
const searchRef: SearchRef = useRef(null);
const contentRef = useRef<HTMLDivElement>(null);
const [minHeight, setMinHeight] = useState(0);
const onSearch = (v: string) => {
setMinHeight(contentRef.current?.clientHeight ?? 0);
searchRef.current?.search(v);
};
return (
<Dialog trigger={props.children} title={__("Link controls")}>
<DialogContent>
<div className="flex items-center gap-2 sticky top-0 relative py-4 bg-linear-to-b from-50% from-level-2 to-level-2/0 px-6">
<Input
icon={IconMagnifyingGlass}
placeholder={__("Search measures...")}
onValueChange={onSearch}
/>
</div>
<div ref={contentRef}>
<Suspense
fallback={
<div style={{ minHeight }}>
<Spinner centered />
</div>
}
>
<LinkedControlsDialogContent {...props} ref={searchRef} />
</Suspense>
</div>
</DialogContent>
</Dialog>
);
}
function LinkedControlsDialogContent(props: Props & { ref: SearchRef }) {
const organizationId = useOrganizationId();
const mainData = useLazyLoadQuery<LinkedControlsDialogQuery>(query, {
organizationId,
});
const { data, loadNext, hasNext, isLoadingNext, refetch } =
usePaginationFragment(
controlsFragment,
mainData.organization as LinkedControlsDialogFragment$key
);
const controls = data.controls?.edges?.map((edge) => edge.node) ?? [];
const controlIds = useMemo(() => {
return new Set(props.linkedControls?.map((c) => c.id) ?? []);
}, [props.linkedControls]);
props.ref.current = {
search: useDebounceCallback((v: string) => {
refetch({
first: 20,
filter: {
query: v,
},
});
}, 500),
};
return (
<>
<div className="divide-y divide-border-low">
{controls.map((control) => (
<ControlRow
key={control.id}
control={control}
controlIds={controlIds}
{...props}
/>
))}
{hasNext && (
<InfiniteScrollTrigger
loading={isLoadingNext}
onView={() => loadNext(20)}
/>
)}
</div>
</>
);
}
function ControlRow(
props: {
control: NodeOf<LinkedControlsDialogFragment$data["controls"]>;
controlIds: Set<string>;
} & Props
) {
const { __ } = useTranslate();
const isLinked = props.controlIds.has(props.control.id);
const onClick = isLinked ? props.onUnlink : props.onLink;
const IconComponent = isLinked ? IconTrashCan : IconPlusLarge;
return (
<button
className="py-4 flex items-center gap-4 hover:bg-subtle cursor-pointer px-6 w-full text-start"
onClick={() => onClick(props.control.id)}
>
{props.control.sectionTitle} : {props.control.name}
<Badge>{props.control.framework.name}</Badge>
<Button
disabled={props.disabled}
className="ml-auto"
variant={isLinked ? "secondary" : "primary"}
asChild
>
<span>
<IconComponent size={16} /> {isLinked ? __("Unlink") : __("Link")}
</span>
</Button>
</button>
);
}

View File

@@ -0,0 +1,79 @@
/**
* @generated SignedSource<<780834b432440afd9363100bb20f2529>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type LinkedControlsCardFragment$data = {
readonly framework: {
readonly id: string;
readonly name: string;
};
readonly id: string;
readonly name: string;
readonly sectionTitle: string;
readonly " $fragmentType": "LinkedControlsCardFragment";
};
export type LinkedControlsCardFragment$key = {
readonly " $data"?: LinkedControlsCardFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"LinkedControlsCardFragment">;
};
const node: ReaderFragment = (function(){
var v0 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v1 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
};
return {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "LinkedControlsCardFragment",
"selections": [
(v0/*: any*/),
(v1/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "sectionTitle",
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "Framework",
"kind": "LinkedField",
"name": "framework",
"plural": false,
"selections": [
(v0/*: any*/),
(v1/*: any*/)
],
"storageKey": null
}
],
"type": "Control",
"abstractKey": null
};
})();
(node as any).hash = "741820c65c4317cac48693023c3f9bcc";
export default node;

View File

@@ -0,0 +1,351 @@
/**
* @generated SignedSource<<68d29ea810c69f34b0da8d3e66b4eb5e>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type ControlOrderField = "CREATED_AT" | "SECTION_TITLE";
export type OrderDirection = "ASC" | "DESC";
export type ControlFilter = {
query?: string | null | undefined;
};
export type ControlOrder = {
direction: OrderDirection;
field: ControlOrderField;
};
export type LinkedControlsDialogControlsQuery$variables = {
after?: any | null | undefined;
before?: any | null | undefined;
filter?: ControlFilter | null | undefined;
first?: number | null | undefined;
id: string;
last?: number | null | undefined;
order?: ControlOrder | null | undefined;
};
export type LinkedControlsDialogControlsQuery$data = {
readonly node: {
readonly " $fragmentSpreads": FragmentRefs<"LinkedControlsDialogFragment">;
};
};
export type LinkedControlsDialogControlsQuery = {
response: LinkedControlsDialogControlsQuery$data;
variables: LinkedControlsDialogControlsQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "after"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "before"
},
v2 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "filter"
},
v3 = {
"defaultValue": 1,
"kind": "LocalArgument",
"name": "first"
},
v4 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "id"
},
v5 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "last"
},
v6 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "order"
},
v7 = [
{
"kind": "Variable",
"name": "id",
"variableName": "id"
}
],
v8 = {
"kind": "Variable",
"name": "after",
"variableName": "after"
},
v9 = {
"kind": "Variable",
"name": "before",
"variableName": "before"
},
v10 = {
"kind": "Variable",
"name": "filter",
"variableName": "filter"
},
v11 = {
"kind": "Variable",
"name": "first",
"variableName": "first"
},
v12 = {
"kind": "Variable",
"name": "last",
"variableName": "last"
},
v13 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
v14 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v15 = [
(v8/*: any*/),
(v9/*: any*/),
(v10/*: any*/),
(v11/*: any*/),
(v12/*: any*/),
{
"kind": "Variable",
"name": "orderBy",
"variableName": "order"
}
],
v16 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/),
(v2/*: any*/),
(v3/*: any*/),
(v4/*: any*/),
(v5/*: any*/),
(v6/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "LinkedControlsDialogControlsQuery",
"selections": [
{
"alias": null,
"args": (v7/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"args": [
(v8/*: any*/),
(v9/*: any*/),
(v10/*: any*/),
(v11/*: any*/),
(v12/*: any*/),
{
"kind": "Variable",
"name": "order",
"variableName": "order"
}
],
"kind": "FragmentSpread",
"name": "LinkedControlsDialogFragment"
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/),
(v2/*: any*/),
(v3/*: any*/),
(v5/*: any*/),
(v6/*: any*/),
(v4/*: any*/)
],
"kind": "Operation",
"name": "LinkedControlsDialogControlsQuery",
"selections": [
{
"alias": null,
"args": (v7/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v13/*: any*/),
(v14/*: any*/),
{
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": (v15/*: any*/),
"concreteType": "ControlConnection",
"kind": "LinkedField",
"name": "controls",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "ControlEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Control",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v14/*: any*/),
(v16/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "sectionTitle",
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "Framework",
"kind": "LinkedField",
"name": "framework",
"plural": false,
"selections": [
(v16/*: any*/),
(v14/*: any*/)
],
"storageKey": null
},
(v13/*: any*/)
],
"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
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasPreviousPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "startCursor",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": (v15/*: any*/),
"filters": [
"orderBy",
"filter"
],
"handle": "connection",
"key": "LinkedControlsDialogControlsQuery_controls",
"kind": "LinkedHandle",
"name": "controls"
}
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "e9bcdc36129e6a05c59f344433459b66",
"id": null,
"metadata": {},
"name": "LinkedControlsDialogControlsQuery",
"operationKind": "query",
"text": "query LinkedControlsDialogControlsQuery(\n $after: CursorKey\n $before: CursorKey = null\n $filter: ControlFilter = null\n $first: Int = 1\n $last: Int = null\n $order: ControlOrder = null\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...LinkedControlsDialogFragment_4cFWzS\n id\n }\n}\n\nfragment LinkedControlsDialogFragment_4cFWzS on Organization {\n controls(first: $first, after: $after, last: $last, before: $before, orderBy: $order, filter: $filter) {\n edges {\n node {\n id\n name\n sectionTitle\n framework {\n name\n id\n }\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n"
}
};
})();
(node as any).hash = "9fc0bfbfc461d56748750b180bc232fc";
export default node;

View File

@@ -0,0 +1,248 @@
/**
* @generated SignedSource<<06b39b49e82019064bd42a03c903fd93>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type LinkedControlsDialogFragment$data = {
readonly controls: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly framework: {
readonly name: string;
};
readonly id: string;
readonly name: string;
readonly sectionTitle: string;
};
}>;
};
readonly id: string;
readonly " $fragmentType": "LinkedControlsDialogFragment";
};
export type LinkedControlsDialogFragment$key = {
readonly " $data"?: LinkedControlsDialogFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"LinkedControlsDialogFragment">;
};
import LinkedControlsDialogControlsQuery_graphql from './LinkedControlsDialogControlsQuery.graphql';
const node: ReaderFragment = (function(){
var v0 = [
"controls"
],
v1 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
};
return {
"argumentDefinitions": [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "after"
},
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "before"
},
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "filter"
},
{
"defaultValue": 1,
"kind": "LocalArgument",
"name": "first"
},
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "last"
},
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "order"
}
],
"kind": "Fragment",
"metadata": {
"connection": [
{
"count": null,
"cursor": null,
"direction": "bidirectional",
"path": (v0/*: any*/)
}
],
"refetch": {
"connection": {
"forward": {
"count": "first",
"cursor": "after"
},
"backward": {
"count": "last",
"cursor": "before"
},
"path": (v0/*: any*/)
},
"fragmentPathInResult": [
"node"
],
"operation": LinkedControlsDialogControlsQuery_graphql,
"identifierInfo": {
"identifierField": "id",
"identifierQueryVariableName": "id"
}
}
},
"name": "LinkedControlsDialogFragment",
"selections": [
{
"alias": "controls",
"args": [
{
"kind": "Variable",
"name": "filter",
"variableName": "filter"
},
{
"kind": "Variable",
"name": "orderBy",
"variableName": "order"
}
],
"concreteType": "ControlConnection",
"kind": "LinkedField",
"name": "__LinkedControlsDialogControlsQuery_controls_connection",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "ControlEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Control",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v1/*: any*/),
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "sectionTitle",
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "Framework",
"kind": "LinkedField",
"name": "framework",
"plural": false,
"selections": [
(v2/*: any*/)
],
"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
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasPreviousPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "startCursor",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
},
(v1/*: any*/)
],
"type": "Organization",
"abstractKey": null
};
})();
(node as any).hash = "9fc0bfbfc461d56748750b180bc232fc";
export default node;

View File

@@ -0,0 +1,253 @@
/**
* @generated SignedSource<<f8a28e26c1260135c13f22f9d35019c2>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type LinkedControlsDialogQuery$variables = {
organizationId: string;
};
export type LinkedControlsDialogQuery$data = {
readonly organization: {
readonly id: string;
readonly " $fragmentSpreads": FragmentRefs<"LinkedControlsDialogFragment">;
};
};
export type LinkedControlsDialogQuery = {
response: LinkedControlsDialogQuery$data;
variables: LinkedControlsDialogQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "organizationId"
}
],
v1 = [
{
"kind": "Variable",
"name": "id",
"variableName": "organizationId"
}
],
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
v4 = [
{
"kind": "Literal",
"name": "first",
"value": 1
}
],
v5 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "LinkedControlsDialogQuery",
"selections": [
{
"alias": "organization",
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
{
"args": null,
"kind": "FragmentSpread",
"name": "LinkedControlsDialogFragment"
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "LinkedControlsDialogQuery",
"selections": [
{
"alias": "organization",
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v3/*: any*/),
(v2/*: any*/),
{
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": (v4/*: any*/),
"concreteType": "ControlConnection",
"kind": "LinkedField",
"name": "controls",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "ControlEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Control",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
(v5/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "sectionTitle",
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "Framework",
"kind": "LinkedField",
"name": "framework",
"plural": false,
"selections": [
(v5/*: any*/),
(v2/*: any*/)
],
"storageKey": null
},
(v3/*: any*/)
],
"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
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasPreviousPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "startCursor",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "controls(first:1)"
},
{
"alias": null,
"args": (v4/*: any*/),
"filters": [
"orderBy",
"filter"
],
"handle": "connection",
"key": "LinkedControlsDialogControlsQuery_controls",
"kind": "LinkedHandle",
"name": "controls"
}
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "a36d424a3b8a087dbb40149bdbc2b6a2",
"id": null,
"metadata": {},
"name": "LinkedControlsDialogQuery",
"operationKind": "query",
"text": "query LinkedControlsDialogQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n id\n ...LinkedControlsDialogFragment\n }\n}\n\nfragment LinkedControlsDialogFragment on Organization {\n controls(first: 1) {\n edges {\n node {\n id\n name\n sectionTitle\n framework {\n name\n id\n }\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n"
}
};
})();
(node as any).hash = "1be60a2dc1efbd8992472862ca32910e";
export default node;

View File

@@ -0,0 +1,15 @@
interface ProgressBarProps {
value: number;
className?: string;
}
export function ProgressBar({ value, className }: ProgressBarProps) {
return (
<div className={`w-full bg-gray-200 rounded-full h-2 ${className}`}>
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
/>
</div>
);
}

View File

@@ -0,0 +1,217 @@
import { graphql } from "relay-runtime";
import {
Card,
IconPlusLarge,
Button,
Tr,
Td,
Table,
Thead,
Tbody,
Th,
IconChevronDown,
IconTrashCan,
DocumentVersionBadge,
DocumentTypeBadge,
TrButton,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import type { LinkedDocumentsCardFragment$key } from "./__generated__/LinkedDocumentsCardFragment.graphql";
import { useFragment } from "react-relay";
import { useMemo, useState } from "react";
import { sprintf } from "@probo/helpers";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { LinkedDocumentDialog } from "./LinkedDocumentsDialog.tsx";
import clsx from "clsx";
const linkedDocumentFragment = graphql`
fragment LinkedDocumentsCardFragment on Document {
id
title
createdAt
documentType
versions(first: 1) {
edges {
node {
id
status
}
}
}
}
`;
type Mutation<Params> = (p: {
variables: {
input: {
documentId: string;
} & Params;
connections: string[];
};
}) => void;
type Props<Params> = {
// Documents linked to the element
documents: (LinkedDocumentsCardFragment$key & { id: string })[];
// Extra params to send to the mutation
params: Params;
// Disable (action when loading for instance)
disabled?: boolean;
// ID of the connection to update
connectionId: string;
// Mutation to attach a document (will receive {documentId, ...params})
onAttach: Mutation<Params>;
// Mutation to detach a document (will receive {documentId, ...params})
onDetach: Mutation<Params>;
variant?: "card" | "table";
};
/**
* Reusable component that displays a list of linked documents
*/
export function LinkedDocumentsCard<Params>(props: Props<Params>) {
const { __ } = useTranslate();
const [limit, setLimit] = useState<number | null>(4);
const documents = useMemo(() => {
return limit ? props.documents.slice(0, limit) : props.documents;
}, [props.documents, limit]);
const showMoreButton = limit !== null && props.documents.length > limit;
const variant = props.variant ?? "table";
const onAttach = (documentId: string) => {
props.onAttach({
variables: {
input: {
documentId,
...props.params,
},
connections: [props.connectionId],
},
});
};
const onDetach = (documentId: string) => {
props.onDetach({
variables: {
input: {
documentId,
...props.params,
},
connections: [props.connectionId],
},
});
};
const Wrapper = variant === "card" ? Card : "div";
return (
<Wrapper padded className="space-y-[10px]">
{variant === "card" && (
<div className="flex justify-between">
<div className="text-lg font-semibold">{__("Documents")}</div>
<LinkedDocumentDialog
connectionId={props.connectionId}
disabled={props.disabled}
linkedDocuments={props.documents}
onLink={onAttach}
onUnlink={onDetach}
>
<Button variant="tertiary" icon={IconPlusLarge}>
{__("Link document")}
</Button>
</LinkedDocumentDialog>
</div>
)}
<Table className={clsx(variant === "card" && "bg-invert")}>
<Thead>
<Tr>
<Th>{__("Name")}</Th>
<Th>{__("Type")}</Th>
<Th>{__("State")}</Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{documents.length === 0 && (
<Tr>
<Td colSpan={4} className="text-center text-txt-secondary">
{__("No documents linked")}
</Td>
</Tr>
)}
{documents.map((document) => (
<DocumentRow
key={document.id}
document={document}
onClick={onDetach}
/>
))}
{variant === "table" && (
<LinkedDocumentDialog
connectionId={props.connectionId}
disabled={props.disabled}
linkedDocuments={props.documents}
onLink={onAttach}
onUnlink={onDetach}
>
<TrButton colspan={4} icon={IconPlusLarge}>
{__("Link document")}
</TrButton>
</LinkedDocumentDialog>
)}
</Tbody>
</Table>
{showMoreButton && (
<Button
variant="tertiary"
onClick={() => setLimit(null)}
className="mt-3 mx-auto"
icon={IconChevronDown}
>
{sprintf(__("Show %s more"), props.documents.length - limit)}
</Button>
)}
</Wrapper>
);
}
function DocumentRow(props: {
document: LinkedDocumentsCardFragment$key & { id: string };
onClick: (documentId: string) => void;
}) {
const document = useFragment(linkedDocumentFragment, props.document);
const organizationId = useOrganizationId();
const { __ } = useTranslate();
return (
<Tr to={`/organizations/${organizationId}/documents/${document.id}`}>
<Td>
<div className="flex gap-4 items-center">
<img
src="/document.png"
alt=""
width={28}
height={36}
className="border-4 border-highlight rounded box-content"
/>
{document.title}
</div>
</Td>
<Td>
<DocumentTypeBadge type={document.documentType} />
</Td>
<Td>
<DocumentVersionBadge state={document.versions.edges[0].node.status} />
</Td>
<Td noLink width={50} className="text-end">
<Button
variant="secondary"
onClick={() => props.onClick(document.id)}
icon={IconTrashCan}
>
{__("Unlink")}
</Button>
</Td>
</Tr>
);
}

View File

@@ -0,0 +1,178 @@
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DocumentTypeBadge,
IconMagnifyingGlass,
IconPlusLarge,
IconTrashCan,
InfiniteScrollTrigger,
Input,
Spinner,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { Suspense, useMemo, useState, type ReactNode } from "react";
import { graphql } from "relay-runtime";
import { useLazyLoadQuery, usePaginationFragment } from "react-relay";
import type { LinkedDocumentsDialogQuery } from "./__generated__/LinkedDocumentsDialogQuery.graphql";
import { useOrganizationId } from "/hooks/useOrganizationId";
import type { NodeOf } from "/types";
import type {
LinkedDocumentsDialogFragment$data,
LinkedDocumentsDialogFragment$key,
} from "./__generated__/LinkedDocumentsDialogFragment.graphql";
const documentsQuery = graphql`
query LinkedDocumentsDialogQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
id
... on Organization {
...LinkedDocumentsDialogFragment
}
}
}
`;
const documentsFragment = graphql`
fragment LinkedDocumentsDialogFragment on Organization
@refetchable(queryName: "LinkedDocumentsDialogQuery_fragment")
@argumentDefinitions(
first: { type: "Int", defaultValue: 20 }
order: { type: "DocumentOrder", defaultValue: null }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
) {
documents(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
) @connection(key: "LinkedDocumentsDialogQuery_documents") {
edges {
node {
id
title
documentType
}
}
}
}
`;
type Props = {
children: ReactNode;
connectionId: string;
disabled?: boolean;
linkedDocuments?: { id: string }[];
onLink: (documentId: string) => void;
onUnlink: (documentId: string) => void;
};
export function LinkedDocumentDialog({ children, ...props }: Props) {
const { __ } = useTranslate();
return (
<Dialog trigger={children} title={__("Link documents")}>
<DialogContent>
<Suspense fallback={<Spinner centered />}>
<LinkedDocumentsDialogContent {...props} />
</Suspense>
</DialogContent>
<DialogFooter exitLabel={__("Close")} />
</Dialog>
);
}
function LinkedDocumentsDialogContent(props: Omit<Props, "children">) {
const organizationId = useOrganizationId();
const query = useLazyLoadQuery<LinkedDocumentsDialogQuery>(documentsQuery, {
organizationId,
});
const { data, loadNext, hasNext, isLoadingNext } = usePaginationFragment(
documentsFragment,
query.organization as LinkedDocumentsDialogFragment$key
);
const { __ } = useTranslate();
const [search, setSearch] = useState("");
const documents = data.documents?.edges?.map((edge) => edge.node) ?? [];
const linkedIds = useMemo(() => {
return new Set(props.linkedDocuments?.map((m) => m.id) ?? []);
}, [props.linkedDocuments]);
const filteredDocuments = useMemo(() => {
return documents.filter((document) =>
document.title.toLowerCase().includes(search.toLowerCase())
);
}, [documents, search]);
return (
<>
<div className="flex items-center gap-2 sticky top-0 relative py-4 bg-linear-to-b from-50% from-level-2 to-level-2/0 px-6">
<Input
icon={IconMagnifyingGlass}
placeholder={__("Search documents...")}
onValueChange={setSearch}
/>
</div>
<div className="divide-y divide-border-low">
{filteredDocuments.map((document) => (
<DocumentRow
key={document.id}
document={document}
linkedDocuments={linkedIds}
onLink={props.onLink}
onUnlink={props.onUnlink}
disabled={props.disabled}
/>
))}
{hasNext && (
<InfiniteScrollTrigger
loading={isLoadingNext}
onView={() => loadNext(20)}
/>
)}
</div>
</>
);
}
type Document = NodeOf<LinkedDocumentsDialogFragment$data["documents"]>;
type RowProps = {
document: Document;
linkedDocuments: Set<string>;
disabled?: boolean;
onLink: (documentId: string) => void;
onUnlink: (documentId: string) => void;
};
function DocumentRow(props: RowProps) {
const { __ } = useTranslate();
const isLinked = props.linkedDocuments.has(props.document.id);
const onClick = isLinked ? props.onUnlink : props.onLink;
const IconComponent = isLinked ? IconTrashCan : IconPlusLarge;
return (
<button
className="py-4 flex items-center gap-4 hover:bg-subtle cursor-pointer px-6 w-full h-[100px]"
onClick={() => onClick(props.document.id)}
>
{props.document.title}
<DocumentTypeBadge type={props.document.documentType} />
<Button
disabled={props.disabled}
className="ml-auto"
variant={isLinked ? "secondary" : "primary"}
asChild
>
<span>
<IconComponent size={16} /> {isLinked ? __("Unlink") : __("Link")}
</span>
</Button>
</button>
);
}

View File

@@ -0,0 +1,126 @@
/**
* @generated SignedSource<<743c5a0c4d380bd05c0d8ccd7a664e58>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
export type DocumentStatus = "DRAFT" | "PUBLISHED";
export type DocumentType = "ISMS" | "OTHER" | "POLICY";
import { FragmentRefs } from "relay-runtime";
export type LinkedDocumentsCardFragment$data = {
readonly createdAt: any;
readonly documentType: DocumentType;
readonly id: string;
readonly title: string;
readonly versions: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly id: string;
readonly status: DocumentStatus;
};
}>;
};
readonly " $fragmentType": "LinkedDocumentsCardFragment";
};
export type LinkedDocumentsCardFragment$key = {
readonly " $data"?: LinkedDocumentsCardFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"LinkedDocumentsCardFragment">;
};
const node: ReaderFragment = (function(){
var v0 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
};
return {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "LinkedDocumentsCardFragment",
"selections": [
(v0/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "title",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "documentType",
"storageKey": null
},
{
"alias": null,
"args": [
{
"kind": "Literal",
"name": "first",
"value": 1
}
],
"concreteType": "DocumentVersionConnection",
"kind": "LinkedField",
"name": "versions",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "DocumentVersionEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "DocumentVersion",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v0/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "status",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "versions(first:1)"
}
],
"type": "Document",
"abstractKey": null
};
})();
(node as any).hash = "952e653849ce0b1b693de8d7e3086e3f";
export default node;

View File

@@ -0,0 +1,223 @@
/**
* @generated SignedSource<<7b0ec493ab146db602cae612158407cb>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
export type DocumentType = "ISMS" | "OTHER" | "POLICY";
import { FragmentRefs } from "relay-runtime";
export type LinkedDocumentsDialogFragment$data = {
readonly documents: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly documentType: DocumentType;
readonly id: string;
readonly title: string;
};
}>;
};
readonly id: string;
readonly " $fragmentType": "LinkedDocumentsDialogFragment";
};
export type LinkedDocumentsDialogFragment$key = {
readonly " $data"?: LinkedDocumentsDialogFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"LinkedDocumentsDialogFragment">;
};
import LinkedDocumentsDialogQuery_fragment_graphql from './LinkedDocumentsDialogQuery_fragment.graphql';
const node: ReaderFragment = (function(){
var v0 = [
"documents"
],
v1 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
};
return {
"argumentDefinitions": [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "after"
},
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "before"
},
{
"defaultValue": 20,
"kind": "LocalArgument",
"name": "first"
},
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "last"
},
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "order"
}
],
"kind": "Fragment",
"metadata": {
"connection": [
{
"count": null,
"cursor": null,
"direction": "bidirectional",
"path": (v0/*: any*/)
}
],
"refetch": {
"connection": {
"forward": {
"count": "first",
"cursor": "after"
},
"backward": {
"count": "last",
"cursor": "before"
},
"path": (v0/*: any*/)
},
"fragmentPathInResult": [
"node"
],
"operation": LinkedDocumentsDialogQuery_fragment_graphql,
"identifierInfo": {
"identifierField": "id",
"identifierQueryVariableName": "id"
}
}
},
"name": "LinkedDocumentsDialogFragment",
"selections": [
{
"alias": "documents",
"args": [
{
"kind": "Variable",
"name": "orderBy",
"variableName": "order"
}
],
"concreteType": "DocumentConnection",
"kind": "LinkedField",
"name": "__LinkedDocumentsDialogQuery_documents_connection",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "DocumentEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Document",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v1/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "title",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "documentType",
"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
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasPreviousPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "startCursor",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
},
(v1/*: any*/)
],
"type": "Organization",
"abstractKey": null
};
})();
(node as any).hash = "392d33b379a58cb3da08ab783c1cda5d";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<6c7503b4c94cbe6c062d634f0192a3ef>>
* @generated SignedSource<<6316cd819a27efb5989750b5f8de0444>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -10,17 +10,18 @@
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type NewRiskViewQuery$variables = {
export type LinkedDocumentsDialogQuery$variables = {
organizationId: string;
};
export type NewRiskViewQuery$data = {
export type LinkedDocumentsDialogQuery$data = {
readonly organization: {
readonly " $fragmentSpreads": FragmentRefs<"PeopleSelector_organization">;
readonly id: string;
readonly " $fragmentSpreads": FragmentRefs<"LinkedDocumentsDialogFragment">;
};
};
export type NewRiskViewQuery = {
response: NewRiskViewQuery$data;
variables: NewRiskViewQuery$variables;
export type LinkedDocumentsDialogQuery = {
response: LinkedDocumentsDialogQuery$data;
variables: LinkedDocumentsDialogQuery$variables;
};
const node: ConcreteRequest = (function(){
@@ -42,29 +43,21 @@ v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"name": "id",
"storageKey": null
},
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"name": "__typename",
"storageKey": null
},
v4 = [
{
"kind": "Literal",
"name": "first",
"value": 100
},
{
"kind": "Literal",
"name": "orderBy",
"value": {
"direction": "ASC",
"field": "FULL_NAME"
}
"value": 20
}
];
return {
@@ -72,7 +65,7 @@ return {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "NewRiskViewQuery",
"name": "LinkedDocumentsDialogQuery",
"selections": [
{
"alias": "organization",
@@ -82,10 +75,18 @@ return {
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
{
"args": null,
"kind": "FragmentSpread",
"name": "PeopleSelector_organization"
"kind": "InlineFragment",
"selections": [
{
"args": null,
"kind": "FragmentSpread",
"name": "LinkedDocumentsDialogFragment"
}
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
@@ -98,7 +99,7 @@ return {
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "NewRiskViewQuery",
"name": "LinkedDocumentsDialogQuery",
"selections": [
{
"alias": "organization",
@@ -108,23 +109,23 @@ return {
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
(v3/*: any*/),
(v2/*: any*/),
{
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": (v4/*: any*/),
"concreteType": "PeopleConnection",
"concreteType": "DocumentConnection",
"kind": "LinkedField",
"name": "peoples",
"name": "documents",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "PeopleEdge",
"concreteType": "DocumentEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
@@ -132,27 +133,27 @@ return {
{
"alias": null,
"args": null,
"concreteType": "People",
"concreteType": "Document",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v3/*: any*/),
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"name": "title",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "primaryEmailAddress",
"name": "documentType",
"storageKey": null
},
(v2/*: any*/)
(v3/*: any*/)
],
"storageKey": null
},
@@ -187,12 +188,26 @@ return {
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasPreviousPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "startCursor",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "peoples(first:100,orderBy:{\"direction\":\"ASC\",\"field\":\"FULL_NAME\"})"
"storageKey": "documents(first:20)"
},
{
"alias": null,
@@ -201,9 +216,9 @@ return {
"orderBy"
],
"handle": "connection",
"key": "PeopleSelector_organization_peoples",
"key": "LinkedDocumentsDialogQuery_documents",
"kind": "LinkedHandle",
"name": "peoples"
"name": "documents"
}
],
"type": "Organization",
@@ -215,16 +230,16 @@ return {
]
},
"params": {
"cacheID": "395a2ea0b09e5fdc165a19757d0b637a",
"cacheID": "fd4ab41131ead5f74dceb4610ef3f2c5",
"id": null,
"metadata": {},
"name": "NewRiskViewQuery",
"name": "LinkedDocumentsDialogQuery",
"operationKind": "query",
"text": "query NewRiskViewQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n ...PeopleSelector_organization\n id\n }\n}\n\nfragment PeopleSelector_organization on Organization {\n id\n peoples(first: 100, orderBy: {direction: ASC, field: FULL_NAME}) {\n edges {\n node {\n id\n fullName\n primaryEmailAddress\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n"
"text": "query LinkedDocumentsDialogQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n id\n ... on Organization {\n ...LinkedDocumentsDialogFragment\n }\n }\n}\n\nfragment LinkedDocumentsDialogFragment on Organization {\n documents(first: 20) {\n edges {\n node {\n id\n title\n documentType\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n"
}
};
})();
(node as any).hash = "f62e2420e6fd2b9d90840df308fb6c1c";
(node as any).hash = "f97a3016285b1a39cc6925ac39d82acc";
export default node;

View File

@@ -0,0 +1,318 @@
/**
* @generated SignedSource<<29bf3465293095c44e826394c602cde1>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type DocumentOrderField = "CREATED_AT" | "TITLE";
export type OrderDirection = "ASC" | "DESC";
export type DocumentOrder = {
direction: OrderDirection;
field: DocumentOrderField;
};
export type LinkedDocumentsDialogQuery_fragment$variables = {
after?: any | null | undefined;
before?: any | null | undefined;
first?: number | null | undefined;
id: string;
last?: number | null | undefined;
order?: DocumentOrder | null | undefined;
};
export type LinkedDocumentsDialogQuery_fragment$data = {
readonly node: {
readonly " $fragmentSpreads": FragmentRefs<"LinkedDocumentsDialogFragment">;
};
};
export type LinkedDocumentsDialogQuery_fragment = {
response: LinkedDocumentsDialogQuery_fragment$data;
variables: LinkedDocumentsDialogQuery_fragment$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "after"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "before"
},
v2 = {
"defaultValue": 20,
"kind": "LocalArgument",
"name": "first"
},
v3 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "id"
},
v4 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "last"
},
v5 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "order"
},
v6 = [
{
"kind": "Variable",
"name": "id",
"variableName": "id"
}
],
v7 = {
"kind": "Variable",
"name": "after",
"variableName": "after"
},
v8 = {
"kind": "Variable",
"name": "before",
"variableName": "before"
},
v9 = {
"kind": "Variable",
"name": "first",
"variableName": "first"
},
v10 = {
"kind": "Variable",
"name": "last",
"variableName": "last"
},
v11 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
v12 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v13 = [
(v7/*: any*/),
(v8/*: any*/),
(v9/*: any*/),
(v10/*: any*/),
{
"kind": "Variable",
"name": "orderBy",
"variableName": "order"
}
];
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/),
(v2/*: any*/),
(v3/*: any*/),
(v4/*: any*/),
(v5/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "LinkedDocumentsDialogQuery_fragment",
"selections": [
{
"alias": null,
"args": (v6/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"args": [
(v7/*: any*/),
(v8/*: any*/),
(v9/*: any*/),
(v10/*: any*/),
{
"kind": "Variable",
"name": "order",
"variableName": "order"
}
],
"kind": "FragmentSpread",
"name": "LinkedDocumentsDialogFragment"
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/),
(v2/*: any*/),
(v4/*: any*/),
(v5/*: any*/),
(v3/*: any*/)
],
"kind": "Operation",
"name": "LinkedDocumentsDialogQuery_fragment",
"selections": [
{
"alias": null,
"args": (v6/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v11/*: any*/),
(v12/*: any*/),
{
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": (v13/*: any*/),
"concreteType": "DocumentConnection",
"kind": "LinkedField",
"name": "documents",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "DocumentEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Document",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v12/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "title",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "documentType",
"storageKey": null
},
(v11/*: any*/)
],
"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
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasPreviousPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "startCursor",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": (v13/*: any*/),
"filters": [
"orderBy"
],
"handle": "connection",
"key": "LinkedDocumentsDialogQuery_documents",
"kind": "LinkedHandle",
"name": "documents"
}
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "e72639ede26aa4300b69ba3181de482b",
"id": null,
"metadata": {},
"name": "LinkedDocumentsDialogQuery_fragment",
"operationKind": "query",
"text": "query LinkedDocumentsDialogQuery_fragment(\n $after: CursorKey = null\n $before: CursorKey = null\n $first: Int = 20\n $last: Int = null\n $order: DocumentOrder = null\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...LinkedDocumentsDialogFragment_16fISc\n id\n }\n}\n\nfragment LinkedDocumentsDialogFragment_16fISc on Organization {\n documents(first: $first, after: $after, last: $last, before: $before, orderBy: $order) {\n edges {\n node {\n id\n title\n documentType\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n"
}
};
})();
(node as any).hash = "392d33b379a58cb3da08ab783c1cda5d";
export default node;

View File

@@ -0,0 +1,56 @@
import type { ComponentProps, JSX, JSXElementConstructor } from "react";
import { Field } from "@probo/ui";
import { Controller, type Control } from "react-hook-form";
import { Select } from "@probo/ui";
type Props<T extends keyof JSX.IntrinsicElements | JSXElementConstructor<any>> =
ComponentProps<T> & {
control: Control<any>;
name: string;
};
export function ControlledField({
control,
name,
...props
}: Props<typeof Field>) {
return (
<Controller
control={control}
name={name}
render={({ field }) => (
<>
<Field
{...props}
{...field}
// TODO : Find a better way to handle this case (comparing number and string for select create issues)
value={field.value ? field.value.toString() : ""}
onValueChange={field.onChange}
/>
</>
)}
/>
);
}
export function ControlledSelect({
control,
name,
...props
}: Props<typeof Select>) {
return (
<Controller
control={control}
name={name}
render={({ field }) => (
<Select
id={name}
{...props}
{...field}
onValueChange={field.onChange}
value={field.value ?? ""}
/>
)}
/>
);
}

View File

@@ -0,0 +1,49 @@
import { Button, IconPlusLarge, IconTrashCan, Input, Label } from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { useFieldArray } from "react-hook-form";
import type { Control } from "react-hook-form";
import type { UseFormRegister } from "react-hook-form";
type Props = {
control: Control<any>;
register: UseFormRegister<any>;
};
/**
* A field to handle multiple emails
*/
export function EmailsField({ control, register }: Props) {
const { __ } = useTranslate();
const { fields, append, remove } = useFieldArray({
name: "additionalEmailAddresses",
control,
});
return (
<fieldset className="space-y-2">
{fields.length > 0 && <Label>{__("Additional emails")}</Label>}
{fields.map((field, index) => (
<div key={field.id} className="flex items-stretch">
<Input
className="w-full"
{...register(`additionalEmailAddresses.${index}`)}
type="email"
/>
<Button
icon={IconTrashCan}
variant="tertiary"
onClick={() => remove(index)}
/>
</div>
))}
<Button
variant="tertiary"
type="button"
icon={IconPlusLarge}
onClick={() => append("")}
>
{__("Add email")}
</Button>
</fieldset>
);
}

View File

@@ -0,0 +1,90 @@
import { Field, Option, Select } from "@probo/ui";
import { Suspense, useMemo, useState, type ComponentProps } from "react";
import { useTranslate } from "@probo/i18n";
import { type Control, Controller } from "react-hook-form";
import { usePaginatedMeasures } from "/hooks/graph/usePaginatedMeasures";
type Props = {
organizationId: string;
control: Control<any>;
name: string;
label?: string;
error?: string;
disabled?: boolean;
} & ComponentProps<typeof Field>;
export function MeasureSelectField({
organizationId,
control,
disabled,
...props
}: Props) {
return (
<Field {...props}>
<Suspense
fallback={<Select variant="editor" disabled placeholder="Loading..." />}
>
<MeasureSelectWithQuery
organizationId={organizationId}
control={control}
name={props.name}
disabled={disabled}
/>
</Suspense>
</Field>
);
}
function MeasureSelectWithQuery(
props: Pick<Props, "organizationId" | "control" | "name" | "disabled">
) {
const { __ } = useTranslate();
const { name, organizationId, control, disabled } = props;
const { data } = usePaginatedMeasures(organizationId);
const [search, setSearch] = useState("");
const measures = useMemo(() => {
return (
data?.measures.edges
?.filter(
(edge) =>
edge.node.name.toLowerCase().includes(search.toLowerCase()) ||
edge.node.description?.toLowerCase().includes(search.toLowerCase())
)
.map((edge) => edge.node) ?? []
);
}, [data?.measures.edges, search]);
return (
<div>
<Controller
control={control}
name={name}
render={({ field }) => (
<Select
id={name}
variant="editor"
placeholder={__("Select a measure")}
onValueChange={field.onChange}
{...field}
className="w-full"
value={field.value ?? ""}
onSearch={setSearch}
searchValue={search}
disabled={disabled}
>
{measures?.map((m) => (
<Option key={m.id} value={m.id}>
<div className="space-y-1 text-start min-w-0">
<div className="max-w-75 ellipsis overflow-hidden whitespace-pre-wrap">
{m.name}
</div>
<div className="text-sm text-txt-secondary">{m.category}</div>
</div>
</Option>
))}
</Select>
)}
/>
</div>
);
}

View File

@@ -0,0 +1,71 @@
import { Avatar, Field, Option, Select } from "@probo/ui";
import { Suspense, type ComponentProps } from "react";
import { useTranslate } from "@probo/i18n";
import { type Control, Controller } from "react-hook-form";
import { usePeople } from "/hooks/graph/PeopleGraph.ts";
type Props = {
organizationId: string;
control: Control<any>;
name: string;
label?: string;
error?: string;
} & ComponentProps<typeof Field>;
export function PeopleSelectField({
organizationId,
control,
...props
}: Props) {
return (
<Field {...props}>
<Suspense
fallback={<Select variant="editor" disabled placeholder="Loading..." />}
>
<PeopleSelectWithQuery
organizationId={organizationId}
control={control}
name={props.name}
disabled={props.disabled}
/>
</Suspense>
</Field>
);
}
function PeopleSelectWithQuery(
props: Pick<Props, "organizationId" | "control" | "name" | "disabled">
) {
const { __ } = useTranslate();
const { name, organizationId, control } = props;
const people = usePeople(organizationId);
return (
<>
<Controller
control={control}
name={name}
render={({ field }) => (
<Select
disabled={props.disabled}
id={name}
variant="editor"
placeholder={__("Select an owner")}
onValueChange={field.onChange}
key={people?.length.toString() ?? "0"}
{...field}
className="w-full"
value={field.value ?? ""}
>
{people?.map((p) => (
<Option key={p.id} value={p.id} className="flex gap-2">
<Avatar name={p.fullName} />
{p.fullName}
</Option>
))}
</Select>
)}
/>
</>
);
}

View File

@@ -0,0 +1,133 @@
import { Avatar, Field, Option, Select, Badge, Button, IconCrossLargeX } from "@probo/ui";
import { Suspense, useState, type ComponentProps } from "react";
import { useTranslate } from "@probo/i18n";
import { type Control, Controller } from "react-hook-form";
import { useVendors } from "/hooks/graph/VendorGraph.ts";
import { faviconUrl } from "@probo/helpers";
type Props = {
organizationId: string;
control: Control<any>;
name: string;
label?: string;
error?: string;
} & ComponentProps<typeof Field>;
export function VendorsMultiSelectField({
organizationId,
control,
...props
}: Props) {
return (
<Field {...props}>
<Suspense
fallback={<Select variant="editor" disabled placeholder="Loading..." />}
>
<VendorsMultiSelectWithQuery
organizationId={organizationId}
control={control}
name={props.name}
disabled={props.disabled}
/>
</Suspense>
</Field>
);
}
function VendorsMultiSelectWithQuery(
props: Pick<Props, "organizationId" | "control" | "name" | "disabled">
) {
const { __ } = useTranslate();
const { name, organizationId, control } = props;
const vendors = useVendors(organizationId);
const [isOpen, setIsOpen] = useState(false);
return (
<>
<Controller
control={control}
name={name}
render={({ field }) => {
const selectedVendorIds = Array.isArray(field.value) ? field.value : [];
const selectedVendors = vendors.filter(v => selectedVendorIds.includes(v.id));
const availableVendors = vendors.filter(v => !selectedVendorIds.includes(v.id));
const handleAddVendor = (vendorId: string) => {
const newValue = [...selectedVendorIds, vendorId];
field.onChange(newValue);
setIsOpen(false);
};
const handleRemoveVendor = (vendorId: string) => {
const newValue = selectedVendorIds.filter((id: string) => id !== vendorId);
field.onChange(newValue);
};
return (
<div className="space-y-2">
{availableVendors.length > 0 && (
<Select
disabled={props.disabled}
id={name}
variant="editor"
placeholder={__("Add vendors...")}
onValueChange={handleAddVendor}
key={`${selectedVendorIds.length}-${vendors.length}`}
className="w-full"
value=""
open={isOpen}
onOpenChange={setIsOpen}
>
{availableVendors.map((vendor) => (
<Option key={vendor.id} value={vendor.id} className="flex gap-2">
<Avatar
name={vendor.name}
src={faviconUrl(vendor.websiteUrl)}
size="s"
/>
<div className="flex flex-col">
<span>{vendor.name}</span>
{vendor.websiteUrl && (
<span className="text-xs text-txt-secondary">
{vendor.websiteUrl}
</span>
)}
</div>
</Option>
))}
</Select>
)}
{selectedVendors.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedVendors.map((vendor) => (
<Badge key={vendor.id} variant="neutral" className="flex items-center gap-2">
<Avatar
name={vendor.name}
src={faviconUrl(vendor.websiteUrl)}
size="s"
/>
<span>{vendor.name}</span>
<Button
variant="tertiary"
icon={IconCrossLargeX}
onClick={() => handleRemoveVendor(vendor.id)}
className="h-4 w-4 p-0 hover:bg-transparent"
/>
</Badge>
))}
</div>
)}
{selectedVendors.length === 0 && availableVendors.length === 0 && (
<div className="text-sm text-txt-secondary py-2">
{__("No vendors available")}
</div>
)}
</div>
);
}}
/>
</>
);
}

View File

@@ -0,0 +1,190 @@
import { graphql } from "relay-runtime";
import {
Card,
IconPlusLarge,
Button,
Tr,
Td,
Table,
Thead,
Tbody,
Th,
IconChevronDown,
MeasureBadge,
IconTrashCan,
TrButton,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import type { LinkedMeasuresCardFragment$key } from "./__generated__/LinkedMeasuresCardFragment.graphql";
import { useFragment } from "react-relay";
import { useMemo, useState } from "react";
import { sprintf } from "@probo/helpers";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { LinkedMeasureDialog } from "./LinkedMeasuresDialog.tsx";
import clsx from "clsx";
const linkedMeasureFragment = graphql`
fragment LinkedMeasuresCardFragment on Measure {
id
name
state
}
`;
type Mutation<Params> = (p: {
variables: {
input: {
measureId: string;
} & Params;
connections: string[];
};
}) => void;
type Props<Params> = {
// Measures linked to the element
measures: (LinkedMeasuresCardFragment$key & { id: string })[];
// Extra params to send to the mutation
params: Params;
// Disable (action when loading for instance)
disabled?: boolean;
// ID of the connection to update
connectionId: string;
// Mutation to attach a measure (will receive {measureId, ...params})
onAttach: Mutation<Params>;
// Mutation to detach a measure (will receive {measureId, ...params})
onDetach: Mutation<Params>;
variant?: "card" | "table";
};
/**
* Reusable component that displays a list of linked measures
*/
export function LinkedMeasuresCard<Params>(props: Props<Params>) {
const { __ } = useTranslate();
const [limit, setLimit] = useState<number | null>(
props.variant === "card" ? 4 : null
);
const measures = useMemo(() => {
return limit ? props.measures.slice(0, limit) : props.measures;
}, [props.measures, limit]);
const showMoreButton = limit !== null && props.measures.length > limit;
const variant = props.variant ?? "table";
const onAttach = (measureId: string) => {
props.onAttach({
variables: {
input: {
measureId,
...props.params,
},
connections: [props.connectionId],
},
});
};
const onDetach = (measureId: string) => {
props.onDetach({
variables: {
input: {
measureId,
...props.params,
},
connections: [props.connectionId],
},
});
};
const Wrapper = variant === "card" ? Card : "div";
return (
<Wrapper padded className="space-y-[10px]">
{variant === "card" && (
<div className="flex justify-between">
<div className="text-lg font-semibold">{__("Measures")}</div>
<LinkedMeasureDialog
connectionId={props.connectionId}
disabled={props.disabled}
linkedMeasures={props.measures}
onLink={onAttach}
onUnlink={onDetach}
>
<Button variant="tertiary" icon={IconPlusLarge}>
{__("Link measure")}
</Button>
</LinkedMeasureDialog>
</div>
)}
<Table className={clsx(variant === "card" && "bg-invert")}>
<Thead>
<Tr>
<Th>{__("Name")}</Th>
<Th>{__("State")}</Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{measures.length === 0 && (
<Tr>
<Td colSpan={3} className="text-center text-txt-secondary">
{__("No measures linked")}
</Td>
</Tr>
)}
{measures.map((measure) => (
<MeasureRow key={measure.id} measure={measure} onClick={onDetach} />
))}
{variant === "table" && (
<LinkedMeasureDialog
connectionId={props.connectionId}
disabled={props.disabled}
linkedMeasures={props.measures}
onLink={onAttach}
onUnlink={onDetach}
>
<TrButton colspan={3} icon={IconPlusLarge}>
{__("Link measure")}
</TrButton>
</LinkedMeasureDialog>
)}
</Tbody>
</Table>
{showMoreButton && (
<Button
variant="tertiary"
onClick={() => setLimit(null)}
className="mt-3 mx-auto"
icon={IconChevronDown}
>
{sprintf(__("Show %s more"), props.measures.length - limit)}
</Button>
)}
</Wrapper>
);
}
function MeasureRow(props: {
measure: LinkedMeasuresCardFragment$key & { id: string };
onClick: (measureId: string) => void;
}) {
const measure = useFragment(linkedMeasureFragment, props.measure);
const organizationId = useOrganizationId();
const { __ } = useTranslate();
return (
<Tr to={`/organizations/${organizationId}/measures/${measure.id}`}>
<Td>{measure.name}</Td>
<Td>
<MeasureBadge state={measure.state} />
</Td>
<Td noLink width={50} className="text-end">
<Button
variant="secondary"
onClick={() => props.onClick(measure.id)}
icon={IconTrashCan}
>
{__("Unlink")}
</Button>
</Td>
</Tr>
);
}

View File

@@ -0,0 +1,148 @@
import {
Badge,
Button,
Dialog,
DialogContent,
DialogFooter,
IconMagnifyingGlass,
IconPlusLarge,
IconTrashCan,
InfiniteScrollTrigger,
Input,
Option,
Select,
Spinner,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { Suspense, useMemo, useState, type ReactNode } from "react";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { usePaginatedMeasures } from "/hooks/graph/usePaginatedMeasures";
type Props = {
children: ReactNode;
connectionId: string;
disabled?: boolean;
linkedMeasures?: { id: string }[];
onLink: (measureId: string) => void;
onUnlink: (measureId: string) => void;
};
export function LinkedMeasureDialog({ children, ...props }: Props) {
const { __ } = useTranslate();
return (
<Dialog trigger={children} title={__("Link measures")}>
<DialogContent>
<Suspense fallback={<Spinner centered />}>
<LinkedMeasuresDialogContent {...props} />
</Suspense>
</DialogContent>
<DialogFooter exitLabel={__("Close")} />
</Dialog>
);
}
function LinkedMeasuresDialogContent(props: Omit<Props, "children">) {
const organizationId = useOrganizationId();
const { data, loadNext, hasNext, isLoadingNext } =
usePaginatedMeasures(organizationId);
const { __ } = useTranslate();
const [search, setSearch] = useState("");
const [category, setCategory] = useState<string | null>(null);
const measures = data.measures?.edges?.map((edge) => edge.node) ?? [];
const linkedIds = useMemo(() => {
return new Set(props.linkedMeasures?.map((m) => m.id) ?? []);
}, [props.linkedMeasures]);
const filteredMeasures = useMemo(() => {
return measures.filter(
(measure) =>
(category === null || measure.category === category) &&
(measure.name.toLowerCase().includes(search.toLowerCase()) ||
measure.description?.toLowerCase().includes(search.toLowerCase()))
);
}, [measures, search, category]);
const categories = useMemo(
() => Array.from(new Set(measures.map((m) => m.category))),
[measures]
);
return (
<>
<div className="flex items-center gap-2 sticky top-0 relative py-4 bg-linear-to-b from-50% from-level-2 to-level-2/0 px-6">
<Input
icon={IconMagnifyingGlass}
placeholder={__("Search measures...")}
onValueChange={setSearch}
/>
<Select
value={category ?? ""}
placeholder={__("All categories")}
onValueChange={setCategory}
className="max-w-[180px]"
>
{categories.map((category) => (
<Option key={category} value={category}>
{category}
</Option>
))}
</Select>
</div>
<div className="divide-y divide-border-low">
{filteredMeasures.map((measure) => (
<MeasureRow
key={measure.id}
measure={measure}
linkedMeasures={linkedIds}
onLink={props.onLink}
onUnlink={props.onUnlink}
disabled={props.disabled}
/>
))}
{hasNext && (
<InfiniteScrollTrigger
loading={isLoadingNext}
onView={() => loadNext(20)}
/>
)}
</div>
</>
);
}
type RowProps = {
measure: { name: string; category: string; id: string };
linkedMeasures: Set<string>;
disabled?: boolean;
onLink: (measureId: string) => void;
onUnlink: (measureId: string) => void;
};
function MeasureRow(props: RowProps) {
const { __ } = useTranslate();
const isLinked = props.linkedMeasures.has(props.measure.id);
const onClick = isLinked ? props.onUnlink : props.onLink;
const IconComponent = isLinked ? IconTrashCan : IconPlusLarge;
return (
<button
className="py-4 flex items-center gap-4 hover:bg-subtle cursor-pointer px-6 w-full"
onClick={() => onClick(props.measure.id)}
>
{props.measure.name}
<Badge variant="neutral">{props.measure.category}</Badge>
<Button
disabled={props.disabled}
className="ml-auto"
variant={isLinked ? "secondary" : "primary"}
asChild
>
<span>
<IconComponent size={16} /> {isLinked ? __("Unlink") : __("Link")}
</span>
</Button>
</button>
);
}

View File

@@ -0,0 +1,59 @@
/**
* @generated SignedSource<<b7482b43ce0f4dfab470c35e806acf50>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
export type MeasureState = "IMPLEMENTED" | "IN_PROGRESS" | "NOT_APPLICABLE" | "NOT_STARTED";
import { FragmentRefs } from "relay-runtime";
export type LinkedMeasuresCardFragment$data = {
readonly id: string;
readonly name: string;
readonly state: MeasureState;
readonly " $fragmentType": "LinkedMeasuresCardFragment";
};
export type LinkedMeasuresCardFragment$key = {
readonly " $data"?: LinkedMeasuresCardFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"LinkedMeasuresCardFragment">;
};
const node: ReaderFragment = {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "LinkedMeasuresCardFragment",
"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": "state",
"storageKey": null
}
],
"type": "Measure",
"abstractKey": null
};
(node as any).hash = "741a216c02732c1ff97b265ac8dbf39b";
export default node;

View File

@@ -0,0 +1,91 @@
import {
Button,
Dialog,
DialogContent,
DialogFooter,
Field,
useDialogRef,
} from "@probo/ui";
import type { PropsWithChildren } from "react";
import { useTranslate } from "@probo/i18n";
import { graphql } from "relay-runtime";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
import { z } from "zod";
import { useFormWithSchema } from "/hooks/useFormWithSchema";
const inviteMutation = graphql`
mutation InviteUserDialogMutation($input: InviteUserInput!) {
inviteUser(input: $input) {
success
}
}
`;
const schema = z.object({
email: z.string().email(),
fullName: z.string(),
});
export function InviteUserDialog({ children }: PropsWithChildren) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const [inviteUser, isInviting] = useMutationWithToasts(inviteMutation, {
successMessage: __("User invited successfully"),
errorMessage: __("Failed to invite user"),
});
const { register, handleSubmit, formState } = useFormWithSchema(schema, {});
const dialogRef = useDialogRef();
const onSubmit = handleSubmit((data) => {
inviteUser({
variables: {
input: {
organizationId,
email: data.email,
fullName: data.fullName,
},
},
onSuccess: () => {
dialogRef.current?.close();
},
});
});
return (
<Dialog
title={__("Invite member")}
trigger={children}
className="max-w-lg"
ref={dialogRef}
>
<form onSubmit={onSubmit}>
<DialogContent padded className="space-y-4">
<p className="text-txt-secondary text-sm">
Send an invitation to join your workspace.
</p>
<Field
type="email"
label={__("Email")}
placeholder={__("Email")}
{...register("email")}
error={formState.errors.email?.message}
/>
<Field
type="text"
label={__("Full name")}
placeholder={__("Full name")}
{...register("fullName")}
error={formState.errors.fullName?.message}
/>
</DialogContent>
<DialogFooter>
<Button type="submit" disabled={isInviting}>
{__("Invite user")}
</Button>
</DialogFooter>
</form>
</Dialog>
);
}

Some files were not shown because too many files have changed in this diff Show More