Merge branch 'main' of https://github.com/snowball-tools/snowballtools-base into ayungavis/T-4917-project-deployments-layout-and-empty-state

This commit is contained in:
Wahyu Kurniawan 2024-03-05 04:37:49 +07:00
commit 3335c26f82
No known key found for this signature in database
GPG Key ID: 040A1549143A8E33
34 changed files with 982 additions and 464 deletions

View File

@ -1,6 +1,7 @@
{ {
// IntelliSense for taiwind variants // IntelliSense for taiwind variants
"tailwindCSS.experimental.classRegex": [ "tailwindCSS.experimental.classRegex": [
["tv\\((([^()]*|\\([^()]*\\))*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"] "tv\\('([^)]*)\\')",
"(?:'|\"|`)([^\"'`]*)(?:'|\"|`)"
] ]
} }

View File

@ -9,14 +9,6 @@ if [[ -d "$DEST_DIR" ]]; then
exit 1 exit 1
fi fi
if [[ -f "$PKG_DIR/.env" ]]; then
echo "Using existing .env file"
else
mv "$PKG_DIR/.env.example" "$PKG_DIR/.env"
echo "Created .env file. Please populate with the correct values."
exit 1
fi
cat > $PKG_DIR/.env <<EOF cat > $PKG_DIR/.env <<EOF
REACT_APP_SERVER_URL = 'LACONIC_HOSTED_CONFIG_app_server_url' REACT_APP_SERVER_URL = 'LACONIC_HOSTED_CONFIG_app_server_url'
REACT_APP_GITHUB_CLIENT_ID = 'LACONIC_HOSTED_CONFIG_app_github_clientid' REACT_APP_GITHUB_CLIENT_ID = 'LACONIC_HOSTED_CONFIG_app_github_clientid'

View File

