mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 16:02:19 +00:00
feat(frontend): Button with popup (#639)
* feat(frontend): Add ButtonPopup component
This commit is contained in:
@@ -1,29 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
import { classNames } from '$lib/utils'
|
||||
import Icon from 'svelte-awesome'
|
||||
import type { Button } from './model'
|
||||
import { ButtonType } from './model'
|
||||
|
||||
export let size: Button.Size = 'md'
|
||||
export let spacingSize: Button.Size = size
|
||||
export let color: Button.Color = 'blue'
|
||||
export let variant: Button.Variant = 'contained'
|
||||
export let size: ButtonType.Size = 'md'
|
||||
export let spacingSize: ButtonType.Size = size
|
||||
export let color: ButtonType.Color = 'blue'
|
||||
export let variant: ButtonType.Variant = 'contained'
|
||||
export let btnClasses: string = ''
|
||||
export let disabled: boolean = false
|
||||
export let href: string | undefined = undefined
|
||||
export let target: Button.Target = '_self'
|
||||
export let target: ButtonType.Target = '_self'
|
||||
export let iconOnly: boolean = false
|
||||
export let startIcon: ButtonType.Icon | undefined = undefined
|
||||
export let endIcon: ButtonType.Icon | undefined = undefined
|
||||
export let element: ButtonType.Element | undefined = undefined
|
||||
export let id: string = ''
|
||||
|
||||
export let startIcon: { icon: any; classes?: string } | undefined = undefined
|
||||
export let endIcon: { icon: any; classes?: string } | undefined = undefined
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
// Order of classes: border, border modifier, bg, bg modifier, text, text modifier, everything else
|
||||
const colorVariants: Record<Button.Color, Record<Button.Variant, string>> = {
|
||||
const colorVariants: Record<ButtonType.Color, Record<ButtonType.Variant, string>> = {
|
||||
blue: {
|
||||
border:
|
||||
'border-blue-500 hover:border-blue-700 bg-white hover:bg-blue-100 text-blue-500 hover:text-blue-700 focus:ring-blue-300',
|
||||
contained: 'bg-blue-500 hover:bg-blue-700 text-white focus:ring-blue-300'
|
||||
'border-blue-500 hover:border-blue-700 focus:border-blue-700 bg-white hover:bg-blue-100 focus:bg-blue-100 text-blue-500 hover:text-blue-700 focus:text-blue-700 focus:ring-blue-300',
|
||||
contained: 'bg-blue-500 hover:bg-blue-700 focus:bg-blue-700 text-white focus:ring-blue-300'
|
||||
},
|
||||
red: {
|
||||
border:
|
||||
@@ -32,102 +34,62 @@
|
||||
},
|
||||
dark: {
|
||||
border:
|
||||
'border-gray-800 hover:border-gray-900 bg-white hover:bg-gray-200 text-gray-800 hover:text-gray-900 focus:ring-gray-300',
|
||||
contained: 'bg-gray-700 hover:bg-gray-900 text-white focus:ring-gray-300'
|
||||
'border-gray-800 hover:border-gray-900 focus:border-gray-900 bg-white hover:bg-gray-200 focus:bg-gray-200 text-gray-800 hover:text-gray-900 focus:text-gray-900 focus:ring-gray-300',
|
||||
contained: 'bg-gray-700 hover:bg-gray-900 focus:bg-gray-900 text-white focus:ring-gray-300'
|
||||
},
|
||||
light: {
|
||||
border:
|
||||
'border bg-white hover:bg-gray-100 text-gray-700 hover:text-gray-800 focus:ring-gray-300',
|
||||
contained: 'bg-white hover:bg-gray-100 text-gray-700 focus:ring-gray-300'
|
||||
'border bg-white hover:bg-gray-100 focus:bg-gray-100 text-gray-700 hover:text-gray-800 focus:text-gray-800 focus:ring-gray-300',
|
||||
contained: 'bg-white hover:bg-gray-100 focus:bg-gray-100 text-gray-700 focus:ring-gray-300'
|
||||
}
|
||||
}
|
||||
|
||||
const fontSizeClasses: Record<Button.Size, string> = {
|
||||
xs: 'text-xs',
|
||||
sm: 'text-sm',
|
||||
md: 'text-md',
|
||||
lg: 'text-lg',
|
||||
xl: 'text-xl'
|
||||
}
|
||||
|
||||
const spacingClasses: Record<Button.Size, string> = {
|
||||
xs: 'px-3 py-1.5',
|
||||
sm: 'px-3 py-1.5',
|
||||
md: 'px-4 py-2',
|
||||
lg: 'px-4 py-2',
|
||||
xl: 'px-4 py-2'
|
||||
}
|
||||
const iconScale: Record<Button.Size, number> = {
|
||||
xs: 0.6,
|
||||
sm: 0.8,
|
||||
md: 1,
|
||||
lg: 1.1,
|
||||
xl: 1.2
|
||||
}
|
||||
|
||||
$: buttonProps = {
|
||||
class: classNames(
|
||||
colorVariants[color][variant],
|
||||
variant === 'border' ? 'border' : '',
|
||||
fontSizeClasses[size],
|
||||
spacingClasses[spacingSize],
|
||||
ButtonType.FontSizeClasses[size],
|
||||
ButtonType.SpacingClasses[spacingSize],
|
||||
'focus:ring-4 font-medium',
|
||||
'rounded-md',
|
||||
'flex justify-center items-center text-center whitespace-nowrap',
|
||||
btnClasses,
|
||||
disabled ? 'pointer-events-none cursor-default filter grayscale' : ''
|
||||
),
|
||||
disabled
|
||||
disabled,
|
||||
href,
|
||||
target,
|
||||
tabindex: disabled ? -1 : 0
|
||||
}
|
||||
|
||||
function onClick(event: MouseEvent) {
|
||||
dispatch('click', event)
|
||||
if (href) goto(href)
|
||||
}
|
||||
|
||||
$: isSmall = size === 'xs' || size === 'sm'
|
||||
$: startIconClass = classNames(
|
||||
iconOnly ? undefined : isSmall ? 'mr-1' : 'mr-2',
|
||||
startIcon?.classes
|
||||
)
|
||||
$: endIconClass = classNames(iconOnly ? undefined : isSmall ? 'ml-1' : 'ml-2', endIcon?.classes)
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<button
|
||||
{id}
|
||||
type="button"
|
||||
on:click|stopPropagation={() => goto(href ?? '#')}
|
||||
{target}
|
||||
tabindex={disabled ? -1 : 0}
|
||||
{...buttonProps}
|
||||
>
|
||||
{#if startIcon}
|
||||
<Icon
|
||||
data={startIcon.icon}
|
||||
class={classNames(iconOnly ? undefined : 'mr-2', startIcon.classes)}
|
||||
scale={iconScale[size]}
|
||||
/>
|
||||
{/if}
|
||||
{#if !iconOnly}
|
||||
<slot />
|
||||
{/if}
|
||||
{#if endIcon}
|
||||
<Icon
|
||||
data={endIcon.icon}
|
||||
class={classNames(iconOnly ? undefined : 'ml-2', endIcon.classes)}
|
||||
scale={iconScale[size]}
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<button {id} type="button" on:click|stopPropagation {...buttonProps} {...$$restProps}>
|
||||
{#if startIcon}
|
||||
<Icon
|
||||
data={startIcon.icon}
|
||||
class={classNames(iconOnly ? undefined : 'mr-2', startIcon.classes)}
|
||||
scale={iconScale[size]}
|
||||
/>
|
||||
{/if}
|
||||
{#if !iconOnly}
|
||||
<slot />
|
||||
{/if}
|
||||
{#if endIcon}
|
||||
<Icon
|
||||
data={endIcon.icon}
|
||||
class={classNames(iconOnly ? undefined : 'ml-2', endIcon.classes)}
|
||||
scale={iconScale[size]}
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<svelte:element
|
||||
this={href ? 'a' : 'button'}
|
||||
bind:this={element}
|
||||
on:click|stopPropagation={onClick}
|
||||
on:focus
|
||||
on:blur
|
||||
{...buttonProps}
|
||||
>
|
||||
{#if startIcon}
|
||||
<Icon data={startIcon.icon} class={startIconClass} scale={ButtonType.IconScale[size]} />
|
||||
{/if}
|
||||
{#if !iconOnly}
|
||||
<slot />
|
||||
{/if}
|
||||
{#if endIcon}
|
||||
<Icon data={endIcon.icon} class={endIconClass} scale={ButtonType.IconScale[size]} />
|
||||
{/if}
|
||||
</svelte:element>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import { faChevronDown } from '@fortawesome/free-solid-svg-icons'
|
||||
import { setContext } from 'svelte'
|
||||
import Icon from 'svelte-awesome'
|
||||
import { Button, ButtonType, Popup } from '..'
|
||||
|
||||
export let size: ButtonType.Size = 'md'
|
||||
export let color: ButtonType.Color = 'blue'
|
||||
export let variant: ButtonType.Variant = 'contained'
|
||||
export let mainClasses: string = ''
|
||||
export let toggleClasses: string = ''
|
||||
export let disabled: boolean = false
|
||||
export let href: string | undefined = undefined
|
||||
export let target: ButtonType.Target = '_self'
|
||||
export let startIcon: ButtonType.Icon | undefined = undefined
|
||||
export let endIcon: ButtonType.Icon | undefined = undefined
|
||||
|
||||
let ref: ButtonType.Element
|
||||
|
||||
setContext<ButtonType.ItemProps>(ButtonType.ItemContextKey, { size, color })
|
||||
|
||||
$: separator = color === 'red' || color === 'blue' ? 'border-gray-200' : 'border-gray-400'
|
||||
$: commonProps = {
|
||||
size,
|
||||
color,
|
||||
variant,
|
||||
disabled
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex justy-start items-center">
|
||||
{#if $$slots.main}
|
||||
<Button
|
||||
{...commonProps}
|
||||
{href}
|
||||
{target}
|
||||
{startIcon}
|
||||
{endIcon}
|
||||
btnClasses="!rounded-r-none !border-r-0 {mainClasses}"
|
||||
on:click
|
||||
>
|
||||
<slot name="main" />
|
||||
</Button>
|
||||
{/if}
|
||||
<span class={$$slots.main && variant === 'contained' ? 'border-l ' + separator : ''}>
|
||||
<Button
|
||||
bind:element={ref}
|
||||
{...commonProps}
|
||||
btnClasses="{$$slots.main ? '!rounded-l-none' : ''} {toggleClasses}"
|
||||
>
|
||||
<slot name="toggle">
|
||||
<!-- Invisible, but needed to match the height of the 'main' button -->
|
||||
<span class="!opacity-0 !w-0">A</span>
|
||||
<Icon data={faChevronDown} scale={ButtonType.IconScale[size]} />
|
||||
</slot>
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
{#if ref}
|
||||
<Popup
|
||||
{ref}
|
||||
options={{
|
||||
placement: $$slots.main ? 'bottom-end' : 'bottom',
|
||||
strategy: 'absolute',
|
||||
modifiers: [{ name: 'offset', options: { offset: [0, 0] } }]
|
||||
}}
|
||||
>
|
||||
<ul class="bg-white rounded-t border pt-1 pb-2 max-h-40 overflow-auto">
|
||||
<slot />
|
||||
</ul>
|
||||
</Popup>
|
||||
{/if}
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import { Button, ButtonType } from '..'
|
||||
import { classNames } from '../../../utils'
|
||||
|
||||
export let btnClasses: string = ''
|
||||
export let disabled: boolean = false
|
||||
export let href: string | undefined = undefined
|
||||
export let target: ButtonType.Target = '_self'
|
||||
export let iconOnly: boolean = false
|
||||
export let startIcon: ButtonType.Icon | undefined = undefined
|
||||
export let endIcon: ButtonType.Icon | undefined = undefined
|
||||
|
||||
const props = getContext<ButtonType.ItemProps | undefined>(ButtonType.ItemContextKey)
|
||||
const iconWidthClass: Record<ButtonType.Size, string> = {
|
||||
xs: '!w-[12px]',
|
||||
sm: '!w-[14px]',
|
||||
md: '!w-[16px]',
|
||||
lg: '!w-[18px]',
|
||||
xl: '!w-[20px]'
|
||||
}
|
||||
|
||||
const getWidthClass = () => (props?.size ? iconWidthClass[props.size] : undefined)
|
||||
|
||||
$: buttonProps = {
|
||||
...props,
|
||||
variant: <ButtonType.Variant>'border',
|
||||
btnClasses: classNames(btnClasses, '!justify-start !border-0 !rounded-none !w-full'),
|
||||
disabled,
|
||||
href,
|
||||
target,
|
||||
iconOnly,
|
||||
startIcon: startIcon
|
||||
? {
|
||||
icon: startIcon.icon,
|
||||
classes: classNames(startIcon?.classes, getWidthClass())
|
||||
}
|
||||
: undefined,
|
||||
endIcon: endIcon
|
||||
? {
|
||||
icon: endIcon.icon,
|
||||
classes: classNames(endIcon?.classes, getWidthClass())
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
</script>
|
||||
|
||||
<li class="mt-1">
|
||||
<Button {...buttonProps} on:click>
|
||||
<slot />
|
||||
</Button>
|
||||
</li>
|
||||
@@ -1,6 +1,44 @@
|
||||
export namespace Button {
|
||||
export namespace ButtonType {
|
||||
export type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
export type Color = 'blue' | 'red' | 'dark' | 'light'
|
||||
export type Variant = 'contained' | 'border'
|
||||
export type Target = '_self' | '_blank'
|
||||
export type Element = HTMLButtonElement | HTMLAnchorElement
|
||||
export interface Icon {
|
||||
icon: any
|
||||
classes?: string
|
||||
}
|
||||
|
||||
export const FontSizeClasses: Record<ButtonType.Size, string> = {
|
||||
xs: 'text-xs',
|
||||
sm: 'text-sm',
|
||||
md: 'text-md',
|
||||
lg: 'text-lg',
|
||||
xl: 'text-xl'
|
||||
} as const
|
||||
|
||||
export const SpacingClasses: Record<ButtonType.Size, string> = {
|
||||
xs: 'px-3 py-1.5',
|
||||
sm: 'px-3 py-1.5',
|
||||
md: 'px-4 py-2',
|
||||
lg: 'px-4 py-2',
|
||||
xl: 'px-4 py-2'
|
||||
} as const
|
||||
|
||||
export const IconScale: Record<ButtonType.Size, number> = {
|
||||
xs: 0.7,
|
||||
sm: 0.8,
|
||||
md: 1,
|
||||
lg: 1.1,
|
||||
xl: 1.2
|
||||
} as const
|
||||
|
||||
// ButtonPopup types
|
||||
|
||||
export const ItemContextKey = 'popupItemProps' as const
|
||||
|
||||
export interface ItemProps {
|
||||
size: Size
|
||||
color: Color
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
open = !open
|
||||
}
|
||||
|
||||
$: open ? dispatch('open') : dispatch('close')
|
||||
|
||||
onMount(() => {
|
||||
mounted = true
|
||||
scrollLock(open)
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
const dispatch = createEventDispatcher()
|
||||
</script>
|
||||
|
||||
<div class="divide-y h-screen">
|
||||
<div class="flex flex-col divide-y h-screen">
|
||||
<div class="flex justify-between items-center py-2 px-4">
|
||||
<span class="text-sm font-bold">{title}</span>
|
||||
<button on:click={() => dispatch('close')}>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
export { default as ActionRow } from './actionRow/ActionRow.svelte'
|
||||
export { default as Badge } from './badge/Badge.svelte'
|
||||
export { default as Button } from './button/Button.svelte'
|
||||
export { default as ButtonPopup } from './button/ButtonPopup.svelte'
|
||||
export { default as ButtonPopupItem } from './button/ButtonPopupItem.svelte'
|
||||
export { default as Drawer } from './drawer/Drawer.svelte'
|
||||
export { default as DrawerContent } from './drawer/DrawerContent.svelte'
|
||||
export { default as Kbd } from './kbd/Kbd.svelte'
|
||||
export { default as Menu } from './menu/Menu.svelte'
|
||||
export { default as MenuItem } from './menu/MenuItem.svelte'
|
||||
export { default as Popup } from './popup/Popup.svelte'
|
||||
export { default as Tab } from './tabs/Tab.svelte'
|
||||
export { default as TabContent } from './tabs/TabContent.svelte'
|
||||
export { default as Tabs } from './tabs/Tabs.svelte'
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<kbd
|
||||
class="mx-1 px-2 py-1.5 text-xs font-semibold text-gray-800
|
||||
bg-gray-100 border border-gray-200 rounded-lg
|
||||
dark:bg-gray-600 dark:text-gray-100 dark:border-gray-500 {$$props.class}"
|
||||
>
|
||||
<slot />
|
||||
</kbd>
|
||||
@@ -0,0 +1,161 @@
|
||||
<svelte:options accessors />
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import { slide } from 'svelte/transition'
|
||||
import { createPopperActions, type PopperOptions } from 'svelte-popperjs'
|
||||
import { clickOutside } from '../../../utils'
|
||||
import { Kbd } from '..'
|
||||
import { createStateMachine } from '../../../stateMachine'
|
||||
|
||||
export let ref: HTMLElement
|
||||
export let options: PopperOptions<any> = { placement: 'auto' }
|
||||
/** Events on the reference element */
|
||||
export let openOn: (keyof HTMLElementEventMap)[] = ['focus']
|
||||
/** Events on the reference element */
|
||||
export let closeOn: (keyof HTMLElementEventMap)[] = ['blur']
|
||||
export let disableInstruction = false
|
||||
export let innerClasses = ''
|
||||
export let outerClasses = ''
|
||||
|
||||
const states = ['closed', 'open-focus-in', 'open-focus-out'] as const
|
||||
const stateMachine = createStateMachine(states, {
|
||||
to: {
|
||||
closed: ({ previousState, currentState }) => {
|
||||
const activeElem = document.activeElement
|
||||
const revert = popup.contains(activeElem) || ref.contains(activeElem)
|
||||
return revert ? previousState : currentState
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const [popperRef, popperContent] = createPopperActions()
|
||||
let popup: HTMLElement
|
||||
let focusableElements: HTMLElement[]
|
||||
|
||||
function getFocusableElements() {
|
||||
let elements: HTMLElement[] = []
|
||||
|
||||
popup
|
||||
.querySelectorAll<HTMLElement>(
|
||||
'a[href], button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
.forEach((elem) => elements.push(elem))
|
||||
|
||||
focusableElements = elements.filter(
|
||||
(el) => !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden')
|
||||
)
|
||||
focusableElements.forEach((el) => {
|
||||
el.tabIndex = -1
|
||||
el.addEventListener('click', openFocusIn)
|
||||
el.addEventListener('blur', closed)
|
||||
})
|
||||
}
|
||||
|
||||
function closed() {
|
||||
if ($stateMachine.currentState === 'open-focus-out') {
|
||||
setTimeout(() => {
|
||||
stateMachine.setState('closed')
|
||||
}, 0)
|
||||
} else {
|
||||
stateMachine.setState('closed')
|
||||
}
|
||||
}
|
||||
function openFocusOut() {
|
||||
stateMachine.setState('open-focus-out')
|
||||
}
|
||||
function openFocusIn() {
|
||||
stateMachine.setState('open-focus-in')
|
||||
}
|
||||
|
||||
function keyDown(event: KeyboardEvent & { currentTarget: EventTarget & Window }) {
|
||||
const modifiers = ['Shift', 'Control', 'Command', 'Alt']
|
||||
// Prevent closing the popup when the only key pressed is a modifier key
|
||||
if (modifiers.includes(event.key) || $stateMachine.currentState === 'closed') return
|
||||
if (event.key === 'Escape') {
|
||||
return (<HTMLElement>document.activeElement)?.blur()
|
||||
}
|
||||
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
if (popup.contains(document.activeElement)) {
|
||||
const index = focusableElements.findIndex((elem) => elem === document.activeElement)
|
||||
if (index === -1) return
|
||||
|
||||
let targetIndex: number | undefined = undefined
|
||||
if (event.key === 'ArrowUp') {
|
||||
targetIndex = index === 0 ? focusableElements.length - 1 : index - 1
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
targetIndex = index + 1 === focusableElements.length ? 0 : index + 1
|
||||
}
|
||||
if (targetIndex !== undefined) {
|
||||
focusableElements[targetIndex].focus()
|
||||
stateMachine.setState('open-focus-in')
|
||||
}
|
||||
} else {
|
||||
const elem = focusableElements[event.key === 'ArrowUp' ? focusableElements.length - 1 : 0]
|
||||
if (elem) {
|
||||
elem.focus()
|
||||
stateMachine.setState('open-focus-in')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addRefListeners() {
|
||||
openOn.forEach((action) => ref.addEventListener(action, openFocusOut))
|
||||
closeOn.forEach((action) => ref.addEventListener(action, closed))
|
||||
}
|
||||
|
||||
function removeAllListeners() {
|
||||
focusableElements?.forEach((el) => el.removeEventListener('click', openFocusIn))
|
||||
focusableElements?.forEach((el) => el.removeEventListener('blur', closed))
|
||||
openOn.forEach((action) => ref.removeEventListener(action, openFocusOut))
|
||||
closeOn.forEach((action) => ref.removeEventListener(action, closed))
|
||||
}
|
||||
|
||||
$: if ($stateMachine.currentState === 'closed') {
|
||||
focusableElements?.forEach((el) => el.removeEventListener('click', openFocusIn))
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
getFocusableElements()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
popperRef(ref)
|
||||
addRefListeners()
|
||||
})
|
||||
|
||||
onDestroy(removeAllListeners)
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={keyDown} />
|
||||
|
||||
<div
|
||||
bind:this={popup}
|
||||
use:popperContent={options}
|
||||
use:clickOutside
|
||||
on:click_outside={closed}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={$stateMachine.currentState !== 'closed'}
|
||||
>
|
||||
{#if $stateMachine.currentState !== 'closed'}
|
||||
<div transition:slide={{ duration: 200 }} class={outerClasses}>
|
||||
<div class={innerClasses}>
|
||||
<slot />
|
||||
</div>
|
||||
{#if !disableInstruction && focusableElements?.length}
|
||||
<div
|
||||
class="flex justify-center items-center font-semibold
|
||||
text-xs text-gray-700 p-1 border-x border-b rounded-b bg-gray-100"
|
||||
>
|
||||
Use
|
||||
<Kbd class="!bg-gray-200 !border-gray-300 !px-1 !py-0 !rounded-sm">↑</Kbd>
|
||||
and
|
||||
<Kbd class="!bg-gray-200 !border-gray-300 !px-1 !py-0 !rounded-sm">↓</Kbd>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -85,8 +85,8 @@
|
||||
variant="border"
|
||||
startIcon={{ icon: faTrashAlt }}
|
||||
{iconOnly}
|
||||
on:click={(event) => {
|
||||
if (event.shiftKey || shouldPick) {
|
||||
on:click={({ detail }) => {
|
||||
if (detail.shiftKey || shouldPick) {
|
||||
dispatch('delete')
|
||||
select('settings')
|
||||
} else {
|
||||
|
||||
@@ -6,13 +6,12 @@
|
||||
import { hubScripts } from '$lib/stores'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { Script } from '$lib/gen'
|
||||
import type { HubItem } from './model'
|
||||
|
||||
export let kind: Script.kind
|
||||
|
||||
type Item = { summary: String; path: String; version?: String }
|
||||
|
||||
let items: Item[] | undefined
|
||||
$: items = $hubScripts?.filter((x) => x.kind == kind)
|
||||
let items: HubItem[]
|
||||
$: items = $hubScripts?.filter((x) => x.kind == kind) ?? []
|
||||
let itemPicker: ItemPicker
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface HubItem {
|
||||
summary: String
|
||||
path: String
|
||||
version?: String
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import { faPlus } from '@fortawesome/free-solid-svg-icons'
|
||||
import Fuse from 'fuse.js'
|
||||
import { Script } from '$lib/gen'
|
||||
import { ScriptService } from '$lib/gen'
|
||||
import { workspaceStore, hubScripts } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/utils'
|
||||
import { Button, ButtonPopup, ButtonPopupItem } from '$lib/components/common'
|
||||
import ItemPicker from '../ItemPicker.svelte'
|
||||
import type { HubItem } from '../flows/pickers/model'
|
||||
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
|
||||
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
|
||||
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
|
||||
import { flowStore, initFlow } from '$lib/components/flows/flowStore'
|
||||
|
||||
const drawers: {
|
||||
hub: ItemPicker | undefined
|
||||
template: Drawer | undefined
|
||||
json: Drawer | undefined
|
||||
} = {
|
||||
hub: undefined,
|
||||
template: undefined,
|
||||
json: undefined
|
||||
}
|
||||
let hubItems: HubItem[]
|
||||
let pendingJson: string
|
||||
let templateScripts: Script[] = []
|
||||
let templateFilter = ''
|
||||
let filteredTemplates: Script[] | undefined
|
||||
const fuseOptions = {
|
||||
includeScore: false,
|
||||
keys: ['description', 'path', 'content', 'hash', 'summary']
|
||||
}
|
||||
const templateFuse: Fuse<Script> = new Fuse(templateScripts, fuseOptions)
|
||||
|
||||
$: hubItems = $hubScripts?.filter((x) => x.kind == Script.kind.SCRIPT) || []
|
||||
|
||||
$: filteredTemplates =
|
||||
templateFilter.length > 0
|
||||
? templateFuse.search(templateFilter).map((value) => value.item)
|
||||
: templateScripts
|
||||
|
||||
function importJson() {
|
||||
Object.assign($flowStore, JSON.parse(pendingJson))
|
||||
|
||||
initFlow($flowStore)
|
||||
sendUserToast('OpenFlow imported from JSON')
|
||||
drawers.json?.toggleDrawer()
|
||||
}
|
||||
|
||||
async function loadTemplateScripts(): Promise<void> {
|
||||
templateScripts = await ScriptService.listScripts({
|
||||
workspace: $workspaceStore!,
|
||||
isTemplate: true
|
||||
})
|
||||
templateFuse.setCollection(templateScripts)
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="flex flex-row gap-2">
|
||||
<ButtonPopup size="sm" startIcon={{ icon: faPlus }} href="/scripts/add">
|
||||
<svelte:fragment slot="main">New script</svelte:fragment>
|
||||
<ButtonPopupItem on:click={() => drawers.hub?.openModal()}>
|
||||
Import script from WindmillHub
|
||||
</ButtonPopupItem>
|
||||
<ButtonPopupItem on:click={() => drawers.template?.toggleDrawer()}>
|
||||
Import script from template
|
||||
</ButtonPopupItem>
|
||||
<ButtonPopupItem on:click={() => drawers.json?.toggleDrawer()}>
|
||||
Import script from raw JSON
|
||||
</ButtonPopupItem>
|
||||
</ButtonPopup>
|
||||
</div>
|
||||
|
||||
<!-- Initially hidden elements in a drawer -->
|
||||
<!-- WindmillHub script list -->
|
||||
<ItemPicker
|
||||
bind:this={drawers.hub}
|
||||
pickCallback={(path) => {
|
||||
console.log('pick', { path })
|
||||
goto('/scripts/add?hub=' + path)
|
||||
}}
|
||||
itemName={'Script'}
|
||||
extraField="summary"
|
||||
loadItems={async () => {
|
||||
return hubItems
|
||||
}}
|
||||
/>
|
||||
<!-- Template script list -->
|
||||
<Drawer bind:this={drawers.template} size="800px" on:open={loadTemplateScripts}>
|
||||
<DrawerContent title="Pick a template" on:close={() => drawers.template?.toggleDrawer()}>
|
||||
<div class="pt-2 pb-4">
|
||||
<input placeholder="Search templates" bind:value={templateFilter} class="search-bar" />
|
||||
</div>
|
||||
<div class="flex flex-col mb-2 md:mb-6">
|
||||
{#if filteredTemplates && filteredTemplates.length > 0}
|
||||
{#each filteredTemplates as { summary, path, hash }}
|
||||
<a
|
||||
class="p-1 flex flex-row items-baseline gap-2 selected text-gray-700"
|
||||
href="/scripts/add?template={path}"
|
||||
>
|
||||
{#if summary}
|
||||
<p class="text-sm font-semibold">{summary}</p>
|
||||
{/if}
|
||||
|
||||
<p class="text-sm">{path}</p>
|
||||
<p class="text-gray-400 text-xs text-right grow">
|
||||
Last version: {hash}
|
||||
</p>
|
||||
</a>
|
||||
{/each}
|
||||
{:else}
|
||||
<p class="text-sm text-gray-700">No templates</p>
|
||||
{/if}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
<!-- Raw JSON -->
|
||||
<Drawer bind:this={drawers.json} size="800px">
|
||||
<DrawerContent title="Import JSON" on:close={() => drawers.json?.toggleDrawer()}>
|
||||
<div class="p-2"><Button size="sm" on:click={importJson}>Import</Button></div>
|
||||
<SimpleEditor bind:code={pendingJson} lang="json" class="h-full" />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
@@ -0,0 +1,77 @@
|
||||
import { writable, type Readable } from 'svelte/store'
|
||||
|
||||
export interface StateMachine<T extends readonly string[]> {
|
||||
states: T
|
||||
currentState: T[number]
|
||||
}
|
||||
|
||||
export interface StateMachineTransition<T extends readonly string[]> {
|
||||
from?: Partial<Record<T[number], TransitionFromFunction<T>>>
|
||||
to?: Partial<Record<T[number], TransitionToFunction<T>>>
|
||||
}
|
||||
|
||||
type StateMachineInfo<T extends readonly string[]> = {
|
||||
states: StateMachine<T>['states']
|
||||
currentState: StateMachine<T>['currentState']
|
||||
}
|
||||
|
||||
/** The return value should be a `state` that is available on the current machine. */
|
||||
export type TransitionFromFunction<T extends readonly string[]> = (
|
||||
info: StateMachineInfo<T> & { desiredState: T[number] }
|
||||
) => T[number]
|
||||
|
||||
/** Callback after the state has been changed. */
|
||||
export type TransitionToFunction<T extends readonly string[]> = (
|
||||
info: StateMachineInfo<T> & { previousState: T[number] }
|
||||
) => T[number]
|
||||
|
||||
type StateStore<T extends readonly string[]> = Readable<StateMachine<T>> & {
|
||||
setState: (state: T[number]) => StateMachineInfo<T>
|
||||
}
|
||||
|
||||
/** **IMPORTANT:** use the `as const` syntax on the states array to get type safety.
|
||||
* *Example: `createStateMachine(['foo', 'bar'] as const)`*
|
||||
*
|
||||
* Returns a new state machine with the default state set to the first element of the `states` argument. */
|
||||
export function createStateMachine<T extends readonly string[]>(
|
||||
states: T,
|
||||
transition: StateMachineTransition<T> = {}
|
||||
): StateStore<T> {
|
||||
const defaultValue: StateMachine<T> = {
|
||||
states,
|
||||
currentState: states[0]
|
||||
}
|
||||
const defaultStore = writable(defaultValue)
|
||||
const stateStore: StateStore<T> = {
|
||||
subscribe: defaultStore.subscribe,
|
||||
setState: (nextState) => {
|
||||
defaultStore.update((prev) => {
|
||||
const previousState = prev.currentState
|
||||
const beforeFunc = transition?.from && transition.from[previousState]
|
||||
const afterFunc = transition?.to && transition.to[nextState]
|
||||
let returnState = nextState
|
||||
|
||||
if (beforeFunc) {
|
||||
returnState = beforeFunc({
|
||||
states,
|
||||
currentState: previousState,
|
||||
desiredState: nextState
|
||||
})
|
||||
}
|
||||
if (afterFunc) {
|
||||
returnState = afterFunc({
|
||||
states,
|
||||
currentState: returnState,
|
||||
previousState
|
||||
})
|
||||
}
|
||||
|
||||
prev.currentState = returnState
|
||||
return prev
|
||||
})
|
||||
return { states, currentState: nextState }
|
||||
}
|
||||
}
|
||||
|
||||
return stateStore
|
||||
}
|
||||
@@ -99,11 +99,10 @@ export function validatePassword(password: string): boolean {
|
||||
return re.test(password)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function clickOutside(node: any): any {
|
||||
const handleClick = (event: Event) => {
|
||||
if (node && !node.contains(event.target) && !event.defaultPrevented) {
|
||||
node.dispatchEvent(new CustomEvent('click_outside', node))
|
||||
export function clickOutside(node: Node): { destroy(): void } {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (node && !node.contains(<HTMLElement>event.target) && !event.defaultPrevented) {
|
||||
node.dispatchEvent(new CustomEvent<MouseEvent>('click_outside', { detail: event }))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,9 @@
|
||||
faEye,
|
||||
faList,
|
||||
faPlay,
|
||||
faPlus,
|
||||
faShare
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import Fuse from 'fuse.js'
|
||||
import Icon from 'svelte-awesome'
|
||||
import type { Script } from '$lib/gen'
|
||||
import { ScriptService } from '$lib/gen'
|
||||
import { superadmin, userStore, workspaceStore, hubScripts } from '$lib/stores'
|
||||
@@ -44,6 +42,7 @@
|
||||
import { Highlight } from 'svelte-highlight'
|
||||
import { typescript } from 'svelte-highlight/languages/typescript'
|
||||
import { Button } from '$lib/components/common'
|
||||
import CreateActions from '$lib/components/scripts/CreateActions.svelte'
|
||||
|
||||
type Tab = 'all' | 'personal' | 'groups' | 'shared' | 'examples' | 'hub'
|
||||
type Section = [string, ScriptW[]]
|
||||
@@ -55,7 +54,6 @@
|
||||
let groupedScripts: Section[] = []
|
||||
let communityScripts: Section[] = []
|
||||
|
||||
let templateModal: Modal
|
||||
let templateScripts: Script[] = []
|
||||
let templateFilter = ''
|
||||
let filteredTemplates: Script[] | undefined
|
||||
@@ -110,14 +108,6 @@
|
||||
communityScripts = [['examples', filteredScripts.filter((x) => x.tab == 'examples')]]
|
||||
}
|
||||
|
||||
async function loadTemplateScripts(): Promise<void> {
|
||||
templateScripts = await ScriptService.listScripts({
|
||||
workspace: $workspaceStore!,
|
||||
isTemplate: true
|
||||
})
|
||||
templateFuse.setCollection(templateScripts)
|
||||
}
|
||||
|
||||
function tabFromPath(path: string) {
|
||||
let t: Tab = 'shared'
|
||||
let path_prefix = path.split('/').slice(0, 2)
|
||||
@@ -187,19 +177,7 @@
|
||||
granted visibility on the resources and variables it uses, otherwise it will behave as if those
|
||||
items did not exist at runtime of the script."
|
||||
>
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button
|
||||
variant="border"
|
||||
size="sm"
|
||||
startIcon={{ icon: faPlus }}
|
||||
on:click={() => {
|
||||
templateModal.openModal()
|
||||
}}
|
||||
>
|
||||
New script from template
|
||||
</Button>
|
||||
<Button size="sm" startIcon={{ icon: faPlus }} href="/scripts/add">New script</Button>
|
||||
</div>
|
||||
<CreateActions />
|
||||
</PageHeader>
|
||||
|
||||
<Tabs
|
||||
@@ -452,7 +430,7 @@
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
<!-- <Modal
|
||||
bind:this={templateModal}
|
||||
on:open={() => {
|
||||
loadTemplateScripts()
|
||||
@@ -485,8 +463,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
</Modal> -->
|
||||
<style>
|
||||
.selected:hover {
|
||||
@apply border border-gray-500 rounded-md border-opacity-50;
|
||||
|
||||
Reference in New Issue
Block a user