mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-12 16:05:43 +00:00
feat(frontend): Stat card improvement (#2709)
* feat(frontend): add class+ style support + added support for custom images * feat(frontend): add class+ style support + added support for custom images * feat(frontend): fix styling * feat(frontend): add quick style buttons * feat(frontend): fix quick tailwind class * feat(frontend): add space only if needed * feat(frontend): fix build * feat(frontend): fix line heights
This commit is contained in:
@@ -2,13 +2,14 @@
|
||||
import { getContext } from 'svelte'
|
||||
import { initConfig, initOutput } from '../../editor/appUtils'
|
||||
import type { AppViewerContext, ComponentCustomCSS, RichConfigurations } from '../../types'
|
||||
import { initCss } from '../../utils'
|
||||
import { getImageDataURL, initCss } from '../../utils'
|
||||
import { components } from '../../editor/component'
|
||||
import ResolveConfig from '../helpers/ResolveConfig.svelte'
|
||||
import ResolveStyle from '../helpers/ResolveStyle.svelte'
|
||||
import { ArrowDown } from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { loadIcon } from '../icon'
|
||||
import Loader from '../helpers/Loader.svelte'
|
||||
|
||||
export let id: string
|
||||
export let configuration: RichConfigurations
|
||||
@@ -28,13 +29,15 @@
|
||||
|
||||
let iconComponent: any
|
||||
|
||||
$: resolvedConfig.icon && handleIcon()
|
||||
$: isIcon && resolvedConfig?.media?.configuration?.icon.icon && handleIcon()
|
||||
|
||||
async function handleIcon() {
|
||||
if (resolvedConfig.icon) {
|
||||
iconComponent = await loadIcon(resolvedConfig.icon)
|
||||
if (resolvedConfig?.media?.configuration?.icon.icon) {
|
||||
iconComponent = await loadIcon(resolvedConfig.media.configuration.icon.icon)
|
||||
}
|
||||
}
|
||||
|
||||
$: isIcon = resolvedConfig.media?.selected == 'icon'
|
||||
</script>
|
||||
|
||||
{#each Object.keys(components['statcomponent'].initialData.configuration) as key (key)}
|
||||
@@ -57,16 +60,60 @@
|
||||
{/each}
|
||||
|
||||
{#if render}
|
||||
<div class="p-6 border rounded-md h-full flex flex-row gap-6 w-full items-center">
|
||||
{#if resolvedConfig.icon && iconComponent}
|
||||
<div class="bg-blue-500 rounded-md p-2">
|
||||
<svelte:component this={iconComponent} size={24} color="white" />
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class={twMerge(
|
||||
'flex flex-row gap-4 items-center p-4 rounded-md shadow-md h-full',
|
||||
css?.container?.class,
|
||||
'wm-statistic-card-container'
|
||||
)}
|
||||
style={css?.container?.style}
|
||||
>
|
||||
<div
|
||||
class={twMerge(
|
||||
'flex items-center justify-center w-12 h-12 border rounded-md p-2 text-black',
|
||||
css?.media?.class,
|
||||
'wm-statistic-card-media'
|
||||
)}
|
||||
style={css?.media?.style}
|
||||
>
|
||||
{#if isIcon}
|
||||
{#if resolvedConfig.media && iconComponent}
|
||||
<svelte:component this={iconComponent} size={24} />
|
||||
{/if}
|
||||
{:else}
|
||||
<Loader loading={resolvedConfig.media.configuration.image.source == undefined}>
|
||||
<img
|
||||
on:pointerdown|preventDefault
|
||||
src={getImageDataURL(
|
||||
resolvedConfig?.media?.configuration?.image?.sourceKind,
|
||||
resolvedConfig?.media?.configuration?.image?.source
|
||||
)}
|
||||
alt={resolvedConfig.title}
|
||||
/>
|
||||
</Loader>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="w-full">
|
||||
<div class="text-base font-normal text-primary">{resolvedConfig.title}</div>
|
||||
<div
|
||||
class={twMerge(
|
||||
'font-normal text-primary leading-none',
|
||||
css?.title?.class,
|
||||
'wm-statistic-card-title'
|
||||
)}
|
||||
style={css?.title?.style}
|
||||
>
|
||||
{resolvedConfig.title}
|
||||
</div>
|
||||
<div class="mt-1 flex items-baseline justify-between">
|
||||
<div class="flex items-baseline text-2xl font-semibold text-blue-600 dark:text-blue-200">
|
||||
<div
|
||||
class={twMerge(
|
||||
'flex items-baseline text-2xl leading-none font-semibold text-blue-600 dark:text-blue-200',
|
||||
css?.value?.class,
|
||||
'wm-statistic-card-value'
|
||||
)}
|
||||
style={css?.value?.style}
|
||||
>
|
||||
{resolvedConfig?.value}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2721,7 +2721,22 @@ This is a paragraph.
|
||||
icon: FileBarChart,
|
||||
documentationLink: `${documentationBaseUrl}/statistic_card`,
|
||||
dims: '2:4-3:4' as AppComponentDimensions,
|
||||
customCss: {},
|
||||
customCss: {
|
||||
title: {
|
||||
class: '',
|
||||
style: '',
|
||||
quickCss: ['font-size: 1rem', 'font-size: 1.5rem', 'font-size: 2rem'],
|
||||
quickTailwindClasses: ['text-xs', 'text-sm', 'text-base', 'text-lg', 'text-xl', 'text-2xl']
|
||||
},
|
||||
container: { class: '', style: '' },
|
||||
value: {
|
||||
class: '',
|
||||
style: '',
|
||||
quickCss: ['font-size: 1rem', 'font-size: 1.5rem', 'font-size: 2rem'],
|
||||
quickTailwindClasses: ['text-xs', 'text-sm', 'text-base', 'text-lg', 'text-xl', 'text-2xl']
|
||||
},
|
||||
media: { class: '', style: '' }
|
||||
},
|
||||
initialData: {
|
||||
configuration: {
|
||||
title: {
|
||||
@@ -2739,11 +2754,40 @@ This is a paragraph.
|
||||
value: 0,
|
||||
fieldType: 'number'
|
||||
},
|
||||
icon: {
|
||||
type: 'static',
|
||||
value: undefined,
|
||||
fieldType: 'icon-select'
|
||||
}
|
||||
media: {
|
||||
type: 'oneOf',
|
||||
selected: 'image',
|
||||
labels: {
|
||||
icon: 'Icon',
|
||||
image: 'Image'
|
||||
},
|
||||
configuration: {
|
||||
icon: {
|
||||
icon: {
|
||||
type: 'static',
|
||||
value: undefined,
|
||||
fieldType: 'icon-select'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
source: {
|
||||
type: 'static',
|
||||
value: '/logo.svg',
|
||||
fieldType: 'text',
|
||||
fileUpload: {
|
||||
accept: 'image/*',
|
||||
convertTo: 'base64'
|
||||
}
|
||||
},
|
||||
sourceKind: {
|
||||
fieldType: 'select',
|
||||
type: 'static',
|
||||
selectOptions: selectOptions.imageSourceKind,
|
||||
value: 'url' as (typeof selectOptions.imageSourceKind)[number]
|
||||
}
|
||||
}
|
||||
}
|
||||
} as const
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,173 +47,207 @@
|
||||
}
|
||||
|
||||
let dynamicClass: boolean = value?.evalClass !== undefined
|
||||
let render = 0
|
||||
</script>
|
||||
|
||||
<div class=" border-b flex justify-between items-center p-2 text-xs leading-6 font-bold">
|
||||
<div class="flex flex-col gap-1 w-full items-start">
|
||||
<div class="flex flex-row h-8 items-center justify-between w-full">
|
||||
<div class="capitalize">
|
||||
{addWhitespaceBeforeCapitals(name)}
|
||||
</div>
|
||||
{#if shouldDisplayLeft}
|
||||
<Button
|
||||
color="light"
|
||||
size="xs2"
|
||||
variant="border"
|
||||
on:click={() => {
|
||||
dispatch('left')
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-row gap-2 text-2xs items-center">
|
||||
<MoveLeft size={14} />
|
||||
Copy for this component
|
||||
</div>
|
||||
</Button>
|
||||
{/if}
|
||||
{#if shouldDisplayRight}
|
||||
<Button
|
||||
color="light"
|
||||
size="xs2"
|
||||
variant="border"
|
||||
on:click={() => {
|
||||
dispatch('right')
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-row gap-2 text-2xs items-center">
|
||||
Copy for every {componentType ? ccomponents[componentType].name : 'component'}
|
||||
<MoveRight size={14} />
|
||||
</div>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if wmClass}
|
||||
<Badge small>
|
||||
<div class="flex flex-row gap-1 items-center">
|
||||
{wmClass}
|
||||
{#key render}
|
||||
<div class=" border-b flex justify-between items-center p-2 text-xs leading-6 font-bold">
|
||||
<div class="flex flex-col gap-1 w-full items-start">
|
||||
<div class="flex flex-row h-8 items-center justify-between w-full">
|
||||
<div class="capitalize">
|
||||
{addWhitespaceBeforeCapitals(name)}
|
||||
</div>
|
||||
{#if shouldDisplayLeft}
|
||||
<Button
|
||||
color="light"
|
||||
size="xs2"
|
||||
variant="border"
|
||||
on:click={() => {
|
||||
copyToClipboard(wmClass)
|
||||
dispatch('left')
|
||||
}}
|
||||
>
|
||||
<Copy size={14} />
|
||||
<div class="flex flex-row gap-2 text-2xs items-center">
|
||||
<MoveLeft size={14} />
|
||||
Copy for this component
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if value}
|
||||
<div class="p-2">
|
||||
{#if tooltip}
|
||||
<div class="text-tertiary text-2xs py-2">{tooltip}</div>
|
||||
{/if}
|
||||
{#if value.style !== undefined || forceStyle}
|
||||
<div class="pb-2">
|
||||
<!-- svelte-ignore a11y-label-has-associated-control -->
|
||||
<label class="block w-full">
|
||||
<div class="flex flex-row justify-between items-center w-full h-8">
|
||||
<div class="text-xs font-medium text-tertiary"> Plain CSS </div>
|
||||
{#if overriden}
|
||||
<Badge color="red" small>Overriden by local</Badge>
|
||||
{:else if overridding}
|
||||
<Badge color="blue" small>Overriding global</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-1">
|
||||
<div class="relative grow">
|
||||
<ClearableInput
|
||||
bind:value={value.style}
|
||||
type="textarea"
|
||||
wrapperClass="h-full min-h-[72px]"
|
||||
inputClass="h-full !text-xs !rounded-none !p-2"
|
||||
/>
|
||||
{/if}
|
||||
{#if shouldDisplayRight}
|
||||
<Button
|
||||
color="light"
|
||||
size="xs2"
|
||||
variant="border"
|
||||
on:click={() => {
|
||||
dispatch('right')
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-row gap-2 text-2xs items-center">
|
||||
Copy for every {componentType ? ccomponents[componentType].name : 'component'}
|
||||
<MoveRight size={14} />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
{#if quickStyleProperties?.length}
|
||||
<Popover placement="bottom" notClickable disappearTimeout={0}>
|
||||
<Button
|
||||
variant="border"
|
||||
color="light"
|
||||
size="xs"
|
||||
btnClasses="!p-1 !w-[34px] !h-[34px] {isQuickMenuOpen
|
||||
? '!bg-gray-200/60 hover:!bg-gray-200 focus:!bg-gray-200'
|
||||
: ''}"
|
||||
aria-label="{isQuickMenuOpen ? 'Close' : 'Open'} styling menu"
|
||||
on:click={toggleQuickMenu}
|
||||
>
|
||||
<Paintbrush2 size={18} />
|
||||
</Button>
|
||||
<svelte:fragment slot="text">
|
||||
{isQuickMenuOpen ? 'Close' : 'Open'} styling menu
|
||||
</svelte:fragment>
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
{#if quickStyleProperties?.length && isQuickMenuOpen}
|
||||
<div transition:fade|local={{ duration: 200 }} class="w-full pt-1">
|
||||
<QuickStyleMenu
|
||||
bind:value={value.style}
|
||||
properties={quickStyleProperties}
|
||||
{componentType}
|
||||
componentProperty={name}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if value.class !== undefined || forceClass}
|
||||
<!-- svelte-ignore a11y-label-has-associated-control -->
|
||||
<label class="block">
|
||||
<div class="text-xs font-medium text-tertiary">
|
||||
Tailwind classes
|
||||
<Tooltip light documentationLink="https://tailwindcss.com/">
|
||||
Use any tailwind classes to style your component
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<SimpleEditor
|
||||
class="h-24"
|
||||
lang="tailwindcss"
|
||||
bind:code={value.class}
|
||||
fixedOverflowWidgets={true}
|
||||
small
|
||||
automaticLayout
|
||||
deno={false}
|
||||
subType="tailwind"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<Toggle
|
||||
options={{
|
||||
right: 'Use dynamic class'
|
||||
}}
|
||||
size="xs"
|
||||
bind:checked={dynamicClass}
|
||||
on:change={(e) => {
|
||||
if (e.detail && !value.evalClass) {
|
||||
value.evalClass = {
|
||||
type: 'evalv2',
|
||||
expr: '',
|
||||
connections: [],
|
||||
fieldType: 'text'
|
||||
}
|
||||
} else {
|
||||
value.evalClass = undefined
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if value?.evalClass && dynamicClass}
|
||||
<CssEval bind:evalClass={value.evalClass} />
|
||||
{/if}
|
||||
{#if wmClass}
|
||||
<Badge small>
|
||||
<div class="flex flex-row gap-1 items-center">
|
||||
{wmClass}
|
||||
<Button
|
||||
color="light"
|
||||
size="xs2"
|
||||
on:click={() => {
|
||||
copyToClipboard(wmClass)
|
||||
}}
|
||||
>
|
||||
<Copy size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if value}
|
||||
<div class="p-2 flex flex-col">
|
||||
{#if tooltip}
|
||||
<div class="text-tertiary text-2xs py-2">{tooltip}</div>
|
||||
{/if}
|
||||
{#if value.style !== undefined || forceStyle}
|
||||
<div class="pb-2">
|
||||
<!-- svelte-ignore a11y-label-has-associated-control -->
|
||||
<label class="block w-full">
|
||||
<div class="flex flex-row justify-between items-center w-full h-8">
|
||||
<div class="text-xs font-medium text-tertiary"> Plain CSS </div>
|
||||
{#if overriden}
|
||||
<Badge color="red" small>Overriden by local</Badge>
|
||||
{:else if overridding}
|
||||
<Badge color="blue" small>Overriding global</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-1">
|
||||
<div class="relative grow">
|
||||
<ClearableInput
|
||||
bind:value={value.style}
|
||||
type="textarea"
|
||||
wrapperClass="h-full min-h-[72px]"
|
||||
inputClass="h-full !text-xs !rounded-none !p-2"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
{#if quickStyleProperties?.length}
|
||||
<Popover placement="bottom" notClickable disappearTimeout={0}>
|
||||
<Button
|
||||
variant="border"
|
||||
color="light"
|
||||
size="xs"
|
||||
btnClasses="!p-1 !w-[34px] !h-[34px] {isQuickMenuOpen
|
||||
? '!bg-gray-200/60 hover:!bg-gray-200 focus:!bg-gray-200'
|
||||
: ''}"
|
||||
aria-label="{isQuickMenuOpen ? 'Close' : 'Open'} styling menu"
|
||||
on:click={toggleQuickMenu}
|
||||
>
|
||||
<Paintbrush2 size={18} />
|
||||
</Button>
|
||||
<svelte:fragment slot="text">
|
||||
{isQuickMenuOpen ? 'Close' : 'Open'} styling menu
|
||||
</svelte:fragment>
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
{#if quickStyleProperties?.length && isQuickMenuOpen}
|
||||
<div transition:fade|local={{ duration: 200 }} class="w-full pt-1">
|
||||
<QuickStyleMenu
|
||||
bind:value={value.style}
|
||||
properties={quickStyleProperties}
|
||||
{componentType}
|
||||
componentProperty={name}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if value.quickCss}
|
||||
<div class="flex flex-row gap-1 items-center mt-1 flex-wrap">
|
||||
{#each value.quickCss as v}
|
||||
<Badge
|
||||
small
|
||||
baseClass="cursor-pointer"
|
||||
on:click={() => {
|
||||
value.style = value.style === '' ? `${v};` : `${value.style} ${v};`
|
||||
}}
|
||||
>
|
||||
{v}
|
||||
</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if value.class !== undefined || forceClass}
|
||||
<!-- svelte-ignore a11y-label-has-associated-control -->
|
||||
<label class="block">
|
||||
<div class="text-xs font-medium text-tertiary">
|
||||
Tailwind classes
|
||||
<Tooltip light documentationLink="https://tailwindcss.com/">
|
||||
Use any tailwind classes to style your component
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<SimpleEditor
|
||||
class="h-24"
|
||||
lang="tailwindcss"
|
||||
bind:code={value.class}
|
||||
fixedOverflowWidgets={true}
|
||||
small
|
||||
automaticLayout
|
||||
deno={false}
|
||||
subType="tailwind"
|
||||
/>
|
||||
</div>
|
||||
{#if value.quickTailwindClasses}
|
||||
<div class="flex flex-row gap-1 items-center mt-1 flex-wrap">
|
||||
{#each value.quickTailwindClasses as cls}
|
||||
<Badge
|
||||
baseClass="cursor-pointer"
|
||||
small
|
||||
on:click={() => {
|
||||
value.class = value.class === '' ? cls : `${value.class} ${cls}`
|
||||
render++
|
||||
}}
|
||||
>
|
||||
{cls}
|
||||
</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<Toggle
|
||||
options={{
|
||||
right: 'Use dynamic class'
|
||||
}}
|
||||
size="xs"
|
||||
bind:checked={dynamicClass}
|
||||
on:change={(e) => {
|
||||
if (e.detail && !value.evalClass) {
|
||||
value.evalClass = {
|
||||
type: 'evalv2',
|
||||
expr: '',
|
||||
connections: [],
|
||||
fieldType: 'text'
|
||||
}
|
||||
} else {
|
||||
value.evalClass = undefined
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if value?.evalClass && dynamicClass}
|
||||
<CssEval bind:evalClass={value.evalClass} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/key}
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
placeholder={config?.['placeholder']}
|
||||
customTitle={config?.['customTitle']}
|
||||
tooltip={config?.['tooltip']}
|
||||
fileUpload={config?.['fileUpload']}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
@@ -44,6 +44,8 @@ export type ComponentCssProperty = {
|
||||
class?: string
|
||||
style?: string
|
||||
evalClass?: RichConfiguration
|
||||
quickCss?: readonly string[] | undefined
|
||||
quickTailwindClasses?: readonly string[] | undefined
|
||||
}
|
||||
|
||||
export type ComponentCustomCSS<T extends keyof typeof components> = Partial<
|
||||
@@ -173,7 +175,7 @@ export type ListContext = Writable<{
|
||||
disabled: boolean
|
||||
}>
|
||||
|
||||
export type ListInputs = {set: (id: string, value: any) => void, remove: (id: string) => void}
|
||||
export type ListInputs = { set: (id: string, value: any) => void; remove: (id: string) => void }
|
||||
|
||||
export type GroupContext = Writable<Record<string, any>>
|
||||
|
||||
|
||||
@@ -365,3 +365,20 @@ export function transformBareBase64IfNecessary(source: string | undefined) {
|
||||
return `data:application/octet-stream;base64,${source}`
|
||||
}
|
||||
}
|
||||
|
||||
export function getImageDataURL(imageKind: string | undefined, image: string | undefined) {
|
||||
if (!imageKind || !image) {
|
||||
return null
|
||||
}
|
||||
|
||||
switch (imageKind) {
|
||||
case 'png encoded as base64':
|
||||
return 'data:image/png;base64,' + image
|
||||
case 'jpeg encoded as base64':
|
||||
return 'data:image/jpeg;base64,' + image
|
||||
case 'svg encoded as base64':
|
||||
return 'data:image/svg+xml;base64,' + image
|
||||
default:
|
||||
return image
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user