Compare commits

..
Author SHA1 Message Date
Wahyu Kurniawan 6c4d8af14d Merge branch 'andrehadianto/design-system-components' of https://github.com/snowball-tools/snowballtools-base into ayungavis/T-4861-inline-notification 2024-02-22 10:41:46 +07:00
Andre Hadianto 2369f4498a Merge pull request #85 from snowball-tools/andrehadianto/T-4863-input-field
feat: input field
2024-02-22 10:18:59 +08:00
Wahyu Kurniawan eb6a727425 [T-4862: feat] Link component (#88) 2024-02-22 09:17:22 +07:00
Andre H da2f7ede42 🔧 chore: add dependency to usememo 2024-02-22 09:15:17 +07:00
Wahyu Kurniawan ab6bf7ffef 🐛 fix: use the right method of `useCallback 2024-02-22 01:10:01 +07:00
Andre H 448d0ceb7c 🔧 chore: 1 classname per object 2024-02-21 17:10:45 +07:00
Andre H 195d957251 🔧 chore: restructure tailwind classnames 2024-02-21 17:08:33 +07:00
Andre H 807a4b1197 🔧 chore: lint 2024-02-21 17:08:14 +07:00
Andre H 5f6723fad6 ♻️ refactor: create new renderInput for component page 2024-02-21 17:07:59 +07:00
Andre H b663067035 🔧 chore: use absolute import 2024-02-21 17:07:30 +07:00
Wahyu Kurniawan bbb5abbd42 Merge branch 'andrehadianto/design-system-components' of https://github.com/snowball-tools/snowballtools-base into ayungavis/T-4861-inline-notification 2024-02-21 16:43:42 +07:00
Wahyu Kurniawan 2cd1776ded 📝 docs: add js doc comment and add inline notification component to the example page 2024-02-21 16:42:09 +07:00
Wahyu Kurniawan d1cd144dfc ️ feat: create inline notification component 2024-02-21 16:40:12 +07:00
Wahyu Kurniawan 10fae09d15 ️ feat: create info square icon component 2024-02-21 16:39:46 +07:00
Andre Hadianto b3a99475af Merge branch 'andrehadianto/design-system-components' into andrehadianto/T-4863-input-field 2024-02-21 17:39:29 +08:00
Wahyu Kurniawan ea44efa0f2 [T-4867: feat] Horizontal and vertical tabs component (#84)
* ️ feat: create tabs component

* ♻️ refactor: avoid big conflict on the example page

* 🔧 chore: upgrade tailwindcss and install `@radix-ui/react-tabs`

* ️ feat: create globe icon component

* 🎨 style: adjust vertical tab theme

* 📝 docs: add vertical tabs to the example page
2024-02-21 16:13:16 +07:00
Andre H 7d5963d776 🔧 chore: add input to page/components 2024-02-21 15:44:21 +07:00
Andre H 33b6191539 ️ feat: implement Input Field component 2024-02-21 15:43:53 +07:00
Andre H 636f68d7a4 🔧 chore: add more icons 2024-02-21 15:43:32 +07:00
Andre H 237e9e5cb9 🔧 chore: implement classnames utils 2024-02-21 15:43:09 +07:00
Andre H e70bb34190 ⚒️ build: reorder packages 2024-02-21 15:42:35 +07:00
Andre H b5eef95d15 Merge remote-tracking branch 'origin/main' 2024-02-21 11:00:14 +07:00
Zachery f0121605c4 feat: avatar component (#76) 2024-02-21 11:39:38 +08:00
Wahyu Kurniawan c8f1c58507 Merge pull request #81 from snowball-tools/ayungavis/T-4837-badge
[T-4837: feat] Badge component
2024-02-20 23:42:56 +07:00
Wahyu Kurniawan 83508cd81d Merge branch 'andrehadianto/design-system-components' of https://github.com/snowball-tools/snowballtools-base into ayungavis/T-4837-badge 2024-02-20 23:38:35 +07:00
Wahyu Kurniawan 76c59e2c58 Merge pull request #80 from snowball-tools/ayungavis/T-4838-checkbox
[T-4838: feat] Checkbox component
2024-02-20 23:37:30 +07:00
Wahyu Kurniawan a5bce0cda2 🎨 style: adjust disabled hover text color 2024-02-20 23:34:20 +07:00
Wahyu Kurniawan 3a085a79ae Merge branch 'andrehadianto/design-system-components' of https://github.com/snowball-tools/snowballtools-base into ayungavis/T-4838-checkbox 2024-02-20 23:29:18 +07:00
Wahyu Kurniawan abfe6a523c Merge pull request #79 from snowball-tools/ayungavis/T-4842-datepicker
[T-4842: feat] Calendar component
2024-02-20 23:26:38 +07:00
Wahyu Kurniawan f764bea6d1 📝 docs: add badge to the example page 2024-02-20 23:26:04 +07:00
Wahyu Kurniawan cc9c24cca1 🎨 style: add new font sizes 2024-02-20 23:26:04 +07:00
Wahyu Kurniawan e482f998a1 ️ feat: create badge comopnent 2024-02-20 23:26:04 +07:00
Wahyu Kurniawan b255078431 📝 docs: add checkbox to example component page 2024-02-20 23:25:19 +07:00
Wahyu Kurniawan 26260976fb ️ feat: create check icon for checkbox component 2024-02-20 23:25:19 +07:00
Wahyu Kurniawan 5e3a6ad2b5 ️ feat: create checkbox component 2024-02-20 23:25:19 +07:00
Wahyu Kurniawan f31ee7349f 🔧 chore: install @radix-ui/react-checkbox 2024-02-20 23:25:19 +07:00
Wahyu Kurniawan 5c5d759c10 ♻️ refactor: fix typo and change to readable name 2024-02-20 23:24:28 +07:00
Wahyu Kurniawan dc6bf3794a ️ feat: add interaction on the example page 2024-02-20 23:24:28 +07:00
Wahyu Kurniawan af0d28e654 🎨 style: add new shadows 2024-02-20 23:24:27 +07:00
Wahyu Kurniawan 4f7f9cf914 📝 docs: add calendar component to the example page 2024-02-20 23:24:27 +07:00
Wahyu Kurniawan ad7dd1920a ️ feat: create calendar component 2024-02-20 23:24:27 +07:00
Wahyu Kurniawan cc97ddff9d ️ feat: create chevron left, right, and grabbed horizontal component 2024-02-20 23:24:27 +07:00
Wahyu Kurniawan 0a5a1b8c55 🔧 chore: add vscode settings to enable tailwind variant intellisense 2024-02-20 23:24:27 +07:00
Wahyu Kurniawan 4f2944a974 🔧 chore: install date-fns and react-calendar 2024-02-20 23:24:26 +07:00
Wahyu Kurniawan 69198307fb Merge pull request #77 from snowball-tools/ayungavis/T-4834-button
[T-4834: feat] Button component
2024-02-20 23:23:38 +07:00
Wahyu Kurniawan 0f7c6c73c9 ️ feat: make the button component to forward ref 2024-02-20 22:59:31 +07:00
Wahyu Kurniawan 2ac657c32e 🎨 style: add new spacing 2024-02-19 21:16:55 +07:00
Wahyu Kurniawan 943d427db1 📝 docs: add button icon only on component example 2024-02-19 21:16:39 +07:00
Wahyu Kurniawan 7e6b74208d ️ feat: create cloneIcon util function 2024-02-19 21:16:17 +07:00
Wahyu Kurniawan 2b78cab849 ️ feat: create custom icon and plus icon component 2024-02-19 21:15:51 +07:00
Wahyu Kurniawan 862862a9c5 🎨 style: update button and button icon only theme 2024-02-19 21:15:29 +07:00
Wahyu Kurniawan 6a3af491ca Merge branch 'andrehadianto/design-system-components' of https://github.com/snowball-tools/snowballtools-base into ayungavis/T-4834-button 2024-02-19 20:20:31 +07:00
Wahyu Kurniawan 92f7d07f3f 🔧 chore: install tailwind-variants and @fontsource/inter 2024-02-19 20:16:25 +07:00
Wahyu Kurniawan 6fb94aeb11 🔧 chore: add absolute path 2024-02-19 20:15:56 +07:00
Wahyu Kurniawan 1c848c5a89 🎨 style: add inter font family 2024-02-19 20:15:32 +07:00
Wahyu Kurniawan 17b5ff9a74 🎨 style: add colors, font family, and shadow 2024-02-19 20:14:49 +07:00
Wahyu Kurniawan 56adfc4806 🐛 fix: typescript error 2024-02-19 20:14:23 +07:00
Wahyu Kurniawan b51d050ee6 📝 docs: add button to the example comopnent pag 2024-02-19 20:13:56 +07:00
Wahyu Kurniawan 8021ff7ff9 🔧 chore: add vscode settings on frontend package 2024-02-19 20:13:36 +07:00
Wahyu Kurniawan e850435c2d ️ feat: create button component 2024-02-19 20:13:11 +07:00
Zachery 8a43698c43 Merge pull request #75 from snowball-tools/zachery/T-4843-focus-ring-class
feat: focus ring class
2024-02-19 20:55:52 +08:00
Zachery Ng 499dba2c5e chore: add full palette colors 2024-02-19 20:12:25 +08:00
Zachery Ng d498ba7e9a feat: focus ring class 2024-02-19 20:05:29 +08:00
Andre H 082353e9bc ️ feat: implement components page to act as storybook 2024-02-16 12:31:13 +07:00
134 changed files with 3446 additions and 3651 deletions
+6
View File
@@ -0,0 +1,6 @@
{
// IntelliSense for taiwind variants
"tailwindCSS.experimental.classRegex": [
["tv\\((([^()]*|\\([^()]*\\))*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"]
]
}
+43 -149
View File
@@ -1,8 +1,8 @@
# snowballtools-base
# snowballtools
## Setup
- Clone the `snowballtools-base` repo
- Clone the `snowballtools` repo
```bash
git clone git@github.com:snowball-tools/snowballtools-base.git
@@ -28,138 +28,63 @@
cd packages/backend
```
- Set `gitHub.oAuth.clientId` and `gitHub.oAuth.clientSecret` in backend [config file](packages/backend/environments/local.toml)
- Client ID and secret will be available after [creating an OAuth app](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app)
- In "Homepage URL", type `http://localhost:3000`
- In "Authorization callback URL", type `http://localhost:3000/organization/projects/create`
- Generate a new client secret after app is created
### Backend Production
- Let us assume the following domains for backend and frontend
- Backend server: `api.snowballtools.com`
- Frontend app: `dashboard.snowballtools.com`
- Update the following in backend [config file](packages/backend/environments/local.toml)
```toml
[server]
...
[server.session]
# Secret should be changed to a different random string
secret = "p4yfpkqnddkui2iw7t6hbhwq74lbqs7sidnc382"
# Set URL of the frontend app
appOriginUrl = "https://dashboard.snowballtools.com"
# Set to true for session cookies to work behind proxy
trustProxy = true
# Set empty domain when using secure connection
domain = ""
```
- Set `gitHub.oAuth.clientId` and `gitHub.oAuth.clientSecret` in backend [config file](packages/backend/environments/local.toml)
- Client ID and secret will be available after [creating an OAuth app](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app)
- In "Homepage URL", type `https://dashboard.snowballtools.com`
- In "Authorization callback URL", type `https://dashboard.snowballtools.com/organization/projects/create`
- Generate a new client secret after app is created
- Set `gitHub.webhookUrl` in backend [config file](packages/backend/environments/local.toml)
```toml
...
[gitHub]
webhookUrl = "https://api.snowballtools.com"
...
```
- Let us assume domain for Laconicd to be `api.laconic.com` and set the following in backend [config file](packages/backend/environments/local.toml)
```toml
...
[registryConfig]
fetchDeploymentRecordDelay = 5000
# Use actual port for REST endpoint
restEndpoint = "http://api.laconic.com:1317"
# Use actual port for GQL endpoint
gqlEndpoint = "http://api.laconic.com:9473/api"
# Set private key of account to be used in Laconicd
privateKey = "0wtu92cd4f1y791ezpjwgzzazni4dmd3q3mzqc3t6i6r9v06ji784tey6hwmnn69"
# Set Bond ID to be used for publishing records
bondId = "8xk8c2pb61kajwixpm223zvptr2x2ncajq0vd998p6aqhvqqep2reu6pik245epf"
chainId = "laconic_9000-1"
# Set authority that is existing in the chain
authority = "laconic"
[registryConfig.fee]
amount = "200000"
denom = "aphoton"
gas = "750000"
...
```
- Start the server in `packages/backend`
- Load fixtures in database
```bash
yarn start
yarn test:db:load:fixtures
```
### Backend Development
- Set `gitHub.oAuth.clientId` and `gitHub.oAuth.clientSecret` in backend [config file](packages/backend/environments/local.toml)
- Client ID and secret will be available after [creating an OAuth app](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app)
- Client ID and secret will be available after creating Github OAuth app
- https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app
- In "Homepage URL", type `http://localhost:3000`
- In "Authorization callback URL", type `http://localhost:3000/organization/projects/create`
- Generate a new client secret after app is created
- Setup Laconicd
- Run the laconicd stack following this [doc](https://git.vdb.to/cerc-io/stack-orchestrator/src/branch/main/docs/laconicd-with-console.md)
- Get the private key and set `registryConfig.privateKey` in backend [config file](packages/backend/environments/local.toml)
- Run the laconicd stack following this [doc](https://git.vdb.to/cerc-io/stack-orchestrator/src/branch/main/docs/laconicd-with-console.md)
```bash
laconic-so --stack fixturenet-laconic-loaded deploy exec laconicd "laconicd keys export mykey --unarmored-hex --unsafe"
# WARNING: The private key will be exported as an unarmored hexadecimal string. USE AT YOUR OWN RISK. Continue? [y/N]: y
# 754cca7b4b729a99d156913aea95366411d072856666e95ba09ef6c664357d81
```
- Get the private key and set `registryConfig.privateKey` in backend [config file](packages/backend/environments/local.toml)
- Get the REST and GQL endpoint ports of Laconicd and replace the ports for `registryConfig.restEndpoint` and `registryConfig.gqlEndpoint` in backend [config file](packages/backend/environments/local.toml)
```bash
laconic-so --stack fixturenet-laconic-loaded deploy exec laconicd "laconicd keys export mykey --unarmored-hex --unsafe"
# WARNING: The private key will be exported as an unarmored hexadecimal string. USE AT YOUR OWN RISK. Continue? [y/N]: y
# 754cca7b4b729a99d156913aea95366411d072856666e95ba09ef6c664357d81
```
```bash
# For registryConfig.restEndpoint
laconic-so --stack fixturenet-laconic-loaded deploy port laconicd 1317
# 0.0.0.0:32777
- Get the REST and GQL endpoint ports of Laconicd and replace the ports for `registryConfig.restEndpoint` and `registryConfig.gqlEndpoint` in backend [config file](packages/backend/environments/local.toml)
# For registryConfig.gqlEndpoint
laconic-so --stack fixturenet-laconic-loaded deploy port laconicd 9473
# 0.0.0.0:32771
```
```bash
# For registryConfig.restEndpoint
laconic-so --stack fixturenet-laconic-loaded deploy port laconicd 1317
# 0.0.0.0:32777
- Set authority in `registryConfig.authority` in backend [config file](packages/backend/environments/local.toml)
# For registryConfig.gqlEndpoint
laconic-so --stack fixturenet-laconic-loaded deploy port laconicd 9473
# 0.0.0.0:32771
```
- Run the script to create bond, reserve the authority and set authority bond
- Run the script to create bond, reserve the authority and set authority bond
```bash
yarn test:registry:init
# snowball:initialize-registry bondId: 6af0ab81973b93d3511ae79841756fb5da3fd2f70ea1279e81fae7c9b19af6c4 +0ms
```
```bash
yarn test:registry:init
# snowball:initialize-registry bondId: 6af0ab81973b93d3511ae79841756fb5da3fd2f70ea1279e81fae7c9b19af6c4 +0ms
```
- Get the bond id and set `registryConfig.bondId` in backend [config file](packages/backend/environments/local.toml)
- Get the bond id and set `registryConfig.bondId` in backend [config file](packages/backend/environments/local.toml)
- Setup ngrok for GitHub webhooks
- [ngrok getting started](https://ngrok.com/docs/getting-started/)
- https://ngrok.com/docs/getting-started/
- Start ngrok and point to backend server endpoint
```bash
ngrok http http://localhost:8000
```
- Look for the forwarding URL in ngrok
```bash
```
...
Forwarding https://19c1-61-95-158-116.ngrok-free.app -> http://localhost:8000
...
```
- Set `gitHub.webhookUrl` in backend [config file](packages/backend/environments/local.toml)
```toml
...
[gitHub]
@@ -170,7 +95,7 @@
- Start the server in `packages/backend`
```bash
yarn start:dev
yarn start
```
## Frontend
@@ -181,6 +106,12 @@
cd packages/frontend
```
- Copy the graphQL endpoint from terminal and add the endpoint in the [.env](packages/frontend/.env) file present in `packages/frontend`
```env
REACT_APP_GQL_SERVER_URL = 'http://localhost:8000/graphql'
```
- Copy the GitHub OAuth app client ID from previous steps and set it in frontend [.env](packages/frontend/.env) file
```env
@@ -190,32 +121,20 @@
- Set `REACT_APP_GITHUB_TEMPLATE_REPO` in [.env](packages/frontend/.env) file
```env
# Set actual owner/name of the template repo that will be used for creating new repo
REACT_APP_GITHUB_TEMPLATE_REPO = cerc-io/test-progressive-web-app
```
### Frontend Production
### Development
- Let us assume the following domains for backend and frontend
- Backend server: `api.snowballtools.com`
- Frontend app: `dashboard.snowballtools.com`
- Start the React application
- Set the following values in [.env](packages/frontend/.env) file
```env
# Backend server endpoint
REACT_APP_SERVER_URL = 'https://api.snowballtools.com'
```bash
yarn start
```
- Sign in to [wallet connect](https://cloud.walletconnect.com/sign-in) to create a project ID
- Create a project and add information to use wallet connect SDK
- Add project name and select project type as `App`
- Set project home page URL to `https://dashboard.snowballtools.com`
- On creation of project, use the `Project ID` and set it in `REACT_APP_WALLET_CONNECT_ID` in [.env](packages/frontend/.env) file
- The React application will be running in `http://localhost:3000/`
```env
REACT_APP_WALLET_CONNECT_ID = <PROJECT_ID>
```
### Production
- Build the React application
@@ -229,28 +148,3 @@
python3 -m http.server -d build 3000
```
### Frontend Development
- Copy the graphQL endpoint from terminal and add the endpoint in the [.env](packages/frontend/.env) file present in `packages/frontend`
```env
REACT_APP_SERVER_URL = 'http://localhost:8000'
```
- Sign in to [wallet connect](https://cloud.walletconnect.com/sign-in) to create a project ID.
- Create a project and add information to use wallet connect SDK
- Add project name and select project type as `App`
- Project home page URL is not required to be set
- On creation of project, use the `Project ID` and set it in `REACT_APP_WALLET_CONNECT_ID` in [.env](packages/frontend/.env) file
```env
REACT_APP_WALLET_CONNECT_ID = <Project_ID>
```
- Start the React application
```bash
yarn start
```
- The React application will be running in `http://localhost:3000/`
-27
View File
@@ -1,27 +0,0 @@
#!/bin/bash
PKG_DIR="./packages/frontend"
OUTPUT_DIR="${PKG_DIR}/build"
DEST_DIR=${1:-/data}
if [[ -d "$DEST_DIR" ]]; then
echo "${DEST_DIR} already exists." 1>&2
exit 1
fi
cat > $PKG_DIR/.env <<EOF
REACT_APP_SERVER_URL = 'LACONIC_HOSTED_CONFIG_app_server_url'
REACT_APP_GITHUB_CLIENT_ID = 'LACONIC_HOSTED_CONFIG_app_github_clientid'
REACT_APP_GITHUB_TEMPLATE_REPO = 'LACONIC_HOSTED_CONFIG_app_github_templaterepo'
REACT_APP_WALLET_CONNECT_ID = 'LACONIC_HOSTED_CONFIG_app_wallet_connect_id'
EOF
yarn || exit 1
yarn build || exit 1
if [[ ! -d "$OUTPUT_DIR" ]]; then
echo "Missing output directory: $OUTPUT_DIR" 1>&2
exit 1
fi
mv "$OUTPUT_DIR" "$DEST_DIR"
+1 -15
View File
@@ -2,11 +2,6 @@
host = "127.0.0.1"
port = 8000
gqlPath = "/graphql"
[server.session]
secret = "p4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi"
appOriginUrl = "http://localhost:3000"
trustProxy = false
domain = "localhost"
[database]
dbPath = "db/snowball"
@@ -17,11 +12,6 @@
clientId = ""
clientSecret = ""
[gitea]
[gitea.oAuth]
clientId = ""
clientSecret = ""
[registryConfig]
fetchDeploymentRecordDelay = 5000
restEndpoint = "http://localhost:1317"
@@ -29,11 +19,7 @@
chainId = "laconic_9000-1"
privateKey = ""
bondId = ""
authority = ""
[registryConfig.fee]
amount = "200000"
denom = "aphoton"
gas = "750000"
[misc]
projectDomain = "apps.snowballtools.com"
gas = "550000"
-5
View File
@@ -12,16 +12,13 @@
"@types/node": "^20.11.0",
"apollo-server-core": "^3.13.0",
"apollo-server-express": "^3.13.0",
"cors": "^2.8.5",
"debug": "^4.3.1",
"express": "^4.18.2",
"express-session": "^1.18.0",
"fs-extra": "^11.2.0",
"graphql": "^16.8.1",
"luxon": "^3.4.4",
"nanoid": "3",
"nanoid-dictionary": "^5.0.0-beta.1",
"node-fetch": "2",
"octokit": "^3.1.2",
"reflect-metadata": "^0.2.1",
"semver": "^7.6.0",
@@ -45,9 +42,7 @@
"test:db:delete": "DEBUG=snowball:* ts-node ./test/delete-db.ts"
},
"devDependencies": {
"@types/express-session": "^1.17.10",
"@types/fs-extra": "^11.0.4",
"@types/node-fetch": "^2.6.11",
"@typescript-eslint/eslint-plugin": "^6.18.1",
"@typescript-eslint/parser": "^6.18.1",
"better-sqlite3": "^9.2.2",
-17
View File
@@ -1,18 +1,7 @@
export interface SessionConfig {
secret: string;
appOriginUrl: string;
trustProxy: boolean;
domain: string;
}
export interface ServerConfig {
host: string;
port: number;
gqlPath?: string;
sessionSecret: string;
appOriginUrl: string;
isProduction: boolean;
session: SessionConfig;
}
export interface DatabaseConfig {
@@ -34,7 +23,6 @@ export interface RegistryConfig {
privateKey: string;
bondId: string;
fetchDeploymentRecordDelay: number;
authority: string;
fee: {
amount: string;
denom: string;
@@ -42,14 +30,9 @@ export interface RegistryConfig {
}
}
export interface MiscConfig {
projectDomain: string;
}
export interface Config {
server: ServerConfig;
database: DatabaseConfig;
gitHub: GitHubConfig;
registryConfig: RegistryConfig;
misc: MiscConfig;
}
+6 -3
View File
@@ -1,5 +1,8 @@
import process from 'process';
export const DEFAULT_CONFIG_FILE_PATH = process.env.SNOWBALL_BACKEND_CONFIG_FILE_PATH || 'environments/local.toml';
export const DEFAULT_CONFIG_FILE_PATH = 'environments/local.toml';
export const DEFAULT_GQL_PATH = '/graphql';
// Note: temporary hardcoded user, later to be derived from auth token
export const USER_ID = '59f4355d-9549-4aac-9b54-eeefceeabef0';
export const PROJECT_DOMAIN = 'snowball.xyz';
+10 -36
View File
@@ -5,7 +5,7 @@ import assert from 'assert';
import { customAlphabet } from 'nanoid';
import { lowercase, numbers } from 'nanoid-dictionary';
import { DatabaseConfig, MiscConfig } from './config';
import { DatabaseConfig } from './config';
import { User } from './entity/User';
import { Organization } from './entity/Organization';
import { Project } from './entity/Project';
@@ -13,10 +13,7 @@ import { Deployment } from './entity/Deployment';
import { ProjectMember } from './entity/ProjectMember';
import { EnvironmentVariable } from './entity/EnvironmentVariable';
import { Domain } from './entity/Domain';
import { getEntities, loadAndSaveData } from './utils';
import { UserOrganization } from './entity/UserOrganization';
const ORGANIZATION_DATA_PATH = '../test/fixtures/organizations.json';
import { PROJECT_DOMAIN } from './constants';
const log = debug('snowball:database');
@@ -25,9 +22,8 @@ const nanoid = customAlphabet(lowercase + numbers, 8);
// TODO: Fix order of methods
export class Database {
private dataSource: DataSource;
private projectDomain: string;
constructor ({ dbPath } : DatabaseConfig, { projectDomain } : MiscConfig) {
constructor ({ dbPath }: DatabaseConfig) {
this.dataSource = new DataSource({
type: 'better-sqlite3',
database: dbPath,
@@ -35,21 +31,11 @@ export class Database {
synchronize: true,
logging: false
});
this.projectDomain = projectDomain;
}
async init (): Promise<void> {
await this.dataSource.initialize();
log('database initialized');
const organizations = await this.getOrganizations({});
// Load an organization if none exist
if (!organizations.length) {
const orgEntities = await getEntities(path.resolve(__dirname, ORGANIZATION_DATA_PATH));
await loadAndSaveData(Organization, this.dataSource, [orgEntities[0]]);
}
}
async getUser (options: FindOneOptions<User>): Promise<User | null> {
@@ -66,21 +52,14 @@ export class Database {
return user;
}
async updateUser (user: User, data: DeepPartial<User>): Promise<boolean> {
async updateUser (userId: string, data: DeepPartial<User>): Promise<boolean> {
const userRepository = this.dataSource.getRepository(User);
const updateResult = await userRepository.update({ id: user.id }, data);
const updateResult = await userRepository.update({ id: userId }, data);
assert(updateResult.affected);
return updateResult.affected > 0;
}
async getOrganizations (options: FindManyOptions<Organization>): Promise<Organization[]> {
const organizationRepository = this.dataSource.getRepository(Organization);
const organizations = await organizationRepository.find(options);
return organizations;
}
async getOrganization (options: FindOneOptions<Organization>): Promise<Organization | null> {
const organizationRepository = this.dataSource.getRepository(Organization);
const organization = await organizationRepository.findOne(options);
@@ -104,13 +83,6 @@ export class Database {
return userOrgs;
}
async addUserOrganization (data: DeepPartial<UserOrganization>): Promise<UserOrganization> {
const userOrganizationRepository = this.dataSource.getRepository(UserOrganization);
const newUserOrganization = await userOrganizationRepository.save(data);
return newUserOrganization;
}
async getProjects (options: FindManyOptions<Project>): Promise<Project[]> {
const projectRepository = this.dataSource.getRepository(Project);
const projects = await projectRepository.find(options);
@@ -357,7 +329,7 @@ export class Database {
return Boolean(updateResult.affected);
}
async addProject (user: User, organizationId: string, data: DeepPartial<Project>): Promise<Project> {
async addProject (userId: string, organizationId: string, data: DeepPartial<Project>): Promise<Project> {
const projectRepository = this.dataSource.getRepository(Project);
// TODO: Check if organization exists
@@ -367,13 +339,15 @@ export class Database {
// TODO: Set icon according to framework
newProject.icon = '';
newProject.owner = user;
newProject.owner = Object.assign(new User(), {
id: userId
});
newProject.organization = Object.assign(new Organization(), {
id: organizationId
});
newProject.subDomain = `${newProject.name}.${this.projectDomain}`;
newProject.subDomain = `${newProject.name}.${PROJECT_DOMAIN}`;
return projectRepository.save(newProject);
}
-15
View File
@@ -26,15 +26,6 @@ export enum DeploymentStatus {
Error = 'Error',
}
export interface ApplicationDeploymentRequest {
type: string
version: string
name: string
application: string
config: string,
meta: string
}
export interface ApplicationRecord {
type: string;
version:string
@@ -87,12 +78,6 @@ export class Deployment {
@Column('simple-json')
applicationRecordData!: ApplicationRecord;
@Column('varchar', { nullable: true })
applicationDeploymentRequestId!: string | null;
@Column('simple-json', { nullable: true })
applicationDeploymentRequestData!: ApplicationDeploymentRequest | null;
@Column('varchar', { nullable: true })
applicationDeploymentRecordId!: string | null;
+15
View File
@@ -15,6 +15,15 @@ import { Organization } from './Organization';
import { ProjectMember } from './ProjectMember';
import { Deployment } from './Deployment';
export interface ApplicationDeploymentRequest {
type: string
version: string
name: string
application: string
config: string,
meta: string
}
@Entity()
export class Project {
@PrimaryGeneratedColumn('uuid')
@@ -43,6 +52,12 @@ export class Project {
@Column('varchar', { length: 255, default: 'main' })
prodBranch!: string;
@Column('varchar', { nullable: true })
applicationDeploymentRequestId!: string | null;
@Column('simple-json', { nullable: true })
applicationDeploymentRequestData!: ApplicationDeploymentRequest | null;
@Column('text', { default: '' })
description!: string;
-5
View File
@@ -12,15 +12,10 @@ import { UserOrganization } from './UserOrganization';
@Entity()
@Unique(['email'])
@Unique(['ethAddress'])
export class User {
@PrimaryGeneratedColumn('uuid')
id!: string;
// TODO: Set ethAddress as ID
@Column()
ethAddress!: string;
@Column('varchar', { length: 255, nullable: true })
name!: string | null;
@@ -12,7 +12,7 @@ import {
import { User } from './User';
import { Organization } from './Organization';
export enum Role {
enum Role {
Owner = 'Owner',
Maintainer = 'Maintainer',
Reader = 'Reader',
+2 -2
View File
@@ -19,7 +19,7 @@ const OAUTH_CLIENT_TYPE = 'oauth-app';
export const main = async (): Promise<void> => {
// TODO: get config path using cli
const { server, database, gitHub, registryConfig, misc } = await getConfig<Config>(DEFAULT_CONFIG_FILE_PATH);
const { server, database, gitHub, registryConfig } = await getConfig<Config>(DEFAULT_CONFIG_FILE_PATH);
const app = new OAuthApp({
clientType: OAUTH_CLIENT_TYPE,
@@ -27,7 +27,7 @@ export const main = async (): Promise<void> => {
clientSecret: gitHub.oAuth.clientSecret
});
const db = new Database(database, misc);
const db = new Database(database);
await db.init();
const registry = new Registry(registryConfig);
+17 -12
View File
@@ -6,7 +6,8 @@ import { DateTime } from 'luxon';
import { Registry as LaconicRegistry } from '@cerc-io/laconic-sdk';
import { RegistryConfig } from './config';
import { ApplicationRecord, Deployment, ApplicationDeploymentRequest } from './entity/Deployment';
import { ApplicationDeploymentRequest } from './entity/Project';
import { ApplicationRecord, Deployment } from './entity/Deployment';
import { AppDeploymentRecord, PackageJSON } from './types';
const log = debug('snowball:registry');
@@ -26,18 +27,17 @@ export class Registry {
}
async createApplicationRecord ({
appName,
packageJSON,
commitHash,
appType,
repoUrl
}: {
appName: string,
packageJSON: PackageJSON
commitHash: string,
appType: string,
repoUrl: string
}): Promise<{applicationRecordId: string, applicationRecordData: ApplicationRecord}> {
assert(packageJSON.name, "name field doesn't exist in package.json");
// Use laconic-sdk to publish record
// Reference: https://git.vdb.to/cerc-io/test-progressive-web-app/src/branch/main/scripts/publish-app-record.sh
// Fetch previous records
@@ -60,7 +60,7 @@ export class Registry {
repository_ref: commitHash,
repository: [repoUrl],
app_type: appType,
name: appName,
name: packageJSON.name,
...(packageJSON.description && { description: packageJSON.description }),
...(packageJSON.homepage && { homepage: packageJSON.homepage }),
...(packageJSON.license && { license: packageJSON.license }),
@@ -81,7 +81,7 @@ export class Registry {
log('Application record data:', applicationRecord);
// TODO: Discuss computation of CRN
const crn = this.getCrn(appName);
const crn = this.getCrn(packageJSON.name);
log(`Setting name: ${crn} for record ID: ${result.data.id}`);
await this.registry.setName({ cid: result.data.id, crn }, this.registryConfig.privateKey, this.registryConfig.fee);
@@ -92,9 +92,8 @@ export class Registry {
}
async createApplicationDeploymentRequest (data: {
deployment: Deployment,
appName: string,
packageJsonName: string,
commitHash: string,
repository: string,
environmentVariables: { [key: string]: string }
}): Promise<{
@@ -115,9 +114,9 @@ export class Registry {
version: '1.0.0',
name: `${applicationRecord.attributes.name}@${applicationRecord.attributes.app_version}`,
application: `${crn}@${applicationRecord.attributes.app_version}`,
dns: `${data.deployment.project.name}-${data.deployment.id}`,
// TODO: Not set in test-progressive-web-app CI
// dns: '$CERC_REGISTRY_DEPLOYMENT_SHORT_HOSTNAME',
// deployment: '$CERC_REGISTRY_DEPLOYMENT_CRN',
// https://git.vdb.to/cerc-io/laconic-registry-cli/commit/129019105dfb93bebcea02fde0ed64d0f8e5983b
@@ -127,7 +126,7 @@ export class Registry {
meta: JSON.stringify({
note: `Added by Snowball @ ${DateTime.utc().toFormat('EEE LLL dd HH:mm:ss \'UTC\' yyyy')}`,
repository: data.repository,
repository_ref: data.deployment.commitHash
repository_ref: data.commitHash
})
};
@@ -160,8 +159,14 @@ export class Registry {
return records.filter((record: AppDeploymentRecord) => deployments.some(deployment => deployment.applicationRecordId === record.attributes.application));
}
getCrn (appName: string): string {
assert(this.registryConfig.authority, "Authority doesn't exist");
return `crn://${this.registryConfig.authority}/applications/${appName}`;
getCrn (packageJsonName: string): string {
const [arg1, arg2] = packageJsonName.split('/');
if (arg2) {
const authority = arg1.replace('@', '');
return `crn://${authority}/applications/${arg2}`;
}
return `crn://${arg1}/applications/${arg1}`;
}
}
+10 -20
View File
@@ -6,7 +6,6 @@ import { Permission } from './entity/ProjectMember';
import { Domain } from './entity/Domain';
import { Project } from './entity/Project';
import { EnvironmentVariable } from './entity/EnvironmentVariable';
import { GitType } from './types';
const log = debug('snowball:resolver');
@@ -15,11 +14,11 @@ export const createResolvers = async (service: Service): Promise<any> => {
Query: {
// TODO: add custom type for context
user: (_: any, __: any, context: any) => {
return context.user;
return service.getUser(context.userId);
},
organizations: async (_:any, __: any, context: any) => {
return service.getOrganizationsByUserId(context.user);
return service.getOrganizationsByUserId(context.userId);
},
project: async (_: any, { projectId }: { projectId: string }) => {
@@ -27,7 +26,7 @@ export const createResolvers = async (service: Service): Promise<any> => {
},
projectsInOrganization: async (_: any, { organizationSlug }: {organizationSlug: string }, context: any) => {
return service.getProjectsInOrganization(context.user, organizationSlug);
return service.getProjectsInOrganization(context.userId, organizationSlug);
},
deployments: async (_: any, { projectId }: { projectId: string }) => {
@@ -43,7 +42,7 @@ export const createResolvers = async (service: Service): Promise<any> => {
},
searchProjects: async (_: any, { searchText }: { searchText: string }, context: any) => {
return service.searchProjects(context.user, searchText);
return service.searchProjects(context.userId, searchText);
},
domains: async (_:any, { projectId, filter }: { projectId: string, filter?: FindOptionsWhere<Domain> }) => {
@@ -55,7 +54,7 @@ export const createResolvers = async (service: Service): Promise<any> => {
Mutation: {
removeProjectMember: async (_: any, { projectMemberId }: { projectMemberId: string }, context: any) => {
try {
return await service.removeProjectMember(context.user, projectMemberId);
return await service.removeProjectMember(context.userId, projectMemberId);
} catch (err) {
log(err);
return false;
@@ -120,7 +119,7 @@ export const createResolvers = async (service: Service): Promise<any> => {
updateDeploymentToProd: async (_: any, { deploymentId }: { deploymentId: string }, context: any) => {
try {
return Boolean(await service.updateDeploymentToProd(context.user, deploymentId));
return Boolean(await service.updateDeploymentToProd(context.userId, deploymentId));
} catch (err) {
log(err);
return false;
@@ -129,7 +128,7 @@ export const createResolvers = async (service: Service): Promise<any> => {
addProject: async (_: any, { organizationSlug, data }: { organizationSlug: string, data: DeepPartial<Project> }, context: any) => {
try {
return await service.addProject(context.user, organizationSlug, data);
return await service.addProject(context.userId, organizationSlug, data);
} catch (err) {
log(err);
throw err;
@@ -147,7 +146,7 @@ export const createResolvers = async (service: Service): Promise<any> => {
redeployToProd: async (_: any, { deploymentId }: { deploymentId: string }, context: any) => {
try {
return Boolean(await service.redeployToProd(context.user, deploymentId));
return Boolean(await service.redeployToProd(context.userId, deploymentId));
} catch (err) {
log(err);
return false;
@@ -200,16 +199,7 @@ export const createResolvers = async (service: Service): Promise<any> => {
authenticateGitHub: async (_: any, { code }: { code: string }, context: any) => {
try {
return await service.authenticateGitHub(code, context.user);
} catch (err) {
log(err);
return false;
}
},
authenticateGit: async (_: any, { type, code }: { type: GitType, code: string }, context: any) => {
try {
return await service.authenticateGit(type, code, context.user);
return await service.authenticateGitHub(code, context.userId);
} catch (err) {
log(err);
return false;
@@ -218,7 +208,7 @@ export const createResolvers = async (service: Service): Promise<any> => {
unauthenticateGitHub: async (_: any, __: object, context: any) => {
try {
return service.unauthenticateGitHub(context.user, { gitHubToken: null });
return service.unauthenticateGitHub(context.userId, { gitHubToken: null });
} catch (err) {
log(err);
return false;
-41
View File
@@ -1,41 +0,0 @@
import { Router } from 'express';
import { SiweMessage, generateNonce } from 'siwe';
const router = Router();
router.get('/nonce', async (_, res) => {
res.send(generateNonce());
});
router.post('/validate', async (req, res) => {
const { message, signature } = req.body;
const { success, data } = await new SiweMessage(message).verify({
signature
});
if (success) {
req.session.address = data.address;
req.session.chainId = data.chainId;
}
res.send({ success });
});
router.get('/session', (req, res) => {
if (req.session.address && req.session.chainId) {
res.send({ address: req.session.address, chainId: req.session.chainId });
} else {
res.status(401).send({ error: 'Unauthorized: No active session' });
}
});
router.post('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.send({ success: false });
}
res.send({ success: true });
});
});
export default router;
-6
View File
@@ -26,11 +26,6 @@ enum DomainStatus {
Pending
}
enum GitType {
GitHub
Gitea
}
type User {
id: String!
name: String
@@ -208,6 +203,5 @@ type Mutation {
addDomain(projectId: String!, data: AddDomainInput!): Boolean!
updateDomain(domainId: String!, data: UpdateDomainInput!): Boolean!
authenticateGitHub(code: String!): AuthResult!
authenticateGit(type: GitType!, code: String!): AuthResult!
unauthenticateGitHub: Boolean!
}
+7 -65
View File
@@ -1,33 +1,22 @@
import debug from 'debug';
import express from 'express';
import cors from 'cors';
import { ApolloServer } from 'apollo-server-express';
import { createServer } from 'http';
import {
ApolloServerPluginDrainHttpServer,
ApolloServerPluginLandingPageLocalDefault,
AuthenticationError
ApolloServerPluginLandingPageLocalDefault
} from 'apollo-server-core';
import session from 'express-session';
import { TypeSource } from '@graphql-tools/utils';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { ServerConfig } from './config';
import { DEFAULT_GQL_PATH } from './constants';
import { DEFAULT_GQL_PATH, USER_ID } from './constants';
import githubRouter from './routes/github';
import authRouter from './routes/auth';
import { Service } from './service';
const log = debug('snowball:server');
declare module 'express-session' {
interface SessionData {
address: string;
chainId: number;
}
}
export const createAndStartServer = async (
serverConfig: ServerConfig,
typeDefs: TypeSource,
@@ -35,7 +24,6 @@ export const createAndStartServer = async (
service: Service
): Promise<ApolloServer> => {
const { host, port, gqlPath = DEFAULT_GQL_PATH } = serverConfig;
const { appOriginUrl, secret, domain, trustProxy } = serverConfig.session;
const app = express();
@@ -51,19 +39,9 @@ export const createAndStartServer = async (
const server = new ApolloServer({
schema,
csrfPrevention: true,
context: async ({ req }) => {
// https://www.apollographql.com/docs/apollo-server/v3/security/authentication#api-wide-authorization
const { address } = req.session;
if (!address) {
throw new AuthenticationError('Unauthorized: No active session');
}
// Find/create user from ETH address in request session
const user = await service.loadOrCreateUser(address);
return { user };
context: () => {
// TODO: Use userId derived from auth token
return { userId: USER_ID };
},
plugins: [
// Proper shutdown for the HTTP server
@@ -74,49 +52,13 @@ export const createAndStartServer = async (
await server.start();
app.use(cors({
origin: appOriginUrl,
credentials: true
}));
const sessionOptions: session.SessionOptions = {
secret: secret,
resave: false,
saveUninitialized: true,
cookie: {
secure: new URL(appOriginUrl).protocol === 'https:',
// TODO: Set cookie maxAge and handle cookie expiry in frontend
// maxAge: SESSION_COOKIE_MAX_AGE,
sameSite: new URL(appOriginUrl).protocol === 'https:' ? 'none' : 'lax'
}
};
if (domain) {
sessionOptions.cookie!.domain = domain;
}
if (trustProxy) {
// trust first proxy
app.set('trust proxy', 1);
}
app.use(
session(sessionOptions)
);
server.applyMiddleware({
app,
path: gqlPath,
cors: {
origin: [appOriginUrl],
credentials: true
}
path: gqlPath
});
app.use(express.json());
app.set('service', service);
app.use('/auth', authRouter);
app.use(express.json());
app.use('/api/github', githubRouter);
httpServer.listen(port, host, () => {
+51 -130
View File
@@ -2,7 +2,6 @@ import assert from 'assert';
import debug from 'debug';
import { DeepPartial, FindOptionsWhere } from 'typeorm';
import { Octokit, RequestError } from 'octokit';
import fetch from 'node-fetch';
import { OAuthApp } from '@octokit/oauth-app';
@@ -16,13 +15,11 @@ import { Permission, ProjectMember } from './entity/ProjectMember';
import { User } from './entity/User';
import { Registry } from './registry';
import { GitHubConfig, RegistryConfig } from './config';
import { AppDeploymentRecord, GitPushEventPayload, GitType, PackageJSON } from './types';
import { Role } from './entity/UserOrganization';
import { AppDeploymentRecord, GitPushEventPayload } from './types';
const log = debug('snowball:service');
const GITHUB_UNIQUE_WEBHOOK_ERROR = 'Hook already exists on this repository';
const GITEA_ACCESS_TOKEN_ENDPOINT = 'https://git.vdb.to/login/oauth/access_token';
interface Config {
gitHubConfig: GitHubConfig
@@ -151,36 +148,6 @@ export class Service {
});
}
async loadOrCreateUser (ethAddress: string): Promise<User> {
// Get user by ETH address
let user = await this.db.getUser({
where: {
ethAddress
}
});
if (!user) {
const [org] = await this.db.getOrganizations({});
assert(org, 'No organizations exists in database');
// Create user with new address
user = await this.db.addUser({
email: `${ethAddress}@example.com`,
name: ethAddress,
isVerified: true,
ethAddress
});
await this.db.addUserOrganization({
member: user,
organization: org,
role: Role.Owner
});
}
return user;
}
async getOctokit (userId: string): Promise<Octokit> {
const user = await this.db.getUser({ where: { id: userId } });
assert(user && user.gitHubToken, 'User needs to be authenticated with GitHub token');
@@ -188,8 +155,8 @@ export class Service {
return new Octokit({ auth: user.gitHubToken });
}
async getOrganizationsByUserId (user: User): Promise<Organization[]> {
const dbOrganizations = await this.db.getOrganizationsByUserId(user.id);
async getOrganizationsByUserId (userId: string): Promise<Organization[]> {
const dbOrganizations = await this.db.getOrganizationsByUserId(userId);
return dbOrganizations;
}
@@ -198,8 +165,8 @@ export class Service {
return dbProject;
}
async getProjectsInOrganization (user: User, organizationSlug: string): Promise<Project[]> {
const dbProjects = await this.db.getProjectsInOrganization(user.id, organizationSlug);
async getProjectsInOrganization (userId:string, organizationSlug: string): Promise<Project[]> {
const dbProjects = await this.db.getProjectsInOrganization(userId, organizationSlug);
return dbProjects;
}
@@ -218,8 +185,8 @@ export class Service {
return dbProjectMembers;
}
async searchProjects (user: User, searchText: string): Promise<Project[]> {
const dbProjects = await this.db.getProjectsBySearchText(user.id, searchText);
async searchProjects (userId: string, searchText: string): Promise<Project[]> {
const dbProjects = await this.db.getProjectsBySearchText(userId, searchText);
return dbProjects;
}
@@ -264,17 +231,17 @@ export class Service {
return newProjectMember;
}
async removeProjectMember (user: User, projectMemberId: string): Promise<boolean> {
async removeProjectMember (userId: string, projectMemberId: string): Promise<boolean> {
const member = await this.db.getProjectMemberById(projectMemberId);
if (String(member.member.id) === user.id) {
if (String(member.member.id) === userId) {
throw new Error('Invalid operation: cannot remove self');
}
const memberProject = member.project;
assert(memberProject);
if (String(user.id) === String(memberProject.owner.id)) {
if (String(userId) === String(memberProject.owner.id)) {
return this.db.removeProjectMemberById(projectMemberId);
} else {
throw new Error('Invalid operation: not authorized');
@@ -307,7 +274,7 @@ export class Service {
return this.db.deleteEnvironmentVariable(environmentVariableId);
}
async updateDeploymentToProd (user: User, deploymentId: string): Promise<Deployment> {
async updateDeploymentToProd (userId: string, deploymentId: string): Promise<Deployment> {
const oldDeployment = await this.db.getDeployment({
where: { id: deploymentId },
relations: {
@@ -321,9 +288,9 @@ export class Service {
const prodBranchDomains = await this.db.getDomainsByProjectId(oldDeployment.project.id, { branch: oldDeployment.project.prodBranch });
const octokit = await this.getOctokit(user.id);
const octokit = await this.getOctokit(userId);
const newDeployment = await this.createDeployment(user.id,
const newDeployment = await this.createDeployment(userId,
octokit,
{
project: oldDeployment.project,
@@ -359,9 +326,7 @@ export class Service {
}
assert(!Array.isArray(packageJSONData) && packageJSONData.type === 'file');
const packageJSON: PackageJSON = JSON.parse(atob(packageJSONData.content));
assert(packageJSON.name, "name field doesn't exist in package.json");
const packageJSON = JSON.parse(atob(packageJSONData.content));
if (!recordData.repoUrl) {
const { data: repoDetails } = await octokit.rest.repos.get({ owner, repo });
@@ -370,7 +335,6 @@ export class Service {
// TODO: Set environment variables for each deployment (environment variables can`t be set in application record)
const { applicationRecordId, applicationRecordData } = await this.registry.createApplicationRecord({
appName: repo,
packageJSON,
appType: data.project!.template!,
commitHash: data.commitHash!,
@@ -379,13 +343,11 @@ export class Service {
// Update previous deployment with prod branch domain
// TODO: Fix unique constraint error for domain
if (data.domain) {
await this.db.updateDeployment({
domainId: data.domain.id
}, {
domain: null
});
}
await this.db.updateDeployment({
domainId: data.domain?.id
}, {
domain: null
});
const newDeployment = await this.db.addDeployment({
project: data.project,
@@ -403,30 +365,10 @@ export class Service {
});
log(`Created deployment ${newDeployment.id} and published application record ${applicationRecordId}`);
const environmentVariables = await this.db.getEnvironmentVariablesByProjectId(data.project.id!, { environment: Environment.Production });
const environmentVariablesObj = environmentVariables.reduce((acc, env) => {
acc[env.key] = env.value;
return acc;
}, {} as { [key: string]: string });
const { applicationDeploymentRequestId, applicationDeploymentRequestData } = await this.registry.createApplicationDeploymentRequest(
{
deployment: newDeployment,
appName: repo,
packageJsonName: packageJSON.name,
repository: recordData.repoUrl,
environmentVariables: environmentVariablesObj
});
await this.db.updateDeploymentById(newDeployment.id, { applicationDeploymentRequestId, applicationDeploymentRequestData });
return newDeployment;
}
async addProject (user: User, organizationSlug: string, data: DeepPartial<Project>): Promise<Project | undefined> {
async addProject (userId: string, organizationSlug: string, data: DeepPartial<Project>): Promise<Project | undefined> {
const organization = await this.db.getOrganization({
where: {
slug: organizationSlug
@@ -436,9 +378,9 @@ export class Service {
throw new Error('Organization does not exist');
}
const project = await this.db.addProject(user, organization.id, data);
const project = await this.db.addProject(userId, organization.id, data);
const octokit = await this.getOctokit(user.id);
const octokit = await this.getOctokit(userId);
const [owner, repo] = project.repository.split('/');
const { data: [latestCommit] } = await octokit.rest.repos.listCommits({
@@ -451,7 +393,7 @@ export class Service {
const { data: repoDetails } = await octokit.rest.repos.get({ owner, repo });
// Create deployment with prod branch and latest commit
await this.createDeployment(user.id,
const newDeployment = await this.createDeployment(userId,
octokit,
{
project,
@@ -466,6 +408,27 @@ export class Service {
}
);
const environmentVariables = await this.db.getEnvironmentVariablesByProjectId(project.id, { environment: Environment.Production });
const environmentVariablesObj = environmentVariables.reduce((acc, env) => {
acc[env.key] = env.value;
return acc;
}, {} as { [key: string]: string });
const { applicationDeploymentRequestId, applicationDeploymentRequestData } = await this.registry.createApplicationDeploymentRequest(
{
appName: newDeployment.applicationRecordData.name!,
commitHash: latestCommit.sha,
repository: repoDetails.html_url,
environmentVariables: environmentVariablesObj
});
await this.db.updateProjectById(project.id, {
applicationDeploymentRequestId,
applicationDeploymentRequestData
});
await this.createRepoHook(octokit, project);
return project;
@@ -551,7 +514,7 @@ export class Service {
return this.db.deleteDomainById(domainId);
}
async redeployToProd (user: User, deploymentId: string): Promise<Deployment> {
async redeployToProd (userId: string, deploymentId: string): Promise<Deployment> {
const oldDeployment = await this.db.getDeployment({
relations: {
project: true,
@@ -567,9 +530,9 @@ export class Service {
throw new Error('Deployment not found');
}
const octokit = await this.getOctokit(user.id);
const octokit = await this.getOctokit(userId);
const newDeployment = await this.createDeployment(user.id,
const newDeployment = await this.createDeployment(userId,
octokit,
{
project: oldDeployment.project,
@@ -693,59 +656,17 @@ export class Service {
return updateResult;
}
async authenticateGitHub (code:string, user: User): Promise<{token: string}> {
async authenticateGitHub (code:string, userId: string): Promise<{token: string}> {
const { authentication: { token } } = await this.oauthApp.createToken({
code
});
await this.db.updateUser(user, { gitHubToken: token });
await this.db.updateUser(userId, { gitHubToken: token });
return { token };
}
async authenticateGit (type: GitType, code:string, user: User): Promise<{token: string}> {
let token: string;
switch (type) {
case GitType.GitHub:
({ authentication: { token } } = await this.oauthApp.createToken({
code
}));
break;
case GitType.Gitea: {
const response = await fetch(GITEA_ACCESS_TOKEN_ENDPOINT, {
method: 'post',
body: JSON.stringify({
// TODO: Fetch from config
client_id: '',
client_secret: '',
code,
grant_type: 'authorization_code',
// TODO: Get frontend app URL from config
redirect_uri: 'http://localhost:3000/organization/projects/create'
}),
headers: { 'Content-Type': 'application/json' }
});
assert(response.ok, `HTTP Error Response: ${response.status} ${response.statusText}`);
const data: any = await response.json();
({ access_token: token } = data);
break;
}
default: throw new Error(`Type ${type} not handled for Git authentication`);
}
assert(token, `Access token is not set for type ${type}`);
await this.db.updateUser(user, { gitHubToken: token });
return { token };
}
async unauthenticateGitHub (user: User, data: DeepPartial<User>): Promise<boolean> {
return this.db.updateUser(user, data);
async unauthenticateGitHub (userId: string, data: DeepPartial<User>): Promise<boolean> {
return this.db.updateUser(userId, data);
}
}
+2 -7
View File
@@ -1,6 +1,6 @@
export interface PackageJSON {
name: string;
version: string;
name?: string;
version?: string;
author?: string;
description?: string;
homepage?: string;
@@ -49,8 +49,3 @@ interface RegistryRecord {
export interface AppDeploymentRecord extends RegistryRecord {
attributes: AppDeploymentRecordAttributes
}
export enum GitType {
GitHub = 'GitHub',
Gitea = 'Gitea',
}
-42
View File
@@ -2,7 +2,6 @@ import fs from 'fs-extra';
import path from 'path';
import toml from 'toml';
import debug from 'debug';
import { DataSource, DeepPartial, EntityTarget, ObjectLiteral } from 'typeorm';
const log = debug('snowball:utils');
@@ -20,44 +19,3 @@ export const getConfig = async <ConfigType>(
return config;
};
export const checkFileExists = async (filePath: string): Promise<boolean> => {
try {
await fs.access(filePath, fs.constants.F_OK);
return true;
} catch (err) {
log(err);
return false;
}
};
export const getEntities = async (filePath: string): Promise<any> => {
const entitiesData = await fs.readFile(filePath, 'utf-8');
const entities = JSON.parse(entitiesData);
return entities;
};
export const loadAndSaveData = async <Entity extends ObjectLiteral>(entityType: EntityTarget<Entity>, dataSource: DataSource, entities: any, relations?: any | undefined): Promise<Entity[]> => {
const entityRepository = dataSource.getRepository(entityType);
const savedEntity:Entity[] = [];
for (const entityData of entities) {
let entity = entityRepository.create(entityData as DeepPartial<Entity>);
if (relations) {
for (const field in relations) {
const valueIndex = String(field + 'Index');
entity = {
...entity,
[field]: relations[field][entityData[valueIndex]]
};
}
}
const dbEntity = await entityRepository.save(entity);
savedEntity.push(dbEntity);
}
return savedEntity;
};
-22
View File
@@ -9,8 +9,6 @@
"isCurrent": true,
"applicationRecordId": "qbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationRecordData": {},
"applicationDeploymentRequestId": "xqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "main",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
@@ -26,8 +24,6 @@
"isCurrent": false,
"applicationRecordId": "wbafyreihvzya6ovp4yfpkqnddkui2iw7thbhwq74lbqs7bhobvmfhrowoi",
"applicationRecordData": {},
"applicationDeploymentRequestId": "wqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "test",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
@@ -43,8 +39,6 @@
"isCurrent": false,
"applicationRecordId": "ebafyreihvzya6ovp4yfpkqnddkui2iw7t6bhwq74lbqs7bhobvmfhrowoi",
"applicationRecordData": {},
"applicationDeploymentRequestId": "kqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "test",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
@@ -60,8 +54,6 @@
"isCurrent": false,
"applicationRecordId": "rbafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhw74lbqs7bhobvmfhrowoi",
"applicationRecordData": {},
"applicationDeploymentRequestId": "yqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "prod",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
@@ -77,8 +69,6 @@
"isCurrent": true,
"applicationRecordId": "tbafyreihvzya6ovp4yfpqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationRecordData": {},
"applicationDeploymentRequestId": "pqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "main",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
@@ -94,8 +84,6 @@
"isCurrent": false,
"applicationRecordId": "ybafyreihvzya6ovp4yfpkqnddkui2iw7t6bhwq74lbqs7bhobvmfhrowoi",
"applicationRecordData": {},
"applicationDeploymentRequestId": "tqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "test",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
@@ -111,8 +99,6 @@
"isCurrent": false,
"applicationRecordId": "ubafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvfhrowoi",
"applicationRecordData": {},
"applicationDeploymentRequestId": "eqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "test",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
@@ -128,8 +114,6 @@
"isCurrent": true,
"applicationRecordId": "ibayreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationRecordData": {},
"applicationDeploymentRequestId": "dqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "main",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
@@ -145,8 +129,6 @@
"isCurrent": false,
"applicationRecordId": "obafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationRecordData": {},
"applicationDeploymentRequestId": "aqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "test",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
@@ -162,8 +144,6 @@
"isCurrent": false,
"applicationRecordId": "pbafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowo",
"applicationRecordData": {},
"applicationDeploymentRequestId": "uqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "test",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
@@ -179,8 +159,6 @@
"isCurrent": true,
"applicationRecordId": "pbafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowo",
"applicationRecordData": {},
"applicationDeploymentRequestId": "pqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "test",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
+10
View File
@@ -10,6 +10,8 @@
"framework": "test",
"webhooks": [],
"icon": "",
"applicationDeploymentRequestId": "hbafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"subDomain": "testProject.snowball.xyz"
},
{
@@ -23,6 +25,8 @@
"framework": "test-2",
"webhooks": [],
"icon": "",
"applicationDeploymentRequestId": "gbafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"subDomain": "testProject-2.snowball.xyz"
},
{
@@ -36,6 +40,8 @@
"framework": "test-3",
"webhooks": [],
"icon": "",
"applicationDeploymentRequestId": "ebafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"subDomain": "iglootools.snowball.xyz"
},
{
@@ -49,6 +55,8 @@
"framework": "test-4",
"webhooks": [],
"icon": "",
"applicationDeploymentRequestId": "qbafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"subDomain": "iglootools-2.snowball.xyz"
},
{
@@ -62,6 +70,8 @@
"framework": "test-5",
"webhooks": [],
"icon": "",
"applicationDeploymentRequestId": "xbafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"subDomain": "snowball-2.snowball.xyz"
}
]
+6 -9
View File
@@ -1,23 +1,20 @@
[
{
"id": "59f4355d-9549-4aac-9b54-eeefceeabef0",
"name": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
"name": "Snowball",
"email": "snowball@snowballtools.xyz",
"isVerified": true,
"ethAddress": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"
"isVerified": true
},
{
"id": "e505b212-8da6-48b2-9614-098225dab34b",
"name": "0xbe0eb53f46cd790cd13851d5eff43d12404d33e8",
"name": "Alice Anderson",
"email": "alice@snowballtools.xyz",
"isVerified": true,
"ethAddress": "0xbe0eb53f46cd790cd13851d5eff43d12404d33e8"
"isVerified": true
},
{
"id": "cd892fad-9138-4aa2-a62c-414a32776ea7",
"name": "0x8315177ab297ba92a06054ce80a67ed4dbd7ed3a",
"name": "Bob Banner",
"email": "bob@snowballtools.xyz",
"isVerified": true,
"ethAddress": "0x8315177ab297ba92a06054ce80a67ed4dbd7ed3a"
"isVerified": true
}
]
+49 -21
View File
@@ -1,4 +1,5 @@
import { DataSource } from 'typeorm';
import { DataSource, DeepPartial, EntityTarget, ObjectLiteral } from 'typeorm';
import * as fs from 'fs/promises';
import debug from 'debug';
import path from 'path';
@@ -10,7 +11,7 @@ import { EnvironmentVariable } from '../src/entity/EnvironmentVariable';
import { Domain } from '../src/entity/Domain';
import { ProjectMember } from '../src/entity/ProjectMember';
import { Deployment } from '../src/entity/Deployment';
import { checkFileExists, getConfig, getEntities, loadAndSaveData } from '../src/utils';
import { getConfig } from '../src/utils';
import { Config } from '../src/config';
import { DEFAULT_CONFIG_FILE_PATH } from '../src/constants';
@@ -26,20 +27,43 @@ const DEPLOYMENT_DATA_PATH = './fixtures/deployments.json';
const ENVIRONMENT_VARIABLE_DATA_PATH = './fixtures/environment-variables.json';
const REDIRECTED_DOMAIN_DATA_PATH = './fixtures/redirected-domains.json';
const generateTestData = async (dataSource: DataSource) => {
const userEntities = await getEntities(path.resolve(__dirname, USER_DATA_PATH));
const savedUsers = await loadAndSaveData(User, dataSource, userEntities);
const loadAndSaveData = async <Entity extends ObjectLiteral>(entityType: EntityTarget<Entity>, dataSource: DataSource, filePath: string, relations?: any | undefined) => {
const entitiesData = await fs.readFile(filePath, 'utf-8');
const entities = JSON.parse(entitiesData);
const entityRepository = dataSource.getRepository(entityType);
const orgEntities = await getEntities(path.resolve(__dirname, ORGANIZATION_DATA_PATH));
const savedOrgs = await loadAndSaveData(Organization, dataSource, orgEntities);
const savedEntity:Entity[] = [];
for (const entityData of entities) {
let entity = entityRepository.create(entityData as DeepPartial<Entity>);
if (relations) {
for (const field in relations) {
const valueIndex = String(field + 'Index');
entity = {
...entity,
[field]: relations[field][entityData[valueIndex]]
};
}
}
const dbEntity = await entityRepository.save(entity);
savedEntity.push(dbEntity);
}
return savedEntity;
};
const generateTestData = async (dataSource: DataSource) => {
const savedUsers = await loadAndSaveData(User, dataSource, path.resolve(__dirname, USER_DATA_PATH));
const savedOrgs = await loadAndSaveData(Organization, dataSource, path.resolve(__dirname, ORGANIZATION_DATA_PATH));
const projectRelations = {
owner: savedUsers,
organization: savedOrgs
};
const projectEntities = await getEntities(path.resolve(__dirname, PROJECT_DATA_PATH));
const savedProjects = await loadAndSaveData(Project, dataSource, projectEntities, projectRelations);
const savedProjects = await loadAndSaveData(Project, dataSource, path.resolve(__dirname, PROJECT_DATA_PATH), projectRelations);
const domainRepository = dataSource.getRepository(Domain);
@@ -47,16 +71,14 @@ const generateTestData = async (dataSource: DataSource) => {
project: savedProjects
};
const primaryDomainsEntities = await getEntities(path.resolve(__dirname, PRIMARY_DOMAIN_DATA_PATH));
const savedPrimaryDomains = await loadAndSaveData(Domain, dataSource, primaryDomainsEntities, domainPrimaryRelations);
const savedPrimaryDomains = await loadAndSaveData(Domain, dataSource, path.resolve(__dirname, PRIMARY_DOMAIN_DATA_PATH), domainPrimaryRelations);
const domainRedirectedRelations = {
project: savedProjects,
redirectTo: savedPrimaryDomains
};
const redirectDomainsEntities = await getEntities(path.resolve(__dirname, REDIRECTED_DOMAIN_DATA_PATH));
await loadAndSaveData(Domain, dataSource, redirectDomainsEntities, domainRedirectedRelations);
await loadAndSaveData(Domain, dataSource, path.resolve(__dirname, REDIRECTED_DOMAIN_DATA_PATH), domainRedirectedRelations);
const savedDomains = await domainRepository.find();
@@ -65,16 +87,14 @@ const generateTestData = async (dataSource: DataSource) => {
organization: savedOrgs
};
const userOrganizationsEntities = await getEntities(path.resolve(__dirname, USER_ORGANIZATION_DATA_PATH));
await loadAndSaveData(UserOrganization, dataSource, userOrganizationsEntities, userOrganizationRelations);
await loadAndSaveData(UserOrganization, dataSource, path.resolve(__dirname, USER_ORGANIZATION_DATA_PATH), userOrganizationRelations);
const projectMemberRelations = {
member: savedUsers,
project: savedProjects
};
const projectMembersEntities = await getEntities(path.resolve(__dirname, PROJECT_MEMBER_DATA_PATH));
await loadAndSaveData(ProjectMember, dataSource, projectMembersEntities, projectMemberRelations);
await loadAndSaveData(ProjectMember, dataSource, path.resolve(__dirname, PROJECT_MEMBER_DATA_PATH), projectMemberRelations);
const deploymentRelations = {
project: savedProjects,
@@ -82,15 +102,23 @@ const generateTestData = async (dataSource: DataSource) => {
createdBy: savedUsers
};
const deploymentsEntities = await getEntities(path.resolve(__dirname, DEPLOYMENT_DATA_PATH));
await loadAndSaveData(Deployment, dataSource, deploymentsEntities, deploymentRelations);
await loadAndSaveData(Deployment, dataSource, path.resolve(__dirname, DEPLOYMENT_DATA_PATH), deploymentRelations);
const environmentVariableRelations = {
project: savedProjects
};
const environmentVariablesEntities = await getEntities(path.resolve(__dirname, ENVIRONMENT_VARIABLE_DATA_PATH));
await loadAndSaveData(EnvironmentVariable, dataSource, environmentVariablesEntities, environmentVariableRelations);
await loadAndSaveData(EnvironmentVariable, dataSource, path.resolve(__dirname, ENVIRONMENT_VARIABLE_DATA_PATH), environmentVariableRelations);
};
const checkFileExists = async (filePath: string) => {
try {
await fs.access(filePath, fs.constants.F_OK);
return true;
} catch (err) {
log(err);
return false;
}
};
const main = async () => {
+4 -4
View File
@@ -11,19 +11,19 @@ const log = debug('snowball:initialize-registry');
const DENOM = 'aphoton';
const BOND_AMOUNT = '1000000000';
// TODO: Get authority names from args
const AUTHORITY_NAMES = ['snowballtools', 'cerc-io'];
async function main () {
const { registryConfig } = await getConfig<Config>(DEFAULT_CONFIG_FILE_PATH);
// TODO: Get authority names from args
const authorityNames = ['snowballtools', registryConfig.authority];
const registry = new Registry(registryConfig.gqlEndpoint, registryConfig.restEndpoint, registryConfig.chainId);
const bondId = await registry.getNextBondId(registryConfig.privateKey);
log('bondId:', bondId);
await registry.createBond({ denom: DENOM, amount: BOND_AMOUNT }, registryConfig.privateKey, registryConfig.fee);
for await (const name of authorityNames) {
for await (const name of AUTHORITY_NAMES) {
await registry.reserveAuthority({ name }, registryConfig.privateKey, registryConfig.fee);
log('Reserved authority name:', name);
await registry.setAuthorityBond({ name, bondId }, registryConfig.privateKey, registryConfig.fee);
@@ -5,14 +5,14 @@ import path from 'path';
import { Registry } from '@cerc-io/laconic-sdk';
import { Config } from '../src/config';
import { DEFAULT_CONFIG_FILE_PATH } from '../src/constants';
import { DEFAULT_CONFIG_FILE_PATH, PROJECT_DOMAIN } from '../src/constants';
import { getConfig } from '../src/utils';
import { Deployment, DeploymentStatus } from '../src/entity/Deployment';
const log = debug('snowball:publish-deploy-records');
async function main () {
const { registryConfig, database, misc } = await getConfig<Config>(DEFAULT_CONFIG_FILE_PATH);
const { registryConfig, database } = await getConfig<Config>(DEFAULT_CONFIG_FILE_PATH);
const registry = new Registry(registryConfig.gqlEndpoint, registryConfig.restEndpoint, registryConfig.chainId);
@@ -36,7 +36,7 @@ async function main () {
});
for await (const deployment of deployments) {
const url = `${deployment.project.name}-${deployment.id}.${misc.projectDomain}`;
const url = `${deployment.project.name}-${deployment.id}.${PROJECT_DOMAIN}`;
const applicationDeploymentRecord = {
type: 'ApplicationDeploymentRecord',
@@ -53,7 +53,7 @@ async function main () {
so: '66fcfa49a1664d4cb4ce4f72c1c0e151'
}),
request: deployment.applicationDeploymentRequestId,
request: deployment.project.applicationDeploymentRequestId,
url
};
+1 -5
View File
@@ -1,8 +1,4 @@
REACT_APP_SERVER_URL = 'http://localhost:8000'
REACT_APP_GQL_SERVER_URL = 'http://localhost:8000/graphql'
REACT_APP_GITHUB_CLIENT_ID =
REACT_APP_GITHUB_TEMPLATE_REPO =
REACT_APP_GITEA_CLIENT_ID =
REACT_APP_WALLET_CONNECT_ID =
+39
View File
@@ -0,0 +1,39 @@
{
// eslint extension options
"eslint.enable": true,
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
],
"css.customData": [".vscode/tailwind.json"],
// prettier extension setting
"editor.formatOnSave": true,
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"editor.rulers": [80],
"editor.codeActionsOnSave": [
"source.addMissingImports",
"source.fixAll",
"source.organizeImports"
],
// Show in vscode "Problems" tab when there are errors
"typescript.tsserver.experimental.enableProjectDiagnostics": true,
// Use absolute import for typescript files
"typescript.preferences.importModuleSpecifier": "non-relative",
// IntelliSense for taiwind variants
"tailwindCSS.experimental.classRegex": [
["tv\\((([^()]*|\\([^()]*\\))*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"]
]
}
-7
View File
@@ -1,7 +0,0 @@
<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="500" height="500" fill="#0F86F5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M191.873 125.126C224.893 126.765 250.458 150.121 274.042 172.995C297.925 196.158 323.089 221.108 324.868 254.114C326.718 288.42 308.902 321.108 283.281 344.355C258.67 366.687 225.288 373.859 191.873 374.788C157.228 375.752 119.038 374.394 95.1648 349.588C71.6207 325.125 74.6696 287.843 75.7341 254.114C76.7518 221.865 79.2961 188.525 101.009 164.41C123.845 139.047 157.543 123.423 191.873 125.126Z" fill="#4BA4F7"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M229.373 125.126C262.393 126.765 287.958 150.121 311.542 172.995C335.425 196.158 360.589 221.108 362.368 254.114C364.218 288.42 346.402 321.108 320.781 344.355C296.17 366.687 262.788 373.859 229.373 374.788C194.728 375.752 156.538 374.394 132.665 349.588C109.121 325.125 112.17 287.843 113.234 254.114C114.252 221.865 116.796 188.525 138.509 164.41C161.345 139.047 195.043 123.423 229.373 125.126Z" fill="#8AC4FA"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M266.873 125.126C299.893 126.765 325.458 150.121 349.042 172.995C372.925 196.158 398.089 221.108 399.868 254.114C401.718 288.42 383.902 321.108 358.281 344.355C333.67 366.687 300.288 373.859 266.873 374.788C232.228 375.752 194.038 374.394 170.165 349.588C146.621 325.125 149.67 287.843 150.734 254.114C151.752 221.865 154.296 188.525 176.009 164.41C198.845 139.047 232.543 123.423 266.873 125.126Z" fill="#CAE4FD"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M304.373 125.126C337.393 126.765 362.958 150.121 386.542 172.995C410.425 196.158 435.589 221.108 437.368 254.114C439.218 288.42 421.402 321.108 395.781 344.355C371.17 366.687 337.788 373.859 304.373 374.788C269.728 375.752 231.538 374.394 207.665 349.588C184.121 325.125 187.17 287.843 188.234 254.114C189.252 221.865 191.796 188.525 213.509 164.41C236.345 139.047 270.043 123.423 304.373 125.126Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

+9 -9
View File
@@ -3,8 +3,11 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@fontsource/inter": "^5.0.16",
"@material-tailwind/react": "^2.1.7",
"@tanstack/react-query": "^5.22.2",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-tabs": "^1.0.4",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
@@ -12,17 +15,16 @@
"@types/node": "^16.18.68",
"@types/react": "^18.2.42",
"@types/react-dom": "^18.2.17",
"@web3modal/siwe": "^4.0.5",
"@web3modal/wagmi": "^4.0.5",
"assert": "^2.1.0",
"axios": "^1.6.7",
"date-fns": "^3.0.1",
"clsx": "^2.1.0",
"date-fns": "^3.3.1",
"downshift": "^8.2.3",
"eslint-config-react-app": "^7.0.1",
"gql-client": "^1.0.0",
"luxon": "^3.4.4",
"octokit": "^3.1.2",
"react": "^18.2.0",
"react-calendar": "^4.8.0",
"react-code-blocks": "^0.1.6",
"react-day-picker": "^8.9.1",
"react-dom": "^18.2.0",
@@ -33,12 +35,10 @@
"react-router-dom": "^6.20.1",
"react-scripts": "5.0.1",
"react-timer-hook": "^3.0.7",
"siwe": "^2.1.4",
"tailwind-variants": "^0.2.0",
"typescript": "^4.9.5",
"usehooks-ts": "^2.10.0",
"vertical-stepper-nav": "^1.0.2",
"viem": "^2.7.11",
"wagmi": "^2.5.7",
"web-vitals": "^2.1.4"
},
"scripts": {
@@ -78,6 +78,6 @@
"eslint-plugin-prettier": "^5.0.1",
"eslint-plugin-react": "^7.33.2",
"prettier": "^3.1.0",
"tailwindcss": "^3.3.6"
"tailwindcss": "^3.4.1"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#2d89ef</TileColor>
</tile>
</msapplication>
</browserconfig>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 674 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 989 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

+27 -8
View File
@@ -2,23 +2,42 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="snowball tools dashboard"
content="Web site created using create-react-app"
/>
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/favicon-16x16.png">
<link rel="manifest" href="%PUBLIC_URL%/site.webmanifest">
<meta name="msapplication-TileColor" content="#2d89ef">
<meta name="theme-color" content="#ffffff">
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Snowball</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

-19
View File
@@ -1,19 +0,0 @@
{
"name": "Snowball Tools Dashboard",
"short_name": "snowball tools",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
+6 -6
View File
@@ -9,8 +9,8 @@ import {
projectsRoutesWithoutSearch,
} from './pages/org-slug/projects/routes';
import ProjectSearchLayout from './layouts/ProjectSearch';
import { OctokitProvider } from './context/OctokitContext';
import Index from './pages';
import Login from './pages/Login';
const router = createBrowserRouter([
{
@@ -44,14 +44,14 @@ const router = createBrowserRouter([
path: '/',
element: <Index />,
},
{
path: '/login',
element: <Login />,
},
]);
function App() {
return <RouterProvider router={router} />;
return (
<OctokitProvider>
<RouterProvider router={router} />
</OctokitProvider>
);
}
export default App;
+2 -12
View File
@@ -3,7 +3,6 @@ import { Link, NavLink, useNavigate, useParams } from 'react-router-dom';
import { Organization } from 'gql-client';
import { Typography, Option } from '@material-tailwind/react';
import { useDisconnect } from 'wagmi';
import { useGQLClient } from '../context/GQLClientContext';
import AsyncSelect from './shared/AsyncSelect';
@@ -12,7 +11,6 @@ const Sidebar = () => {
const { orgSlug } = useParams();
const navigate = useNavigate();
const client = useGQLClient();
const { disconnect } = useDisconnect();
const [selectedOrgSlug, setSelectedOrgSlug] = useState(orgSlug);
const [organizations, setOrganizations] = useState<Organization[]>([]);
@@ -27,11 +25,6 @@ const Sidebar = () => {
setSelectedOrgSlug(orgSlug);
}, [orgSlug]);
const handleLogOut = useCallback(() => {
disconnect();
navigate('/login');
}, [disconnect, navigate]);
return (
<div className="flex flex-col h-full p-4">
<div className="grow">
@@ -83,11 +76,8 @@ const Sidebar = () => {
</div>
</div>
<div className="grow flex flex-col justify-end">
<a className="cursor-pointer" onClick={handleLogOut}>
Log Out
</a>
<a className="cursor-pointer">Documentation</a>
<a className="cursor-pointer">Support</a>
<div>Documentation</div>
<div>Support</div>
</div>
</div>
);
@@ -1,21 +1,15 @@
import { Button } from '@material-tailwind/react';
import React from 'react';
import OauthPopup from 'react-oauth-popup';
import { GitType } from 'gql-client';
import { Button } from '@material-tailwind/react';
import { useGQLClient } from '../../../context/GQLClientContext';
import ConnectAccountTabPanel from './ConnectAccountTabPanel';
const SCOPES = 'repo user';
const GITHUB_OAUTH_URL = `https://github.com/login/oauth/authorize?client_id=${
process.env.REACT_APP_GITHUB_CLIENT_ID
}&scope=${encodeURIComponent(SCOPES)}`;
const REDIRECT_URI = `${window.location.origin}/organization/projects/create`;
const GITEA_OAUTH_URL = `https://git.vdb.to/login/oauth/authorize?client_id=${process.env.REACT_APP_GITEA_CLIENT_ID}&redirect_uri=${REDIRECT_URI}&response_type=code`;
interface ConnectAccountInterface {
onAuth: (token: string) => void;
}
@@ -23,13 +17,11 @@ interface ConnectAccountInterface {
const ConnectAccount = ({ onAuth: onToken }: ConnectAccountInterface) => {
const client = useGQLClient();
const handleCode = async (type: GitType, code: string) => {
const handleCode = async (code: string) => {
// Pass code to backend and get access token
const {
authenticateGit: { token },
} = await client.authenticateGit(type, code);
// TODO: Handle token according to Git type
authenticateGitHub: { token },
} = await client.authenticateGitHub(code);
onToken(token);
};
@@ -48,7 +40,7 @@ const ConnectAccount = ({ onAuth: onToken }: ConnectAccountInterface) => {
<div className="mt-2 flex">
<OauthPopup
url={GITHUB_OAUTH_URL}
onCode={(code) => handleCode(GitType.GitHub, code)}
onCode={handleCode}
onClose={() => {}}
title="Snowball"
width={1000}
@@ -56,16 +48,7 @@ const ConnectAccount = ({ onAuth: onToken }: ConnectAccountInterface) => {
>
<Button className="rounded-full mx-2">Connect to Github</Button>
</OauthPopup>
<OauthPopup
url={GITEA_OAUTH_URL}
onCode={(code) => handleCode(GitType.Gitea, code)}
onClose={() => {}}
title="Snowball"
width={1000}
height={1000}
>
<Button className="rounded-full mx-2">Connect to Gitea</Button>
</OauthPopup>
<Button className="rounded-full mx-2">Connect to Gitea</Button>
</div>
<ConnectAccountTabPanel />
</div>
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect } from 'react';
import React, { useCallback } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { Button, Typography } from '@material-tailwind/react';
@@ -7,7 +7,6 @@ import { DeployStep, DeployStatus } from './DeployStep';
import { Stopwatch, setStopWatchOffset } from '../../StopWatch';
import ConfirmDialog from '../../shared/ConfirmDialog';
const INTERVAL_DURATION = 5000;
const Deploy = () => {
const [searchParams] = useSearchParams();
const projectId = searchParams.get('projectId');
@@ -22,14 +21,6 @@ const Deploy = () => {
navigate(`/${orgSlug}/projects/create`);
}, []);
useEffect(() => {
const timerID = setTimeout(() => {
navigate(`/${orgSlug}/projects/create/success/${projectId}`);
}, INTERVAL_DURATION);
return () => clearInterval(timerID);
}, []);
return (
<div>
<div className="flex justify-between mb-6">
@@ -83,6 +74,14 @@ const Deploy = () => {
status={DeployStatus.NOT_STARTED}
step="4"
/>
<Button
onClick={() => {
navigate(`/${orgSlug}/projects/create/success/${projectId}`);
}}
>
VIEW DEMO
</Button>
</div>
);
};
@@ -1,8 +1,7 @@
import React, { useCallback } from 'react';
import toast from 'react-hot-toast';
import { useNavigate, useParams } from 'react-router-dom';
import { Chip, IconButton, Spinner } from '@material-tailwind/react';
import { Chip, IconButton } from '@material-tailwind/react';
import { relativeTimeISO } from '../../../utils/time';
import { GitRepositoryDetails } from '../../../types';
@@ -15,7 +14,6 @@ interface ProjectRepoCardProps {
const ProjectRepoCard: React.FC<ProjectRepoCardProps> = ({ repository }) => {
const client = useGQLClient();
const navigate = useNavigate();
const [isLoading, setIsLoading] = React.useState(false);
const { orgSlug } = useParams();
@@ -24,7 +22,6 @@ const ProjectRepoCard: React.FC<ProjectRepoCardProps> = ({ repository }) => {
return;
}
setIsLoading(true);
const { addProject } = await client.addProject(orgSlug!, {
name: `${repository.owner!.login}-${repository.name}`,
prodBranch: repository.default_branch!,
@@ -33,13 +30,7 @@ const ProjectRepoCard: React.FC<ProjectRepoCardProps> = ({ repository }) => {
template: 'webapp',
});
if (Boolean(addProject)) {
setIsLoading(false);
navigate(`import?projectId=${addProject.id}`);
} else {
setIsLoading(false);
toast.error('Failed to create project');
}
navigate(`import?projectId=${addProject.id}`);
}, [client, repository]);
return (
@@ -62,13 +53,9 @@ const ProjectRepoCard: React.FC<ProjectRepoCardProps> = ({ repository }) => {
</div>
<p>{repository.updated_at && relativeTimeISO(repository.updated_at)}</p>
</div>
{isLoading ? (
<Spinner className="h-4 w-4" />
) : (
<div className="hidden group-hover:block">
<IconButton size="sm">{'>'}</IconButton>
</div>
)}
<div className="hidden group-hover:block">
<IconButton size="sm">{'>'}</IconButton>
</div>
</div>
);
};
@@ -1,6 +1,6 @@
import React from 'react';
import { Typography, IconButton, Avatar } from '@material-tailwind/react';
import { Typography, IconButton } from '@material-tailwind/react';
import { relativeTimeISO } from '../../../utils/time';
import { GitCommitWithBranch } from '../../../types';
@@ -11,10 +11,9 @@ interface ActivityCardProps {
const ActivityCard = ({ activity }: ActivityCardProps) => {
return (
<div className="group flex gap-2 hover:bg-gray-200 rounded mt-1">
<div className="w-8">
<Avatar src={activity.author?.avatar_url} variant="rounded" size="sm" />
</div>
<div className="group flex hover:bg-gray-200 rounded mt-1">
<div className="w-4">^</div>
<div className="grow">
<Typography>{activity.commit.author?.name}</Typography>
<Typography variant="small" color="gray">
@@ -16,7 +16,6 @@ import {
Typography,
Chip,
ChipProps,
Tooltip,
} from '@material-tailwind/react';
import { relativeTimeMs } from '../../../../utils/time';
@@ -25,7 +24,6 @@ import DeploymentDialogBodyCard from './DeploymentDialogBodyCard';
import AssignDomainDialog from './AssignDomainDialog';
import { useGQLClient } from '../../../../context/GQLClientContext';
import { SHORT_COMMIT_HASH_LENGTH } from '../../../../constants';
import { formatAddress } from '../../../../utils/format';
interface DeployDetailsCardProps {
deployment: Deployment;
@@ -89,12 +87,18 @@ const DeploymentDetailsCard = ({
};
return (
<div className="grid grid-cols-8 gap-2 border-b border-gray-300 p-3 my-2">
<div className="col-span-3">
<div className="grid grid-cols-4 gap-2 border-b border-gray-300 p-3 my-2">
<div className="col-span-2">
<div className="flex">
{deployment.url && (
<Typography className=" basis-3/4">{deployment.url}</Typography>
)}
<Chip
value={deployment.status}
color={STATUS_COLORS[deployment.status] ?? 'gray'}
variant="ghost"
icon={<i>^</i>}
/>
</div>
<Typography color="gray">
{deployment.environment === Environment.Production
@@ -103,26 +107,15 @@ const DeploymentDetailsCard = ({
</Typography>
</div>
<div className="col-span-1">
<Chip
value={deployment.status}
color={STATUS_COLORS[deployment.status] ?? 'gray'}
variant="ghost"
icon={<i>^</i>}
/>
</div>
<div className="col-span-2">
<Typography color="gray">^ {deployment.branch}</Typography>
<Typography color="gray">
^ {deployment.commitHash.substring(0, SHORT_COMMIT_HASH_LENGTH)}{' '}
{deployment.commitMessage}
</Typography>
</div>
<div className="col-span-2 flex items-center">
<div className="col-span-1 flex items-center">
<Typography color="gray" className="grow">
^ {relativeTimeMs(deployment.createdAt)} ^{' '}
<Tooltip content={deployment.createdBy.name}>
{formatAddress(deployment.createdBy.name ?? '')}
</Tooltip>
^ {relativeTimeMs(deployment.createdAt)} ^ {deployment.createdBy.name}
</Typography>
<Menu placement="bottom-start">
<MenuHandler>
@@ -5,7 +5,6 @@ import { Typography, Chip, Card } from '@material-tailwind/react';
import { color } from '@material-tailwind/react/types/components/chip';
import { relativeTimeMs } from '../../../../utils/time';
import { SHORT_COMMIT_HASH_LENGTH } from '../../../../constants';
import { formatAddress } from '../../../../utils/format';
interface DeploymentDialogBodyCardProps {
deployment: Deployment;
@@ -40,8 +39,7 @@ const DeploymentDialogBodyCard = ({
{deployment.commitMessage}
</Typography>
<Typography variant="small">
^ {relativeTimeMs(deployment.createdAt)} ^{' '}
{formatAddress(deployment.createdBy.name ?? '')}
^ {relativeTimeMs(deployment.createdAt)} ^ {deployment.createdBy.name}
</Typography>
</Card>
);
@@ -44,18 +44,18 @@ const FilterForm = ({ value, onChange }: FilterFormProps) => {
}, [value]);
return (
<div className="grid grid-cols-8 gap-2 text-sm text-gray-600">
<div className="col-span-4">
<div className="grid grid-cols-4 gap-2 text-sm text-gray-600">
<div className="col-span-2">
<SearchBar
placeholder="Search branches"
value={searchedBranch}
onChange={(event) => setSearchedBranch(event.target.value)}
/>
</div>
<div className="col-span-2">
<div className="col-span-1">
<DatePicker mode="range" selected={dateRange} onSelect={setDateRange} />
</div>
<div className="col-span-2 relative">
<div className="col-span-1 relative">
<Select
value={selectedStatus}
onChange={(value) => setSelectedStatus(value as StatusOptions)}
@@ -1,9 +1,8 @@
import React, { useState } from 'react';
import { Card, Collapse, Typography } from '@material-tailwind/react';
import { Environment, EnvironmentVariable } from 'gql-client/dist/src/types';
import EditEnvironmentVariableRow from './EditEnvironmentVariableRow';
import { Environment, EnvironmentVariable } from 'gql-client';
interface DisplayEnvironmentVariablesProps {
environment: Environment;
@@ -7,11 +7,9 @@ import {
Option,
Chip,
IconButton,
Tooltip,
} from '@material-tailwind/react';
import ConfirmDialog from '../../../shared/ConfirmDialog';
import { formatAddress } from '../../../../utils/format';
const PERMISSION_OPTIONS = [
{
@@ -50,7 +48,6 @@ const MemberCard = ({
onRemoveProjectMember,
onUpdateProjectMember,
}: MemberCardProps) => {
const [ethAddress, emailDomain] = member.email.split('@');
const [selectedPermission, setSelectedPermission] = useState(
permissions.join('+'),
);
@@ -82,16 +79,8 @@ const MemberCard = ({
>
<div>^</div>
<div className="basis-1/2">
{member.name && (
<Tooltip content={member.name}>
{formatAddress(member.name ?? '')}
</Tooltip>
)}
<Tooltip content={member.email}>
<p>
{formatAddress(ethAddress)}@{emailDomain}
</p>
</Tooltip>
{member.name && <Typography variant="small">{member.name}</Typography>}
<Typography variant="small">{member.email}</Typography>
</div>
<div className="basis-1/2">
{!isPending ? (
@@ -153,9 +142,8 @@ const MemberCard = ({
color="red"
>
<Typography variant="small">
Once removed, {formatAddress(member.name ?? '')} (
{formatAddress(ethAddress)}@{emailDomain}) will not be able to access
this project.
Once removed, {member.name} ({member.email}) will not be able to
access this project.
</Typography>
</ConfirmDialog>
</div>
@@ -0,0 +1,71 @@
import { tv, type VariantProps } from 'tailwind-variants';
export const avatarTheme = tv(
{
base: ['relative', 'block', 'rounded-full', 'overflow-hidden'],
slots: {
image: [
'h-full',
'w-full',
'rounded-[inherit]',
'object-cover',
'object-center',
],
fallback: [
'grid',
'select-none',
'place-content-center',
'h-full',
'w-full',
'rounded-[inherit]',
'font-medium',
],
},
variants: {
type: {
gray: {
fallback: ['text-elements-highEm', 'bg-base-bg-emphasized'],
},
orange: {
fallback: ['text-elements-warning', 'bg-base-bg-emphasized-warning'],
},
blue: {
fallback: ['text-elements-info', 'bg-base-bg-emphasized-info'],
},
},
size: {
18: {
base: ['rounded-md', 'h-[18px]', 'w-[18px]', 'text-[0.625rem]'],
},
20: {
base: ['rounded-md', 'h-5', 'w-5', 'text-[0.625rem]'],
},
24: {
base: ['rounded-md', 'h-6', 'w-6', 'text-[0.625rem]'],
},
28: {
base: ['rounded-lg', 'h-[28px]', 'w-[28px]', 'text-[0.625rem]'],
},
32: {
base: ['rounded-lg', 'h-8', 'w-8', 'text-xs'],
},
36: {
base: ['rounded-xl', 'h-[36px]', 'w-[36px]', 'text-xs'],
},
40: {
base: ['rounded-xl', 'h-10', 'w-10', 'text-sm'],
},
44: {
base: ['rounded-xl', 'h-[44px]', 'w-[44px]', 'text-sm'],
},
},
},
defaultVariants: {
size: 24,
type: 'gray',
},
},
{ responsiveVariants: true },
);
export type AvatarVariants = VariantProps<typeof avatarTheme>;
@@ -0,0 +1,40 @@
import React from 'react';
import { type ComponentPropsWithoutRef, type ComponentProps } from 'react';
import { avatarTheme, type AvatarVariants } from './Avatar.theme';
import * as PrimitiveAvatar from '@radix-ui/react-avatar';
export type AvatarProps = ComponentPropsWithoutRef<'div'> & {
imageSrc?: string | null;
initials?: string;
imageProps?: ComponentProps<typeof PrimitiveAvatar.Image>;
fallbackProps?: ComponentProps<typeof PrimitiveAvatar.Fallback>;
} & AvatarVariants;
export const Avatar = ({
className,
size,
type,
imageSrc,
imageProps,
fallbackProps,
initials,
}: AvatarProps) => {
const { base, image, fallback } = avatarTheme({ size, type });
return (
<PrimitiveAvatar.Root className={base({ className })}>
{imageSrc && (
<PrimitiveAvatar.Image
{...imageProps}
className={image({ className: imageProps?.className })}
src={imageSrc}
/>
)}
<PrimitiveAvatar.Fallback asChild {...fallbackProps}>
<div className={fallback({ className: fallbackProps?.className })}>
{initials}
</div>
</PrimitiveAvatar.Fallback>
</PrimitiveAvatar.Root>
);
};
@@ -0,0 +1,2 @@
export * from './Avatar';
export * from './Avatar.theme';
@@ -0,0 +1,43 @@
import { VariantProps, tv } from 'tailwind-variants';
export const badgeTheme = tv({
slots: {
wrapper: ['rounded-full', 'grid', 'place-content-center'],
},
variants: {
variant: {
primary: {
wrapper: ['bg-controls-primary', 'text-elements-on-primary'],
},
secondary: {
wrapper: ['bg-controls-secondary', 'text-elements-on-secondary'],
},
tertiary: {
wrapper: [
'bg-controls-tertiary',
'border',
'border-border-interactive/10',
'text-elements-high-em',
'shadow-button',
],
},
inset: {
wrapper: ['bg-controls-inset', 'text-elements-high-em'],
},
},
size: {
sm: {
wrapper: ['h-5', 'w-5', 'text-xs'],
},
xs: {
wrapper: ['h-4', 'w-4', 'text-2xs', 'font-medium'],
},
},
},
defaultVariants: {
variant: 'primary',
size: 'sm',
},
});
export type BadgeTheme = VariantProps<typeof badgeTheme>;
@@ -0,0 +1,33 @@
import React from 'react';
import { ComponentPropsWithoutRef } from 'react';
import { BadgeTheme, badgeTheme } from './Badge.theme';
export interface BadgeProps
extends ComponentPropsWithoutRef<'div'>,
BadgeTheme {}
/**
* A badge is a small status descriptor for UI elements.
* It can be used to indicate a status, a count, or a category.
* It is typically used in lists, tables, or navigation elements.
*
* @example
* ```tsx
* <Badge variant="primary" size="sm">1</Badge
* ```
*/
export const Badge = ({
className,
children,
variant,
size,
...props
}: BadgeProps) => {
const { wrapper } = badgeTheme();
return (
<div {...props} className={wrapper({ className, variant, size })}>
{children}
</div>
);
};
@@ -0,0 +1 @@
export * from './Badge';
@@ -0,0 +1,185 @@
import { tv } from 'tailwind-variants';
import type { VariantProps } from 'tailwind-variants';
/**
* Defines the theme for a button component.
*/
export const buttonTheme = tv(
{
base: [
'h-fit',
'inline-flex',
'items-center',
'justify-center',
'whitespace-nowrap',
'focus-ring',
'disabled:cursor-not-allowed',
],
variants: {
size: {
lg: ['gap-2', 'py-3.5', 'px-5', 'text-base', 'tracking-[-0.011em]'],
md: ['gap-2', 'py-3.25', 'px-5', 'text-sm', 'tracking-[-0.006em]'],
sm: ['gap-1', 'py-2', 'px-3', 'text-xs'],
xs: ['gap-1', 'py-1', 'px-2', 'text-xs'],
},
fullWidth: {
true: 'w-full',
},
shape: {
default: '',
rounded: 'rounded-full',
},
iconOnly: {
true: '',
},
variant: {
primary: [
'text-elements-on-primary',
'border',
'border-transparent',
'bg-controls-primary',
'shadow-button',
'hover:bg-controls-primary-hovered',
'focus-visible:bg-controls-primary-hovered',
'disabled:text-elements-on-disabled',
'disabled:bg-controls-disabled',
'disabled:border-transparent',
'disabled:shadow-none',
],
secondary: [
'text-elements-on-secondary',
'border',
'border-transparent',
'bg-controls-secondary',
'hover:bg-controls-secondary-hovered',
'focus-visible:bg-controls-secondary-hovered',
'disabled:text-elements-on-disabled',
'disabled:bg-controls-disabled',
'disabled:border-transparent',
'disabled:shadow-none',
],
tertiary: [
'text-elements-on-tertiary',
'border',
'border-border-interactive/10',
'bg-transparent',
'hover:bg-controls-tertiary-hovered',
'hover:border-border-interactive-hovered',
'hover:border-border-interactive-hovered/[0.14]',
'focus-visible:bg-controls-tertiary-hovered',
'focus-visible:border-border-interactive-hovered',
'focus-visible:border-border-interactive-hovered/[0.14]',
'disabled:text-elements-on-disabled',
'disabled:bg-controls-disabled',
'disabled:border-transparent',
'disabled:shadow-none',
],
ghost: [
'text-elements-on-tertiary',
'border',
'border-transparent',
'bg-transparent',
'hover:bg-controls-tertiary-hovered',
'hover:border-border-interactive-hovered',
'hover:border-border-interactive-hovered/[0.14]',
'focus-visible:bg-controls-tertiary-hovered',
'focus-visible:border-border-interactive-hovered',
'focus-visible:border-border-interactive-hovered/[0.14]',
'disabled:text-elements-on-disabled',
'disabled:bg-controls-disabled',
'disabled:border-transparent',
'disabled:shadow-none',
],
danger: [
'text-elements-on-danger',
'border',
'border-transparent',
'bg-border-danger',
'hover:bg-controls-danger-hovered',
'focus-visible:bg-controls-danger-hovered',
'disabled:text-elements-on-disabled',
'disabled:bg-controls-disabled',
'disabled:border-transparent',
'disabled:shadow-none',
],
'danger-ghost': [
'text-elements-danger',
'border',
'border-transparent',
'bg-transparent',
'hover:bg-controls-tertiary-hovered',
'hover:border-border-interactive-hovered',
'hover:border-border-interactive-hovered/[0.14]',
'focus-visible:bg-controls-tertiary-hovered',
'focus-visible:border-border-interactive-hovered',
'focus-visible:border-border-interactive-hovered/[0.14]',
'disabled:text-elements-on-disabled',
'disabled:bg-controls-disabled',
'disabled:border-transparent',
'disabled:shadow-none',
],
link: [
'p-0',
'gap-1.5',
'text-elements-link',
'rounded',
'focus-ring',
'hover:underline',
'hover:text-elements-link-hovered',
'disabled:text-controls-disabled',
'disabled:hover:no-underline',
],
'link-emphasized': [
'p-0',
'gap-1.5',
'text-elements-high-em',
'rounded',
'underline',
'focus-ring',
'hover:text-elements-link-hovered',
'disabled:text-controls-disabled',
'disabled:hover:no-underline',
'dark:text-elements-on-high-contrast',
],
unstyled: [],
},
},
compoundVariants: [
{
size: 'lg',
iconOnly: true,
class: ['py-3.5', 'px-3.5'],
},
{
size: 'md',
iconOnly: true,
class: ['py-3.25', 'px-3.25'],
},
{
size: 'sm',
iconOnly: true,
class: ['py-2', 'px-2'],
},
{
size: 'xs',
iconOnly: true,
class: ['py-1', 'px-1'],
},
],
defaultVariants: {
size: 'md',
variant: 'primary',
fullWidth: false,
iconOnly: false,
shape: 'rounded',
},
},
{
responsiveVariants: true,
},
);
/**
* Represents the type of a button theme, which is derived from the `buttonTheme` variant props.
*/
export type ButtonTheme = VariantProps<typeof buttonTheme>;
@@ -0,0 +1,178 @@
import React, { forwardRef, useCallback } from 'react';
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
import { buttonTheme } from './Button.theme';
import type { ButtonTheme } from './Button.theme';
import { Link } from 'react-router-dom';
import { cloneIcon } from 'utils/cloneIcon';
/**
* Represents the properties of a base button component.
*/
interface ButtonBaseProps {
/**
* The optional left icon element for a component.
* @type {ReactNode}
*/
leftIcon?: ReactNode;
/**
* The optional right icon element to display.
* @type {ReactNode}
*/
rightIcon?: ReactNode;
}
/**
* Interface for the props of a button link component.
*/
export interface ButtonLinkProps
extends Omit<ComponentPropsWithoutRef<'a'>, 'color'> {
/**
* Specifies the optional property `as` with a value of `'a'`.
* @type {'a'}
*/
as?: 'a';
/**
* Indicates whether the item is external or not.
* @type {boolean}
*/
external?: boolean;
/**
* The URL of a web page or resource.
* @type {string}
*/
href: string;
}
export interface ButtonProps
extends Omit<ComponentPropsWithoutRef<'button'>, 'color'> {
/**
* Specifies the optional property `as` with a value of `'button'`.
* @type {'button'}
*/
as?: 'button';
}
/**
* Interface representing the props for a button component.
* Extends the ComponentPropsWithoutRef<'button'> and ButtonTheme interfaces.
*/
export type ButtonOrLinkProps = (ButtonLinkProps | ButtonProps) &
ButtonBaseProps &
ButtonTheme;
/**
* A custom button component that can be used in React applications.
*/
const Button = forwardRef<
HTMLButtonElement | HTMLAnchorElement,
ButtonOrLinkProps
>(
(
{
children,
className,
leftIcon,
rightIcon,
fullWidth,
iconOnly,
shape,
variant,
...props
},
ref,
) => {
// Conditionally render between <NextLink>, <a> or <button> depending on props
// useCallback to prevent unnecessary re-rendering
const Component = useCallback(
({ children: _children, ..._props }: ButtonOrLinkProps) => {
if (_props.as === 'a') {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { external, href, as, ...baseLinkProps } = _props;
// External link
if (external) {
const externalLinkProps = {
target: '_blank',
rel: 'noopener',
href,
...baseLinkProps,
};
return <a {...externalLinkProps}>{_children}</a>;
}
// Internal link
return (
<Link {...baseLinkProps} to={href}>
{_children}
</Link>
);
} else {
const { ...buttonProps } = _props;
// @ts-expect-error - as prop is not a valid prop for button elements
return <button {...buttonProps}>{_children}</button>;
}
},
[],
);
/**
* Extracts specific style properties from the given props object and returns them as a new object.
*/
const styleProps = (({
variant = 'primary',
size = 'md',
fullWidth = false,
iconOnly = false,
shape = 'rounded',
as,
}) => ({
variant,
size,
fullWidth,
iconOnly,
shape,
as,
}))({ ...props, fullWidth, iconOnly, shape, variant });
/**
* Validates that a button component has either children or an aria-label prop.
*/
if (typeof children === 'undefined' && !props['aria-label']) {
throw new Error(
'Button components must have either children or an aria-label prop',
);
}
const iconSize = useCallback(() => {
switch (styleProps.size) {
case 'lg':
return { width: 20, height: 20 };
case 'sm':
case 'xs':
return { width: 16, height: 16 };
case 'md':
default: {
return { width: 18, height: 18 };
}
}
}, [styleProps.size])();
return (
<Component
{...props}
// @ts-expect-error - ref is not a valid prop for button elements
ref={ref}
className={buttonTheme({ ...styleProps, class: className })}
>
{cloneIcon(leftIcon, { ...iconSize })}
{children}
{cloneIcon(rightIcon, { ...iconSize })}
</Component>
);
},
);
Button.displayName = 'Button';
export { Button };
@@ -0,0 +1,2 @@
export * from './Button';
export * from './Button.theme';
@@ -0,0 +1,128 @@
/* React Calendar */
.react-calendar {
@apply border-none font-sans;
}
/* Weekdays -- START */
.react-calendar__month-view__weekdays {
@apply p-0 flex items-center justify-center;
}
.react-calendar__month-view__weekdays__weekday {
@apply h-8 w-12 flex items-center justify-center p-0 font-medium text-xs text-elements-disabled mb-2;
}
abbr[title] {
text-decoration: none;
}
/* Weekdays -- END */
/* Days -- START */
.react-calendar__month-view__days {
@apply p-0 gap-0;
}
.react-calendar__month-view__days__day--neighboringMonth {
@apply !text-elements-disabled;
}
.react-calendar__month-view__days__day--neighboringMonth:hover {
@apply !text-elements-disabled !bg-transparent;
}
.react-calendar__month-view__days__day--neighboringMonth {
@apply !text-elements-disabled !bg-transparent;
}
/* For weekend days */
.react-calendar__month-view__days__day--weekend {
/* color: ${colors.grey[950]} !important; */
}
.react-calendar__tile {
@apply h-12 w-12 text-elements-high-em;
}
.react-calendar__tile:hover {
@apply bg-base-bg-emphasized rounded-lg;
}
.react-calendar__tile:focus-visible {
@apply bg-base-bg-emphasized rounded-lg focus-ring z-10;
}
.react-calendar__tile--now {
@apply bg-base-bg-emphasized text-elements-high-em rounded-lg;
}
.react-calendar__tile--now:hover {
@apply bg-base-bg-emphasized text-elements-high-em rounded-lg;
}
.react-calendar__tile--now:focus-visible {
@apply bg-base-bg-emphasized text-elements-high-em rounded-lg focus-ring;
}
.react-calendar__tile--active {
@apply bg-controls-primary text-elements-on-primary rounded-lg;
}
.react-calendar__tile--active:hover {
@apply bg-controls-primary-hovered;
}
.react-calendar__tile--active:focus-visible {
@apply bg-controls-primary-hovered focus-ring;
}
/* Range -- START */
.react-calendar__tile--range {
@apply bg-controls-secondary text-elements-on-secondary rounded-none;
}
.react-calendar__tile--range:hover {
@apply bg-controls-secondary-hovered text-elements-on-secondary rounded-none;
}
.react-calendar__tile--range:focus-visible {
@apply bg-controls-secondary-hovered text-elements-on-secondary rounded-lg;
}
.react-calendar__tile--rangeStart {
@apply bg-controls-primary text-elements-on-primary rounded-lg;
}
.react-calendar__tile--rangeStart:hover {
@apply bg-controls-primary-hovered text-elements-on-primary rounded-lg;
}
.react-calendar__tile--rangeStart:focus-visible {
@apply bg-controls-primary-hovered text-elements-on-primary rounded-lg focus-ring;
}
.react-calendar__tile--rangeEnd {
@apply bg-controls-primary text-elements-on-primary rounded-lg;
}
.react-calendar__tile--rangeEnd:hover {
@apply bg-controls-primary-hovered text-elements-on-primary rounded-lg;
}
.react-calendar__tile--rangeEnd:focus-visible {
@apply bg-controls-primary-hovered text-elements-on-primary rounded-lg focus-ring;
}
/* Range -- END */
/* Days -- END */
/* Months -- START */
.react-calendar__tile--hasActive {
@apply bg-controls-primary text-elements-on-primary rounded-lg;
}
.react-calendar__tile--hasActive:hover {
@apply bg-controls-primary-hovered text-elements-on-primary rounded-lg;
}
.react-calendar__tile--hasActive:focus-visible {
@apply bg-controls-primary-hovered text-elements-on-primary rounded-lg focus-ring;
}
@@ -0,0 +1,49 @@
import { VariantProps, tv } from 'tailwind-variants';
export const calendarTheme = tv({
slots: {
wrapper: [
'max-w-[352px]',
'bg-surface-floating',
'shadow-calendar',
'rounded-xl',
],
calendar: ['flex', 'flex-col', 'py-2', 'px-2', 'gap-2'],
navigation: [
'flex',
'items-center',
'justify-between',
'gap-3',
'py-2.5',
'px-1',
],
actions: ['flex', 'items-center', 'justify-center', 'gap-1.5', 'flex-1'],
button: [
'flex',
'items-center',
'gap-2',
'px-2',
'py-2',
'rounded-lg',
'border',
'border-border-interactive',
'text-elements-high-em',
'shadow-field',
'bg-white',
'hover:bg-base-bg-alternate',
'focus-visible:bg-base-bg-alternate',
],
footer: [
'flex',
'items-center',
'justify-end',
'py-3',
'px-2',
'gap-3',
'border-t',
'border-border-separator',
],
},
});
export type CalendarTheme = VariantProps<typeof calendarTheme>;
@@ -0,0 +1,298 @@
import React, {
ComponentPropsWithRef,
MouseEvent,
ReactNode,
useCallback,
useState,
} from 'react';
import {
Calendar as ReactCalendar,
CalendarProps as ReactCalendarProps,
} from 'react-calendar';
import { Value } from 'react-calendar/dist/cjs/shared/types';
import { CalendarTheme, calendarTheme } from './Calendar.theme';
import { Button } from 'components/shared/Button';
import {
ChevronGrabberHorizontal,
ChevronLeft,
ChevronRight,
} from 'components/shared/CustomIcon';
import './Calendar.css';
import { format } from 'date-fns';
const CALENDAR_VIEW = ['month', 'year', 'decade'] as const;
export type CalendarView = (typeof CALENDAR_VIEW)[number];
/**
* Defines a custom set of props for a React calendar component by excluding specific props
* from the original ReactCalendarProps type.
* @type {CustomReactCalendarProps}
*/
type CustomReactCalendarProps = Omit<
ReactCalendarProps,
'view' | 'showNavigation' | 'onClickMonth' | 'onClickYear'
>;
export interface CalendarProps extends CustomReactCalendarProps, CalendarTheme {
/**
* Optional props for wrapping a component with a div element.
*/
wrapperProps?: ComponentPropsWithRef<'div'>;
/**
* Props for the calendar wrapper component.
*/
calendarWrapperProps?: ComponentPropsWithRef<'div'>;
/**
* Optional props for the footer component.
*/
footerProps?: ComponentPropsWithRef<'div'>;
/**
* Optional custom actions to be rendered.
*/
actions?: ReactNode;
/**
* Optional callback function that is called when a value is selected.
* @param {Value} value - The selected value
* @returns None
*/
onSelect?: (value: Value) => void;
/**
* Optional callback function that is called when a cancel action is triggered.
* @returns None
*/
onCancel?: () => void;
}
/**
* Calendar component that allows users to select dates and navigate through months and years.
* @param {Object} CalendarProps - Props for the Calendar component.
* @returns {JSX.Element} A calendar component with navigation, date selection, and actions.
*/
export const Calendar = ({
selectRange,
activeStartDate: activeStartDateProp,
value: valueProp,
wrapperProps,
calendarWrapperProps,
footerProps,
actions,
onSelect,
onCancel,
onChange: onChangeProp,
...props
}: CalendarProps): JSX.Element => {
const {
wrapper,
calendar,
navigation,
actions: actionsClass,
button,
footer,
} = calendarTheme();
const today = new Date();
const currentMonth = format(today, 'MMM');
const currentYear = format(today, 'yyyy');
const [view, setView] = useState<CalendarView>('month');
const [activeDate, setActiveDate] = useState<Date>(
activeStartDateProp ?? today,
);
const [value, setValue] = useState<Value>(valueProp as Value);
const [month, setMonth] = useState(currentMonth);
const [year, setYear] = useState(currentYear);
/**
* Update the navigation label based on the active date
*/
const changeNavigationLabel = useCallback(
(date: Date) => {
setMonth(format(date, 'MMM'));
setYear(format(date, 'yyyy'));
},
[setMonth, setYear],
);
/**
* Change the active date base on the action and range
*/
const handleNavigate = useCallback(
(action: 'previous' | 'next', view: CalendarView) => {
setActiveDate((date) => {
const newDate = new Date(date);
switch (view) {
case 'month':
newDate.setMonth(
action === 'previous' ? date.getMonth() - 1 : date.getMonth() + 1,
);
break;
case 'year':
newDate.setFullYear(
action === 'previous'
? date.getFullYear() - 1
: date.getFullYear() + 1,
);
break;
case 'decade':
newDate.setFullYear(
action === 'previous'
? date.getFullYear() - 10
: date.getFullYear() + 10,
);
break;
}
changeNavigationLabel(newDate);
return newDate;
});
},
[setActiveDate, changeNavigationLabel],
);
/**
* Change the view of the calendar
*/
const handleChangeView = useCallback(
(view: CalendarView) => {
setView(view);
},
[setView],
);
/**
* Change the active date and set the view to the selected type
* and also update the navigation label
*/
const handleChangeNavigation = useCallback(
(view: 'month' | 'year', date: Date) => {
setActiveDate(date);
changeNavigationLabel(date);
setView(view);
},
[setActiveDate, changeNavigationLabel, setView],
);
const handlePrevious = useCallback(() => {
switch (view) {
case 'month':
return handleNavigate('previous', 'month');
case 'year':
return handleNavigate('previous', 'year');
case 'decade':
return handleNavigate('previous', 'decade');
}
}, [view]);
const handleNext = useCallback(() => {
switch (view) {
case 'month':
return handleNavigate('next', 'month');
case 'year':
return handleNavigate('next', 'year');
case 'decade':
return handleNavigate('next', 'decade');
}
}, [view]);
const handleChange = useCallback(
(newValue: Value, event: MouseEvent<HTMLButtonElement>) => {
setValue(newValue);
// Call the onChange prop if it exists
onChangeProp?.(newValue, event);
/**
* Update the active date and navigation label
*
* NOTE:
* For range selection, the active date is not updated
* The user only can select multiple dates within the same month
*/
if (!selectRange) {
setActiveDate(newValue as Date);
changeNavigationLabel(newValue as Date);
}
},
[setValue, setActiveDate, changeNavigationLabel, selectRange],
);
return (
<div
{...wrapperProps}
className={wrapper({ className: wrapperProps?.className })}
>
{/* Calendar wrapper */}
<div
{...calendarWrapperProps}
className={calendar({ className: calendarWrapperProps?.className })}
>
{/* Navigation */}
<div className={navigation()}>
<Button iconOnly size="sm" variant="ghost" onClick={handlePrevious}>
<ChevronLeft />
</Button>
<div className={actionsClass()}>
<Button
variant="unstyled"
className={button()}
rightIcon={
<ChevronGrabberHorizontal className="text-elements-low-em" />
}
onClick={() => handleChangeView('year')}
>
{month}
</Button>
<Button
variant="unstyled"
className={button()}
rightIcon={
<ChevronGrabberHorizontal className="text-elements-low-em" />
}
onClick={() => handleChangeView('decade')}
>
{year}
</Button>
</div>
<Button iconOnly size="sm" variant="ghost" onClick={handleNext}>
<ChevronRight />
</Button>
</div>
{/* Calendar */}
<ReactCalendar
{...props}
activeStartDate={activeDate}
view={view}
value={value}
showNavigation={false}
selectRange={selectRange}
onChange={handleChange}
onClickMonth={(date) => handleChangeNavigation('month', date)}
onClickYear={(date) => handleChangeNavigation('year', date)}
/>
</div>
{/* Footer or CTA */}
<div
{...footerProps}
className={footer({ className: footerProps?.className })}
>
{actions ? (
actions
) : (
<>
<Button variant="tertiary" onClick={onCancel}>
Cancel
</Button>
<Button
disabled={!value}
onClick={() => (value ? onSelect?.(value) : null)}
>
Select
</Button>
</>
)}
</div>
</div>
);
};
@@ -0,0 +1 @@
export * from './Calendar';
@@ -0,0 +1,68 @@
import { tv, type VariantProps } from 'tailwind-variants';
export const getCheckboxVariant = tv({
slots: {
wrapper: ['group', 'flex', 'gap-3'],
indicator: [
'grid',
'place-content-center',
'text-transparent',
'group-hover:text-controls-disabled',
'focus-visible:text-controls-disabled',
'group-focus-visible:text-controls-disabled',
'data-[state=checked]:text-elements-on-primary',
'data-[state=checked]:group-focus-visible:text-elements-on-primary',
'data-[state=indeterminate]:text-elements-on-primary',
'data-[state=checked]:data-[disabled]:text-elements-on-disabled-active',
],
icon: ['w-3', 'h-3', 'stroke-current', 'text-current'],
input: [
'h-5',
'w-5',
'group',
'border',
'border-border-interactive/10',
'bg-controls-tertiary',
'rounded-md',
'transition-all',
'duration-150',
'focus-ring',
'shadow-button',
'group-hover:border-border-interactive/[0.14]',
'group-hover:bg-controls-tertiary',
'data-[state=checked]:bg-controls-primary',
'data-[state=checked]:hover:bg-controls-primary-hovered',
'data-[state=checked]:focus-visible:bg-controls-primary-hovered',
'data-[disabled]:bg-controls-disabled',
'data-[disabled]:shadow-none',
'data-[disabled]:hover:border-border-interactive/10',
'data-[disabled]:cursor-not-allowed',
'data-[state=checked]:data-[disabled]:bg-controls-disabled-active',
],
label: [
'text-sm',
'tracking-[-0.006em]',
'text-elements-high-em',
'flex',
'flex-col',
'gap-1',
'px-1',
],
description: ['text-xs', 'text-elements-low-em'],
},
variants: {
disabled: {
true: {
wrapper: ['cursor-not-allowed'],
indicator: ['group-hover:text-transparent'],
input: [
'group-hover:border-border-interactive/[0.14]',
'group-hover:bg-controls-disabled',
],
label: ['cursor-not-allowed'],
},
},
},
});
export type CheckboxVariants = VariantProps<typeof getCheckboxVariant>;
@@ -0,0 +1,76 @@
import React from 'react';
import * as CheckboxRadix from '@radix-ui/react-checkbox';
import { type CheckboxProps as CheckboxRadixProps } from '@radix-ui/react-checkbox';
import { getCheckboxVariant, type CheckboxVariants } from './Checkbox.theme';
import { CheckIcon } from 'components/shared/CustomIcon';
interface CheckBoxProps extends CheckboxRadixProps, CheckboxVariants {
/**
* The label of the checkbox.
*/
label?: string;
/**
* The description of the checkbox.
*/
description?: string;
}
/**
* Checkbox component is used to allow users to select one or more items from a set.
* It is a wrapper around the `@radix-ui/react-checkbox` component.
*
* It accepts all the props from `@radix-ui/react-checkbox` component and the variants from the theme.
*
* It also accepts `label` and `description` props to display the label and description of the checkbox.
*
* @example
* ```tsx
* <Checkbox
* id="checkbox"
* label="Checkbox"
* description="This is a checkbox"
* />
* ```
*/
export const Checkbox = ({
id,
className,
label,
description,
onCheckedChange,
...props
}: CheckBoxProps) => {
const {
wrapper: wrapperStyle,
indicator: indicatorStyle,
icon: iconStyle,
input: inputStyle,
label: labelStyle,
description: descriptionStyle,
} = getCheckboxVariant({
disabled: props?.disabled,
});
return (
<div className={wrapperStyle()}>
<CheckboxRadix.Root
{...props}
className={inputStyle({ className })}
id={id}
onCheckedChange={onCheckedChange}
>
<CheckboxRadix.Indicator forceMount className={indicatorStyle()}>
<CheckIcon className={iconStyle()} />
</CheckboxRadix.Indicator>
</CheckboxRadix.Root>
{label && (
<label className={labelStyle()} htmlFor={id}>
{label}
{description && (
<span className={descriptionStyle()}>{description}</span>
)}
</label>
)}
</div>
);
};
@@ -0,0 +1 @@
export * from './Checkbox';
@@ -0,0 +1,22 @@
import React from 'react';
import { CustomIcon, CustomIconProps } from './CustomIcon';
export const CheckIcon = (props: CustomIconProps) => {
return (
<CustomIcon
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
{...props}
>
<path
d="M1.5 7.5L4.64706 10L10.5 2"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</CustomIcon>
);
};
@@ -0,0 +1,20 @@
import React from 'react';
import { CustomIcon, CustomIconProps } from './CustomIcon';
export const ChevronGrabberHorizontal = (props: CustomIconProps) => {
return (
<CustomIcon
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
{...props}
>
<path
d="M6.66666 12.5L9.99999 15.8333L13.3333 12.5M6.66666 7.5L9.99999 4.16666L13.3333 7.5"
stroke="currentColor"
strokeLinecap="square"
/>
</CustomIcon>
);
};
@@ -0,0 +1,21 @@
import React from 'react';
import { CustomIcon, CustomIconProps } from './CustomIcon';
export const ChevronLeft = (props: CustomIconProps) => {
return (
<CustomIcon
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10.7071 2.66666L5.37378 8.00001L10.7071 13.3333L10 14.0404L3.95956 8.00001L10 1.95956L10.7071 2.66666Z"
fill="currentColor"
/>
</CustomIcon>
);
};
@@ -0,0 +1,21 @@
import React from 'react';
import { CustomIcon, CustomIconProps } from './CustomIcon';
export const ChevronRight = (props: CustomIconProps) => {
return (
<CustomIcon
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.00001 1.95956L12.0405 7.99998L6.00001 14.0404L5.29291 13.3333L10.6262 7.99999L5.29291 2.66666L6.00001 1.95956Z"
fill="currentColor"
/>
</CustomIcon>
);
};
@@ -0,0 +1,21 @@
import React from 'react';
import { CustomIcon, CustomIconProps } from './CustomIcon';
export const CrossIcon = (props: CustomIconProps) => {
return (
<CustomIcon
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
{...props}
>
<path
d="M5 5L19 19M19 5L5 19"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</CustomIcon>
);
};
@@ -0,0 +1,30 @@
import React, { ComponentPropsWithoutRef } from 'react';
export interface CustomIconProps extends ComponentPropsWithoutRef<'svg'> {
size?: number | string; // width and height will both be set as the same value
name?: string;
}
export const CustomIcon = ({
children,
width = 24,
height = 24,
size,
viewBox = '0 0 24 24',
name,
...rest
}: CustomIconProps) => {
return (
<svg
aria-labelledby={name}
height={size || height}
role="presentation"
viewBox={viewBox}
width={size || width}
xmlns="http://www.w3.org/2000/svg"
{...rest}
>
{children}
</svg>
);
};
@@ -0,0 +1,20 @@
import React from 'react';
import { CustomIcon, CustomIconProps } from './CustomIcon';
export const GlobeIcon = (props: CustomIconProps) => {
return (
<CustomIcon
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
{...props}
>
<path
d="M10 17.9167C14.3723 17.9167 17.9167 14.3723 17.9167 10C17.9167 5.62776 14.3723 2.08334 10 2.08334M10 17.9167C5.62776 17.9167 2.08334 14.3723 2.08334 10C2.08334 5.62776 5.62776 2.08334 10 2.08334M10 17.9167C8.15906 17.9167 6.66668 14.3723 6.66668 10C6.66668 5.62776 8.15906 2.08334 10 2.08334M10 17.9167C11.841 17.9167 13.3333 14.3723 13.3333 10C13.3333 5.62776 11.841 2.08334 10 2.08334M17.5 10H2.50001"
stroke="currentColor"
strokeLinecap="square"
/>
</CustomIcon>
);
};
@@ -0,0 +1,21 @@
import React from 'react';
import { CustomIcon, CustomIconProps } from './CustomIcon';
export const InfoSquareIcon = (props: CustomIconProps) => {
return (
<CustomIcon
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.96786 2.5C5.52858 2.49999 5.14962 2.49997 4.83748 2.52548C4.50802 2.55239 4.18034 2.61182 3.86503 2.77249C3.39462 3.01217 3.01217 3.39462 2.77249 3.86503C2.61182 4.18034 2.55239 4.50802 2.52548 4.83748C2.49997 5.14962 2.49999 5.52857 2.5 5.96785V14.0321C2.49999 14.4714 2.49997 14.8504 2.52548 15.1625C2.55239 15.492 2.61182 15.8197 2.77249 16.135C3.01217 16.6054 3.39462 16.9878 3.86503 17.2275C4.18034 17.3882 4.50802 17.4476 4.83748 17.4745C5.14962 17.5 5.52858 17.5 5.96786 17.5H14.0321C14.4714 17.5 14.8504 17.5 15.1625 17.4745C15.492 17.4476 15.8197 17.3882 16.135 17.2275C16.6054 16.9878 16.9878 16.6054 17.2275 16.135C17.3882 15.8197 17.4476 15.492 17.4745 15.1625C17.5 14.8504 17.5 14.4714 17.5 14.0321V5.96786C17.5 5.52858 17.5 5.14962 17.4745 4.83748C17.4476 4.50802 17.3882 4.18034 17.2275 3.86503C16.9878 3.39462 16.6054 3.01217 16.135 2.77249C15.8197 2.61182 15.492 2.55239 15.1625 2.52548C14.8504 2.49997 14.4714 2.49999 14.0322 2.5H5.96786ZM8.33333 9.16667C8.33333 8.70643 8.70643 8.33333 9.16667 8.33333H10C10.4602 8.33333 10.8333 8.70643 10.8333 9.16667V13.3333C10.8333 13.7936 10.4602 14.1667 10 14.1667C9.53976 14.1667 9.16667 13.7936 9.16667 13.3333V10C8.70643 10 8.33333 9.6269 8.33333 9.16667ZM10 5.83333C9.53976 5.83333 9.16667 6.20643 9.16667 6.66667C9.16667 7.1269 9.53976 7.5 10 7.5C10.4602 7.5 10.8333 7.1269 10.8333 6.66667C10.8333 6.20643 10.4602 5.83333 10 5.83333Z"
fill="currentColor"
/>
</CustomIcon>
);
};
@@ -0,0 +1,21 @@
import React from 'react';
import { CustomIcon, CustomIconProps } from './CustomIcon';
export const PlusIcon = (props: CustomIconProps) => {
return (
<CustomIcon
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M1.66666 9.99999C1.66666 5.39762 5.39762 1.66666 9.99999 1.66666C14.6024 1.66666 18.3333 5.39762 18.3333 9.99999C18.3333 14.6024 14.6024 18.3333 9.99999 18.3333C5.39762 18.3333 1.66666 14.6024 1.66666 9.99999ZM10.625 6.46483C10.625 6.11966 10.3452 5.83983 9.99999 5.83983C9.65481 5.83983 9.37499 6.11966 9.37499 6.46483V9.37537H6.46446C6.11928 9.37537 5.83946 9.65519 5.83946 10.0004C5.83946 10.3455 6.11928 10.6254 6.46446 10.6254H9.37499V13.5359C9.37499 13.8811 9.65481 14.1609 9.99999 14.1609C10.3452 14.1609 10.625 13.8811 10.625 13.5359V10.6254H13.5355C13.8807 10.6254 14.1605 10.3455 14.1605 10.0004C14.1605 9.65519 13.8807 9.37537 13.5355 9.37537H10.625V6.46483Z"
fill="currentColor"
/>
</CustomIcon>
);
};
@@ -0,0 +1,24 @@
# 1. What icons are compatible with this component?
- Viewbox "0 0 24 24": From where you're exporting from, please make sure the icon is using viewBox="0 0 24 24" before downloading/exporting. Not doing so will result in incorrect icon scaling
# 2. How to add a new icon?
**2.1 Sanitising the icon**
1. Duplicate a current icon e.g. CrossIcon and rename it accordingly.
2. Rename the function inside the new file you duplicated too
3. Replace the markup with your SVG markup (make sure it complies with the above section's rule)
4. Depending on the svg you pasted...
A. If the `<svg>` has only 1 child, remove the `<svg>` parent entirely so you only have the path left
B. If your component has more than 1 paths, rename `<svg>` tag with the `<g>` tag. Then, remove all attributes of this `<g>` tag so that it's just `<g>`
5. Usually, icons are single colored. If that's the case, replace all fill/stroke color with `currentColor`. E.g. <path d="..." fill="currentColor">. Leave the other attributes without removing them.
6. If your icon has more than one colour, then it's up to you to decide whether we want to use tailwind to help set the fill and stroke colors
7. Lastly, export your icon in `index.ts` by following what was done for CrossIcon
8. Make sure to provide a name to the `<CustomIcon>` component for accessibility sake
9. Done!
**2.3 Use your newly imported icon**
1. You can change simply use `<BellIcon size="32" />` to quickly change both width and height with the same value (square). For custom viewBox, width and height, simply provide all three props.
2. Coloring the icon: Simply add a className with text color. E.g. `<BellIcon className="text-gray-500" />`
@@ -0,0 +1,21 @@
import React from 'react';
import { CustomIcon, CustomIconProps } from './CustomIcon';
export const SearchIcon = (props: CustomIconProps) => {
return (
<CustomIcon
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
{...props}
>
<path
d="M20 20L16.05 16.05M18 11C18 14.866 14.866 18 11 18C7.13401 18 4 14.866 4 11C4 7.13401 7.13401 4 11 4C14.866 4 18 7.13401 18 11Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</CustomIcon>
);
};
@@ -0,0 +1,21 @@
import React from 'react';
import { CustomIcon, CustomIconProps } from './CustomIcon';
export const WarningIcon = (props: CustomIconProps) => {
return (
<CustomIcon
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10.4902 2.84406C11.1661 1.69 12.8343 1.69 13.5103 2.84406L22.0156 17.3654C22.699 18.5321 21.8576 19.9999 20.5056 19.9999H3.49483C2.14281 19.9999 1.30147 18.5321 1.98479 17.3654L10.4902 2.84406ZM12 9C12.4142 9 12.75 9.33579 12.75 9.75V13.25C12.75 13.6642 12.4142 14 12 14C11.5858 14 11.25 13.6642 11.25 13.25V9.75C11.25 9.33579 11.5858 9 12 9ZM13 15.75C13 16.3023 12.5523 16.75 12 16.75C11.4477 16.75 11 16.3023 11 15.75C11 15.1977 11.4477 14.75 12 14.75C12.5523 14.75 13 15.1977 13 15.75Z"
fill="currentColor"
/>
</CustomIcon>
);
};
@@ -0,0 +1,11 @@
export * from './PlusIcon';
export * from './CustomIcon';
export * from './CheckIcon';
export * from './ChevronGrabberHorizontal';
export * from './ChevronLeft';
export * from './ChevronRight';
export * from './InfoSquareIcon';
export * from './WarningIcon';
export * from './SearchIcon';
export * from './CrossIcon';
export * from './GlobeIcon';
@@ -0,0 +1,78 @@
import { VariantProps, tv } from 'tailwind-variants';
export const inlineNotificationTheme = tv({
slots: {
wrapper: ['rounded-xl', 'flex', 'gap-2', 'items-start', 'w-full', 'border'],
content: ['flex', 'flex-col', 'gap-1'],
title: [],
description: [],
icon: ['flex', 'items-start'],
},
variants: {
variant: {
info: {
wrapper: ['border-border-info-light', 'bg-base-bg-emphasized-info'],
title: ['text-elements-on-emphasized-info'],
description: ['text-elements-on-emphasized-info'],
icon: ['text-elements-info'],
},
danger: {
wrapper: ['border-border-danger-light', 'bg-base-bg-emphasized-danger'],
title: ['text-elements-on-emphasized-danger'],
description: ['text-elements-on-emphasized-danger'],
icon: ['text-elements-danger'],
},
warning: {
wrapper: [
'border-border-warning-light',
'bg-base-bg-emphasized-warning',
],
title: ['text-elements-on-emphasized-warning'],
description: ['text-elements-on-emphasized-warning'],
icon: ['text-elements-warning'],
},
success: {
wrapper: [
'border-border-success-light',
'bg-base-bg-emphasized-success',
],
title: ['text-elements-on-emphasized-success'],
description: ['text-elements-on-emphasized-success'],
icon: ['text-elements-success'],
},
generic: {
wrapper: ['border-border-separator', 'bg-base-bg-emphasized'],
title: ['text-elements-high-em'],
description: ['text-elements-on-emphasized-info'],
icon: ['text-elements-high-em'],
},
},
size: {
sm: {
wrapper: ['px-2', 'py-2'],
title: ['leading-4', 'text-xs'],
description: ['leading-4', 'text-xs'],
icon: ['h-4', 'w-4'],
},
md: {
wrapper: ['px-3', 'py-3'],
title: ['leading-5', 'tracking-[-0.006em]', 'text-sm'],
description: ['leading-5', 'tracking-[-0.006em]', 'text-sm'],
icon: ['h-5', 'w-5'],
},
},
hasDescription: {
true: {
title: ['font-medium'],
},
},
},
defaultVariants: {
variant: 'generic',
size: 'md',
},
});
export type InlineNotificationTheme = VariantProps<
typeof inlineNotificationTheme
>;
@@ -0,0 +1,68 @@
import React, { ReactNode, useCallback } from 'react';
import { ComponentPropsWithoutRef } from 'react';
import {
InlineNotificationTheme,
inlineNotificationTheme,
} from './InlineNotification.theme';
import { InfoSquareIcon } from 'components/shared/CustomIcon';
import { cloneIcon } from 'utils/cloneIcon';
export interface InlineNotificationProps
extends ComponentPropsWithoutRef<'div'>,
InlineNotificationTheme {
/**
* The title of the notification
*/
title: string;
/**
* The description of the notification
*/
description?: string;
/**
* The icon to display in the notification
* @default <InfoSquareIcon />
*/
icon?: ReactNode;
}
/**
* A notification that is displayed inline with the content
*
* @example
* ```tsx
* <InlineNotification title="Notification title goes here" />
* ```
*/
export const InlineNotification = ({
className,
title,
description,
size,
variant,
icon,
...props
}: InlineNotificationProps) => {
const {
wrapper,
content,
title: titleClass,
description: descriptionClass,
icon: iconClass,
} = inlineNotificationTheme({ size, variant, hasDescription: !!description });
// Render custom icon or default icon
const renderIcon = useCallback(() => {
if (!icon) return <InfoSquareIcon className={iconClass()} />;
return cloneIcon(icon, { className: iconClass() });
}, [icon]);
return (
<div {...props} className={wrapper({ className })}>
{renderIcon()}
<div className={content()}>
<p className={titleClass()}>{title}</p>
{description && <p className={descriptionClass()}>{description}</p>}
</div>
</div>
);
};
@@ -0,0 +1 @@
export * from './InlineNotification';
@@ -0,0 +1,84 @@
import { VariantProps, tv } from 'tailwind-variants';
export const inputTheme = tv(
{
slots: {
container: [
'flex',
'items-center',
'rounded-lg',
'relative',
'placeholder:text-elements-disabled',
'disabled:cursor-not-allowed',
'disabled:bg-controls-disabled',
],
label: ['text-sm', 'text-elements-high-em'],
description: ['text-xs', 'text-elements-low-em'],
input: [
'focus-ring',
'block',
'w-full',
'h-full',
'rounded-lg',
'text-elements-mid-em',
'shadow-sm',
'border',
'border-border-interactive',
'disabled:shadow-none',
'disabled:border-none',
],
icon: ['text-elements-mid-em'],
iconContainer: [
'absolute',
'inset-y-0',
'flex',
'items-center',
'z-10',
'cursor-pointer',
],
helperIcon: [],
helperText: ['flex', 'gap-2', 'items-center', 'text-elements-danger'],
},
variants: {
state: {
default: {
input: '',
},
error: {
input: [
'outline',
'outline-offset-0',
'outline-border-danger',
'shadow-none',
],
helperText: 'text-elements-danger',
},
},
size: {
md: {
container: 'h-11',
input: ['text-sm pl-4 pr-4'],
icon: ['h-[18px] w-[18px]'],
helperText: 'text-sm',
helperIcon: ['h-5 w-5'],
},
sm: {
container: 'h-8',
input: ['text-xs pl-3 pr-3'],
icon: ['h-4 w-4'],
helperText: 'text-xs',
helperIcon: ['h-4 w-4'],
},
},
},
defaultVariants: {
size: 'md',
state: 'default',
},
},
{
responsiveVariants: true,
},
);
export type InputTheme = VariantProps<typeof inputTheme>;
@@ -0,0 +1,100 @@
import React, { ReactNode, useMemo } from 'react';
import { ComponentPropsWithoutRef } from 'react';
import { InputTheme, inputTheme } from './Input.theme';
import { WarningIcon } from 'components/shared/CustomIcon';
import { cloneIcon } from 'utils/cloneIcon';
import { cn } from 'utils/classnames';
export interface InputProps
extends InputTheme,
Omit<ComponentPropsWithoutRef<'input'>, 'size'> {
label?: string;
description?: string;
leftIcon?: ReactNode;
rightIcon?: ReactNode;
helperText?: string;
}
export const Input = ({
className,
label,
description,
leftIcon,
rightIcon,
helperText,
size,
state,
...props
}: InputProps) => {
const styleProps = (({ size = 'md', state }) => ({
size,
state,
}))({ size, state });
const {
container: containerCls,
label: labelCls,
description: descriptionCls,
input: inputCls,
icon: iconCls,
iconContainer: iconContainerCls,
helperText: helperTextCls,
helperIcon: helperIconCls,
} = inputTheme({ ...styleProps });
const renderLabels = useMemo(
() => (
<div className="space-y-1">
<p className={labelCls()}>{label}</p>
<p className={descriptionCls()}>{description}</p>
</div>
),
[labelCls, descriptionCls, label, description],
);
const renderLeftIcon = useMemo(() => {
return (
<div className={iconContainerCls({ class: 'left-0 pl-4' })}>
{cloneIcon(leftIcon, { className: iconCls(), ariaHidden: true })}
</div>
);
}, [cloneIcon, iconCls, iconContainerCls, leftIcon]);
const renderRightIcon = useMemo(() => {
return (
<div className={iconContainerCls({ class: 'pr-4 right-0' })}>
{cloneIcon(rightIcon, { className: iconCls(), ariaHidden: true })}
</div>
);
}, [cloneIcon, iconCls, iconContainerCls, rightIcon]);
const renderHelperText = useMemo(
() => (
<div className={helperTextCls()}>
{state &&
cloneIcon(<WarningIcon className={helperIconCls()} />, {
ariaHidden: true,
})}
<p>{helperText}</p>
</div>
),
[cloneIcon, state, helperIconCls, helperText, helperTextCls],
);
return (
<div className="space-y-2">
{renderLabels}
<div className={containerCls({ class: className })}>
{leftIcon && renderLeftIcon}
<input
className={cn(inputCls({ class: 'w-80' }), {
'pl-10': leftIcon,
})}
{...props}
/>
{rightIcon && renderRightIcon}
</div>
{renderHelperText}
</div>
);
};
@@ -0,0 +1,2 @@
export * from './Input';
export * from './Input.theme';
@@ -0,0 +1,80 @@
import { tv, type VariantProps } from 'tailwind-variants';
export type TabsVariants = VariantProps<typeof tabsTheme>;
export const tabsTheme = tv({
slots: {
root: ['flex', 'data-[orientation=horizontal]:w-full'],
triggerWrapper: [
// Horizontal default
'px-1',
'pb-5',
'text-elements-low-em',
'border-b-2',
'border-transparent',
'hover:border-border-interactive/10',
'hover:text-elements-mid-em',
'focus-within:border-border-interactive/10',
'data-[state=active]:font-medium',
'data-[state=active]:text-elements-high-em',
'data-[state=active]:border-elements-high-em',
// Vertical
'data-[orientation=vertical]:px-3',
'data-[orientation=vertical]:py-3',
'data-[orientation=vertical]:min-w-[240px]',
'data-[orientation=vertical]:focus-ring',
'data-[orientation=vertical]:rounded-xl',
'data-[orientation=vertical]:border-transparent',
'data-[orientation=vertical]:hover:bg-base-bg-emphasized',
'data-[orientation=vertical]:hover:text-elements-mid-em',
'data-[orientation=vertical]:hover:border-transparent',
'data-[orientation=vertical]:focus-visible:border-transparent',
'data-[orientation=vertical]:focus-visible:bg-base-bg-emphasized',
'data-[orientation=vertical]:focus-visible:text-elements-mid-em',
'data-[orientation=vertical]:data-[state=active]:font-normal',
'data-[orientation=vertical]:data-[state=active]:bg-base-bg-emphasized',
'data-[orientation=vertical]:data-[state=active]:border-transparent',
'data-[orientation=vertical]:data-[state=active]:hover:text-elements-high-em',
'data-[orientation=vertical]:data-[state=active]:focus-visible:text-elements-high-em',
],
trigger: [
'flex',
'gap-1.5',
'cursor-default',
'select-none',
'items-center',
'justify-center',
'outline-none',
'leading-none',
'tracking-[-0.006em]',
'rounded-md',
// Horizontal default
'data-[orientation=horizontal]:focus-ring',
// Vertical
'data-[orientation=vertical]:gap-2',
],
triggerList: [
'flex',
'shrink-0',
'gap-5',
'border-b',
'border-transparent',
// Horizontal default
'data-[orientation=horizontal]:border-border-interactive/10',
// Vertical
'data-[orientation=vertical]:flex-col',
'data-[orientation=vertical]:gap-0.5',
],
content: ['text-elements-high-em', 'grow', 'outline-none', 'tab-content'],
},
variants: {
fillWidth: {
true: {
trigger: ['flex-1'],
},
},
},
defaultVariants: {
fillWidth: false,
},
});
@@ -0,0 +1,51 @@
import React, { type ComponentPropsWithoutRef } from 'react';
import { Root as TabsRoot } from '@radix-ui/react-tabs';
import { tabsTheme } from './Tabs.theme';
import TabsContent from './TabsContent';
import TabsList from './TabsList';
import TabsTrigger from './TabsTrigger';
import TabsProvider, { TabsProviderProps } from './TabsProvider';
export interface TabsProps extends ComponentPropsWithoutRef<typeof TabsRoot> {
/**
* The configuration for the tabs component.
*/
config?: TabsProviderProps;
}
/**
* A component that allows users to switch between different tabs.
* @returns JSX element representing the tabs component.
*/
export const Tabs = ({
config,
className,
orientation = 'horizontal',
...props
}: TabsProps) => {
const { root } = tabsTheme(config);
return (
<TabsProvider {...config} orientation={orientation}>
<TabsRoot
{...props}
orientation={orientation}
className={root({ className })}
/>
</TabsProvider>
);
};
/**
* Assigns the TabsTrigger class to the Trigger property of the Tabs object.
*/
Tabs.Trigger = TabsTrigger;
/**
* Assigns the TabsList object to the List property of the Tabs object.
*/
Tabs.List = TabsList;
/**
* Assigns the TabsContent component to the Content property of the Tabs component.
*/
Tabs.Content = TabsContent;
@@ -0,0 +1,26 @@
import React, {
forwardRef,
type ComponentPropsWithoutRef,
type ElementRef,
} from 'react';
import { Content } from '@radix-ui/react-tabs';
import { tabsTheme } from '../Tabs.theme';
export interface TabsContentProps
extends ComponentPropsWithoutRef<typeof Content> {}
/**
* A component that represents the content of the tabs component.
*/
const TabsContent = forwardRef<ElementRef<typeof Content>, TabsContentProps>(
({ className, ...props }, ref) => {
const { content } = tabsTheme();
return <Content ref={ref} className={content({ className })} {...props} />;
},
);
// Assigns the display name to the TabsContent component.
TabsContent.displayName = 'TabsContent';
export { TabsContent };
@@ -0,0 +1,3 @@
import { TabsContent } from './TabsContent';
export default TabsContent;
@@ -0,0 +1,25 @@
import React, {
forwardRef,
type ComponentPropsWithoutRef,
type ElementRef,
} from 'react';
import { List } from '@radix-ui/react-tabs';
import { tabsTheme } from 'components/shared/Tabs/Tabs.theme';
export interface TabsListProps extends ComponentPropsWithoutRef<typeof List> {}
/**
* A component that represents the list of tabs.
*/
const TabsList = forwardRef<ElementRef<typeof List>, TabsListProps>(
({ className, ...props }, ref) => {
const { triggerList } = tabsTheme({ className });
return <List ref={ref} className={triggerList({ className })} {...props} />;
},
);
// Assigns the display name to the TabsList component.
TabsList.displayName = 'TabsList';
export { TabsList };
@@ -0,0 +1,3 @@
import { TabsList } from './TabsList';
export default TabsList;
@@ -0,0 +1,47 @@
import React, {
createContext,
useContext,
type PropsWithChildren,
ComponentPropsWithoutRef,
} from 'react';
import { TabsVariants } from './Tabs.theme';
import { Root as TabsRoot } from '@radix-ui/react-tabs';
export interface TabsProviderProps
extends Partial<TabsVariants>,
ComponentPropsWithoutRef<typeof TabsRoot> {}
type TabsProviderContext = ReturnType<typeof useTabsValues>;
const TabsContext = createContext<Partial<TabsProviderContext>>({});
// For inferring return type
const useTabsValues = (props: TabsProviderProps) => {
return props;
};
/**
* A provider component that allows users to switch between different tabs.
* @returns JSX element representing the tabs provider component.
*/
export const TabsProvider = ({
children,
...props
}: PropsWithChildren<TabsProviderProps>): JSX.Element => {
const values = useTabsValues(props);
return <TabsContext.Provider value={values}>{children}</TabsContext.Provider>;
};
/**
* A hook that returns the context of the tabs provider.
* @returns The context of the tabs provider.
*/
export const useTabs = () => {
const context = useContext(TabsContext);
if (context === undefined) {
throw new Error('useTabs was used outside of its Provider');
}
return context;
};
export default TabsProvider;

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