From 29aa99a46275bd55fe8eb5d5188074f9b0a34f05 Mon Sep 17 00:00:00 2001 From: psiddharthdesign <107192927+psiddharthdesign@users.noreply.github.com> Date: Mon, 29 Jul 2024 23:28:16 +0530 Subject: [PATCH] feature / status labels and urls --- .../(specific-project-pages)/AllRunsTable.tsx | 24 +- .../runs/[runId]/ProjectRunDetails.tsx | 219 ++++++++---------- .../[projectSlug]/runs/[runId]/page.tsx | 16 +- src/data/user/projects.tsx | 2 +- src/data/user/runs.ts | 41 +++- src/lib/utils.ts | 20 +- 6 files changed, 169 insertions(+), 153 deletions(-) diff --git a/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/project/[projectSlug]/(specific-project-pages)/AllRunsTable.tsx b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/project/[projectSlug]/(specific-project-pages)/AllRunsTable.tsx index d446ced5..eaa1d7e4 100644 --- a/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/project/[projectSlug]/(specific-project-pages)/AllRunsTable.tsx +++ b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/project/[projectSlug]/(specific-project-pages)/AllRunsTable.tsx @@ -1,40 +1,32 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Tables } from "@/lib/database.types"; +import { ToSnakeCase } from "@/lib/utils"; import { AnimatePresence, motion } from "framer-motion"; import { Activity } from "lucide-react"; import moment from "moment"; import Link from "next/link"; -import { runStageEnum } from "./enums"; export type StatusColor = { [key: string]: string; }; -const statusOrder = [ - runStageEnum.running, - runStageEnum.queued, - runStageEnum.pending_approval, - runStageEnum.pending_apply, - runStageEnum.succeeded, - runStageEnum.rejected, - runStageEnum.failed -]; - export const statusColors: StatusColor = { queued: 'bg-yellow-200/50 text-yellow-800 dark:bg-yellow-900/50 dark:text-yellow-200', + pending_plan: 'bg-amber-200/50 text-amber-800 dark:bg-amber-900/50 dark:text-amber-200', + running_plan: 'bg-purple-200/50 text-purple-800 dark:bg-purple-900/50 dark:text-purple-200', pending_approval: 'bg-blue-200/50 text-blue-800 dark:bg-blue-900/50 dark:text-blue-200', - running: 'bg-purple-200/50 text-purple-800 dark:bg-purple-900/50 dark:text-purple-200', + approved: 'bg-green-200/50 text-green-800 dark:bg-green-900/50 dark:text-green-200', pending_apply: 'bg-green-200/50 text-green-800 dark:bg-green-900/50 dark:text-green-200', + running_apply: 'bg-teal-200/50 text-teal-800 dark:bg-teal-900/50 dark:text-teal-200', succeeded: 'bg-green-200/50 text-green-800 dark:bg-green-900/50 dark:text-green-200', failed: 'bg-red-200/50 text-red-800 dark:bg-red-900/50 dark:text-red-200', + discarded: 'bg-neutral-200 text-neutral-800 dark:bg-neutral-950 dark:text-neutral-200', }; export const AllRunsTable = ({ runs, projectSlug }: { runs: Tables<'digger_runs'>[], projectSlug: string }) => { const sortedRuns = [...runs].sort((a, b) => { - const statusA = statusOrder.indexOf(a.status.toLowerCase() as runStageEnum); - const statusB = statusOrder.indexOf(b.status.toLowerCase() as runStageEnum); - if (statusA !== statusB) return statusA - statusB; + // Sort primarily by created_at in descending order (most recent first) return moment(b.created_at).valueOf() - moment(a.created_at).valueOf(); }); @@ -69,7 +61,7 @@ export const AllRunsTable = ({ runs, projectSlug }: { runs: Tables<'digger_runs' {run.commit_id} - + {run.status.toUpperCase()} diff --git a/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/project/[projectSlug]/runs/[runId]/ProjectRunDetails.tsx b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/project/[projectSlug]/runs/[runId]/ProjectRunDetails.tsx index fcc0cb34..b0659000 100644 --- a/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/project/[projectSlug]/runs/[runId]/ProjectRunDetails.tsx +++ b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/project/[projectSlug]/runs/[runId]/ProjectRunDetails.tsx @@ -4,118 +4,67 @@ import { T } from "@/components/ui/Typography"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { approveRun, changeRunStatus, rejectRun } from "@/data/user/runs"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { approveRun, rejectRun } from "@/data/user/runs"; import { useSAToastMutation } from "@/hooks/useSAToastMutation"; +import { ToSnakeCase, ToTitleCase } from "@/lib/utils"; import { Table } from "@/types"; import { DotFilledIcon } from "@radix-ui/react-icons"; import { AnimatePresence, motion } from 'framer-motion'; -import { CheckCircle2, Clock, GitPullRequest, Loader2, Play, XCircle } from 'lucide-react'; +import { CheckCircle2, Clock, GitPullRequest, LinkIcon, Loader2, Play, XCircle } from 'lucide-react'; import Image from 'next/image'; +import Link from "next/link"; import { useRouter } from 'next/navigation'; -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { statusColors } from "../../(specific-project-pages)/AllRunsTable"; -const logEntries = [ - { time: "2024-07-01T12:34:56Z", author: "Siddharth Ponnapalli", title: "Added basic VPC setup" }, - { time: "2024-07-01T13:45:22Z", author: "Siddharth Ponnapalli", title: "Added subnets and associated them with VPC" }, - { time: "2024-07-02T09:14:33Z", author: "Siddharth Ponnapalli", title: "Created security groups for web servers" }, - { time: "2024-07-02T10:22:12Z", author: "Siddharth Ponnapalli", title: "Added EC2 instances in different subnets" }, - { time: "2024-07-03T11:55:47Z", author: "Siddharth Ponnapalli", title: "Configured S3 buckets and objects" }, - { time: "2024-07-03T14:33:29Z", author: "Siddharth Ponnapalli", title: "Added RDS instance with MySQL" }, - { time: "2024-07-04T08:45:22Z", author: "Siddharth Ponnapalli", title: "Created IAM role, policy, and instance profile" }, - { time: "2024-07-04T10:12:09Z", author: "Siddharth Ponnapalli", title: "Added third EC2 instance with IAM role" }, - { time: "2024-07-05T11:34:56Z", author: "Siddharth Ponnapalli", title: "Updated security group rules for enhanced security" }, - { time: "2024-07-05T13:45:22Z", author: "Siddharth Ponnapalli", title: "Refactored subnet CIDR blocks for better segmentation" }, - { time: "2024-07-06T09:14:33Z", author: "Siddharth Ponnapalli", title: "Improved S3 bucket policies for enhanced security" }, - { time: "2024-07-06T10:22:12Z", author: "Siddharth Ponnapalli", title: "Added outputs for easier infrastructure management" }, - { time: "2024-07-07T11:55:47Z", author: "Siddharth Ponnapalli", title: "Enhanced instance tagging for better identification" }, - { time: "2024-07-07T14:33:29Z", author: "Siddharth Ponnapalli", title: "Updated RDS instance settings for improved performance" }, - { time: "2024-07-08T08:45:22Z", author: "Siddharth Ponnapalli", title: "Optimized VPC and subnet configurations" }, - { time: "2024-07-08T10:12:09Z", author: "Siddharth Ponnapalli", title: "Cleaned up IAM policies for better security practices" }, - { time: "2024-07-09T11:34:56Z", author: "Siddharth Ponnapalli", title: "Added new S3 bucket for log storage" }, - { time: "2024-07-09T13:45:22Z", author: "Siddharth Ponnapalli", title: "Implemented CloudWatch logs for monitoring" }, - { time: "2024-07-10T09:14:33Z", author: "Siddharth Ponnapalli", title: "Configured CloudFront distribution for S3 bucket" }, - { time: "2024-07-10T10:22:12Z", author: "Siddharth Ponnapalli", title: "Added Route 53 records for domain management" }, - { time: "2024-07-11T11:55:47Z", author: "Siddharth Ponnapalli", title: "Updated EC2 instance types for better performance" }, - { time: "2024-07-11T14:33:29Z", author: "Siddharth Ponnapalli", title: "Refined security group rules for better protection" }, - { time: "2024-07-12T08:45:22Z", author: "Siddharth Ponnapalli", title: "Added auto-scaling configuration for EC2 instances" }, - { time: "2024-07-12T10:12:09Z", author: "Siddharth Ponnapalli", title: "Configured RDS backup and retention policies" }, - { time: "2024-07-13T11:34:56Z", author: "Siddharth Ponnapalli", title: "Updated IAM role policies for least privilege access" }, - { time: "2024-07-13T13:45:22Z", author: "Siddharth Ponnapalli", title: "Implemented Lambda function for automated backups" }, - { time: "2024-07-14T09:14:33Z", author: "Siddharth Ponnapalli", title: "Integrated CloudWatch alarms for monitoring" }, - { time: "2024-07-14T10:22:12Z", author: "Siddharth Ponnapalli", title: "Updated S3 bucket lifecycle policies for cost optimization" }, - { time: "2024-07-15T11:55:47Z", author: "Siddharth Ponnapalli", title: "Configured VPC peering for multi-region setup" }, - { time: "2024-07-15T14:33:29Z", author: "Siddharth Ponnapalli", title: "Enhanced CloudFront caching settings" }, - { time: "2024-07-16T08:45:22Z", author: "Siddharth Ponnapalli", title: "Added new security group for database access" }, - { time: "2024-07-16T10:12:09Z", author: "Siddharth Ponnapalli", title: "Updated RDS instance class for better performance" }, - { time: "2024-07-17T11:34:56Z", author: "Siddharth Ponnapalli", title: "Implemented SNS for notification alerts" }, - { time: "2024-07-17T13:45:22Z", author: "Siddharth Ponnapalli", title: "Configured SES for email notifications" }, - { time: "2024-07-18T09:14:33Z", author: "Siddharth Ponnapalli", title: "Added DynamoDB table for session management" }, - { time: "2024-07-18T10:22:12Z", author: "Siddharth Ponnapalli", title: "Updated IAM policies for DynamoDB access" }, - { time: "2024-07-19T11:55:47Z", author: "Siddharth Ponnapalli", title: "Enhanced EC2 instance monitoring with detailed metrics" }, - { time: "2024-07-19T14:33:29Z", author: "Siddharth Ponnapalli", title: "Configured VPC flow logs for network monitoring" }, - { time: "2024-07-20T08:45:22Z", author: "Siddharth Ponnapalli", title: "Updated security group ingress rules for specific IP ranges" }, - { time: "2024-07-20T10:12:09Z", author: "Siddharth Ponnapalli", title: "Added IAM policy for S3 read-only access" }, - { time: "2024-07-21T11:34:56Z", author: "Siddharth Ponnapalli", title: "Refined Lambda function code for efficiency" }, - { time: "2024-07-21T13:45:22Z", author: "Siddharth Ponnapalli", title: "Enhanced VPC subnet configuration for better isolation" }, - { time: "2024-07-22T09:14:33Z", author: "Siddharth Ponnapalli", title: "Configured CloudFormation stack for infrastructure as code" }, - { time: "2024-07-22T10:22:12Z", author: "Siddharth Ponnapalli", title: "Updated EC2 instance AMI for latest security patches" }, - { time: "2024-07-23T11:55:47Z", author: "Siddharth Ponnapalli", title: "Implemented SSM for instance management" }, - { time: "2024-07-23T14:33:29Z", author: "Siddharth Ponnapalli", title: "Enhanced IAM roles for cross-account access" }, - { time: "2024-07-24T08:45:22Z", author: "Siddharth Ponnapalli", title: "Configured RDS read replicas for high availability" }, - { time: "2024-07-24T10:12:09Z", author: "Siddharth Ponnapalli", title: "Added CloudFront invalidation for cache management" }, - { time: "2024-07-25T11:34:56Z", author: "Siddharth Ponnapalli", title: "Updated Route 53 health checks for better uptime monitoring" }, - { time: "2024-07-25T13:45:22Z", author: "Siddharth Ponnapalli", title: "Enhanced S3 bucket encryption settings for data security" }, - { time: "2024-07-26T09:14:33Z", author: "Siddharth Ponnapalli", title: "Configured ELB for load balancing EC2 instances" }, - { time: "2024-07-26T10:22:12Z", author: "Siddharth Ponnapalli", title: "Updated IAM policies for better resource access control" }, - { time: "2024-07-27T11:55:47Z", author: "Siddharth Ponnapalli", title: "Enhanced CloudWatch dashboards for better visibility" }, - { time: "2024-07-27T14:33:29Z", author: "Siddharth Ponnapalli", title: "Configured EC2 instance user data for automation" } -]; - -const terraformOutput = `Terraform used the selected providers to generate the following -execution plan. Resource actions are indicated with the -following symbols: - + create - -Terraform will perform the following actions: - - # null_resource.test5000 will be created - + resource "null_resource" "test5000" { - + id = (known after apply) - } - -Plan: 1 to add, 0 to change, 0 to destroy. - -Do you want to perform these actions? - Terraform will perform the actions described above. - Only 'yes' will be accepted to approve. - - Enter a value: yes - -null_resource.test5000: Creating... -null_resource.test5000: Creation complete after 0s [id=1100760775258092881] - -Apply complete! Resources: 1 added, 0 changed, 0 destroyed.`; - function RenderContent({ activeStage, run, + tfOutput, + workflowRunUrl }: { activeStage: string; run: Table<'digger_runs'>; + tfOutput: string | null; + workflowRunUrl: string | null; }) { if (activeStage === 'plan') { return (
-

Terraform Plan Output

-
-                    {run.terraform_output || 'No plan output available'}
-                
+
+

Terraform Plan Output

+ + {workflowRunUrl && run.status !== ToTitleCase('queued') && ( + + + + + + + + + View workflow run + + + + )} +
+ {run.status !== ToTitleCase('queued') && + run.status !== ToTitleCase('pending_plan') && + run.status !== ToTitleCase('running_plan') && ( +
+                            {run.terraform_output || 'No plan output available'}
+                        
+ )}
); } else if (activeStage === 'apply') { - if (run.status === 'pending_approval' || run.status === 'rejected') { + if (run.status === ToTitleCase('pending_approval') || run.status === ToTitleCase('rejected')) { return (
@@ -129,7 +78,7 @@ function RenderContent({

Apply logs

- {terraformOutput} + {tfOutput}
); @@ -141,22 +90,13 @@ export const ProjectRunDetails: React.FC<{ run: Table<'digger_runs'>, loggedInUser: Table<'user_profiles'> isUserOrgAdmin: boolean -}> = ({ run, loggedInUser, isUserOrgAdmin }) => { + tfOutput: string | null + workflowRunUrl: string | null + fullRepoName: string | null +}> = ({ run, loggedInUser, isUserOrgAdmin, tfOutput, workflowRunUrl, fullRepoName }) => { const router = useRouter(); const [activeStage, setActiveStage] = useState<'plan' | 'apply'>('plan'); - useEffect(() => { - if (run.status === 'pending_apply') { - const timer = setTimeout(async () => { - await changeRunStatus(run.id, 'succeeded'); - setActiveStage('apply'); - router.refresh(); - }, 5000); - - return () => clearTimeout(timer); - } - }, [run.status, run.id, router]); - const { mutate: approveMutation, isLoading: isApproving } = useSAToastMutation( async () => await approveRun(run.id, loggedInUser.id), { @@ -181,8 +121,6 @@ export const ProjectRunDetails: React.FC<{ } ); - - return (
- + + {run.status.toUpperCase()} } /> @@ -212,18 +150,19 @@ export const ProjectRunDetails: React.FC<{ icon={} name="Plan" isActive={activeStage === 'plan'} - isComplete={run.status !== 'pending_approval'} + isRunning={run.status === ToTitleCase('running_plan')} + isComplete={!['queued', 'pending_plan', 'running_plan'].includes(ToSnakeCase(run.status))} onClick={() => setActiveStage('plan')} /> - {run.status === 'pending_approval' && isUserOrgAdmin && ( + {run.status === ToTitleCase('pending_approval') && isUserOrgAdmin && (

Do you approve the proposed changes?

-
@@ -234,30 +173,44 @@ export const ProjectRunDetails: React.FC<{ icon={} name="Apply" isActive={activeStage === 'apply'} - isComplete={run.status === 'succeeded'} - isDisabled={run.status === 'pending_approval' || run.status === 'rejected'} + isRunning={run.status === ToTitleCase('running_apply')} + isComplete={run.status === ToTitleCase('succeeded')} + isDisabled={run.status !== ToTitleCase('succeeded')} onClick={() => setActiveStage('apply')} /> - {run.status === 'pending_apply' && run.is_approved && ( + {run.status === ToTitleCase('running_plan') && ( + + + Running plan... + + )} + + {run.status === ToTitleCase('running_apply') && ( - Applying changes... + Applying changes... )} +
- {run.status === 'succeeded' && ( + {['approved', 'pending_apply', 'running_apply', 'succeeded', 'failed'].includes(ToSnakeCase(run.status)) && ( Approved by: )} - {run.status === 'rejected' && ( -

Rejected by:

+ {run.status === ToTitleCase('discarded') && ( + Discarded by: )} - {(run.status === 'succeeded' || run.status === 'rejected') && ( + {!(["queued", "pending_plan", "running_plan", "pending_approval"].includes(ToSnakeCase(run.status))) && ( - +
@@ -321,14 +274,16 @@ export const ProjectRunDetails: React.FC<{ ); }; + const RunStageSidebarItem: React.FC<{ icon: React.ReactNode, name: string, isActive: boolean, + isRunning: boolean, isComplete: boolean, isDisabled?: boolean, onClick: () => void -}> = ({ icon, name, isActive, isComplete, isDisabled = false, onClick }) => ( +}> = ({ icon, name, isActive, isRunning, isComplete, isDisabled = false, onClick }) => ( {name}

- {isActive && !isComplete && } - {isComplete && } +
+ {isComplete && ( + + )} + {isActive && ( + + )} +
); -const DetailItem: React.FC<{ label: string, value: string | React.ReactNode }> = ({ label, value }) => ( +const DetailItem: React.FC<{ label: string, value: string | React.ReactNode, link?: string }> = ({ label, value, link }) => (

{label}:

- {typeof value === 'string' ?

{value}

: value} + {link ? ( + {typeof value === 'string' ? value : value} + ) : ( + typeof value === 'string' ? {value} : value + )}
); \ No newline at end of file diff --git a/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/project/[projectSlug]/runs/[runId]/page.tsx b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/project/[projectSlug]/runs/[runId]/page.tsx index c862c521..09c9e9c1 100644 --- a/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/project/[projectSlug]/runs/[runId]/page.tsx +++ b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/project/[projectSlug]/runs/[runId]/page.tsx @@ -3,7 +3,8 @@ import { PageHeading } from "@/components/PageHeading"; import { T } from "@/components/ui/Typography"; import { getLoggedInUserOrganizationRole } from "@/data/user/organizations"; import { getSlimProjectById } from "@/data/user/projects"; -import { getRunById } from "@/data/user/runs"; +import { getRepoDetails } from "@/data/user/repos"; +import { getBatchIdFromPlanStageId, getRunById, getTFOutputAndWorkflowURLFromBatchId } from "@/data/user/runs"; import { getUserProfile } from "@/data/user/user"; import { Table } from "@/types"; import { serverGetLoggedInUser } from "@/utils/server/serverGetLoggedInUser"; @@ -29,6 +30,9 @@ type ProjectRunDetailsProps = { run: Table<'digger_runs'>, loggedInUser: Table<'user_profiles'>, isUserOrgAdmin: boolean + tfOutput: string | null + workflowRunUrl: string | null + fullRepoName: string | null } @@ -50,8 +54,14 @@ export default async function RunDetailPage({ getSlimProjectById(project_id), serverGetLoggedInUser() ]); + const { repo_full_name } = await getRepoDetails(project.repo_id); + const userProfile = await getUserProfile(user.id); - const organizationRole = await getLoggedInUserOrganizationRole(project.organization_id); + const [organizationRole, batchId] = await Promise.all([ + getLoggedInUserOrganizationRole(project.organization_id), + getBatchIdFromPlanStageId(run.plan_stage_id) + ]); + const { terraform_output, workflow_run_url } = await getTFOutputAndWorkflowURLFromBatchId(batchId); const isOrganizationAdmin = organizationRole === "admin" || organizationRole === "owner"; @@ -71,7 +81,7 @@ export default async function RunDetailPage({ } > - + } diff --git a/src/data/user/projects.tsx b/src/data/user/projects.tsx index 2c2f68cd..6f59c3f1 100644 --- a/src/data/user/projects.tsx +++ b/src/data/user/projects.tsx @@ -14,7 +14,7 @@ export async function getSlimProjectById(projectId: string) { const supabaseClient = createSupabaseUserServerComponentClient(); const { data, error } = await supabaseClient .from("projects") - .select("id,name,project_status,organization_id,team_id,slug") + .select("id,name,project_status,organization_id,team_id,slug, repo_id") .eq("id", projectId) .single(); if (error) { diff --git a/src/data/user/runs.ts b/src/data/user/runs.ts index f4585578..8aa26ce3 100644 --- a/src/data/user/runs.ts +++ b/src/data/user/runs.ts @@ -130,7 +130,7 @@ export async function requestRunApproval( const supabase = createSupabaseUserServerComponentClient(); const { data, error } = await supabase .from('digger_runs') - .update({ status: 'pending_approval' }) + .update({ status: 'Pending Approval' }) .eq('id', runId) .select('id') .single(); @@ -149,7 +149,7 @@ export async function approveRun( const { data, error } = await supabase .from('digger_runs') .update({ - status: 'pending_apply', + status: 'Approved', approver_user_id: userId, is_approved: true, }) @@ -171,7 +171,7 @@ export async function rejectRun( const { data, error } = await supabase .from('digger_runs') .update({ - status: 'rejected', + status: 'Discarded', approver_user_id: userId, is_approved: false, }) @@ -196,3 +196,38 @@ export async function changeRunStatus(runId: string, status: string) { if (error) throw error; } + +export async function getBatchIdFromPlanStageId(planStageId: string | null) { + if (!planStageId) return null; + const supabase = createSupabaseUserServerComponentClient(); + const { error, data } = await supabase + .from('digger_run_stages') + .select('batch_id') + .eq('id', planStageId) + .single(); + + if (error) throw error; + + return data.batch_id; +} + +export async function getTFOutputAndWorkflowURLFromBatchId( + batchId: string | null, +) { + if (!batchId) return { terraform_output: null, workflow_run_url: null }; + + const supabase = createSupabaseUserServerComponentClient(); + const { error, data } = await supabase + .from('digger_jobs') + .select('terraform_output, workflow_run_url') + .eq('batch_id', batchId) + .limit(1) + .maybeSingle(); + + if (error) throw error; + + return { + terraform_output: data?.terraform_output ?? null, + workflow_run_url: data?.workflow_run_url ?? null, + }; +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 095cb802..5e8a35ec 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -13,10 +13,24 @@ export const generateSlug = (title: string) => { strict: true, replacement: '-', }); - return slug -} + return slug; +}; export const nanoid = customAlphabet( '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', 7, -); // \ No newline at end of file +); // + +export const ToTitleCase = (str: string) => { + return str + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' '); +}; + +export const ToSnakeCase = (str: string) => { + return str + .split(' ') + .map((word) => word.toLowerCase()) + .join('_'); +};