@ -12,12 +12,14 @@ import {
ApplicationDeploymentRequest ApplicationDeploymentRequest
} from './entity/Deployment'; } from './entity/Deployment';
import { AppDeploymentRecord, PackageJSON } from './types'; import { AppDeploymentRecord, PackageJSON } from './types';
import { sleep } from './utils';
const log = debug('snowball:registry'); const log = debug('snowball:registry');
const APP_RECORD_TYPE = 'ApplicationRecord'; const APP_RECORD_TYPE = 'ApplicationRecord';
const APP_DEPLOYMENT_REQUEST_TYPE = 'ApplicationDeploymentRequest'; const APP_DEPLOYMENT_REQUEST_TYPE = 'ApplicationDeploymentRequest';
const APP_DEPLOYMENT_RECORD_TYPE = 'ApplicationDeploymentRecord'; const APP_DEPLOYMENT_RECORD_TYPE = 'ApplicationDeploymentRecord';
const SLEEP_DURATION = 1000;
// TODO: Move registry code to laconic-sdk/watcher-ts // TODO: Move registry code to laconic-sdk/watcher-ts
export class Registry { export class Registry {
@ -111,16 +113,21 @@ export class Registry {
const crn = this.getCrn(appName); const crn = this.getCrn(appName);
log(`Setting name: ${crn} for record ID: ${result.data.id}`); log(`Setting name: ${crn} for record ID: ${result.data.id}`);
await sleep(SLEEP_DURATION);
await this.registry.setName( await this.registry.setName(
{ cid: result.data.id, crn }, { cid: result.data.id, crn },
this.registryConfig.privateKey, this.registryConfig.privateKey,
this.registryConfig.fee this.registryConfig.fee
); );
await sleep(SLEEP_DURATION);
await this.registry.setName( await this.registry.setName(
{ cid: result.data.id, crn: `${crn}@${applicationRecord.app_version}` }, { cid: result.data.id, crn: `${crn}@${applicationRecord.app_version}` },
this.registryConfig.privateKey, this.registryConfig.privateKey,
this.registryConfig.fee this.registryConfig.fee
); );
await sleep(SLEEP_DURATION);
await this.registry.setName( await this.registry.setName(
{ {
cid: result.data.id, cid: result.data.id,
@ -139,9 +146,9 @@ export class Registry {
async createApplicationDeploymentRequest (data: { async createApplicationDeploymentRequest (data: {
deployment: Deployment, deployment: Deployment,
appName: string, appName: string,
packageJsonName: string,
repository: string, repository: string,
environmentVariables: { [key: string]: string } environmentVariables: { [key: string]: string },
dns: string,
}): Promise<{ }): Promise<{
applicationDeploymentRequestId: string; applicationDeploymentRequestId: string;
applicationDeploymentRequestData: ApplicationDeploymentRequest; applicationDeploymentRequestData: ApplicationDeploymentRequest;
@ -160,7 +167,7 @@ export class Registry {
version: '1.0.0', version: '1.0.0',
name: `${applicationRecord.attributes.name}@${applicationRecord.attributes.app_version}`, name: `${applicationRecord.attributes.name}@${applicationRecord.attributes.app_version}`,
application: `${crn}@${applicationRecord.attributes.app_version}`, application: `${crn}@${applicationRecord.attributes.app_version}`,
dns: `${data.deployment.project.name}-${data.deployment.id}`, dns: data.dns,
// TODO: Not set in test-progressive-web-app CI // TODO: Not set in test-progressive-web-app CI
// deployment: '$CERC_REGISTRY_DEPLOYMENT_CRN', // deployment: '$CERC_REGISTRY_DEPLOYMENT_CRN',
@ -178,6 +185,7 @@ export class Registry {
}) })
}; };
await sleep(SLEEP_DURATION);
const result = await this.registry.setRecord( const result = await this.registry.setRecord(
{ {
privateKey: this.registryConfig.privateKey, privateKey: this.registryConfig.privateKey,
@ -211,11 +219,12 @@ export class Registry {
true true
); );
// Filter records with ApplicationRecord ids // Filter records with ApplicationRecord ID and Deployment specific URL
return records.filter((record: AppDeploymentRecord) => return records.filter((record: AppDeploymentRecord) =>
deployments.some( deployments.some(
(deployment) => (deployment) =>
deployment.applicationRecordId === record.attributes.application deployment.applicationRecordId === record.attributes.application &&
record.attributes.url.includes(deployment.id)
) )
); );
} }

View File

@ -382,8 +382,7 @@ export class Service {
async createDeployment ( async createDeployment (
userId: string, userId: string,
octokit: Octokit, octokit: Octokit,
data: DeepPartial<Deployment>, data: DeepPartial<Deployment>
recordData: { repoUrl?: string } = {}
): Promise<Deployment> { ): Promise<Deployment> {
assert(data.project?.repository, 'Project repository not found'); assert(data.project?.repository, 'Project repository not found');
log( log(
@ -407,13 +406,10 @@ export class Service {
assert(packageJSON.name, "name field doesn't exist in package.json"); assert(packageJSON.name, "name field doesn't exist in package.json");
if (!recordData.repoUrl) { const repoUrl = (await octokit.rest.repos.get({
const { data: repoDetails } = await octokit.rest.repos.get({ owner,
owner, repo
repo })).data.html_url;
});
recordData.repoUrl = repoDetails.html_url;
}
// TODO: Set environment variables for each deployment (environment variables can`t be set in application record) // TODO: Set environment variables for each deployment (environment variables can`t be set in application record)
const { applicationRecordId, applicationRecordData } = const { applicationRecordId, applicationRecordData } =
@ -422,7 +418,7 @@ export class Service {
packageJSON, packageJSON,
appType: data.project!.template!, appType: data.project!.template!,
commitHash: data.commitHash!, commitHash: data.commitHash!,
repoUrl: recordData.repoUrl repoUrl
}); });
// Update previous deployment with prod branch domain // Update previous deployment with prod branch domain
@ -464,11 +460,23 @@ export class Service {
{ {
deployment: newDeployment, deployment: newDeployment,
appName: repo, appName: repo,
packageJsonName: packageJSON.name, repository: repoUrl,
repository: recordData.repoUrl, environmentVariables: environmentVariablesObj,
environmentVariables: environmentVariablesObj dns: `${newDeployment.project.name}-${newDeployment.id}`
}); });
// To set project DNS
if (data.environment === Environment.Production) {
await this.registry.createApplicationDeploymentRequest(
{
deployment: newDeployment,
appName: repo,
repository: repoUrl,
environmentVariables: environmentVariablesObj,
dns: `${newDeployment.project.name}`
});
}
await this.db.updateDeploymentById(newDeployment.id, { applicationDeploymentRequestId, applicationDeploymentRequestData }); await this.db.updateDeploymentById(newDeployment.id, { applicationDeploymentRequestId, applicationDeploymentRequestData });
return newDeployment; return newDeployment;
@ -498,8 +506,6 @@ export class Service {
per_page: 1 per_page: 1
}); });
const { data: repoDetails } = await octokit.rest.repos.get({ owner, repo });
// Create deployment with prod branch and latest commit // Create deployment with prod branch and latest commit
await this.createDeployment(user.id, await this.createDeployment(user.id,
octokit, octokit,
@ -510,9 +516,6 @@ export class Service {
domain: null, domain: null,
commitHash: latestCommit.sha, commitHash: latestCommit.sha,
commitMessage: latestCommit.commit.message commitMessage: latestCommit.commit.message
},
{
repoUrl: repoDetails.html_url
} }
); );
@ -555,8 +558,14 @@ export class Service {
} }
async handleGitHubPush (data: GitPushEventPayload): Promise<void> { async handleGitHubPush (data: GitPushEventPayload): Promise<void> {
const { repository, ref, head_commit: headCommit } = data; const { repository, ref, head_commit: headCommit, deleted } = data;
log(`Handling GitHub push event from repository: ${repository.full_name}`);
if (deleted) {
log(`Branch ${ref} deleted for project ${repository.full_name}`);
return;
}
log(`Handling GitHub push event from repository: ${repository.full_name}, branch: ${ref}`);
const projects = await this.db.getProjects({ const projects = await this.db.getProjects({
where: { repository: repository.full_name } where: { repository: repository.full_name }
}); });

View File

@ -24,6 +24,7 @@ export interface GitPushEventPayload {
id: string; id: string;
message: string; message: string;
}; };
deleted: boolean;
} }
export interface AppDeploymentRecordAttributes { export interface AppDeploymentRecordAttributes {

View File

@ -66,3 +66,5 @@ export const loadAndSaveData = async <Entity extends ObjectLiteral>(
return savedEntity; return savedEntity;
}; };
export const sleep = async (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms));

View File

@ -8,57 +8,6 @@
brew install jq # if you do not have jq installed already brew install jq # if you do not have jq installed already
``` ```
Example of how to make the necessary deploy edits [here](https://github.com/snowball-tools/snowballtools-base/pull/131/files).
- Replace variables in the following files
- [records/application-deployment-request.yml](records/application-deployment-request.yml)
- update the name & application version numbers
- `<CURRENT_DATE_TIME>`: Replace with current time which can be generated by command `date -u`
```yml
# Example
record:
...
meta:
note: Added by Snowball @ Friday 23 February 2024 06:35:50 AM UTC
...
```
- Update record version in [records/application-record.yml](records/application-record.yml)
```yml
record:
type: ApplicationRecord
version: <NEW_VERSION>
...
```
- Update commit hash in the following places:
- [records/application-record.yml](records/application-record.yml)
```yml
record:
...
repository_ref: <COMMIT_HASH>
...
```
- [records/application-deployment-request.yml](records/application-deployment-request.yml)
```yml
record:
...
meta:
...
repository_ref: <COMMIT_HASH>
```
- [deploy-frontend.sh](deploy-frontend.sh)
Also be sure to update the app version
```bash
...
RCD_APP_VERSION="<NEW_VERSION>"
REPO_REF="<COMMIT_HASH>"
...
```
- Run script to deploy app - Run script to deploy app
``` ```
./deploy-frontend.sh ./deploy-frontend.sh

View File

@ -2,8 +2,8 @@ services:
cns: cns:
restEndpoint: http://console.laconic.com:1317 restEndpoint: http://console.laconic.com:1317
gqlEndpoint: http://console.laconic.com:9473/api gqlEndpoint: http://console.laconic.com:9473/api
userKey: 489c9dd3931c2a2d4dd77973302dc5eb01e2a49552f9d932c58d9da823512311
bondId: 99c0e9aec0ac1b8187faa579be3b54f93fafb6060ac1fd29170b860df605be32
chainId: laconic_9000-1 chainId: laconic_9000-1
gas: 1000000 gas: 1200000
fees: 200000aphoton fees: 200000aphoton
userKey: 0524fc22ea0a12e6c5cc4cfe08e73c95dffd0ab5ed72a59f459ed33134fa3b16
bondId: 8fcf44b2f326b4b63ac57547777f1c78b7d494e5966e508f09001af53cb440ac

View File

@ -1,11 +1,67 @@
#!/bin/bash #!/bin/bash
# Repository URL
REPO_URL="https://git.vdb.to/cerc-io/snowballtools-base"
# Get the latest commit hash from the repository
LATEST_HASH=$(git ls-remote $REPO_URL HEAD | awk '{print $1}')
# Extract version from ../frontend/package.json
PACKAGE_VERSION=$(jq -r '.version' ../frontend/package.json)
# Current date and time for note
CURRENT_DATE_TIME=$(date -u)
CONFIG_FILE=config.yml
REGISTRY_BOND_ID="8fcf44b2f326b4b63ac57547777f1c78b7d494e5966e508f09001af53cb440ac"
# Reference: https://git.vdb.to/cerc-io/test-progressive-web-app/src/branch/main/scripts # Reference: https://git.vdb.to/cerc-io/test-progressive-web-app/src/branch/main/scripts
# Get latest version from registry and increment application-record version
NEW_APPLICATION_VERSION=$(yarn --silent laconic -c $CONFIG_FILE cns record list --type ApplicationRecord --all --name "snowballtools-base-frontend" 2>/dev/null | jq -r -s ".[] | sort_by(.createTime) | reverse | [ .[] | select(.bondId == \"$REGISTRY_BOND_ID\") ] | .[0].attributes.version" | awk -F. -v OFS=. '{$NF += 1 ; print}')
if [ -z "$NEW_APPLICATION_VERSION" ] || [ "1" == "$NEW_APPLICATION_VERSION" ]; then
# Set application-record version if no previous records were found
NEW_APPLICATION_VERSION=0.0.1
fi
# Generate application-deployment-request.yml
cat > ./records/application-deployment-request.yml <<EOF
record:
type: ApplicationDeploymentRequest
version: '1.0.0'
name: snowballtools-base-frontend@$PACKAGE_VERSION
application: crn://snowballtools/applications/snowballtools-base-frontend@$PACKAGE_VERSION
dns: dashboard
config:
env:
LACONIC_HOSTED_CONFIG_app_server_url: https://snowballtools-base-api-001.apps.snowballtools.com
LACONIC_HOSTED_CONFIG_app_github_clientid: b7c63b235ca1dd5639ab
LACONIC_HOSTED_CONFIG_app_github_templaterepo: snowball-tools-platform/test-progressive-web-app
LACONIC_HOSTED_CONFIG_app_github_pwa_templaterepo: snowball-tools-platform/test-progressive-web-app
LACONIC_HOSTED_CONFIG_app_github_image_upload_templaterepo: snowball-tools-platform/image-upload-pwa-example
LACONIC_HOSTED_CONFIG_app_wallet_connect_id: eda9ba18042a5ea500f358194611ece2
meta:
note: Added by Snowball @ $CURRENT_DATE_TIME
repository: "$REPO_URL"
repository_ref: $LATEST_HASH
EOF
# Generate application-record.yml with incremented version
cat > ./records/application-record.yml <<EOF
record:
type: ApplicationRecord
version: $NEW_APPLICATION_VERSION
repository_ref: $LATEST_HASH
repository: ["$REPO_URL"]
app_type: webapp
name: snowballtools-base-frontend
app_version: $PACKAGE_VERSION
EOF
echo "Files generated successfully."
RECORD_FILE=records/application-record.yml RECORD_FILE=records/application-record.yml
CONFIG_FILE=config.yml
RCD_APP_VERSION="0.1.3"
REPO_REF="513ca69d01bee857cf207a0605483205b384e218"
# Publish ApplicationRecord # Publish ApplicationRecord
RECORD_ID=$(yarn --silent laconic -c $CONFIG_FILE cns record publish --filename $RECORD_FILE | jq -r '.id') RECORD_ID=$(yarn --silent laconic -c $CONFIG_FILE cns record publish --filename $RECORD_FILE | jq -r '.id')
@ -15,8 +71,11 @@ echo $RECORD_ID
# Set name to record # Set name to record
REGISTRY_APP_CRN="crn://snowballtools/applications/snowballtools-base-frontend" REGISTRY_APP_CRN="crn://snowballtools/applications/snowballtools-base-frontend"
yarn --silent laconic -c $CONFIG_FILE cns name set "$REGISTRY_APP_CRN@${RCD_APP_VERSION}" "$RECORD_ID" sleep 2
yarn --silent laconic -c $CONFIG_FILE cns name set "$REGISTRY_APP_CRN@${REPO_REF}" "$RECORD_ID" yarn --silent laconic -c $CONFIG_FILE cns name set "$REGISTRY_APP_CRN@${PACKAGE_VERSION}" "$RECORD_ID"
sleep 2
yarn --silent laconic -c $CONFIG_FILE cns name set "$REGISTRY_APP_CRN@${LATEST_HASH}" "$RECORD_ID"
sleep 2
# Set name if latest release # Set name if latest release
yarn --silent laconic -c $CONFIG_FILE cns name set "$REGISTRY_APP_CRN" "$RECORD_ID" yarn --silent laconic -c $CONFIG_FILE cns name set "$REGISTRY_APP_CRN" "$RECORD_ID"
echo "$REGISTRY_APP_CRN set for ApplicationRecord" echo "$REGISTRY_APP_CRN set for ApplicationRecord"
@ -30,6 +89,7 @@ fi
RECORD_FILE=records/application-deployment-request.yml RECORD_FILE=records/application-deployment-request.yml
sleep 2
DEPLOYMENT_REQUEST_ID=$(yarn --silent laconic -c $CONFIG_FILE cns record publish --filename $RECORD_FILE | jq -r '.id') DEPLOYMENT_REQUEST_ID=$(yarn --silent laconic -c $CONFIG_FILE cns record publish --filename $RECORD_FILE | jq -r '.id')
echo "ApplicationDeploymentRequest published" echo "ApplicationDeploymentRequest published"
echo $DEPLOYMENT_REQUEST_ID echo $DEPLOYMENT_REQUEST_ID

View File

@ -1,21 +1,18 @@
record: record:
type: ApplicationDeploymentRequest type: ApplicationDeploymentRequest
version: '1.0.0' version: '1.0.0'
name: snowballtools-base-frontend@0.1.3 name: snowballtools-base-frontend@0.1.7
application: crn://snowballtools/applications/snowballtools-base-frontend@0.1.3 application: crn://snowballtools/applications/snowballtools-base-frontend@0.1.7
dns: dashboard dns: dashboard
config: config:
env: env:
LACONIC_HOSTED_CONFIG_app_server_url: https://snowballtools-base-api-001.apps.snowballtools.com LACONIC_HOSTED_CONFIG_app_server_url: https://snowballtools-base-api-001.apps.snowballtools.com
# If GitHub client ID is changed, same ID and corresponding secret has to be set in backend config
LACONIC_HOSTED_CONFIG_app_github_clientid: b7c63b235ca1dd5639ab LACONIC_HOSTED_CONFIG_app_github_clientid: b7c63b235ca1dd5639ab
LACONIC_HOSTED_CONFIG_app_github_templaterepo: snowball-tools-platform/test-progressive-web-app LACONIC_HOSTED_CONFIG_app_github_templaterepo: snowball-tools-platform/test-progressive-web-app
# New config env after changes for image upload PWA
LACONIC_HOSTED_CONFIG_app_github_pwa_templaterepo: snowball-tools-platform/test-progressive-web-app LACONIC_HOSTED_CONFIG_app_github_pwa_templaterepo: snowball-tools-platform/test-progressive-web-app
LACONIC_HOSTED_CONFIG_app_github_image_upload_templaterepo: snowball-tools-platform/image-upload-pwa-example LACONIC_HOSTED_CONFIG_app_github_image_upload_templaterepo: snowball-tools-platform/image-upload-pwa-example
LACONIC_HOSTED_CONFIG_app_wallet_connect_id: eda9ba18042a5ea500f358194611ece2 LACONIC_HOSTED_CONFIG_app_wallet_connect_id: eda9ba18042a5ea500f358194611ece2
meta: meta:
# Set CURRENT_DATE_TIME; Use command date -u note: Added by Snowball @ Thursday 29 February 2024 04:36:04 PM UTC
note: Added by Snowball @ Tue Feb 27 17:24:06 UTC 2024
repository: "https://git.vdb.to/cerc-io/snowballtools-base" repository: "https://git.vdb.to/cerc-io/snowballtools-base"
repository_ref: 513ca69d01bee857cf207a0605483205b384e218 repository_ref: 1ff5ab3dfdba9dcf5dd1cb0f9435bd863a6d0340

View File

@ -1,10 +1,8 @@
record: record:
type: ApplicationRecord type: ApplicationRecord
version: 0.0.11 version: 0.0.1
repository_ref: 513ca69d01bee857cf207a0605483205b384e218 repository_ref: 1ff5ab3dfdba9dcf5dd1cb0f9435bd863a6d0340
repository: ["https://git.vdb.to/cerc-io/snowballtools-base"] repository: ["https://git.vdb.to/cerc-io/snowballtools-base"]
app_type: webapp app_type: webapp
# name is set to repo name
name: snowballtools-base-frontend name: snowballtools-base-frontend
# app_version is set from package.json app_version: 0.1.7
app_version: 0.1.3

View File

@ -1,6 +1,6 @@
{ {
"name": "frontend", "name": "frontend",
"version": "0.1.3", "version": "0.1.7",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.0.19", "@fontsource-variable/jetbrains-mono": "^5.0.19",
@ -8,6 +8,7 @@
"@material-tailwind/react": "^2.1.7", "@material-tailwind/react": "^2.1.7",
"@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-switch": "^1.0.3",

View File

@ -18,14 +18,14 @@ export default [
name: 'Kotlin', name: 'Kotlin',
icon: 'kotlin', icon: 'kotlin',
repoFullName: '', repoFullName: '',
isComingSoon: false, isComingSoon: true,
}, },
{ {
id: '4', id: '4',
name: 'React Native', name: 'React Native',
icon: 'react-native', icon: 'react-native',
repoFullName: '', repoFullName: '',
isComingSoon: false, isComingSoon: true,
}, },
{ {
id: '5', id: '5',

View File

@ -1,13 +1,22 @@
import { Duration } from 'luxon'; import { Duration } from 'luxon';
import React from 'react'; import React, { ComponentPropsWithoutRef } from 'react';
import { cn } from 'utils/classnames';
const FormatMillisecond = ({ time }: { time: number }) => { export interface FormatMilliSecondProps
extends ComponentPropsWithoutRef<'div'> {
time: number;
}
const FormatMillisecond = ({ time, ...props }: FormatMilliSecondProps) => {
const formatTime = Duration.fromMillis(time) const formatTime = Duration.fromMillis(time)
.shiftTo('days', 'hours', 'minutes', 'seconds') .shiftTo('days', 'hours', 'minutes', 'seconds')
.toObject(); .toObject();
return ( return (
<div> <div
{...props}
className={cn('text-sm text-elements-mid-em', props?.className)}
>
{formatTime.days !== 0 && <span>{formatTime.days}d&nbsp;</span>} {formatTime.days !== 0 && <span>{formatTime.days}d&nbsp;</span>}
{formatTime.hours !== 0 && <span>{formatTime.hours}h&nbsp;</span>} {formatTime.hours !== 0 && <span>{formatTime.hours}h&nbsp;</span>}
{formatTime.minutes !== 0 && <span>{formatTime.minutes}m&nbsp;</span>} {formatTime.minutes !== 0 && <span>{formatTime.minutes}m&nbsp;</span>}

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { useStopwatch } from 'react-timer-hook'; import { useStopwatch } from 'react-timer-hook';
import FormatMillisecond from './FormatMilliSecond'; import FormatMillisecond, { FormatMilliSecondProps } from './FormatMilliSecond';
const setStopWatchOffset = (time: string) => { const setStopWatchOffset = (time: string) => {
const providedTime = new Date(time); const providedTime = new Date(time);
@ -11,13 +11,17 @@ const setStopWatchOffset = (time: string) => {
return currentTime; return currentTime;
}; };
const Stopwatch = ({ offsetTimestamp }: { offsetTimestamp: Date }) => { interface StopwatchProps extends Omit<FormatMilliSecondProps, 'time'> {
offsetTimestamp: Date;
}
const Stopwatch = ({ offsetTimestamp, ...props }: StopwatchProps) => {
const { totalSeconds } = useStopwatch({ const { totalSeconds } = useStopwatch({
autoStart: true, autoStart: true,
offsetTimestamp: offsetTimestamp, offsetTimestamp: offsetTimestamp,
}); });
return <FormatMillisecond time={totalSeconds * 1000} />; return <FormatMillisecond time={totalSeconds * 1000} {...props} />;
}; };
export { Stopwatch, setStopWatchOffset }; export { Stopwatch, setStopWatchOffset };

View File

@ -1,11 +1,14 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { Button, Typography } from '@material-tailwind/react'; import { Typography } from '@material-tailwind/react';
import { DeployStep, DeployStatus } from './DeployStep'; import { DeployStep, DeployStatus } from './DeployStep';
import { Stopwatch, setStopWatchOffset } from '../../StopWatch'; import { Stopwatch, setStopWatchOffset } from '../../StopWatch';
import ConfirmDialog from 'components/shared/ConfirmDialog'; import ConfirmDialog from 'components/shared/ConfirmDialog';
import { Heading } from 'components/shared/Heading';
import { Button } from 'components/shared/Button';
import { ClockOutlineIcon, WarningIcon } from 'components/shared/CustomIcon';
const TIMEOUT_DURATION = 5000; const TIMEOUT_DURATION = 5000;
const Deploy = () => { const Deploy = () => {
@ -31,27 +34,27 @@ const Deploy = () => {
}, []); }, []);
return ( return (
<div> <div className="space-y-7">
<div className="flex justify-between mb-6"> <div className="flex justify-between">
<div> <div className="space-y-1.5">
<h4>Deployment started ...</h4> <Heading as="h4" className="md:text-lg font-medium">
<div className="flex"> Deployment started ...
^&nbsp; </Heading>
<div className="flex items-center gap-1.5">
<ClockOutlineIcon size={16} className="text-elements-mid-em" />
<Stopwatch <Stopwatch
offsetTimestamp={setStopWatchOffset(Date.now().toString())} offsetTimestamp={setStopWatchOffset(Date.now().toString())}
/> />
</div> </div>
</div> </div>
<div> <Button
<Button onClick={handleOpen}
onClick={handleOpen} size="sm"
variant="outlined" variant="tertiary"
size="sm" leftIcon={<WarningIcon size={16} />}
placeholder={''} >
> Cancel
^ Cancel </Button>
</Button>
</div>
<ConfirmDialog <ConfirmDialog
dialogTitle="Cancel deployment?" dialogTitle="Cancel deployment?"
handleOpen={handleOpen} handleOpen={handleOpen}
@ -66,28 +69,31 @@ const Deploy = () => {
</Typography> </Typography>
</ConfirmDialog> </ConfirmDialog>
</div> </div>
<DeployStep
title="Building" <div>
status={DeployStatus.COMPLETE} <DeployStep
step="1" title="Building"
processTime="72000" status={DeployStatus.COMPLETE}
/> step="1"
<DeployStep processTime="72000"
title="Deployment summary" />
status={DeployStatus.PROCESSING} <DeployStep
step="2" title="Deployment summary"
startTime={Date.now().toString()} status={DeployStatus.PROCESSING}
/> step="2"
<DeployStep startTime={Date.now().toString()}
title="Running checks" />
status={DeployStatus.NOT_STARTED} <DeployStep
step="3" title="Running checks"
/> status={DeployStatus.NOT_STARTED}
<DeployStep step="3"
title="Assigning domains" />
status={DeployStatus.NOT_STARTED} <DeployStep
step="4" title="Assigning domains"
/> status={DeployStatus.NOT_STARTED}
step="4"
/>
</div>
</div> </div>
); );
}; };

View File

@ -1,11 +1,22 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import toast from 'react-hot-toast';
import { Collapse, Button, Typography } from '@material-tailwind/react'; import { Collapse } from '@material-tailwind/react';
import { Stopwatch, setStopWatchOffset } from '../../StopWatch'; import { Stopwatch, setStopWatchOffset } from '../../StopWatch';
import FormatMillisecond from '../../FormatMilliSecond'; import FormatMillisecond from '../../FormatMilliSecond';
import processLogs from '../../../assets/process-logs.json'; import processLogs from '../../../assets/process-logs.json';
import { cn } from 'utils/classnames';
import {
CheckRoundFilledIcon,
ClockOutlineIcon,
CopyIcon,
LoaderIcon,
MinusCircleIcon,
PlusIcon,
} from 'components/shared/CustomIcon';
import { Button } from 'components/shared/Button';
import { useToast } from 'components/shared/Toast';
import { useIntersectionObserver } from 'usehooks-ts';
enum DeployStatus { enum DeployStatus {
PROCESSING = 'progress', PROCESSING = 'progress',
@ -28,61 +39,110 @@ const DeployStep = ({
startTime, startTime,
processTime, processTime,
}: DeployStepsProps) => { }: DeployStepsProps) => {
const [collapse, setCollapse] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { toast, dismiss } = useToast();
const { isIntersecting: hideGradientOverlay, ref } = useIntersectionObserver({
threshold: 1,
});
const disableCollapse = status !== DeployStatus.COMPLETE;
return ( return (
<div className="border-b-2"> <div className="border-b border-border-separator">
<div className="flex justify-between p-2 gap-2"> {/* Collapisble trigger */}
{status === DeployStatus.NOT_STARTED && <div>{step}</div>} <button
{status === DeployStatus.PROCESSING && <div>O</div>} className={cn(
{status === DeployStatus.COMPLETE && ( 'flex justify-between w-full py-5 gap-2',
<div> disableCollapse && 'cursor-auto',
<button )}
onClick={() => { tabIndex={disableCollapse ? -1 : undefined}
setCollapse(!collapse); onClick={() => {
}} if (!disableCollapse) {
> setIsOpen((val) => !val);
{collapse ? '-' : '+'} }
</button> }}
>
<div className={cn('grow flex items-center gap-3')}>
{/* Icon */}
<div className="w-6 h-6 grid place-content-center">
{status === DeployStatus.NOT_STARTED && (
<div className="grid place-content-center w-6 h-6 rounded-[48px] bg-base-bg-emphasized font-mono text-xs">
{step}
</div>
)}
{status === DeployStatus.PROCESSING && (
<LoaderIcon className="animate-spin text-elements-link" />
)}
{status === DeployStatus.COMPLETE && (
<div className="text-controls-primary">
{!isOpen && <PlusIcon size={24} />}
{isOpen && <MinusCircleIcon size={24} />}
</div>
)}
</div>
{/* Title */}
<span
className={cn(
'text-left text-sm md:text-base',
status === DeployStatus.PROCESSING && 'text-elements-link',
)}
>
{title}
</span>
</div>
{/* Timer */}
{status === DeployStatus.PROCESSING && (
<div className="flex items-center gap-1.5">
<ClockOutlineIcon size={16} className="text-elements-low-em" />
<Stopwatch offsetTimestamp={setStopWatchOffset(startTime!)} />
</div> </div>
)} )}
<div className="grow">{title}</div>
{status === DeployStatus.PROCESSING && (
<>
^<Stopwatch offsetTimestamp={setStopWatchOffset(startTime!)} />
</>
)}
{status === DeployStatus.COMPLETE && ( {status === DeployStatus.COMPLETE && (
<> <div className="flex items-center gap-1.5">
^<FormatMillisecond time={Number(processTime)} />{' '} <CheckRoundFilledIcon className="text-elements-success" size={18} />
</> <FormatMillisecond time={Number(processTime)} />{' '}
</div>
)} )}
</div> </button>
<Collapse open={collapse}>
<div className="p-2 text-sm text-gray-500 h-36 overflow-y-scroll"> {/* Collapsible */}
<Collapse open={isOpen}>
<div className="relative text-xs text-elements-low-em h-36 overflow-y-auto">
{/* Logs */}
{processLogs.map((log, key) => { {processLogs.map((log, key) => {
return ( return (
<Typography <p className="font-mono" key={key}>
variant="small"
color="gray"
key={key}
placeholder={''}
>
{log} {log}
</Typography> </p>
); );
})} })}
<div className="sticky bottom-0 left-1/2 flex justify-center">
{/* End of logs ref used for hiding gradient overlay */}
<div ref={ref} />
{/* Overflow gradient overlay */}
{!hideGradientOverlay && (
<div className="h-14 w-full sticky bottom-0 inset-x-0 bg-gradient-to-t from-white to-transparent" />
)}
{/* Copy log button */}
<div className={cn('sticky bottom-4 left-1/2 flex justify-center')}>
<Button <Button
size="sm" size="xs"
onClick={() => { onClick={() => {
navigator.clipboard.writeText(processLogs.join('\n')); navigator.clipboard.writeText(processLogs.join('\n'));
toast.success('Logs copied'); toast({
title: 'Logs copied',
variant: 'success',
id: 'logs',
onDismiss: dismiss,
});
}} }}
color="blue" leftIcon={<CopyIcon size={16} />}
placeholder={''}
> >
^ Copy log Copy log
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -1,3 +1,4 @@
import React, { ComponentPropsWithoutRef, useCallback } from 'react';
import { Button } from 'components/shared/Button'; import { Button } from 'components/shared/Button';
import { import {
ArrowRightCircleIcon, ArrowRightCircleIcon,
@ -6,8 +7,7 @@ import {
TemplateIconType, TemplateIconType,
} from 'components/shared/CustomIcon'; } from 'components/shared/CustomIcon';
import { Tag } from 'components/shared/Tag'; import { Tag } from 'components/shared/Tag';
import React, { ComponentPropsWithoutRef, useCallback } from 'react'; import { useNavigate, useParams } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import { useToast } from 'components/shared/Toast'; import { useToast } from 'components/shared/Toast';
import { cn } from 'utils/classnames'; import { cn } from 'utils/classnames';
@ -24,9 +24,13 @@ export interface TemplateCardProps extends ComponentPropsWithoutRef<'div'> {
isGitAuth: boolean; isGitAuth: boolean;
} }
export const TemplateCard = ({ template, isGitAuth }: TemplateCardProps) => { export const TemplateCard: React.FC<TemplateCardProps> = ({
template,
isGitAuth,
}: TemplateCardProps) => {
const { toast, dismiss } = useToast(); const { toast, dismiss } = useToast();
const navigate = useNavigate(); const navigate = useNavigate();
const { orgSlug } = useParams();
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
if (template?.isComingSoon) { if (template?.isComingSoon) {
@ -38,7 +42,9 @@ export const TemplateCard = ({ template, isGitAuth }: TemplateCardProps) => {
}); });
} }
if (isGitAuth) { if (isGitAuth) {
return navigate(`/template?templateId=${template.id}`); return navigate(
`/${orgSlug}/projects/create/template?templateId=${template.id}`,
);
} }
return toast({ return toast({
id: 'connect-git-account', id: 'connect-git-account',
@ -46,7 +52,7 @@ export const TemplateCard = ({ template, isGitAuth }: TemplateCardProps) => {
variant: 'error', variant: 'error',
onDismiss: dismiss, onDismiss: dismiss,
}); });
}, [isGitAuth, navigate, template, toast]); }, [orgSlug, dismiss, isGitAuth, navigate, template, toast]);
return ( return (
<button <button

View File

@ -1,31 +1,28 @@
import React, { useState } from 'react'; import React from 'react';
import toast from 'react-hot-toast';
import { import {
Deployment,
DeploymentStatus,
Domain,
Environment, Environment,
Project, Project,
Domain,
DeploymentStatus,
Deployment,
} from 'gql-client'; } from 'gql-client';
import { Avatar } from 'components/shared/Avatar';
import { import {
Menu, BranchStrokeIcon,
MenuHandler, CheckRoundFilledIcon,
MenuList, ClockOutlineIcon,
MenuItem, CommitIcon,
Typography, LoadingIcon,
Chip, WarningIcon,
ChipProps, } from 'components/shared/CustomIcon';
Tooltip, import { Heading } from 'components/shared/Heading';
} from '@material-tailwind/react'; import { OverflownText } from 'components/shared/OverflownText';
import { Tag, TagTheme } from 'components/shared/Tag';
import { relativeTimeMs } from '../../../../utils/time'; import { getInitials } from 'utils/geInitials';
import ConfirmDialog from '../../../shared/ConfirmDialog'; import { relativeTimeMs } from 'utils/time';
import DeploymentDialogBodyCard from './DeploymentDialogBodyCard';
import AssignDomainDialog from './AssignDomainDialog';
import { useGQLClient } from '../../../../context/GQLClientContext';
import { SHORT_COMMIT_HASH_LENGTH } from '../../../../constants'; import { SHORT_COMMIT_HASH_LENGTH } from '../../../../constants';
import { formatAddress } from '../../../../utils/format'; import { formatAddress } from '../../../../utils/format';
import { DeploymentMenu } from './DeploymentMenu';
interface DeployDetailsCardProps { interface DeployDetailsCardProps {
deployment: Deployment; deployment: Deployment;
@ -35,10 +32,12 @@ interface DeployDetailsCardProps {
prodBranchDomains: Domain[]; prodBranchDomains: Domain[];
} }
const STATUS_COLORS: { [key in DeploymentStatus]: ChipProps['color'] } = { const STATUS_COLORS: {
[DeploymentStatus.Building]: 'blue', [key in DeploymentStatus]: TagTheme['type'];
[DeploymentStatus.Ready]: 'green', } = {
[DeploymentStatus.Error]: 'red', [DeploymentStatus.Building]: 'emphasized',
[DeploymentStatus.Ready]: 'positive',
[DeploymentStatus.Error]: 'negative',
}; };
const DeploymentDetailsCard = ({ const DeploymentDetailsCard = ({
@ -48,241 +47,99 @@ const DeploymentDetailsCard = ({
project, project,
prodBranchDomains, prodBranchDomains,
}: DeployDetailsCardProps) => { }: DeployDetailsCardProps) => {
const client = useGQLClient(); const getIconByDeploymentStatus = (status: DeploymentStatus) => {
if (status === DeploymentStatus.Building) {
const [changeToProduction, setChangeToProduction] = useState(false); return <LoadingIcon className="animate-spin" />;
const [redeployToProduction, setRedeployToProduction] = useState(false);
const [rollbackDeployment, setRollbackDeployment] = useState(false);
const [assignDomainDialog, setAssignDomainDialog] = useState(false);
const updateDeployment = async () => {
const isUpdated = await client.updateDeploymentToProd(deployment.id);
if (isUpdated) {
await onUpdate();
toast.success('Deployment changed to production');
} else {
toast.error('Unable to change deployment to production');
} }
}; if (status === DeploymentStatus.Ready) {
return <CheckRoundFilledIcon />;
const redeployToProd = async () => {
const isRedeployed = await client.redeployToProd(deployment.id);
if (isRedeployed) {
await onUpdate();
toast.success('Redeployed to production');
} else {
toast.error('Unable to redeploy to production');
} }
};
const rollbackDeploymentHandler = async () => { if (status === DeploymentStatus.Error) {
const isRollbacked = await client.rollbackDeployment( return <WarningIcon />;
project.id,
deployment.id,
);
if (isRollbacked) {
await onUpdate();
toast.success('Deployment rolled back');
} else {
toast.error('Unable to rollback deployment');
} }
}; };
return ( return (
<div className="grid grid-cols-8 gap-2 border-b border-gray-300 p-3 my-2"> <div className="flex lg:flex gap-2 lg:gap-2 2xl:gap-6 py-4 px-3 pb-6 mb-2 last:mb-0 last:pb-4 border-b border-border-separator last:border-b-transparent ">
<div className="col-span-3"> <div className="flex-1 max-w-[30%] space-y-2">
<div className="flex"> {/* DEPLOYMENT URL */}
{deployment.url && ( {deployment.url && (
<Typography className="basis-3/4" placeholder={''}> <Heading
className="text-sm font-medium text-elements-high-em tracking-tight"
as="h2"
>
<OverflownText content={deployment.url}>
{deployment.url} {deployment.url}
</Typography> </OverflownText>
)} </Heading>
</div> )}
<Typography color="gray" placeholder={''}> <span className="text-sm text-elements-low-em tracking-tight">
{deployment.environment === Environment.Production {deployment.environment === Environment.Production
? `Production ${deployment.isCurrent ? '(Current)' : ''}` ? `Production ${deployment.isCurrent ? '(Current)' : ''}`
: 'Preview'} : 'Preview'}
</Typography> </span>
</div> </div>
<div className="col-span-1">
<Chip {/* DEPLOYMENT STATUS */}
value={deployment.status} <div className="w-[10%] max-w-[110px]">
color={STATUS_COLORS[deployment.status] ?? 'gray'} <Tag
variant="ghost" leftIcon={getIconByDeploymentStatus(deployment.status)}
icon={<i>^</i>} size="xs"
type={STATUS_COLORS[deployment.status] ?? 'neutral'}
>
{deployment.status}
</Tag>
</div>
{/* DEPLOYMENT COMMIT DETAILS */}
<div className="text-sm w-[25%] space-y-2 text-elements-low-em">
<span className="flex gap-1.5 items-center">
<BranchStrokeIcon className="h-4 w-4" />
{deployment.branch}
</span>
<span className="flex gap-2 items-center">
<CommitIcon />
<OverflownText content={deployment.commitMessage}>
{deployment.commitHash.substring(0, SHORT_COMMIT_HASH_LENGTH)}{' '}
{deployment.commitMessage}
</OverflownText>
</span>
</div>
{/* DEPLOYMENT INFOs */}
<div className="ml-auto max-w-[312px] w-[30%] gap-1 2xl:gap-5 flex items-center justify-between text-elements-low-em text-sm">
<div className="flex w-[70%] items-center gap-0.5 2xl:gap-2 flex-1">
<ClockOutlineIcon className="h-4 w-4" />
<OverflownText content={relativeTimeMs(deployment.createdAt) ?? ''}>
{relativeTimeMs(deployment.createdAt)}
</OverflownText>
<div>
<Avatar
type="orange"
initials={getInitials(deployment.createdBy.name ?? '')}
className="lg:size-5 2xl:size-6"
// TODO: Add avatarUrl
// imageSrc={deployment.createdBy.avatarUrl}
></Avatar>
</div>
<OverflownText
// className="min-w-[200px]"
content={formatAddress(deployment.createdBy?.name ?? '')}
>
{formatAddress(deployment.createdBy.name ?? '')}
</OverflownText>
</div>
<DeploymentMenu
className="ml-auto"
deployment={deployment}
currentDeployment={currentDeployment}
onUpdate={onUpdate}
project={project}
prodBranchDomains={prodBranchDomains}
/> />
</div> </div>
<div className="col-span-2">
<Typography color="gray" placeholder={''}>
^ {deployment.branch}
</Typography>
<Typography color="gray" placeholder={''}>
^ {deployment.commitHash.substring(0, SHORT_COMMIT_HASH_LENGTH)}{' '}
{deployment.commitMessage}
</Typography>
</div>
<div className="col-span-2 flex items-center">
<Typography color="gray" className="grow" placeholder={''}>
^ {relativeTimeMs(deployment.createdAt)} ^{' '}
<Tooltip content={deployment.createdBy.name}>
{formatAddress(deployment.createdBy.name ?? '')}
</Tooltip>
</Typography>
<Menu placement="bottom-start">
<MenuHandler>
<button className="self-start">...</button>
</MenuHandler>
<MenuList placeholder={''}>
<a href={deployment.url} target="_blank" rel="noreferrer">
<MenuItem disabled={!Boolean(deployment.url)} placeholder={''}>
^ Visit
</MenuItem>
</a>
<MenuItem
onClick={() => setAssignDomainDialog(!assignDomainDialog)}
placeholder={''}
>
^ Assign domain
</MenuItem>
<MenuItem
onClick={() => setChangeToProduction(!changeToProduction)}
disabled={!(deployment.environment !== Environment.Production)}
placeholder={''}
>
^ Change to production
</MenuItem>
<hr className="my-3" />
<MenuItem
onClick={() => setRedeployToProduction(!redeployToProduction)}
disabled={
!(
deployment.environment === Environment.Production &&
deployment.isCurrent
)
}
placeholder={''}
>
^ Redeploy to production
</MenuItem>
<MenuItem
onClick={() => setRollbackDeployment(!rollbackDeployment)}
disabled={
deployment.isCurrent ||
deployment.environment !== Environment.Production ||
!Boolean(currentDeployment)
}
placeholder={''}
>
^ Rollback to this version
</MenuItem>
</MenuList>
</Menu>
</div>
<ConfirmDialog
dialogTitle="Change to production?"
handleOpen={() => setChangeToProduction((preVal) => !preVal)}
open={changeToProduction}
confirmButtonTitle="Change"
color="blue"
handleConfirm={async () => {
await updateDeployment();
setChangeToProduction((preVal) => !preVal);
}}
>
<div className="flex flex-col gap-2">
<Typography variant="small" placeholder={''}>
Upon confirmation, this deployment will be changed to production.
</Typography>
<DeploymentDialogBodyCard deployment={deployment} />
<Typography variant="small" placeholder={''}>
The new deployment will be associated with these domains:
</Typography>
{prodBranchDomains.length > 0 &&
prodBranchDomains.map((value) => {
return (
<Typography
variant="small"
color="blue"
key={value.id}
placeholder={''}
>
^ {value.name}
</Typography>
);
})}
</div>
</ConfirmDialog>
<ConfirmDialog
dialogTitle="Redeploy to production?"
handleOpen={() => setRedeployToProduction((preVal) => !preVal)}
open={redeployToProduction}
confirmButtonTitle="Redeploy"
color="blue"
handleConfirm={async () => {
await redeployToProd();
setRedeployToProduction((preVal) => !preVal);
}}
>
<div className="flex flex-col gap-2">
<Typography variant="small" placeholder={''}>
Upon confirmation, new deployment will be created with the same
source code as current deployment.
</Typography>
<DeploymentDialogBodyCard deployment={deployment} />
<Typography variant="small" placeholder={''}>
These domains will point to your new deployment:
</Typography>
{deployment.domain?.name && (
<Typography variant="small" color="blue" placeholder={''}>
{deployment.domain?.name}
</Typography>
)}
</div>
</ConfirmDialog>
{Boolean(currentDeployment) && (
<ConfirmDialog
dialogTitle="Rollback to this deployment?"
handleOpen={() => setRollbackDeployment((preVal) => !preVal)}
open={rollbackDeployment}
confirmButtonTitle="Rollback"
color="blue"
handleConfirm={async () => {
await rollbackDeploymentHandler();
setRollbackDeployment((preVal) => !preVal);
}}
>
<div className="flex flex-col gap-2">
<Typography variant="small" placeholder={''}>
Upon confirmation, this deployment will replace your current
deployment
</Typography>
<DeploymentDialogBodyCard
deployment={currentDeployment}
chip={{
value: 'Live Deployment',
color: 'green',
}}
/>
<DeploymentDialogBodyCard
deployment={deployment}
chip={{
value: 'New Deployment',
color: 'orange',
}}
/>
<Typography variant="small" placeholder={''}>
These domains will point to your new deployment:
</Typography>
<Typography variant="small" color="blue" placeholder={''}>
^ {currentDeployment.domain?.name}
</Typography>
</div>
</ConfirmDialog>
)}
<AssignDomainDialog
open={assignDomainDialog}
handleOpen={() => setAssignDomainDialog(!assignDomainDialog)}
/>
</div> </div>
); );
}; };

View File

@ -0,0 +1,268 @@
import React, { useState } from 'react';
import toast from 'react-hot-toast';
import { Deployment, Domain, Environment, Project } from 'gql-client';
import { Button } from 'components/shared/Button';
import {
GlobeIcon,
HorizontalDotIcon,
LinkIcon,
RefreshIcon,
RocketIcon,
UndoIcon,
} from 'components/shared/CustomIcon';
import {
Menu,
MenuHandler,
MenuItem,
MenuList,
} from '@material-tailwind/react';
import { ComponentPropsWithRef } from 'react';
import ConfirmDialog from '../../../shared/ConfirmDialog';
import AssignDomainDialog from './AssignDomainDialog';
import DeploymentDialogBodyCard from './DeploymentDialogBodyCard';
import { Typography } from '@material-tailwind/react';
import { useGQLClient } from '../../../../context/GQLClientContext';
import { cn } from 'utils/classnames';
interface DeploymentMenuProps extends ComponentPropsWithRef<'div'> {
deployment: Deployment;
currentDeployment: Deployment;
onUpdate: () => Promise<void>;
project: Project;
prodBranchDomains: Domain[];
}
export const DeploymentMenu = ({
deployment,
currentDeployment,
onUpdate,
project,
prodBranchDomains,
className,
...props
}: DeploymentMenuProps) => {
const client = useGQLClient();
const [changeToProduction, setChangeToProduction] = useState(false);
const [redeployToProduction, setRedeployToProduction] = useState(false);
const [rollbackDeployment, setRollbackDeployment] = useState(false);
const [assignDomainDialog, setAssignDomainDialog] = useState(false);
const updateDeployment = async () => {
const isUpdated = await client.updateDeploymentToProd(deployment.id);
if (isUpdated) {
await onUpdate();
toast.success('Deployment changed to production');
} else {
toast.error('Unable to change deployment to production');
}
};
const redeployToProd = async () => {
const isRedeployed = await client.redeployToProd(deployment.id);
if (isRedeployed) {
await onUpdate();
toast.success('Redeployed to production');
} else {
toast.error('Unable to redeploy to production');
}
};
const rollbackDeploymentHandler = async () => {
const isRollbacked = await client.rollbackDeployment(
project.id,
deployment.id,
);
if (isRollbacked) {
await onUpdate();
toast.success('Deployment rolled back');
} else {
toast.error('Unable to rollback deployment');
}
};
return (
<>
<div className={cn('max-w-[32px]', className)} {...props}>
<Menu placement="bottom-start">
<MenuHandler>
<Button
shape="default"
size="xs"
variant="unstyled"
className={cn(
'h-8 w-8 rounded-full border border-transparent transition-colors background-transparent',
'[&[aria-expanded=true]]:border [&[aria-expanded=true]]:border-border-interactive [&[aria-expanded=true]]:bg-controls-tertiary [&[aria-expanded=true]]:shadow-button',
)}
leftIcon={<HorizontalDotIcon />}
aria-label="Toggle Menu"
/>
</MenuHandler>
<MenuList className="text-elements-high-em" placeholder={''}>
<MenuItem
className="hover:bg-base-bg-emphasized"
disabled={!Boolean(deployment.url)}
placeholder={''}
>
<a
className="flex items-center gap-3"
href={deployment.url}
target="_blank"
rel="noreferrer"
>
<LinkIcon /> Visit
</a>
</MenuItem>
<MenuItem
className="hover:bg-base-bg-emphasized flex items-center gap-3"
onClick={() => setAssignDomainDialog(!assignDomainDialog)}
placeholder={''}
>
<GlobeIcon /> Assign domain
</MenuItem>
<MenuItem
className="hover:bg-base-bg-emphasized flex items-center gap-3"
onClick={() => setChangeToProduction(!changeToProduction)}
disabled={!(deployment.environment !== Environment.Production)}
placeholder={''}
>
<RocketIcon /> Change to production
</MenuItem>
<hr className="my-3" />
<MenuItem
className="hover:bg-base-bg-emphasized flex items-center gap-3"
onClick={() => setRedeployToProduction(!redeployToProduction)}
disabled={
!(
deployment.environment === Environment.Production &&
deployment.isCurrent
)
}
placeholder={''}
>
<RefreshIcon /> Redeploy to production
</MenuItem>
<MenuItem
className="hover:bg-base-bg-emphasized flex items-center gap-3"
onClick={() => setRollbackDeployment(!rollbackDeployment)}
disabled={
deployment.isCurrent ||
deployment.environment !== Environment.Production ||
!Boolean(currentDeployment)
}
placeholder={''}
>
<UndoIcon /> Rollback to this version
</MenuItem>
</MenuList>
</Menu>
</div>
{/* Dialogs */}
<ConfirmDialog
dialogTitle="Change to production?"
handleOpen={() => setChangeToProduction((preVal) => !preVal)}
open={changeToProduction}
confirmButtonTitle="Change"
color="blue"
handleConfirm={async () => {
await updateDeployment();
setChangeToProduction((preVal) => !preVal);
}}
>
<div className="flex flex-col gap-2">
<Typography variant="small" placeholder={''}>
Upon confirmation, this deployment will be changed to production.
</Typography>
<DeploymentDialogBodyCard deployment={deployment} />
<Typography variant="small" placeholder={''}>
The new deployment will be associated with these domains:
</Typography>
{prodBranchDomains.length > 0 &&
prodBranchDomains.map((value) => {
return (
<Typography
variant="small"
color="blue"
key={value.id}
placeholder={''}
>
^ {value.name}
</Typography>
);
})}
</div>
</ConfirmDialog>
<ConfirmDialog
dialogTitle="Redeploy to production?"
handleOpen={() => setRedeployToProduction((preVal) => !preVal)}
open={redeployToProduction}
confirmButtonTitle="Redeploy"
color="blue"
handleConfirm={async () => {
await redeployToProd();
setRedeployToProduction((preVal) => !preVal);
}}
>
<div className="flex flex-col gap-2">
<Typography variant="small" placeholder={''}>
Upon confirmation, new deployment will be created with the same
source code as current deployment.
</Typography>
<DeploymentDialogBodyCard deployment={deployment} />
<Typography variant="small" placeholder={''}>
These domains will point to your new deployment:
</Typography>
{deployment.domain?.name && (
<Typography variant="small" color="blue" placeholder={''}>
{deployment.domain?.name}
</Typography>
)}
</div>
</ConfirmDialog>
{Boolean(currentDeployment) && (
<ConfirmDialog
dialogTitle="Rollback to this deployment?"
handleOpen={() => setRollbackDeployment((preVal) => !preVal)}
open={rollbackDeployment}
confirmButtonTitle="Rollback"
color="blue"
handleConfirm={async () => {
await rollbackDeploymentHandler();
setRollbackDeployment((preVal) => !preVal);
}}
>
<div className="flex flex-col gap-2">
<Typography variant="small" placeholder={''}>
Upon confirmation, this deployment will replace your current
deployment
</Typography>
<DeploymentDialogBodyCard
deployment={currentDeployment}
chip={{
value: 'Live Deployment',
color: 'green',
}}
/>
<DeploymentDialogBodyCard
deployment={deployment}
chip={{
value: 'New Deployment',
color: 'orange',
}}
/>
<Typography variant="small" placeholder={''}>
These domains will point to your new deployment:
</Typography>
<Typography variant="small" color="blue" placeholder={''}>
^ {currentDeployment.domain?.name}
</Typography>
</div>
</ConfirmDialog>
)}
<AssignDomainDialog
open={assignDomainDialog}
handleOpen={() => setAssignDomainDialog(!assignDomainDialog)}
/>
</>
);
};

View File

@ -0,0 +1,26 @@
import React from 'react';
import { CustomIcon, CustomIconProps } from './CustomIcon';
export const CommitIcon = (props: CustomIconProps) => {
return (
<CustomIcon
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
{...props}
>
<g clipPath="url(#clip0_755_4058)">
<path
d="M15.5 7.50001H11.4637C11.343 6.66752 10.9264 5.90636 10.2904 5.35589C9.65427 4.80541 8.84121 4.50244 8 4.50244C7.15879 4.50244 6.34573 4.80541 5.70964 5.35589C5.07355 5.90636 4.65701 6.66752 4.53625 7.50001H0.5C0.367392 7.50001 0.240215 7.55269 0.146447 7.64646C0.0526784 7.74023 0 7.8674 0 8.00001C0 8.13262 0.0526784 8.2598 0.146447 8.35357C0.240215 8.44733 0.367392 8.50001 0.5 8.50001H4.53625C4.65701 9.33251 5.07355 10.0937 5.70964 10.6441C6.34573 11.1946 7.15879 11.4976 8 11.4976C8.84121 11.4976 9.65427 11.1946 10.2904 10.6441C10.9264 10.0937 11.343 9.33251 11.4637 8.50001H15.5C15.6326 8.50001 15.7598 8.44733 15.8536 8.35357C15.9473 8.2598 16 8.13262 16 8.00001C16 7.8674 15.9473 7.74023 15.8536 7.64646C15.7598 7.55269 15.6326 7.50001 15.5 7.50001ZM8 10.5C7.50555 10.5 7.0222 10.3534 6.61107 10.0787C6.19995 9.80398 5.87952 9.41354 5.6903 8.95672C5.50108 8.49991 5.45157 7.99724 5.54804 7.51229C5.6445 7.02733 5.8826 6.58188 6.23223 6.23224C6.58186 5.88261 7.02732 5.64451 7.51227 5.54805C7.99723 5.45158 8.49989 5.50109 8.95671 5.69031C9.41352 5.87953 9.80397 6.19996 10.0787 6.61109C10.3534 7.02221 10.5 7.50556 10.5 8.00001C10.5 8.66305 10.2366 9.29894 9.76777 9.76778C9.29893 10.2366 8.66304 10.5 8 10.5Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_755_4058">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</CustomIcon>
);
};

View File

@ -0,0 +1,21 @@
import React from 'react';
import { CustomIcon, CustomIconProps } from './CustomIcon';
export const CopyIcon = (props: CustomIconProps) => {
return (
<CustomIcon
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M1.33301 2.66665C1.33301 1.93027 1.92996 1.33331 2.66634 1.33331H9.33301C10.0694 1.33331 10.6663 1.93027 10.6663 2.66665V5.33331H13.333C14.0694 5.33331 14.6663 5.93027 14.6663 6.66665V13.3333C14.6663 14.0697 14.0694 14.6666 13.333 14.6666H6.66634C5.92996 14.6666 5.33301 14.0697 5.33301 13.3333V10.6666H2.66634C1.92996 10.6666 1.33301 10.0697 1.33301 9.33331V2.66665ZM9.66634 5.33331H6.66634C5.92996 5.33331 5.33301 5.93027 5.33301 6.66665V9.66665H2.66634C2.48225 9.66665 2.33301 9.51741 2.33301 9.33331V2.66665C2.33301 2.48255 2.48225 2.33331 2.66634 2.33331H9.33301C9.5171 2.33331 9.66634 2.48255 9.66634 2.66665V5.33331Z"
fill="currentColor"
/>
</CustomIcon>
);
};

View File

@ -0,0 +1,22 @@
import React from 'react';
import { CustomIcon, CustomIconProps } from './CustomIcon';
export const LoaderIcon = (props: CustomIconProps) => {
return (
<CustomIcon
width="25"
height="24"
viewBox="0 0 25 24"
fill="none"
{...props}
>
<path
d="M12.5003 3V6M12.5003 18V21M6.13634 5.63604L8.25766 7.75736M16.7429 16.2426L18.8643 18.364M3.5 12.0007H6.5M18.5 12.0007H21.5M6.13634 18.364L8.25766 16.2426M16.7429 7.75736L18.8643 5.63604"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</CustomIcon>
);
};

View File

@ -0,0 +1,21 @@
import React from 'react';
import { CustomIcon, CustomIconProps } from './CustomIcon';
export const MinusCircleIcon = (props: CustomIconProps) => {
return (
<CustomIcon
width="25"
height="24"
viewBox="0 0 25 24"
fill="none"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2.5 12C2.5 6.47715 6.97715 2 12.5 2C18.0228 2 22.5 6.47715 22.5 12C22.5 17.5228 18.0228 22 12.5 22C6.97715 22 2.5 17.5228 2.5 12ZM16.5 12.9999C17.0523 12.9999 17.5 12.5522 17.5 11.9999C17.5 11.4476 17.0523 10.9999 16.5 10.9999L8.49997 11.0001C7.94769 11.0001 7.49998 11.4479 7.5 12.0001C7.50002 12.5524 7.94774 13.0001 8.50003 13.0001L16.5 12.9999Z"
fill="#0F86F5"
/>
</CustomIcon>
);
};

View File

@ -0,0 +1,20 @@
import React from 'react';
import { CustomIcon, CustomIconProps } from './CustomIcon';
export const RocketIcon = (props: CustomIconProps) => {
return (
<CustomIcon
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
{...props}
>
<path
d="M4.87626 10.1252H3.34633C2.77976 10.1252 2.41778 9.52109 2.68496 9.02148L4.07738 6.41779C4.33833 5.92983 4.84675 5.62517 5.40011 5.62517H8.40751M4.87626 10.1252L7.87626 13.1252M4.87626 10.1252L8.40751 5.62517M7.87626 13.1252V14.6551C7.87626 15.2217 8.48034 15.5836 8.97995 15.3165L11.5836 13.924C12.0716 13.6631 12.3763 13.1547 12.3763 12.6013V9.66486M7.87626 13.1252L12.3763 9.66486M12.3763 9.66486C14.5604 7.66276 15.8988 5.43328 16.0998 2.62499C16.1294 2.21183 15.7895 1.87198 15.3764 1.90172C12.5784 2.10308 10.4093 3.4414 8.40751 5.62517M3.37626 16.1252H1.875V14.6258C1.875 13.797 2.54717 13.1252 3.37594 13.1252C4.20437 13.1252 4.87626 13.7967 4.87626 14.6252C4.87626 15.4536 4.20469 16.1252 3.37626 16.1252Z"
stroke="currentColor"
strokeLinejoin="round"
/>
</CustomIcon>
);
};

View File

@ -0,0 +1,21 @@
import React from 'react';
import { CustomIcon, CustomIconProps } from './CustomIcon';
export const UndoIcon = (props: CustomIconProps) => {
return (
<CustomIcon
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
{...props}
>
<path
d="M4.49989 3.75L2.03022 6.21967C1.73732 6.51256 1.73732 6.98744 2.03022 7.28033L4.49989 9.75M2.24989 6.75H13.1249C14.7817 6.75 16.1249 8.09315 16.1249 9.75V10.875C16.1249 12.5319 14.7817 13.875 13.1249 13.875H8.99989"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
</CustomIcon>
);
};

View File

@ -43,6 +43,13 @@ export * from './LinkIcon';
export * from './CursorBoxIcon'; export * from './CursorBoxIcon';
export * from './CrossCircleIcon'; export * from './CrossCircleIcon';
export * from './RefreshIcon'; export * from './RefreshIcon';
export * from './CommitIcon';
export * from './RocketIcon';
export * from './RefreshIcon';
export * from './UndoIcon';
export * from './LoaderIcon';
export * from './MinusCircleIcon';
export * from './CopyIcon';
// Templates // Templates
export * from './templates'; export * from './templates';

View File

@ -0,0 +1,71 @@
import { cn } from 'utils/classnames';
import { Tooltip, TooltipProps } from 'components/shared/Tooltip';
import { debounce } from 'lodash';
import React, {
ComponentPropsWithRef,
PropsWithChildren,
useRef,
useState,
useEffect,
} from 'react';
import { PolymorphicProps } from 'types/common';
interface OverflownTextProps extends ComponentPropsWithRef<'span'> {
tooltipProps?: TooltipProps;
content?: string;
}
type ElementType = 'span' | 'div';
// This component is used to truncate text and show a tooltip if the text is overflown.
export const OverflownText = <Element extends ElementType>({
tooltipProps,
children,
content,
className,
as,
...props
}: PropsWithChildren<PolymorphicProps<Element, OverflownTextProps>>) => {
const ref = useRef(null);
const [isOverflown, setIsOverflown] = useState(false);
useEffect(() => {
const element = ref.current as HTMLElement | null;
if (!element) return;
setIsOverflown(element.scrollWidth > element.clientWidth);
const handleResize = () => {
const isOverflown = element.scrollWidth > element.clientWidth;
setIsOverflown(isOverflown);
};
window.addEventListener('resize', debounce(handleResize, 500));
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
const Component = as || 'span';
return (
<Tooltip
content={content ?? children}
delayDuration={500}
contentProps={{
className: 'text-xs',
}}
open={isOverflown ? undefined : false}
{...tooltipProps}
>
<Component
ref={ref}
className={cn('truncate block', className)} // line-clamp-1 won't work here
{...props}
>
{children}
</Component>
</Tooltip>
);
};

View File

@ -0,0 +1 @@
export * from './OverflownText';

View File

@ -4,7 +4,7 @@ import type { VariantProps } from 'tailwind-variants';
export const tagTheme = tv( export const tagTheme = tv(
{ {
slots: { slots: {
wrapper: ['flex', 'gap-1.5', 'rounded-lg', 'border'], wrapper: ['inline-flex', 'gap-1.5', 'rounded-lg', 'border'],
icon: [], icon: [],
label: ['font-inter', 'text-xs'], label: ['font-inter', 'text-xs'],
}, },

View File

@ -6,6 +6,7 @@ import { WavyBorder } from 'components/shared/WavyBorder';
import { Button } from 'components/shared/Button'; import { Button } from 'components/shared/Button';
import { CrossIcon } from 'components/shared/CustomIcon'; import { CrossIcon } from 'components/shared/CustomIcon';
import { cn } from 'utils/classnames'; import { cn } from 'utils/classnames';
import * as Dialog from '@radix-ui/react-dialog';
export interface CreateProjectLayoutProps export interface CreateProjectLayoutProps
extends ComponentPropsWithoutRef<'section'> {} extends ComponentPropsWithoutRef<'section'> {}
@ -16,24 +17,77 @@ export const CreateProjectLayout = ({
}: CreateProjectLayoutProps) => { }: CreateProjectLayoutProps) => {
const { orgSlug } = useParams(); const { orgSlug } = useParams();
const closeBtnLink = `/${orgSlug}`;
const heading = (
<Heading as="h2" className="flex-1 text-xl md:text-2xl font-medium">
Create new project
</Heading>
);
return ( return (
<section {...props} className={cn('h-full flex flex-col', className)}> <>
<div className="sticky top-0"> {/* Desktop */}
<div className="flex px-6 py-4 bg-base-bg items-center gap-4"> <section
<Heading as="h2" className="flex-1 text-[24px] font-medium"> {...props}
Create new project className={cn('h-full flex-col hidden md:flex', className)}
</Heading> >
<Link to={`/${orgSlug}`}> <div className="sticky top-0">
<Button iconOnly variant="tertiary"> <div className="flex px-6 py-4 bg-base-bg items-center gap-4">
<CrossIcon size={18} /> {heading}
</Button>
</Link> {/* Cannot save btn as variable since responsive variant don't work with compoundVariant */}
<Link to={closeBtnLink}>
<Button
iconOnly
variant="tertiary"
leftIcon={<CrossIcon />}
aria-label="close"
/>
</Link>
</div>
<WavyBorder />
</div> </div>
<WavyBorder />
</div> <section className="px-6 h-full flex-1 py-6 overflow-y-auto">
<section className="px-6 h-full flex-1 py-6 overflow-y-auto"> <Outlet />
<Outlet /> </section>
</section> </section>
</section>
{/* Mobile */}
{/* Setting modal={false} so even if modal is active on desktop, it doesn't block clicks */}
<Dialog.Root modal={false} open={true}>
<Dialog.Portal>
{/* Not using <Dialog.Overlay> since modal={false} disables it and its content will not show */}
<div className="bg-base-canvas fixed inset-0 md:hidden overflow-y-auto p-1">
<Dialog.Content className="min-h-full overflow-hidden rounded-2xl bg-base-bg shadow-card focus:outline-none">
{/* Heading */}
<div className="flex px-6 py-4 h-20 items-center gap-4">
{heading}
<Dialog.Close asChild>
<Link to={closeBtnLink}>
<Button
iconOnly
variant="tertiary"
leftIcon={<CrossIcon />}
aria-label="close"
size="sm"
/>
</Link>
</Dialog.Close>
</div>
{/* Border */}
<WavyBorder />
{/* Page content */}
<div className="px-4 py-6">
<Outlet />
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog.Root>
</>
); );
}; };

View File

@ -95,7 +95,7 @@ const DeploymentsTabPanel = () => {
setFilterValue(DEFAULT_FILTER_VALUE); setFilterValue(DEFAULT_FILTER_VALUE);
}, []); }, []);
const onUpdateDeploymenToProd = async () => { const onUpdateDeploymentToProd = async () => {
await fetchDeployments(); await fetchDeployments();
}; };
@ -113,7 +113,7 @@ const DeploymentsTabPanel = () => {
deployment={deployment} deployment={deployment}
key={key} key={key}
currentDeployment={currentDeployment!} currentDeployment={currentDeployment!}
onUpdate={onUpdateDeploymenToProd} onUpdate={onUpdateDeploymentToProd}
project={project} project={project}
prodBranchDomains={prodBranchDomains} prodBranchDomains={prodBranchDomains}
/> />

View File

@ -12,6 +12,9 @@ export default withMT({
zIndex: { zIndex: {
tooltip: '52', tooltip: '52',
}, },
letterSpacing: {
tight: '-0.084px',
},
fontFamily: { fontFamily: {
sans: ['Inter', 'sans-serif'], sans: ['Inter', 'sans-serif'],
display: ['Inter Display', 'sans-serif'], display: ['Inter Display', 'sans-serif'],
@ -84,6 +87,7 @@ export default withMT({
900: '#0a3a5c', 900: '#0a3a5c',
}, },
base: { base: {
canvas: '#ECF6FE',
bg: '#ffffff', bg: '#ffffff',
'bg-alternate': '#f8fafc', 'bg-alternate': '#f8fafc',
'bg-emphasized': '#f1f5f9', 'bg-emphasized': '#f1f5f9',

View File

@ -3786,6 +3786,27 @@
dependencies: dependencies:
"@babel/runtime" "^7.13.10" "@babel/runtime" "^7.13.10"
"@radix-ui/react-dialog@^1.0.5":
version "1.0.5"
resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz#71657b1b116de6c7a0b03242d7d43e01062c7300"
integrity sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "1.0.1"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-context" "1.0.1"
"@radix-ui/react-dismissable-layer" "1.0.5"
"@radix-ui/react-focus-guards" "1.0.1"
"@radix-ui/react-focus-scope" "1.0.4"
"@radix-ui/react-id" "1.0.1"
"@radix-ui/react-portal" "1.0.4"
"@radix-ui/react-presence" "1.0.1"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-slot" "1.0.2"
"@radix-ui/react-use-controllable-state" "1.0.1"
aria-hidden "^1.1.1"
react-remove-scroll "2.5.5"
"@radix-ui/react-direction@1.0.1": "@radix-ui/react-direction@1.0.1":
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.0.1.tgz#9cb61bf2ccf568f3421422d182637b7f47596c9b" resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.0.1.tgz#9cb61bf2ccf568f3421422d182637b7f47596c9b"