|
1 | 1 | <script lang="ts">
|
2 |
| - import CopyToClipboardButton from '$comp/copy-to-clipboard-button.svelte'; |
3 | 2 | import ObjectDump from '$comp/object-dump.svelte';
|
4 |
| - import { Code, H4 } from '$comp/typography'; |
| 3 | + import { Code, CodeBlock, H4 } from '$comp/typography'; |
5 | 4 | import { Button } from '$comp/ui/button';
|
| 5 | + import * as DropdownMenu from '$comp/ui/dropdown-menu'; |
| 6 | + import { isJSONString, isObject, isString, isXmlString } from '$features/shared/typing'; |
| 7 | + import { UseClipboard } from '$lib/hooks/use-clipboard.svelte'; |
6 | 8 | import ArrowDown from '@lucide/svelte/icons/arrow-down';
|
7 | 9 | import ArrowUp from '@lucide/svelte/icons/arrow-up';
|
| 10 | + import Copy from '@lucide/svelte/icons/copy'; |
| 11 | + import MoreVertical from '@lucide/svelte/icons/more-vertical'; |
| 12 | + import ToggleLeft from '@lucide/svelte/icons/toggle-left'; |
| 13 | + import { toast } from 'svelte-sonner'; |
8 | 14 |
|
9 | 15 | interface Props {
|
10 | 16 | canPromote?: boolean;
|
|
18 | 24 |
|
19 | 25 | let { canPromote = true, data, demote = async () => {}, excludedKeys = [], isPromoted = false, promote = async () => {}, title }: Props = $props();
|
20 | 26 |
|
21 |
| - function getData(data: unknown, exclusions: string[]): unknown { |
22 |
| - if (typeof data !== 'object' || !(data instanceof Object)) { |
| 27 | + function transformData(data: unknown): unknown { |
| 28 | + if (isJSONString(data)) { |
| 29 | + try { |
| 30 | + return JSON.parse(data); |
| 31 | + } catch { |
| 32 | + return data; |
| 33 | + } |
| 34 | + } |
| 35 | +
|
| 36 | + return data; |
| 37 | + } |
| 38 | +
|
| 39 | + function getFilteredData(data: unknown, exclusions: string[]): unknown { |
| 40 | + if (Array.isArray(data)) { |
| 41 | + return data.map((item) => getFilteredData(item, exclusions)); |
| 42 | + } |
| 43 | +
|
| 44 | + if (!isObject(data)) { |
23 | 45 | return data;
|
24 | 46 | }
|
25 | 47 |
|
|
32 | 54 | }, {});
|
33 | 55 | }
|
34 | 56 |
|
35 |
| - function hasFilteredData(data: unknown): boolean { |
| 57 | + function isEmpty(data: unknown): boolean { |
36 | 58 | if (data === undefined || data === null) {
|
37 |
| - return false; |
| 59 | + return true; |
38 | 60 | }
|
39 | 61 |
|
40 | 62 | if (Array.isArray(data)) {
|
41 |
| - return data.length > 0; |
| 63 | + return data.length === 0; |
| 64 | + } |
| 65 | +
|
| 66 | + if (isObject(data)) { |
| 67 | + return Object.keys(data).length === 0; |
42 | 68 | }
|
43 | 69 |
|
44 |
| - if (Object.prototype.toString.call(data) === '[object Object]') { |
45 |
| - return Object.keys(data).length > 0; |
| 70 | + if (isString(data)) { |
| 71 | + return data.trim().length === 0; |
46 | 72 | }
|
47 | 73 |
|
48 |
| - return true; |
| 74 | + return false; |
49 | 75 | }
|
50 | 76 |
|
51 |
| - function onToggleView(e: Event) { |
52 |
| - e.preventDefault(); |
| 77 | + function onToggleView() { |
53 | 78 | showRaw = !showRaw;
|
54 | 79 | }
|
55 | 80 |
|
56 | 81 | let showRaw = $state(false);
|
57 |
| - let filteredData = getData(data, excludedKeys); |
58 |
| - let hasData = hasFilteredData(filteredData); |
59 |
| - let json = data ? JSON.stringify(data, null, 2) : null; |
| 82 | + const transformedData = $derived(transformData(data)); |
| 83 | + const filteredData = $derived(getFilteredData(transformedData, excludedKeys)); |
| 84 | + const hasData = $derived(!isEmpty(transformedData)); |
| 85 | + const showJSONCodeEditor = $derived(isJSONString(data) || Array.isArray(transformedData) || isObject(transformedData)); |
| 86 | + const showXmlCodeEditor = $derived(isXmlString(filteredData)); |
| 87 | + const canToggle = $derived(!showXmlCodeEditor); |
| 88 | +
|
| 89 | + const clipboardData = $derived(isString(data) ? data : JSON.stringify(data, null, 2)); |
| 90 | + const code = $derived(isString(filteredData) ? filteredData : JSON.stringify(filteredData, null, 2)); |
| 91 | +
|
| 92 | + const clipboard = new UseClipboard(); |
| 93 | + async function copyToClipboard() { |
| 94 | + await clipboard.copy(clipboardData); |
| 95 | + if (clipboard.copied) { |
| 96 | + toast.success('Copy to clipboard succeeded'); |
| 97 | + } else { |
| 98 | + toast.error('Copy to clipboard failed'); |
| 99 | + } |
| 100 | + } |
60 | 101 | </script>
|
61 | 102 |
|
62 | 103 | {#if hasData}
|
63 |
| - <div class="flex justify-between"> |
64 |
| - <H4 class="mb-2">{title}</H4> |
65 |
| - <div class="flex justify-end gap-x-1"> |
66 |
| - <Button onclick={onToggleView} variant="outline">Toggle View</Button> |
67 |
| - |
68 |
| - <CopyToClipboardButton value={json}></CopyToClipboardButton> |
69 |
| - |
70 |
| - {#if canPromote} |
71 |
| - {#if !isPromoted} |
72 |
| - <Button onclick={async () => await promote(title)} size="icon" title="Promote to Tab" |
73 |
| - ><ArrowUp /><span class="sr-only">Promote to Tab</span></Button |
74 |
| - > |
| 104 | + <div class="flex flex-col space-y-2"> |
| 105 | + <div class="flex items-center justify-between"> |
| 106 | + <H4>{title}</H4> |
| 107 | + <DropdownMenu.Root> |
| 108 | + <DropdownMenu.Trigger> |
| 109 | + <Button variant="ghost" size="icon" title="Options"> |
| 110 | + <MoreVertical class="size-4" /> |
| 111 | + </Button> |
| 112 | + </DropdownMenu.Trigger> |
| 113 | + <DropdownMenu.Content align="end"> |
| 114 | + <DropdownMenu.Group> |
| 115 | + <DropdownMenu.GroupHeading>Actions</DropdownMenu.GroupHeading> |
| 116 | + <DropdownMenu.Separator /> |
| 117 | + {#if canToggle} |
| 118 | + <DropdownMenu.Item onclick={onToggleView} title="Toggle between raw and structured view"> |
| 119 | + <ToggleLeft class="mr-2 size-4" /> |
| 120 | + Toggle View |
| 121 | + </DropdownMenu.Item> |
| 122 | + {/if} |
| 123 | + <DropdownMenu.Item onclick={copyToClipboard} title="Copy to clipboard"> |
| 124 | + <Copy class="mr-2 size-4" /> |
| 125 | + Copy to Clipboard |
| 126 | + </DropdownMenu.Item> |
| 127 | + {#if canPromote} |
| 128 | + {#if !isPromoted} |
| 129 | + <DropdownMenu.Item onclick={async () => await promote(title)} title="Promote to Tab"> |
| 130 | + <ArrowUp class="mr-2 size-4" /> |
| 131 | + Promote to Tab |
| 132 | + </DropdownMenu.Item> |
| 133 | + {:else} |
| 134 | + <DropdownMenu.Item onclick={async () => await demote(title)} title="Demote Tab"> |
| 135 | + <ArrowDown class="mr-2 size-4" /> |
| 136 | + Demote Tab |
| 137 | + </DropdownMenu.Item> |
| 138 | + {/if} |
| 139 | + {/if} |
| 140 | + </DropdownMenu.Group> |
| 141 | + </DropdownMenu.Content> |
| 142 | + </DropdownMenu.Root> |
| 143 | + </div> |
| 144 | + |
| 145 | + <div class="grow overflow-auto text-xs"> |
| 146 | + {#if showRaw || !canToggle} |
| 147 | + {#if showJSONCodeEditor} |
| 148 | + <CodeBlock {code} language="json" /> |
| 149 | + {:else if showXmlCodeEditor} |
| 150 | + <CodeBlock {code} language="xml" /> |
75 | 151 | {:else}
|
76 |
| - <Button onclick={async () => await demote(title)} size="icon" title="Demote Tab" |
77 |
| - ><ArrowDown /><span class="sr-only">Demote Tab</span></Button |
78 |
| - > |
| 152 | + <pre class="bg-muted rounded p-2 break-words whitespace-pre-wrap"><Code class="px-0"><div class="bg-inherit">{clipboardData}</div></Code |
| 153 | + ></pre> |
79 | 154 | {/if}
|
| 155 | + {:else} |
| 156 | + <ObjectDump value={filteredData} /> |
80 | 157 | {/if}
|
81 | 158 | </div>
|
82 | 159 | </div>
|
83 |
| - |
84 |
| - <div class="grow overflow-auto text-xs"> |
85 |
| - {#if showRaw} |
86 |
| - <pre class="bg-muted rounded p-2 break-words whitespace-pre-wrap"><Code class="px-0"><div class="bg-inherit">{json}</div></Code></pre> |
87 |
| - {:else} |
88 |
| - <ObjectDump value={filteredData} /> |
89 |
| - {/if} |
90 |
| - </div> |
91 | 160 | {/if}
|
0 commit comments