Openstatus www.openstatus.dev

Feat: adding clone action to motion page (#921)

* Feat: adding clone action

* Feat: adding clone function

* Feat: using getMonitorById and create to clone

* Feat: adding toast on clone complete

* Feat: adding active to query params in monitor/[id]/edit

* Refactor: search params -> active

* Feat: disable clone if limit is reached

* Feat: adding tags status page and notifications on clone

* Refactor: review changes

* Feat: Clone monitor change log

* Refactor: selectedMonitorData

* Feat: adding `- copy` to cloned monitor

* Feat: adding confirmation dailog

* Refactor: suggested changes

* refactor: move state into memo

* fix: variant and wording

* refactor: removing confirmation modal

* refactor: removing redirect to edit monitor on clone

* refactor: react imports destructure

* feat: refresh after monitor clone

* refactor: removing query params logic in edit page

* chore: single query

---------

Co-authored-by: Maximilian Kaske <maximilian@kaske.org>

authored by

Priyank Rajai
Maximilian Kaske
and committed by
GitHub
9ec3169c cafb3936

+133 -56
apps/web/public/assets/changelog/clone-monitor.png

This is a binary file and will not be displayed.

+1 -1
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/(overview)/page.tsx
··· 99 99 maintenance.monitors.includes(monitor.id) 100 100 ); 101 101 102 - return { monitor, metrics: current, data, incidents, maintenances, tags }; 102 + return { monitor, metrics: current, data, incidents, maintenances, tags, isLimitReached }; 103 103 }) 104 104 ); 105 105
-1
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/edit/page.tsx
··· 30 30 31 31 // default is request 32 32 const search = searchParamsSchema.safeParse(searchParams); 33 - 34 33 return ( 35 34 <MonitorForm 36 35 defaultSection={search.success ? search.data.section : undefined}
+44 -7
apps/web/src/components/data-table/monitor/data-table-row-actions.tsx
··· 4 4 import { MoreHorizontal } from "lucide-react"; 5 5 import Link from "next/link"; 6 6 import { useRouter } from "next/navigation"; 7 - import * as React from "react"; 7 + import { useState, useTransition } from "react"; 8 8 import { z } from "zod"; 9 9 10 10 import { selectMonitorSchema } from "@openstatus/db/src/schema"; ··· 28 28 29 29 import { LoadingAnimation } from "@/components/loading-animation"; 30 30 import type { RegionChecker } from "@/components/ping-response-analysis/utils"; 31 - import { toastAction } from "@/lib/toast"; 31 + import { toastAction, toast } from "@/lib/toast"; 32 32 import { api } from "@/trpc/client"; 33 - 34 33 interface DataTableRowActionsProps<TData> { 35 34 row: Row<TData>; 36 35 } ··· 38 37 export function DataTableRowActions<TData>({ 39 38 row, 40 39 }: DataTableRowActionsProps<TData>) { 41 - const { monitor } = z 42 - .object({ monitor: selectMonitorSchema }) 40 + const { monitor, isLimitReached } = z 41 + .object({ monitor: selectMonitorSchema, isLimitReached: z.boolean() }) 43 42 .parse(row.original); 44 43 const router = useRouter(); 45 - const [alertOpen, setAlertOpen] = React.useState(false); 46 - const [isPending, startTransition] = React.useTransition(); 44 + const [alertOpen, setAlertOpen] = useState(false); 45 + const [isPending, startTransition] = useTransition(); 47 46 48 47 async function onDelete() { 49 48 startTransition(async () => { ··· 85 84 } 86 85 }); 87 86 } 87 + async function onClone() { 88 + startTransition(async () => { 89 + try { 90 + const id = monitor.id; 91 + if (!id) return; 92 + 93 + const selectedMonitorData = await api.monitor.getMonitorById.query({ 94 + id, 95 + }); 96 + 97 + const { notificationIds, pageIds, monitorTagIds } = 98 + await api.monitor.getMonitorRelationsById.query({ id }); 99 + 100 + const cloneMonitorData = { 101 + ...selectedMonitorData, 102 + name: `${selectedMonitorData.name} - copy`, 103 + tags: monitorTagIds, 104 + notifications: notificationIds, 105 + pages: pageIds, 106 + active: false, 107 + id: undefined, 108 + updatedAt: undefined, 109 + createdAt: undefined, 110 + }; 111 + 112 + await api.monitor.create.mutate(cloneMonitorData); 113 + 114 + toast.success("Monitor cloned!"); 115 + router.refresh(); 116 + } catch (error) { 117 + console.log("error", error); 118 + toastAction("error"); 119 + } 120 + }); 121 + } 88 122 89 123 return ( 90 124 <AlertDialog open={alertOpen} onOpenChange={(value) => setAlertOpen(value)}> ··· 105 139 <Link href={`./monitors/${monitor.id}/overview`}> 106 140 <DropdownMenuItem>Details</DropdownMenuItem> 107 141 </Link> 142 + <DropdownMenuItem onClick={onClone} disabled={isLimitReached}> 143 + Clone 144 + </DropdownMenuItem> 108 145 <DropdownMenuItem onClick={onTest}>Test</DropdownMenuItem> 109 146 <DropdownMenuSeparator /> 110 147 <AlertDialogTrigger asChild>
+11
apps/web/src/content/changelog/clone-monitor.mdx
··· 1 + --- 2 + title: Clone Monitor 3 + description: Set custom request timeouts and degradation timing. 4 + image: /assets/changelog/clone-monitor.png 5 + publishedAt: 2024-07-14 6 + --- 7 + Sometimes you need to create a copy of your monitor with the same settings quickly and easily. 8 + 9 + You can now clone a monitor directly from the action menu in the monitor table. 10 + 11 + We have added this feature to the monitor settings.
+77 -47
packages/api/src/router/monitor.ts
··· 43 43 await opts.ctx.db.query.monitor.findMany({ 44 44 where: and( 45 45 eq(monitor.workspaceId, opts.ctx.workspace.id), 46 - isNull(monitor.deletedAt) 46 + isNull(monitor.deletedAt), 47 47 ), 48 48 }) 49 49 ).length; ··· 120 120 const allNotifications = await opts.ctx.db.query.notification.findMany({ 121 121 where: and( 122 122 eq(notification.workspaceId, opts.ctx.workspace.id), 123 - inArray(notification.id, notifications) 123 + inArray(notification.id, notifications), 124 124 ), 125 125 }); 126 126 ··· 136 136 const allTags = await opts.ctx.db.query.monitorTag.findMany({ 137 137 where: and( 138 138 eq(monitorTag.workspaceId, opts.ctx.workspace.id), 139 - inArray(monitorTag.id, tags) 139 + inArray(monitorTag.id, tags), 140 140 ), 141 141 }); 142 142 ··· 152 152 const allPages = await opts.ctx.db.query.page.findMany({ 153 153 where: and( 154 154 eq(page.workspaceId, opts.ctx.workspace.id), 155 - inArray(page.id, pages) 155 + inArray(page.id, pages), 156 156 ), 157 157 }); 158 158 ··· 179 179 where: and( 180 180 eq(monitor.id, opts.input.id), 181 181 eq(monitor.workspaceId, opts.ctx.workspace.id), 182 - isNull(monitor.deletedAt) 182 + isNull(monitor.deletedAt), 183 183 ), 184 184 with: { 185 185 monitorTagsToMonitors: { with: { monitorTag: true } }, ··· 204 204 maintenance: _monitor?.maintenancesToMonitors.some( 205 205 (item) => 206 206 item.maintenance.from.getTime() <= Date.now() && 207 - item.maintenance.to.getTime() >= Date.now() 207 + item.maintenance.to.getTime() >= Date.now(), 208 208 ), 209 209 }); 210 210 ··· 226 226 where: and( 227 227 eq(monitor.id, opts.input.id), 228 228 isNull(monitor.deletedAt), 229 - eq(monitor.public, true) 229 + eq(monitor.public, true), 230 230 ), 231 231 }); 232 232 if (!_monitor) return undefined; ··· 238 238 }); 239 239 240 240 const hasPageRelation = _page?.monitorsToPages.find( 241 - ({ monitorId }) => _monitor.id === monitorId 241 + ({ monitorId }) => _monitor.id === monitorId, 242 242 ); 243 243 244 244 if (!hasPageRelation) return undefined; ··· 314 314 and( 315 315 eq(monitor.id, opts.input.id), 316 316 eq(monitor.workspaceId, opts.ctx.workspace.id), 317 - isNull(monitor.deletedAt) 318 - ) 317 + isNull(monitor.deletedAt), 318 + ), 319 319 ) 320 320 .returning() 321 321 .get(); ··· 330 330 (x) => 331 331 !currentMonitorNotifications 332 332 .map(({ notificationId }) => notificationId) 333 - ?.includes(x) 333 + ?.includes(x), 334 334 ); 335 335 336 336 if (addedNotifications.length > 0) { ··· 354 354 eq(notificationsToMonitors.monitorId, currentMonitor.id), 355 355 inArray( 356 356 notificationsToMonitors.notificationId, 357 - removedNotifications 358 - ) 359 - ) 357 + removedNotifications, 358 + ), 359 + ), 360 360 ) 361 361 .run(); 362 362 } ··· 371 371 (x) => 372 372 !currentMonitorTags 373 373 .map(({ monitorTagId }) => monitorTagId) 374 - ?.includes(x) 374 + ?.includes(x), 375 375 ); 376 376 377 377 if (addedTags.length > 0) { ··· 393 393 .where( 394 394 and( 395 395 eq(monitorTagsToMonitors.monitorId, currentMonitor.id), 396 - inArray(monitorTagsToMonitors.monitorTagId, removedTags) 397 - ) 396 + inArray(monitorTagsToMonitors.monitorTagId, removedTags), 397 + ), 398 398 ) 399 399 .run(); 400 400 } ··· 406 406 .all(); 407 407 408 408 const addedPages = pages.filter( 409 - (x) => !currentMonitorPages.map(({ pageId }) => pageId)?.includes(x) 409 + (x) => !currentMonitorPages.map(({ pageId }) => pageId)?.includes(x), 410 410 ); 411 411 412 412 if (addedPages.length > 0) { ··· 428 428 .where( 429 429 and( 430 430 eq(monitorsToPages.monitorId, currentMonitor.id), 431 - inArray(monitorsToPages.pageId, removedPages) 432 - ) 431 + inArray(monitorsToPages.pageId, removedPages), 432 + ), 433 433 ) 434 434 .run(); 435 435 } ··· 440 440 insertMonitorSchema 441 441 .pick({ public: true, active: true }) 442 442 .partial() // batched updates 443 - .extend({ ids: z.number().array() }) // array of monitor ids to update 443 + .extend({ ids: z.number().array() }), // array of monitor ids to update 444 444 ) 445 445 .mutation(async (opts) => { 446 446 const _monitors = await opts.ctx.db ··· 450 450 and( 451 451 inArray(monitor.id, opts.input.ids), 452 452 eq(monitor.workspaceId, opts.ctx.workspace.id), 453 - isNull(monitor.deletedAt) 454 - ) 453 + isNull(monitor.deletedAt), 454 + ), 455 455 ); 456 456 }), 457 457 ··· 461 461 ids: z.number().array(), 462 462 tagId: z.number(), 463 463 action: z.enum(["add", "remove"]), 464 - }) 464 + }), 465 465 ) 466 466 .mutation(async (opts) => { 467 467 const _monitorTag = await opts.ctx.db.query.monitorTag.findFirst({ 468 468 where: and( 469 469 eq(monitorTag.workspaceId, opts.ctx.workspace.id), 470 - eq(monitorTag.id, opts.input.tagId) 470 + eq(monitorTag.id, opts.input.tagId), 471 471 ), 472 472 }); 473 473 474 474 const _monitors = await opts.ctx.db.query.monitor.findMany({ 475 475 where: and( 476 476 eq(monitor.workspaceId, opts.ctx.workspace.id), 477 - inArray(monitor.id, opts.input.ids) 477 + inArray(monitor.id, opts.input.ids), 478 478 ), 479 479 }); 480 480 ··· 492 492 opts.input.ids.map((id) => ({ 493 493 monitorId: id, 494 494 monitorTagId: opts.input.tagId, 495 - })) 495 + })), 496 496 ) 497 497 .onConflictDoNothing() 498 498 .run(); ··· 504 504 .where( 505 505 and( 506 506 inArray(monitorTagsToMonitors.monitorId, opts.input.ids), 507 - eq(monitorTagsToMonitors.monitorTagId, opts.input.tagId) 508 - ) 507 + eq(monitorTagsToMonitors.monitorTagId, opts.input.tagId), 508 + ), 509 509 ) 510 510 .run(); 511 511 } ··· 520 520 .where( 521 521 and( 522 522 eq(monitor.id, opts.input.id), 523 - eq(monitor.workspaceId, opts.ctx.workspace.id) 524 - ) 523 + eq(monitor.workspaceId, opts.ctx.workspace.id), 524 + ), 525 525 ) 526 526 .get(); 527 527 if (!monitorToDelete) return; ··· 560 560 .where( 561 561 and( 562 562 inArray(monitor.id, opts.input.ids), 563 - eq(monitor.workspaceId, opts.ctx.workspace.id) 564 - ) 563 + eq(monitor.workspaceId, opts.ctx.workspace.id), 564 + ), 565 565 ) 566 566 .all(); 567 567 ··· 601 601 const monitors = await opts.ctx.db.query.monitor.findMany({ 602 602 where: and( 603 603 eq(monitor.workspaceId, opts.ctx.workspace.id), 604 - isNull(monitor.deletedAt) 604 + isNull(monitor.deletedAt), 605 605 ), 606 606 with: { 607 607 monitorTagsToMonitors: { with: { monitorTag: true } }, ··· 614 614 monitorTagsToMonitors: z 615 615 .array(z.object({ monitorTag: selectMonitorTagSchema })) 616 616 .default([]), 617 - }) 617 + }), 618 618 ) 619 619 .parse(monitors); 620 620 }), ··· 625 625 const _page = await opts.ctx.db.query.page.findFirst({ 626 626 where: and( 627 627 eq(page.id, opts.input.id), 628 - eq(page.workspaceId, opts.ctx.workspace.id) 628 + eq(page.workspaceId, opts.ctx.workspace.id), 629 629 ), 630 630 }); 631 631 ··· 634 634 const monitors = await opts.ctx.db.query.monitor.findMany({ 635 635 where: and( 636 636 eq(monitor.workspaceId, opts.ctx.workspace.id), 637 - isNull(monitor.deletedAt) 637 + isNull(monitor.deletedAt), 638 638 ), 639 639 with: { 640 640 monitorTagsToMonitors: { with: { monitorTag: true } }, ··· 650 650 monitorTagsToMonitors: z 651 651 .array(z.object({ monitorTag: selectMonitorTagSchema })) 652 652 .default([]), 653 - }) 653 + }), 654 654 ) 655 655 .parse( 656 656 monitors.filter((monitor) => 657 657 monitor.monitorsToPages 658 658 .map(({ pageId }) => pageId) 659 - .includes(_page.id) 660 - ) 659 + .includes(_page.id), 660 + ), 661 661 ); 662 662 }), 663 663 ··· 671 671 and( 672 672 eq(monitor.id, opts.input.id), 673 673 eq(monitor.workspaceId, opts.ctx.workspace.id), 674 - isNull(monitor.deletedAt) 675 - ) 674 + isNull(monitor.deletedAt), 675 + ), 676 676 ) 677 677 .get(); 678 678 ··· 691 691 .where( 692 692 and( 693 693 eq(monitor.id, opts.input.id), 694 - eq(monitor.workspaceId, opts.ctx.workspace.id) 695 - ) 694 + eq(monitor.workspaceId, opts.ctx.workspace.id), 695 + ), 696 696 ) 697 697 .run(); 698 698 }), ··· 720 720 notification, 721 721 and( 722 722 eq(notificationsToMonitors.notificationId, notification.id), 723 - eq(notification.workspaceId, opts.ctx.workspace.id) 724 - ) 723 + eq(notification.workspaceId, opts.ctx.workspace.id), 724 + ), 725 725 ) 726 726 .where(eq(notificationsToMonitors.monitorId, opts.input.id)) 727 727 .all(); ··· 734 734 await opts.ctx.db.query.monitor.findMany({ 735 735 where: and( 736 736 eq(monitor.workspaceId, opts.ctx.workspace.id), 737 - isNull(monitor.deletedAt) 737 + isNull(monitor.deletedAt), 738 738 ), 739 739 }) 740 740 ).length; 741 741 742 742 return monitorNumbers >= monitorLimit; 743 743 }), 744 + getMonitorRelationsById: protectedProcedure 745 + .input(z.object({ id: z.number() })) 746 + .query(async (opts) => { 747 + const _monitor = await opts.ctx.db.query.monitor.findFirst({ 748 + where: and( 749 + eq(monitor.id, opts.input.id), 750 + eq(monitor.workspaceId, opts.ctx.workspace.id), 751 + isNull(monitor.deletedAt), 752 + ), 753 + with: { 754 + monitorTagsToMonitors: true, 755 + monitorsToNotifications: true, 756 + monitorsToPages: true, 757 + }, 758 + }); 759 + 760 + const parsedMonitorNotification = _monitor?.monitorsToNotifications.map( 761 + ({ notificationId }) => notificationId, 762 + ); 763 + const parsedPages = _monitor?.monitorsToPages.map((val) => val.pageId); 764 + const parsedTags = _monitor?.monitorTagsToMonitors.map( 765 + ({ monitorTagId }) => monitorTagId, 766 + ); 767 + 768 + return { 769 + notificationIds: parsedMonitorNotification, 770 + pageIds: parsedPages, 771 + monitorTagIds: parsedTags, 772 + }; 773 + }), 744 774 });