fix(frontend): Refactor apps to support multiple breakpoints (#957)

* fix(frontend): Refactor apps to support multiples breakpoints

* fix(frontend): WIP

* fix(frontend): redo apps page

* fix(frontend): Hide schema for buttons

* fix(frontend): Add apps menu

* fix(frontend): Properly handle runnable delete

* fix(frontend): simplify ui

* fix(frontend): unify flow

* fix(frontend): table action design

* fix(frontend): reework runnable pick

* fix(frontend): fix apps action

* fix(frontend): Highlight output

* fix(frontend): Fix dimensions

* fix(frontend): Hide alert on button component

* fix(frontend): add number of actions when a table is selected
This commit is contained in:
Faton Ramadani
2022-11-30 15:59:26 +01:00
committed by GitHub
parent 89376e852f
commit d502c9eda9
21 changed files with 603 additions and 285 deletions
@@ -7,9 +7,8 @@
import TestJobLoader from '$lib/components/TestJobLoader.svelte'
import { AppService, type CompletedJob } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { faArrowsRotate, faRefresh } from '@fortawesome/free-solid-svg-icons'
import { faRefresh } from '@fortawesome/free-solid-svg-icons'
import { getContext } from 'svelte'
import Icon from 'svelte-awesome'
import type { Output } from '../../rx'
import type { AppEditorContext, InputsSpec } from '../../types'
import { loadSchema, schemaToInputsSpec } from '../../utils'
@@ -200,11 +199,15 @@
bind:this={testJobLoader}
/>
{#if schemaStripped !== undefined}
{#if schemaStripped !== undefined && autoRefresh}
<SchemaForm schema={schemaStripped} bind:args {isValid} {disabledArgs} shouldHideNoInputs />
{/if}
{#if autoRefresh === true}
{#if inlineScriptName === undefined && path === undefined && runType === undefined && autoRefresh}
<Alert type="warning" size="xs" class="mt-2" title="Missing runnable">
Please select a runnable
</Alert>
{:else if autoRefresh === true}
{#if isValid}
<div class="flex flex-row-reverse">
<Button
@@ -5,7 +5,14 @@
import { Pane } from 'svelte-splitpanes'
import { writable } from 'svelte/store'
import { buildWorld, type World } from '../rx'
import type { App, AppEditorContext, ConnectingInput, EditorMode, InputType } from '../types'
import type {
App,
AppEditorContext,
ConnectingInput,
EditorBreakpoint,
EditorMode,
InputType
} from '../types'
import AppEditorHeader from './AppEditorHeader.svelte'
import GridEditor from './GridEditor.svelte'
@@ -17,15 +24,19 @@
import { faPlus, faSliders } from '@fortawesome/free-solid-svg-icons'
import ComponentPanel from './settingsPanel/ComponentPanel.svelte'
import ContextPanel from './contextPanel/ContextPanel.svelte'
import { classNames } from '$lib/utils'
export let app: App
export let path: string
export let initialMode: EditorMode = 'dnd'
const appStore = writable<App>(app)
const worldStore = writable<World | undefined>(undefined)
const staticOutputs = writable<Record<string, string[]>>({})
const selectedComponent = writable<string | undefined>(undefined)
const mode = writable<EditorMode>('dnd')
const mode = writable<EditorMode>(initialMode)
const breakpoint = writable<EditorBreakpoint>('lg')
const connectingInput = writable<ConnectingInput<InputType, any>>({
opened: false,
input: undefined
@@ -37,7 +48,8 @@
app: appStore,
selectedComponent,
mode,
connectingInput
connectingInput,
breakpoint
})
function clearSelectionOnPreview() {
@@ -52,21 +64,27 @@
})
$: mounted && ($worldStore = buildWorld($staticOutputs))
$: $mode && $selectedComponent && clearSelectionOnPreview()
$: selectedTab = $selectedComponent ? 'settings' : 'insert'
$: previewing = $mode === 'preview'
$: width = $breakpoint === 'sm' ? 'w-[640px]' : 'w-full '
</script>
<AppEditorHeader bind:title={$appStore.title} bind:mode={$mode} />
{#if initialMode !== 'preview'}
<AppEditorHeader bind:title={$appStore.title} bind:mode={$mode} bind:breakpoint={$breakpoint} />
{/if}
<SplitPanesWrapper class="max-w-full overflow-hidden">
<Pane size={previewing ? 0 : 20} minSize={previewing ? 0 : 20} maxSize={40}>
<ContextPanel appPath={path} />
</Pane>
<Pane size={previewing ? 100 : 60}>
<div class="p-4 bg-gray-100 h-full" id="faton">
<div class="p-4 bg-gray-100 h-full w-full">
{#if $appStore.grid}
<GridEditor />
<div class={classNames('mx-auto h-full', width)}>
<GridEditor />
</div>
{/if}
</div>
</Pane>
@@ -5,14 +5,21 @@
import ToggleButtonGroup from '$lib/components/common/toggleButton/ToggleButtonGroup.svelte'
import { AppService, Policy } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { faDisplay, faExternalLink, faHand } from '@fortawesome/free-solid-svg-icons'
import {
faDesktopAlt,
faDisplay,
faExternalLink,
faHand,
faMobileAlt
} from '@fortawesome/free-solid-svg-icons'
import { getContext } from 'svelte'
import { sendUserToast } from '../../../utils'
import type { AppEditorContext, EditorMode } from '../types'
import type { AppEditorContext, EditorBreakpoint, EditorMode } from '../types'
const { app } = getContext<AppEditorContext>('AppEditorContext')
export let title: string = $app.title || ''
export let mode: EditorMode
export let breakpoint: EditorBreakpoint
const loading = {
publish: false,
save: false
@@ -32,27 +39,42 @@
on_behalf_of: `u/${$userStore?.username}`
}
}
}).then(() => {
sendUserToast('Saved successfully.')
}).catch(() => {
sendUserToast('Error during saving. Please try again later.', true)
}).finally(() => {
loading.save = false
})
.then(() => {
sendUserToast('Saved successfully.')
})
.catch(() => {
sendUserToast('Error during saving. Please try again later.', true)
})
.finally(() => {
loading.save = false
})
}
</script>
<div class="border-b flex flex-row justify-between py-1 px-4 items-center">
<input class="text-sm w-64" bind:value={title} />
<div>
<ToggleButtonGroup bind:selected={mode}>
<ToggleButton position="left" value="dnd" startIcon={{ icon: faHand }} size="xs">
Component editor
</ToggleButton>
<ToggleButton position="right" value="preview" startIcon={{ icon: faDisplay }} size="xs">
Preview
</ToggleButton>
</ToggleButtonGroup>
<div class="flex gap-8">
<div>
<ToggleButtonGroup bind:selected={mode}>
<ToggleButton position="left" value="dnd" startIcon={{ icon: faHand }} size="xs">
Editor
</ToggleButton>
<ToggleButton position="right" value="preview" startIcon={{ icon: faDisplay }} size="xs">
Preview
</ToggleButton>
</ToggleButtonGroup>
</div>
<div>
<ToggleButtonGroup bind:selected={breakpoint}>
<ToggleButton position="left" value="sm" startIcon={{ icon: faMobileAlt }} size="xs">
Mobile
</ToggleButton>
<ToggleButton position="right" value="lg" startIcon={{ icon: faDesktopAlt }} size="xs">
Desktop
</ToggleButton>
</ToggleButtonGroup>
</div>
</div>
<div class="flex flex-row gap-2">
<Button color="dark" size="xs" variant="border" startIcon={{ icon: faExternalLink }}>
@@ -31,9 +31,10 @@
<div
class={classNames(
'p-2 border overflow-auto cursor-pointer h-full bg-white',
' border overflow-auto cursor-pointer h-full bg-white',
selected ? 'border-blue-500' : 'border-white',
$mode === 'preview' ? 'border-white' : 'hover:border-blue-500'
$mode === 'preview' ? 'border-white' : 'hover:border-blue-500',
component.card ? 'p-2' : ''
)}
>
{#if component.type === 'displaycomponent'}
@@ -4,37 +4,28 @@
import Grid from 'svelte-grid'
import ComponentEditor from './ComponentEditor.svelte'
import { classNames } from '$lib/utils'
import { columnConfiguration, disableDrag, enableDrag } from '../gridUtils'
const { selectedComponent, app, mode } = getContext<AppEditorContext>('AppEditorContext')
const COLS = 6
const cols: [number, number][] = [[1200, COLS]]
$: if ($mode === 'preview') {
$app.grid.map((c) => {
c[COLS].customDragger = true
c[COLS].customResizer = true
return c
})
$app.grid.map((gridItem) => disableDrag(gridItem))
} else {
$app.grid.map((c) => {
c[COLS].customDragger = false
c[COLS].customResizer = false
return c
})
$app.grid.map((gridItem) => enableDrag(gridItem))
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="bg-white h-full" on:click|preventDefault={() => $selectedComponent = undefined}>
<Grid bind:items={$app.grid} rowHeight={32} let:dataItem {cols}>
<div class="bg-white h-full" on:click|preventDefault={() => ($selectedComponent = undefined)}>
<Grid bind:items={$app.grid} rowHeight={64} let:dataItem cols={columnConfiguration}>
{#each $app.grid as gridComponent (gridComponent.id)}
{#if gridComponent.data.id === dataItem.data.id}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class={classNames(
'h-full w-full flex justify-center align-center border border-gray-100'
'h-full w-full flex justify-center align-center',
gridComponent.data.card ? 'border border-gray-100' : ''
)}
on:click|preventDefault|stopPropagation={() => {
$selectedComponent = dataItem.data.id
@@ -11,6 +11,7 @@
import type { Size } from 'svelte-grid'
import { faAngleDown } from '@fortawesome/free-solid-svg-icons'
import { isOpenStore } from './store'
import { gridColumns } from '../../gridUtils'
const { app } = getContext<AppEditorContext>('AppEditorContext')
const COLS = 6
@@ -18,81 +19,91 @@
function addComponent(
appComponent: AppComponent,
defaultDimensions: Size,
minDimensions: Size = { w: 1, h: 1 },
maxDimensions: Size = { w: 6, h: 12 }
minDimensions: Size = { w: 2, h: 1 },
maxDimensions: Size = { w: 12, h: 12 }
) {
const grid = $app.grid ?? []
const id = getNextId(grid.map((gridItem) => gridItem.data.id))
appComponent.id = id
let newItem: GridItem = {
[COLS]: {
fixed: false,
resizable: true,
draggable: true,
customDragger: false,
customResizer: false,
min: minDimensions,
max: maxDimensions,
x: 0,
y: 0,
...defaultDimensions
},
const newComponent = {
fixed: false,
resizable: true,
draggable: true,
customDragger: false,
customResizer: false,
x: 0,
y: 0,
...defaultDimensions
}
const newItem: GridItem = {
data: JSON.parse(JSON.stringify(appComponent)),
id: id
}
let findOutPosition = gridHelp.findSpace(newItem, grid, COLS)
newItem = {
...newItem,
[COLS]: {
...newItem[COLS],
...findOutPosition
function getMinMaxDimensions(column) {
if (column === 3) {
return {
min: { w: 1, h: 1 },
max: { w: 3, h: 12 }
}
} else {
return {
min: minDimensions,
max: maxDimensions
}
}
}
gridColumns.forEach((column) => {
newItem[column] = newComponent
const position = gridHelp.findSpace(newItem, grid, column)
const dimensions = getMinMaxDimensions(column)
newItem[column] = { ...newItem[column], ...position, ...dimensions }
})
$app.grid = [...grid, newItem]
}
onMount(() => {
isOpenStore.addItems(componentSets.map(set => ({ [set.title]: true })))
isOpenStore.addItems(componentSets.map((set) => ({ [set.title]: true })))
})
</script>
{#each componentSets as {title, components}, index (index)}
{#each componentSets as { title, components }, index (index)}
{@const isOpen = $isOpenStore[title]}
<section class="mt-1 mb-2 px-2">
<button
on:click|preventDefault={() => isOpenStore.toggle(title)}
class="w-full flex justify-between items-center text-gray-700 px-2 py-1
<section class="mt-1 mb-2 px-2">
<button
on:click|preventDefault={() => isOpenStore.toggle(title)}
class="w-full flex justify-between items-center text-gray-700 px-2 py-1
rounded-sm hover:bg-gray-100"
>
<h1 class="text-sm font-semibold text-left">{title}</h1>
<Icon data={faAngleDown} class="rotate-0 duration-300 {isOpen ? '!rotate-180' : ''}" />
</button>
{#if isOpen}
<div transition:slide|local={{ duration: 300 }}>
{#if components.length}
<div class="grid grid-cols-3 gap-1 p-2">
{#each components as item, componentIndex (componentIndex)}
<button
on:click={() => addComponent(item, { w: 2, h: 2 })}
class="border shadow-sm h-16 p-2 flex flex-col gap-2 items-center
>
<h1 class="text-sm font-semibold text-left">{title}</h1>
<Icon data={faAngleDown} class="rotate-0 duration-300 {isOpen ? '!rotate-180' : ''}" />
</button>
{#if isOpen}
<div transition:slide|local={{ duration: 300 }}>
{#if components.length}
<div class="grid grid-cols-3 gap-1 p-2">
{#each components as item, componentIndex (componentIndex)}
<button
on:click={() => addComponent(item, { w: 2, h: 2 })}
class="border shadow-sm h-16 p-2 flex flex-col gap-2 items-center
justify-center bg-white rounded-md scale-100 hover:scale-105 ease-in duration-75"
>
<Icon data={displayData[item.type].icon} scale={1.6} class="text-blue-800" />
<div class="text-xs">{displayData[item.type].name}</div>
</button>
{/each}
</div>
{:else}
<div class="text-xs text-gray-500 text-center py-1">
There are no components in this group yet
</div>
{/if}
</div>
{/if}
</section>
>
<Icon data={displayData[item.type].icon} scale={1.6} class="text-blue-800" />
<div class="text-xs">{displayData[item.type].name}</div>
</button>
{/each}
</div>
{:else}
<div class="text-xs text-gray-500 py-1 px-2">
There are no components in this group yet
</div>
{/if}
</div>
{/if}
</section>
{/each}
@@ -23,16 +23,12 @@ const windmillComponents: ComponentSet = {
const textInputs: ComponentSet = {
title: 'Text Inputs',
components: [
]
components: []
}
const numberInputs: ComponentSet = {
title: 'Number Inputs',
components: [
]
components: []
}
const buttons: ComponentSet = {
@@ -67,8 +63,9 @@ const buttons: ComponentSet = {
defaultValue: 'md'
}
},
runnable: true
},
runnable: true,
card: false
}
]
}
@@ -88,16 +85,15 @@ const selectInputs: ComponentSet = {
fieldType: 'textarea',
defaultValue: 'Lorem ipsum'
}
}
},
card: false
}
]
}
const dateTimeInputs: ComponentSet = {
title: 'Date and Time Inputs',
components: [
]
components: []
}
const dataDisplay: ComponentSet = {
@@ -155,16 +151,10 @@ const dataDisplay: ComponentSet = {
type: 'barchartcomponent',
runnable: true,
card: true
},
}
]
}
const componentSets = [
windmillComponents,
textInputs,
@@ -17,7 +17,7 @@
export let appPath: string
const { connectingInput, staticOutputs, app, worldStore } =
const { connectingInput, staticOutputs, app, worldStore, selectedComponent } =
getContext<AppEditorContext>('AppEditorContext')
let newScriptPath: string
let ignorePathError = false
@@ -57,7 +57,7 @@
[newScriptPath]: inlineScript
}
}
scriptCreationDrawer.closeDrawer()
closeDrawer()
}
function afterCreateScript() {
@@ -70,16 +70,17 @@
| undefined = undefined
let scriptEditorDrawer: Drawer
let scriptCreationDrawer: Drawer
let scriptCreationDrawer: Drawer | undefined = undefined
function closeDrawer() {
if (scriptEditorDrawer.closeDrawer) {
scriptEditorDrawer.closeDrawer()
}
}
</script>
<Drawer bind:this={scriptCreationDrawer} size="600px" on:afterClose={afterCreateScript}>
<DrawerContent
title="Script creation"
on:close={() => {
scriptCreationDrawer.closeDrawer()
}}
>
<DrawerContent title="Script creation" on:close={closeDrawer}>
<label for="pathInput" class="text-sm font-semibold"> Script name </label>
<div class="flex justify-between items-center gap-4">
<!-- svelte-ignore a11y-autofocus -->
@@ -88,7 +89,7 @@
id="pathInput"
class="grow min-w-[150px]"
bind:value={newScriptPath}
on:keypress={e => e.key === 'Enter' && createScript()}
on:keypress={(e) => e.key === 'Enter' && createScript()}
/>
<Button on:click={createScript} size="sm" disabled={isTakenPath} startIcon={{ icon: faPlus }}>
Create
@@ -103,14 +104,7 @@
</Drawer>
<Drawer bind:this={scriptEditorDrawer} size="1200px">
<DrawerContent
title="Script Editor"
noPadding
forceOverflowVisible
on:close={() => {
scriptEditorDrawer.closeDrawer()
}}
>
<DrawerContent title="Script Editor" noPadding forceOverflowVisible on:close={closeDrawer}>
{#if selectedScript}
<ScriptEditor
lang={selectedScript.language}
@@ -127,15 +121,12 @@
<svelte:fragment slot="action">
<Button
size="xs"
color="dark"
variant="contained"
on:click={() => {
scriptCreationDrawer?.openDrawer()
}}
color="light"
variant="border"
on:click={closeDrawer}
startIcon={{ icon: faPlus }}
>
<span>Add script</span>
</Button>
iconOnly
/>
</svelte:fragment>
<div class="w-full border rounded-sm">
@@ -157,7 +148,7 @@
on:click={() => {
if (value) {
selectedScript = value
scriptEditorDrawer.openDrawer()
scriptEditorDrawer.openDrawer && scriptEditorDrawer.openDrawer()
}
}}
/>
@@ -185,7 +176,14 @@
{#if outputs.length > 0 && $worldStore?.outputsById[componentId]}
<Badge color="blue">Component: {componentId}</Badge>
<div class="w-full p-2 rounded-xs border">
<div
class={classNames(
'w-full p-2 rounded-xs border',
$selectedComponent === componentId
? 'outline-1 outline outline-offset-2 outline-blue-500'
: ''
)}
>
<ComponentOutputViewer
{outputs}
{componentId}
@@ -7,7 +7,6 @@
faAlignLeft,
faAlignRight,
faClose,
faPen,
faTrashAlt
} from '@fortawesome/free-solid-svg-icons'
import { getContext } from 'svelte'
@@ -19,6 +18,10 @@
import gridHelp from 'svelte-grid/build/helper/index.mjs'
import PickInlineScript from './PickInlineScript.svelte'
import TableActions from './TableActions.svelte'
import { capitalize } from '$lib/utils'
import Badge from '$lib/components/common/badge/Badge.svelte'
import { gridColumns } from '../../gridUtils'
import { Plus } from 'svelte-lucide'
export let component: AppComponent | undefined
export let onDelete: (() => void) | undefined = undefined
@@ -34,9 +37,11 @@
// Delete static inputs
} else {
if (component) {
const COLS = 6
$app.grid = $app.grid.filter((gridComponent) => gridComponent.data.id !== component?.id)
$app.grid = gridHelp.adjust($app.grid, COLS)
gridColumns.forEach((colIndex) => {
$app.grid = gridHelp.adjust($app.grid, colIndex)
})
// Delete static inputs
delete $staticOutputs[component.id]
@@ -50,27 +55,39 @@
<div class="flex flex-col w-full divide-y">
{#if component.runnable}
<PanelSection title="Runnable">
{#if component['inlineScriptName']}
<span class="text-xs">{component['inlineScriptName']}</span>
<div class="w-full flex flex-row gap-2">
{#if component.runnable && component['inlineScriptName']}
<div class="flex justify-between w-full items-center">
<span class="text-xs">{component['inlineScriptName']}</span>
<Button
size="xs"
color="dark"
startIcon={{ icon: faPen }}
color="red"
startIcon={{ icon: faClose }}
on:click={() => {
alert('TODO')
if (component) {
component['inlineScriptName'] = undefined
}
}}
>
Edit
Clear
</Button>
</div>
{/if}
{#if component.runnable && component['path']}
<div class="flex gap-2 items-center">
<div>
<Badge color="blue">{capitalize(component['runType'])}</Badge>
<span class="text-xs">{component['path']}</span>
</div>
<Button
size="xs"
color="light"
color="red"
variant="border"
startIcon={{ icon: faClose }}
on:click={() => {
alert('TODO')
if (component) {
component['inlineScriptName'] = undefined
}
}}
>
Clear
@@ -79,33 +96,53 @@
{/if}
{#if component.runnable && component['path'] === undefined && component['inlineScriptName'] === undefined}
<span class="text-sm">Select a script or a flow to continue</span>
<PickInlineScript
scripts={(Object.keys($app.inlineScripts) || []).map((summary) => ({ summary }))}
on:pick={({ detail }) => {
if (component?.runnable) {
// @ts-ignore
component.inlineScriptName = detail.summary
}
}}
/>
<PickScript
kind="script"
on:pick={({ detail }) => {
if (component?.runnable) {
component['path'] = detail.path
component['runType'] = 'script'
}
}}
/>
<PickFlow
on:pick={({ detail }) => {
if (component?.runnable) {
component['path'] = detail.path
component['runType'] = 'flow'
}
}}
/>
<div class="text-sm">Inline scripts:</div>
<div class="flex gap-2">
<Button
btnClasses="w-24 truncate"
size="sm"
spacingSize="md"
variant="border"
color="light"
>
<div class="flex justify-center flex-col items-center gap-2">
<Plus size="18px" />
<span class="text-xs">Create</span>
</div>
</Button>
<PickInlineScript
scripts={(Object.keys($app.inlineScripts) || []).map((summary) => ({ summary }))}
on:pick={({ detail }) => {
if (component?.runnable) {
// @ts-ignore
component.inlineScriptName = detail.summary
}
}}
/>
</div>
<div class="text-sm">Pick from workspace:</div>
<div class="flex gap-2">
<PickScript
kind="script"
on:pick={({ detail }) => {
if (component?.runnable) {
component['path'] = detail.path
component['runType'] = 'script'
}
}}
/>
<PickFlow
on:pick={({ detail }) => {
if (component?.runnable) {
component['path'] = detail.path
component['runType'] = 'flow'
}
}}
/>
</div>
{/if}
</PanelSection>
{/if}
@@ -51,9 +51,8 @@
<div
class={classNames(
'w-full text-xs font-bold py-1.5 px-2 cursor-pointer transition-all justify-between flex items-center border border-gray-3 rounded-md',
openedProp === inputSpecKey
? 'bg-gray-700 hover:bg-gray-900 focus:bg-gray-900 text-white'
: 'bg-white border-gray-300 hover:bg-gray-100 focus:bg-gray-100 text-gray-700'
'bg-white border-gray-300 hover:bg-gray-100 focus:bg-gray-100 text-gray-700',
openedProp === inputSpecKey ? 'outline outline-gray-500 outline-offset-1' : ''
)}
on:keypress
on:click={() => {
@@ -4,6 +4,7 @@
import { workspaceStore } from '$lib/stores'
import { createEventDispatcher } from 'svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { Wind } from 'svelte-lucide'
type Item = { summary: String; path: String; version?: String }
@@ -25,6 +26,17 @@
{loadItems}
/>
<Button size="xs" color="dark" on:click={() => itemPicker.openDrawer()}>
Pick flow from workspace
<Button
on:click={() => itemPicker.openDrawer()}
btnClasses="w-24 truncate"
size="sm"
spacingSize="md"
variant="border"
color="light"
>
<div class="flex justify-center flex-col items-center gap-2">
<Wind size="18px" />
<span class="text-xs">Flow</span>
</div>
</Button>
@@ -2,6 +2,7 @@
import ItemPicker from '$lib/components/ItemPicker.svelte'
import { createEventDispatcher } from 'svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { MousePointerClick } from 'svelte-lucide'
export let scripts: Item[]
@@ -27,6 +28,17 @@
{loadItems}
/>
<Button size="xs" color="dark" on:click={() => itemPicker.openDrawer()}>
Pick an inline script
<Button
on:click={() => itemPicker.openDrawer()}
btnClasses="w-24 truncate"
size="sm"
spacingSize="md"
variant="border"
color="light"
>
<div class="flex justify-center flex-col items-center gap-2">
<MousePointerClick size="18px" />
<span class="text-xs">Pick</span>
</div>
</Button>
@@ -1,6 +1,9 @@
<script lang="ts">
import { Badge } from '$lib/components/common'
import Alert from '$lib/components/common/alert/Alert.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { getNextId } from '$lib/components/flows/flowStateUtils'
import { classNames } from '$lib/utils'
import { faPlus } from '@fortawesome/free-solid-svg-icons'
import { getContext } from 'svelte'
import type { ButtonComponent, AppEditorContext, BaseAppComponent } from '../../types'
@@ -16,7 +19,7 @@
const grid = $app.grid ?? []
const id = getNextId(grid.map((gridItem) => gridItem.data.id))
const newComponent: (BaseAppComponent & ButtonComponent) = {
const newComponent: BaseAppComponent & ButtonComponent = {
...defaultProps,
id,
type: 'buttoncomponent',
@@ -50,23 +53,63 @@
components = [...components, newComponent]
}
let openedComponentId: string | undefined = components[0]?.id
</script>
<PanelSection title="Table actions">
<PanelSection title={`Table actions ${components.length > 0 ? `(${components.length})` : ''}`}>
<svelte:fragment slot="action">
<Button size="xs" color="dark" startIcon={{ icon: faPlus }} on:click={addComponent}>
Create an action
</Button>
<Button
size="xs"
color="light"
variant="border"
startIcon={{ icon: faPlus }}
on:click={addComponent}
iconOnly
/>
</svelte:fragment>
{#each components as component}
<div class="w-full border">
<div class="border-b py-1 px-2 text-sm text-white bg-gray-500">Component: {component.id}</div>
<ComponentPanel
bind:component
onDelete={() => {
components = components.filter((c) => c.id !== component.id)
}}
/>
{#if components.length > 0}
<div class="w-full">
<Alert title="Special argument" size="xs">
A "row" argument is automatically added to the script. It contains the row data.
</Alert>
</div>
{/if}
{#each components as component}
<div
class={classNames(
'w-full text-xs font-bold py-1.5 px-2 cursor-pointer transition-all justify-between flex items-center border border-gray-3 rounded-md',
'bg-white border-gray-300 hover:bg-gray-100 focus:bg-gray-100 text-gray-700',
openedComponentId === component.id ? 'outline outline-gray-500 outline-offset-1' : ''
)}
on:click={() => {
if (openedComponentId === component.id) {
openedComponentId = undefined
} else {
openedComponentId = component.id
}
}}
on:keypress
>
<div
>{component.componentInputs.label.type === 'static'
? component.componentInputs.label.value
: ''}
</div>
<Badge color="dark-blue">
Component: {component.id}
</Badge>
</div>
{#if openedComponentId === component.id}
<div class="w-full border">
<ComponentPanel
bind:component
onDelete={() => {
components = components.filter((c) => c.id !== component.id)
}}
/>
</div>
{/if}
{/each}
</PanelSection>
@@ -0,0 +1,33 @@
import type { GridItem } from './types'
type ColumnConfiguration = [number, number][]
const Breakpoints = {
sm: 640,
lg: 1024
}
const columnConfiguration: ColumnConfiguration = [
[Breakpoints.lg, 12],
[Breakpoints.sm, 3]
]
const gridColumns = columnConfiguration.map((value) => value[1])
function disableDrag(component: GridItem): GridItem {
gridColumns.forEach((column: number) => {
component[column].customDragger = true
component[column].customResizer = true
})
return component
}
function enableDrag(component: GridItem): GridItem {
gridColumns.forEach((column: number) => {
component[column].customDragger = false
component[column].customResizer = false
})
return component
}
export { gridColumns, columnConfiguration, disableDrag, enableDrag, Breakpoints }
+10 -4
View File
@@ -142,12 +142,16 @@ export type GridItem = FilledItem<{
id: string
}>
export type InlineScript = {
content: string
language: Preview.language
path: string
schema: Schema
}
export type App = {
grid: GridItem[]
inlineScripts: Record<
string,
{ content: string; language: Preview.language; path: string; schema: Schema }
>
inlineScripts: Record<string, InlineScript>
title: string
}
@@ -163,9 +167,11 @@ export type AppEditorContext = {
selectedComponent: Writable<string | undefined>
mode: Writable<EditorMode>
connectingInput: Writable<ConnectingInput<any, any>>
breakpoint: Writable<EditorBreakpoint>
}
export type EditorMode = 'dnd' | 'preview'
export type EditorBreakpoint = 'sm' | 'lg'
type FieldID = string
@@ -12,6 +12,8 @@
export let type: AlertType = 'info'
export let title: string
export let size: 'xs' | 'sm' = 'sm'
const icons: Record<AlertType, IconDefinition> = {
info: faInfoCircle,
warning: faWarning,
@@ -47,15 +49,36 @@
}
</script>
<div class={classNames('rounded-md p-4 ', classes[type].bgClass, $$props.class)}>
<div
class={classNames(
'rounded-md',
size === 'sm' ? 'p-4' : 'p-2 ',
classes[type].bgClass,
$$props.class
)}
>
<div class="flex">
<div class="flex h-8 w-8 items-center justify-center rounded-full">
<Icon data={icons[type]} class={classes[type].iconClass} />
</div>
<div class="ml-2 w-full">
<span class={classNames('text-sm font-medium', classes[type].titleClass)}>{title}</span>
<div class={classNames('mt-2 text-sm', classes[type].descriptionClass)}>
<span
class={classNames(
size === 'sm' ? 'text-sm' : 'text-xs ',
'font-medium',
classes[type].titleClass
)}
>
{title}
</span>
<div
class={classNames(
size === 'sm' ? 'text-sm' : 'text-xs ',
'mt-2',
classes[type].descriptionClass
)}
>
<slot />
</div>
</div>
@@ -4,6 +4,7 @@
import { workspaceStore } from '$lib/stores'
import { createEventDispatcher } from 'svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { Code } from 'svelte-lucide'
export let kind: string
@@ -27,6 +28,17 @@
{loadItems}
/>
<Button size="xs" color="dark" on:click={() => itemPicker.openDrawer()}
>Pick script from workflow</Button
<Button
on:click={() => itemPicker.openDrawer()}
btnClasses="w-24 truncate"
size="sm"
spacingSize="md"
variant="border"
color="light"
>
<div class="flex justify-center flex-col items-center gap-2">
<Code size="18px" />
<span class="text-xs">Script</span>
</div>
</Button>
@@ -12,7 +12,7 @@
faCog,
faStar
} from '@fortawesome/free-solid-svg-icons'
import { faDiscord, faGithub } from '@fortawesome/free-brands-svg-icons'
import { faAppStore, faDiscord, faGithub } from '@fortawesome/free-brands-svg-icons'
import MenuLink from './MenuLink.svelte'
import { starStore, userStore, workspaceStore } from '$lib/stores'
import { FlowService, ScriptService } from '$lib/gen'
+153 -64
View File
@@ -1,24 +1,66 @@
<script context="module">
export function load() {
return {
stuff: { title: 'Scripts' }
}
}
</script>
<script lang="ts">
import { goto } from '$app/navigation'
import type { App } from '$lib/components/apps/types'
import { faCodeFork, faEdit, faPlay, faPlus, faTrash } from '@fortawesome/free-solid-svg-icons'
import { AppService, Policy, type ListableApp } from '$lib/gen'
import { superadmin, userStore, workspaceStore } from '$lib/stores'
import { canWrite, sendUserToast } from '$lib/utils'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { DrawerContent, Skeleton } from '$lib/components/common'
import Button from '$lib/components/common/button/Button.svelte'
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import Path from '$lib/components/Path.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import { AppService, ListableApp, Policy } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/utils'
import { faEdit, faPlus } from '@fortawesome/free-solid-svg-icons'
import { Button, Badge, Skeleton, Drawer, DrawerContent } from '$lib/components/common'
type ListableAppMarked = ListableApp & { canWrite: boolean; marked?: string }
let apps: ListableAppMarked[] = []
let preFilteredApps: ListableAppMarked[] = []
let filteredApps: ListableAppMarked[] = []
let filter = ''
let loading = true
let path: string = ''
let initialPath = ''
let pathError = ''
let apps: ListableApp[] | undefined = undefined
async function loadApps(): Promise<void> {
apps = (await AppService.listApps({ workspace: $workspaceStore! })).map((x) => {
return {
canWrite:
x.path && x.extra_perms
? canWrite(x.path, x.extra_perms, $userStore) && x.workspace_id == $workspaceStore
: false,
...x
}
})
loading = false
}
import SearchItems from '$lib/components/SearchItems.svelte'
import type { App } from '$lib/components/apps/types'
import { goto } from '$app/navigation'
import Path from '$lib/components/Path.svelte'
$: owners = Array.from(
new Set(filteredApps?.map((x) => x.path?.split('/').slice(0, 2).join('/')) ?? [])
).sort()
$: preFilteredApps =
ownerFilter != undefined ? apps.filter((x) => x.path?.startsWith(ownerFilter ?? '')) : apps
let ownerFilter: string | undefined = undefined
$: {
if ($workspaceStore && ($userStore || $superadmin)) {
loadApps()
}
}
async function createApp() {
const appJson: App = {
grid: [],
@@ -48,21 +90,11 @@
}
}
async function loadApps(): Promise<void> {
apps = await AppService.listApps({ workspace: $workspaceStore! })
}
let drawerOpen = false
function closeDrawer() {
drawerOpen = false
}
$: {
if ($workspaceStore && $userStore) {
loadApps()
}
}
</script>
<Drawer bind:open={drawerOpen} size="800px">
@@ -79,57 +111,114 @@
</DrawerContent>
</Drawer>
<SearchItems
{filter}
items={preFilteredApps}
bind:filteredItems={filteredApps}
f={(x) => x.summary + ' (' + x.path + ')'}
/>
<CenteredPage>
<PageHeader title="Apps">
<Button size="sm" startIcon={{ icon: faPlus }} on:click={() => (drawerOpen = true)}>
<PageHeader
title="Apps"
tooltip="A Script can be used standalone or as part of a Flow.
When standalone, it has webhooks and an auto-generated UI from its parameters whom you can access clicking on 'Run'.
Scripts have owners (users or groups) and can be shared to users and groups."
>
<Button size="md" startIcon={{ icon: faPlus }} on:click={() => (drawerOpen = true)}>
New app
</Button>
</PageHeader>
<div class="p-4 border ">
{#if !apps}
<div class="grid gap-4 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-3 mt-2">
{#each new Array(3) as _}
<Skeleton layout={[[8.5]]} />
{/each}
</div>
{:else if apps?.length == 0}
<p class="text-xs text-gray-600 italic mt-2">No scripts yet</p>
{:else if apps}
<div class="grid md:grid-cols-2 gap-4 sm:grid-cols-1 xl:grid-cols-3 mt-2">
{#each apps as { summary, path, extra_perms }}
<a
class="border p-4 rounded-sm shadow-sm space-y-2 hover:border-blue-600 text-gray-800 flex flex-col justify-between"
href="/apps/get/{path}"
>
<div class="flex flex-col gap-1">
<a href="/apps/get/{path}" class="px-6">
<div class="font-semibold text-gray-700">
{!summary || summary.length == 0 ? path : summary}
<div class="mb-1" />
<input type="text" placeholder="Search Scripts" bind:value={filter} class="text-2xl mt-2" />
<div class="gap-2 w-full flex flex-wrap pb-1 pt-2">
{#each owners as owner}
<Badge
class="cursor-pointer hover:bg-gray-200"
on:click={() => {
ownerFilter = ownerFilter == owner ? undefined : owner
}}
color={owner === ownerFilter ? 'blue' : 'gray'}
>
{owner}
{#if owner === ownerFilter}&cross;{/if}
</Badge>
{/each}
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2 mt-2 w-full">
{#if !loading}
{#each filteredApps as { summary, path, extra_perms, marked, canWrite }, index (`${path}-${index}`)}
<a
class="border border-gray-400 p-2 rounded-sm shadow-sm hover:border-blue-600 text-gray-800"
href="/apps/get/{path}"
>
<div class="flex flex-col gap-1 w-full h-full">
<div class="font-semibold text-gray-700 truncate">
{#if marked}
{@html marked}
{:else}
{!summary || summary.length == 0 ? path : summary}
{/if}
</div>
<div class="flex flex-row justify-between w-full grow gap-2 items-start">
<div class="text-gray-700 text-xs flex flex-row flex-wrap gap-x-1 items-center">
{path}
<SharedBadge {canWrite} extraPerms={extra_perms} />
</div>
<div class="flex flex-col items-end grow pt-4">
<div class="flex flex-row-reverse place gap-x-2 items-end">
<div>
<Button
color="dark"
size="xs"
startIcon={{ icon: faPlay }}
href="/apps/get/{path}"
>
Preview
</Button>
</div>
{#if canWrite}
<div>
<Button
variant="border"
color="dark"
size="xs"
startIcon={{ icon: faEdit }}
href="/apps/edit/{path}"
>
Edit
</Button>
<Button
variant="border"
color="red"
size="xs"
startIcon={{ icon: faTrash }}
on:click={() => {
if ($workspaceStore && path) {
AppService.deleteApp({
workspace: $workspaceStore,
path
})
}
}}
>
Delete
</Button>
</div>
{/if}
</div>
<p class="text-gray-700 text-xs">
{path}
</p>
</a>
<div class="flex flex-wrap items-center gap-2 mt-1 px-6">
<SharedBadge canWrite={true} extraPerms={extra_perms} />
</div>
</div>
<div class="flex flex-row-reverse items-end w-full gap-2 pr-2 mt-2">
<div>
<Button
variant="border"
size="xs"
startIcon={{ icon: faEdit }}
href="/apps/edit/{path}"
>
Edit
</Button>
</div>
</div>
</a>
{/each}
</div>
</div>
</a>
{/each}
{:else}
{#each Array(10).fill(0) as _}
<Skeleton layout={[[4]]} />
{/each}
{/if}
</div>
</CenteredPage>
@@ -4,6 +4,8 @@
import { workspaceStore } from '$lib/stores'
import { page } from '$app/stores'
import gridHelp from 'svelte-grid/build/helper/index.mjs'
let app: AppWithLastVersion | undefined = undefined
let path = $page.params.path
+20 -4
View File
@@ -8,16 +8,21 @@
<script lang="ts">
import { page } from '$app/stores'
import AppEditor from '$lib/components/apps/editor/AppEditor.svelte'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { Skeleton } from '$lib/components/common'
import Button from '$lib/components/common/button/Button.svelte'
import { AppService, AppWithLastVersion } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { faArrowLeft, faPen } from '@fortawesome/free-solid-svg-icons'
let app: AppWithLastVersion | undefined = undefined
async function loadApp() {
app = await AppService.getAppByPath({ workspace: $workspaceStore!, path: $page.params.path })
}
$: if ($workspaceStore) {
loadApp()
}
@@ -26,10 +31,21 @@
<Skeleton loading={app == undefined} layout={[10]} />
<CenteredPage>
<a href="/apps">Back to apps</a>
{#if app}
<a href="/apps/edit/{$page.params.path}">Edit</a>
<div>{JSON.stringify(app, null, 4)} </div>
<div class="flex justify-between my-2 items-center">
<div class="flex gap-4 items-center">
<Button size="xs" href="/apps" startIcon={{ icon: faArrowLeft }} color="dark">
Back to apps
</Button>
<div>{app.value.title}</div>
</div>
<Button size="xs" href="/apps/edit/{$page.params.path}" startIcon={{ icon: faPen }}>
Edit
</Button>
</div>
<div class="h-screen">
<AppEditor app={app.value} path={app.path} initialMode="preview" />
</div>
{/if}
</CenteredPage>