feat: add support for custom components in react or vanilla JS (#2603)

* cc

* cc

* cc

* all

* sqlx
This commit is contained in:
Ruben Fiszel
2023-11-09 19:38:52 +01:00
committed by GitHub
parent 08782f66e7
commit dd24ed59ec
34 changed files with 711 additions and 82 deletions

View File

@@ -42,6 +42,7 @@
"bash",
"postgresql",
"nativets",
"Nativets",
"bun",
"mysql",
"bigquery",

View File

@@ -67,6 +67,7 @@
"bash",
"postgresql",
"nativets",
"Nativets",
"bun",
"mysql",
"bigquery",

View File

@@ -28,6 +28,7 @@
"bash",
"postgresql",
"nativets",
"Nativets",
"bun",
"mysql",
"bigquery",

View File

@@ -60,6 +60,7 @@
"bash",
"postgresql",
"nativets",
"Nativets",
"bun",
"mysql",
"bigquery",

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms) VALUES ($1, 'app_custom', 'App Custom Components', ARRAY[]::TEXT[], '{\"g/all\": false}') ON CONFLICT DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar"
]
},
"nullable": []
},
"hash": "98c8e2c58b38beedcb474251c2554a2f1b768ec7ed1a419313f4842f26425e5d"
}

View File

@@ -40,6 +40,7 @@
"bash",
"postgresql",
"nativets",
"Nativets",
"bun",
"mysql",
"bigquery",

View File

@@ -46,6 +46,7 @@
"bash",
"postgresql",
"nativets",
"Nativets",
"bun",
"mysql",
"bigquery",

View File

@@ -42,6 +42,7 @@
"bash",
"postgresql",
"nativets",
"Nativets",
"bun",
"mysql",
"bigquery",

View File

@@ -42,6 +42,7 @@
"bash",
"postgresql",
"nativets",
"Nativets",
"bun",
"mysql",
"bigquery",

View File

@@ -37,6 +37,7 @@
"bash",
"postgresql",
"nativets",
"Nativets",
"bun",
"mysql",
"bigquery",

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value->>'js' FROM resource\n WHERE path = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "e92536776a1960fa739bbb01d5a8d1b451ff2689a95442bdf820c65f0a8412a5"
}

View File

@@ -0,0 +1 @@
-- Add down migration script here

View File

@@ -0,0 +1,2 @@
-- Add up migration script here
INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms) SELECT id, 'app_custom', 'App Custom Components', ARRAY[]::TEXT[], '{"g/all": false}' FROM workspace ON CONFLICT DO NOTHING;

View File

@@ -245,6 +245,10 @@ pub async fn run_server(
"/w/:workspace_id/jobs_u",
jobs::global_service().layer(cors.clone()),
)
.nest(
"/w/:workspace_id/resources_u",
resources::public_service().layer(cors.clone()),
)
.nest(
"/w/:workspace_id/capture_u",
capture::global_service().layer(cors),

View File

@@ -12,11 +12,14 @@ use crate::{
webhook_util::{WebhookMessage, WebhookShared},
};
use axum::{
body,
extract::{Extension, Path, Query},
response::Response,
routing::{delete, get, post},
Json, Router,
};
use hyper::StatusCode;
use bytes::Bytes;
use hyper::{header, StatusCode};
use serde::{Deserialize, Serialize};
use serde_json::{value::RawValue, Value};
use sql_builder::{bind::Bind, SqlBuilder};
@@ -56,6 +59,10 @@ pub fn workspaced_service() -> Router {
.route("/type/create", post(create_resource_type))
}
pub fn public_service() -> Router {
Router::new().route("/custom_component/:name", get(custom_component))
}
#[derive(FromRow, Serialize, Deserialize)]
pub struct ResourceType {
pub workspace_id: String,
@@ -306,6 +313,33 @@ async fn get_resource_value(
Ok(Json(value))
}
async fn custom_component(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, name)): Path<(String, String)>,
) -> Result<Response> {
let mut tx = user_db.begin(&authed).await?;
let cc_o = sqlx::query_scalar!(
"SELECT value->>'js' FROM resource
WHERE path = $1 AND workspace_id = $2",
format!("f/app_custom/{name}"),
&w_id
)
.fetch_optional(&mut *tx)
.await?
.flatten();
tx.commit().await?;
let cc = not_found_if_none(cc_o, "Custom Component", name)?;
let res = Response::builder().header(header::CONTENT_TYPE, "text/javascript");
Ok(res
.body(body::boxed(body::Full::from(Bytes::from(cc))))
.unwrap())
}
#[derive(Deserialize)]
struct JobInfo {
job_id: Option<Uuid>,

View File

@@ -1028,6 +1028,13 @@ async fn create_workspace(
.execute(&mut *tx)
.await?;
sqlx::query!(
"INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms) VALUES ($1, 'app_custom', 'App Custom Components', ARRAY[]::TEXT[], '{\"g/all\": false}') ON CONFLICT DO NOTHING",
nw.id,
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms) VALUES ($1, 'app_groups', 'App Groups', ARRAY[]::TEXT[], '{\"g/all\": false}') ON CONFLICT DO NOTHING",
nw.id,

View File

@@ -133,7 +133,14 @@
workspace: $workspaceStore!
})
)
.filter((x) => x != initialFolder)
.filter(
(x) =>
x != initialFolder &&
x != 'app_groups' &&
x != 'app_custom' &&
x != 'app_themes' &&
x != 'app_custom'
)
.map((x) => ({
name: x,
write:

View File

@@ -0,0 +1,113 @@
<script lang="ts">
import { getContext, onMount } from 'svelte'
import { initOutput } from '../../editor/appUtils'
import type { AppViewerContext } from '../../types'
type CCProps<Input> = {
id: string
render: boolean
passSetters: (setter: Setter<Input>) => void
setOutput: (output: any) => void
}
interface Setter<Input> {
onRender(bool: boolean): void
onInput(input: Input): void
}
import InitializeComponent from '../helpers/InitializeComponent.svelte'
import { sendUserToast } from '$lib/toast'
import type { AppInput } from '../../inputType'
import { RunnableWrapper } from '../helpers'
import { workspaceStore } from '$lib/stores'
import type { CustomComponentConfig } from '../../editor/component'
import { Loader2 } from 'lucide-svelte'
export let id: string
export let render: boolean
export let componentInput: AppInput | undefined
export let customComponent: CustomComponentConfig
let divId = `custom-component-${id}`
const { worldStore } = getContext<AppViewerContext>('AppViewerContext')
const outputs = initOutput($worldStore, id, {
result: undefined,
output: undefined,
loading: false
})
let setInput
let setRender
let ccProps: CCProps<any> = {
render,
id: divId,
passSetters: (setter) => {
setInput = setter.onInput
setRender = setter.onRender
},
setOutput: (output) => {
outputs.output.set(output)
}
}
let loaded = false
// $: renderer && divEl && renderer(ccProps)
onMount(async () => {
// //@ts-ignore
// await import('http://localhost:3000/app.iife.js')
/* @vite-ignore */
if (customComponent?.additionalLibs?.reactVersion) {
let reactVersion = customComponent.additionalLibs.reactVersion
//@ts-ignore
await import(`https://unpkg.com/react@${reactVersion}/umd/react.development.js`)
//@ts-ignore
await import(`https://unpkg.com/react-dom@${reactVersion}/umd/react-dom.development.js`)
}
//@ts-ignore
await import(
`http://localhost:3000/api/w/${$workspaceStore ?? 'NO_W'}/resources_u/custom_component/${
customComponent.name
}`
)
loaded = true
try {
let renderer: (props: CCProps<number>) => void =
globalThis.windmill[customComponent?.name ?? 'no_name']
if (!renderer) {
sendUserToast('Custom Component seem to be ill-defined (renderer missing)', true)
return
}
renderer?.(ccProps)
} catch (e) {
sendUserToast('Custom Component seem to be ill-defined', true)
console.error(e)
}
})
let result
$: render != undefined && handleRender()
function handleRender() {
setRender?.(render)
}
$: result != undefined && handleResult()
function handleResult() {
setInput?.(result)
}
// $: result && setInput && setInput(result)
</script>
<InitializeComponent {id} />
{#if render}
<div class="w-full h-full overflow-auto {customComponent?.name ?? 'no_name'}">
<RunnableWrapper {outputs} {render} autoRefresh {componentInput} {id} bind:result>
{#if !loaded}
<Loader2 class="animate-spin" />
{/if}
<div id={divId} />
</RunnableWrapper>
</div>
{/if}

View File

@@ -388,16 +388,20 @@
let css: string | undefined = undefined
let lastTheme: string | undefined = undefined
appStore.subscribe(async (currentAppStore) => {
if (!currentAppStore.theme) {
return
}
if (currentAppStore.theme.type === 'inlined') {
css = currentAppStore.theme.css
} else if (currentAppStore.theme.type === 'path' && currentAppStore.theme?.path) {
let loadedCss = await getTheme($workspaceStore!, currentAppStore.theme.path)
css = loadedCss.value
if (JSON.stringify(currentAppStore.theme) != lastTheme) {
if (currentAppStore.theme.type === 'inlined') {
css = currentAppStore.theme.css
} else if (currentAppStore.theme.type === 'path' && currentAppStore.theme?.path) {
let loadedCss = await getTheme($workspaceStore!, currentAppStore.theme.path)
css = loadedCss.value
}
lastTheme = JSON.stringify(currentAppStore.theme)
}
})

View File

@@ -268,7 +268,8 @@ export function cleanseOneOfConfiguration(
export function appComponentFromType<T extends keyof typeof components>(
type: T,
overrideConfiguration?: Partial<InitialAppComponent['configuration']>
overrideConfiguration?: Partial<InitialAppComponent['configuration']>,
extra?: any
): (id: string) => BaseAppComponent & BaseComponent<T> {
return (id: string) => {
const init = JSON.parse(JSON.stringify(ccomponents[type].initialData)) as InitialAppComponent
@@ -304,7 +305,8 @@ export function appComponentFromType<T extends keyof typeof components>(
numberOfSubgrids: init.numberOfSubgrids,
horizontalAlignment: init.horizontalAlignment,
verticalAlignment: init.verticalAlignment,
id
id,
...(extra ?? {})
}
}
}
@@ -424,9 +426,7 @@ export function getAllSubgridsAndComponentIds(
return [subgrids, components]
}
export function getAllGridItems(
app: App
): GridItem[] {
export function getAllGridItems(app: App): GridItem[] {
return app.grid.concat(Object.values(app.subgrids ?? {}).flat())
}

View File

@@ -62,6 +62,7 @@
import AppJobIdFlowStatus from '../../components/display/AppJobIdFlowStatus.svelte'
import AppCarouselList from '../../components/display/AppCarouselList.svelte'
import AppAggridTableEe from '../../components/display/table/AppAggridTableEe.svelte'
import AppCustomComponent from '../../components/display/AppCustomComponent.svelte'
export let component: AppComponent
export let selected: boolean
@@ -231,6 +232,13 @@
componentInput={component.componentInput}
{render}
/>
{:else if component.type === 'customcomponent'}
<AppCustomComponent
customComponent={component.customComponent}
id={component.id}
componentInput={component.componentInput}
{render}
/>
{:else if component.type === 'mardowncomponent'}
<AppMarkdown
id={component.id}

View File

@@ -63,6 +63,12 @@ export type RecomputeOthersSource = {
recomputeIds: string[] | undefined
}
export type CustomComponentConfig = {
name: string
additionalLibs?: {
reactVersion?: string
}
}
export type TextComponent = BaseComponent<'textcomponent'>
export type TextInputComponent = BaseComponent<'textinputcomponent'>
export type QuillComponent = BaseComponent<'quillcomponent'>
@@ -75,6 +81,9 @@ export type CurrencyComponent = BaseComponent<'currencycomponent'>
export type SliderComponent = BaseComponent<'slidercomponent'>
export type RangeComponent = BaseComponent<'rangecomponent'>
export type HtmlComponent = BaseComponent<'htmlcomponent'>
export type CustomComponent = BaseComponent<'customcomponent'> & {
customComponent: CustomComponentConfig
}
export type MarkdownComponent = BaseComponent<'mardowncomponent'>
export type VegaLiteComponent = BaseComponent<'vegalitecomponent'>
export type PlotlyComponent = BaseComponent<'plotlycomponent'>
@@ -173,6 +182,7 @@ export type TypedComponent =
| BarChartComponent
| TimeseriesComponent
| HtmlComponent
| CustomComponent
| MarkdownComponent
| TableComponent
| TextComponent
@@ -1198,6 +1208,23 @@ Hello \${ctx.username}
configuration: {}
}
},
customcomponent: {
name: 'Custom',
icon: Code2,
documentationLink: `${documentationBaseUrl}#custom`,
dims: '1:2-1:2' as AppComponentDimensions,
customCss: {
container: { class: '', style: '' }
},
initialData: {
componentInput: {
type: 'static',
fieldType: 'object',
value: {}
},
configuration: {}
}
},
mardowncomponent: {
name: 'Markdown',
icon: Heading1,

View File

@@ -63,7 +63,8 @@ const display: ComponentSet = {
'pdfcomponent',
'displaycomponent',
'jobidlogcomponent',
'jobidflowstatuscomponent'
'jobidflowstatuscomponent',
'customcomponent'
]
} as const

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import type { AppEditorContext, AppViewerContext } from '../../types'
import { getContext, onMount, tick } from 'svelte'
import { getContext, tick } from 'svelte'
import {
components as componentsRecord,
presets as presetsRecord,
@@ -11,10 +11,13 @@
import ListItem from './ListItem.svelte'
import { appComponentFromType, copyComponent, insertNewGridItem } from '../appUtils'
import { push } from '$lib/history'
import { ClearableInput } from '../../../common'
import { workspaceStore } from '$lib/stores'
import { ClearableInput, Drawer, DrawerContent } from '../../../common'
import { enterpriseLicense, workspaceStore } from '$lib/stores'
import { getGroup, listGroups } from './groupUtils'
import { LayoutDashboard } from 'lucide-svelte'
import { LayoutDashboard, Plus } from 'lucide-svelte'
import { ResourceService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import ComponentsList from './CustomComponentsList.svelte'
const { app, selectedComponent, focusedGrid } = getContext<AppViewerContext>('AppViewerContext')
@@ -25,10 +28,20 @@
path: string
}> = []
let customComponents: Array<{
name: string
path: string
}> = []
async function fetchGroups() {
if ($workspaceStore) {
groups = await listGroups($workspaceStore)
}
groups = await listGroups($workspaceStore ?? '')
}
async function fetchCustomComponents() {
customComponents = await ResourceService.listResourceNames({
workspace: $workspaceStore ?? '',
name: 'app_custom'
})
}
function addComponent(appComponentType: TypedComponent['type']): string {
@@ -61,6 +74,59 @@
}
}
async function addNewGroup() {
push(history, $app)
const id = insertNewGridItem(
$app,
appComponentFromType('containercomponent', undefined, { groupFields: {} }) as (
id: string
) => AppComponent,
$focusedGrid
)
if (id) {
$selectedComponent = [id]
$app = $app
}
}
async function addCustomComponent(cc: { name: string; path: string }) {
if (!$workspaceStore) return
let res: any = undefined
try {
res = await ResourceService.getResourceValue({
workspace: $workspaceStore ?? '',
path: cc.path
})
} catch (e) {
sendUserToast(`Custom Component not found at ${cc.path}`)
return
}
if (!res) return
push(history, $app)
const id = insertNewGridItem(
$app,
appComponentFromType('customcomponent', undefined, {
customComponent: {
name: cc.name.replace(/-/g, '_').replace(/\s/g, '_'),
additionalLibs: {
reactVersion: '18.2.0'
}
}
}) as (id: string) => AppComponent,
$focusedGrid
)
if (id) {
$selectedComponent = [id]
$app = $app
}
}
function addPresetComponent(appComponentType: string): void {
const preset = presetsRecord[appComponentType]
@@ -92,14 +158,25 @@
})
}))
onMount(() => {
fetchGroups()
})
$: {
if ($workspaceStore) {
fetchGroups()
fetchCustomComponents()
}
}
let dndTimeout: NodeJS.Timeout | undefined = undefined
let ccDrawer: Drawer
</script>
<section class="p-2 sticky w-full z-10 top-0 bg-surface border-b">
<Drawer bind:this={ccDrawer}>
<DrawerContent title="Custom Components" on:close={ccDrawer.closeDrawer}>
<ComponentsList on:reload={fetchCustomComponents} />
</DrawerContent>
</Drawer>
<section class="p-2 sticky w-full z-10 top-0 bg-surface">
<ClearableInput bind:value={search} placeholder="Search components..." />
</section>
@@ -170,7 +247,7 @@
<ListItem title={'Groups'}>
<div class="flex flex-wrap gap-3 py-2">
{#if groups}
{#each groups as group (group)}
{#each groups as group (group.path)}
<div class="w-20">
<button
on:click={() => {
@@ -188,6 +265,59 @@
</div>
{/each}
{/if}
<div class="w-20">
<button
on:click={() => {
addNewGroup()
}}
title=""
class="transition-all border w-20 shadow-sm h-16 p-2 flex flex-col gap-2 items-center
justify-center bg-surface rounded-md hover:bg-blue-50 dark:hover:bg-blue-900 duration-200 hover:border-blue-500"
>
<Plus class="text-secondary" />
</button>
<div class="text-xs text-center flex-wrap text-secondary mt-1"> Add new </div>
</div>
</div>
</ListItem>
<ListItem title={'Custom Components'} tooltip={'Create components in React or vanilla JS'}>
<div class="flex flex-wrap gap-3 py-2">
{#if customComponents}
{#each customComponents as cc (cc.path)}
<div class="w-20">
<button
on:click={() => {
addCustomComponent(cc)
}}
title={cc.name}
class="transition-all border w-20 shadow-sm h-16 p-2 flex flex-col gap-2 items-center
justify-center bg-surface rounded-md hover:bg-blue-50 dark:hover:bg-blue-900 duration-200 hover:border-blue-500"
>
<LayoutDashboard class="text-secondary" />
</button>
<div class="text-xs text-center flex-wrap text-secondary mt-1">
{cc.name}
</div>
</div>
{/each}
{/if}
<div class="w-20">
<button
on:click={() => {
if (!$enterpriseLicense) {
sendUserToast('Custom components are only available on the EE', true)
} else {
ccDrawer.openDrawer()
}
}}
title=""
class="transition-all border w-20 shadow-sm h-16 p-2 flex flex-col gap-2 items-center
justify-center bg-surface rounded-md hover:bg-blue-50 dark:hover:bg-blue-900 duration-200 hover:border-blue-500"
>
<Plus class="text-secondary" />
</button>
<div class="text-xs text-center flex-wrap text-secondary mt-1"> Add new </div>
</div>
</div>
</ListItem>
</div>

View File

@@ -0,0 +1,87 @@
<script lang="ts">
import { workspaceStore } from '$lib/stores'
import Cell from '$lib/components/table/Cell.svelte'
import { Trash } from 'lucide-svelte'
import { sendUserToast } from '$lib/toast'
import { twMerge } from 'tailwind-merge'
import { createEventDispatcher } from 'svelte'
import NameEditor from './NameEditor.svelte'
import ButtonDropdown from '$lib/components/common/button/ButtonDropdown.svelte'
import { classNames } from '$lib/utils'
import { MenuItem } from '@rgossiaux/svelte-headlessui'
import { ResourceService } from '$lib/gen/services/ResourceService'
export let row: {
name: string
path: string
}
const dispatch = createEventDispatcher()
async function toggleDelete() {
if ($workspaceStore) {
await ResourceService.deleteResource({
workspace: $workspaceStore,
path: row.path
})
}
dispatch('reload')
sendUserToast('Component deleted:\n' + row.name)
}
</script>
<tr>
<Cell first>
<div class="flex flex-row gap-1 items-center">
<NameEditor
kind="custom component"
on:update={async (e) => {
if (!$workspaceStore) return
const cc = await ResourceService.getResourceValue({
workspace: $workspaceStore ?? '',
path: row.path
})
await ResourceService.updateResource({
workspace: $workspaceStore ?? '',
path: row.path,
requestBody: {
path: `f/app_custom/${e.detail.name.replace(/-/g, '_').replace(/\s/g, '_')}`,
value: {
...cc,
name: e.detail.name
}
}
})
dispatch('reload')
sendUserToast('Component name updated:\n' + e.detail.name)
}}
{row}
/>
{row.name}
</div>
</Cell>
<Cell last>
<div class={twMerge('flex flex-row gap-1 justify-end z-[10000]')}>
<button on:pointerdown|stopPropagation>
<ButtonDropdown target="#cc_portal" hasPadding={false}>
<svelte:fragment slot="items">
<MenuItem on:click={toggleDelete}>
<div
class={classNames(
'!text-red-600 flex flex-row items-center text-left px-4 py-2 gap-2 cursor-pointer hover:bg-gray-100 !text-xs font-semibold'
)}
>
<Trash size={16} />
Delete
</div>
</MenuItem>
</svelte:fragment>
</ButtonDropdown>
</button>
</div>
</Cell>
</tr>

View File

@@ -0,0 +1,132 @@
<script lang="ts">
import { workspaceStore } from '$lib/stores'
import Button from '$lib/components/common/button/Button.svelte'
import DataTable from '$lib/components/table/DataTable.svelte'
import Head from '$lib/components/table/Head.svelte'
import Cell from '$lib/components/table/Cell.svelte'
import { sendUserToast } from '$lib/toast'
import { ResourceService } from '$lib/gen'
import CustomComponentRow from './CustomComponentRow.svelte'
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { createEventDispatcher } from 'svelte'
let customcomponents: Array<{
name: string
path: string
}> = []
let loading: boolean = false
async function getCustomComponents() {
loading = true
customcomponents = await ResourceService.listResourceNames({
workspace: $workspaceStore ?? '',
name: 'app_custom'
})
loading = false
}
const dispatch = createEventDispatcher()
async function addCustomComponent(nameField: string) {
try {
const message = await ResourceService.createResource({
workspace: $workspaceStore!,
requestBody: {
resource_type: 'app_custom',
path: `f/app_custom/${nameField.replace(/-/g, '_').replace(/\s/g, '_')}`,
value: {
value: {
additionalLibs: {
reactVersion: useReact ? reactVersion : undefined
}
},
name: nameField,
js: await files[0].text()
}
}
})
sendUserToast('Component created: ' + message)
dispatch('reload')
} catch (e) {
sendUserToast(
'Component creation failed. You need write privilege on folder app_custom: ' + e.body ?? e,
true
)
}
getCustomComponents()
nameField = ''
}
let nameField: string = ''
let reactVersion: string = '18.2.0'
let files: FileList
let useReact = true
getCustomComponents()
</script>
<div id="cc_portal" />
<div class="p-2 flex flex-col items-start w-auto gap-2 relative">
<div class="w-full flex flex-col gap-y-2 pb-8">
<div>
<input type="text" bind:value={nameField} placeholder={'Custom Component name'} />
</div>
<div>
<input type="file" accept="text/javascript " bind:files multiple={false} />
</div>
<div class="flex gap-4">
<Toggle bind:checked={useReact} options={{ right: 'React Component' }} />
<div>
<input
disabled={!useReact}
type="text"
bind:value={reactVersion}
placeholder={'React version'}
/></div
></div
>
<Button on:click={() => addCustomComponent(nameField)} color="dark" size="xs"
>Add Custom Component</Button
>
</div>
{#if loading}
<div class="flex flex-col w-full pt-12">
{#each new Array(6) as _}
<Skeleton layout={[[2], 0.5]} />
{/each}
</div>
{:else if Array.isArray(customcomponents) && customcomponents.length > 0}
<div class="w-full">
<DataTable size="xs">
<Head>
<tr>
<Cell first head>Path</Cell>
<Cell last head />
</tr>
</Head>
<tbody class="divide-y">
{#if customcomponents && customcomponents.length > 0}
{#each customcomponents as row}
{#key row}
<CustomComponentRow
{row}
on:reload={() => {
getCustomComponents()
dispatch('reload')
}}
/>
{/key}
{/each}
{:else}
<tr>Loading...</tr>
{/if}
</tbody>
</DataTable>
</div>
{/if}
</div>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { listGroups, type Group, createGroup } from './groupUtils'
import { workspaceStore } from '$lib/stores'
import { onMount } from 'svelte'
import { getContext } from 'svelte'
import Button from '$lib/components/common/button/Button.svelte'
import DataTable from '$lib/components/table/DataTable.svelte'
import Head from '$lib/components/table/Head.svelte'
@@ -11,8 +11,11 @@
import { ResourceService } from '$lib/gen'
import GroupRow from './GroupRow.svelte'
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
import type { AppViewerContext, GridItem } from '../../types'
import { getAllSubgridsAndComponentIds } from '../appUtils'
export let selectedGroup: object = {}
export let item: GridItem
const { app } = getContext<AppViewerContext>('AppViewerContext')
let groups: Array<{
name: string
@@ -27,36 +30,52 @@
loading = false
}
function getSubgrids(item: GridItem) {
let allSubgrids = {}
let subgrids = getAllSubgridsAndComponentIds($app, item.data)[0]
for (let key of subgrids) {
allSubgrids[key] = $app.subgrids?.[key]
}
return allSubgrids
}
async function addGroup(nameField: string) {
const groups = await ResourceService.listResourceNames({
workspace: $workspaceStore!,
name: 'app_group'
})
let subgrids = getSubgrids(item)
const group: Group = {
path: 'f/app_groups/group_' + groups.length,
value: {
value: selectedGroup,
value: { item, subgrids },
name: nameField
}
}
const message = await createGroup($workspaceStore!, group)
try {
const message = await createGroup($workspaceStore!, group)
sendUserToast('Group created: ' + message)
} catch (e) {
sendUserToast(
'Group creation failed. You need write privilege on folder app_groups: ' + e.body ?? e,
true
)
}
getGroups()
nameField = ''
sendUserToast('Group created:' + message)
}
let nameField: string = ''
onMount(() => {
getGroups()
})
getGroups()
</script>
<div id="group_portal" />
<div class="p-2 flex flex-col items-start w-auto gap-2 relative">
<div class="w-full flex flex-row gap-2 items-center">
<input bind:value={nameField} placeholder={'Group name'} />

View File

@@ -1,25 +1,10 @@
<script lang="ts">
import { Drawer, DrawerContent } from '$lib/components/common'
import GroupList from './GroupList.svelte'
import { getContext } from 'svelte'
import type { AppViewerContext, GridItem } from '../../types'
import { getAllSubgridsAndComponentIds } from '../appUtils'
import type { GridItem } from '../../types'
export let item: GridItem
const { app } = getContext<AppViewerContext>('AppViewerContext')
$: subgrids = getSubgrids(item)
function getSubgrids(item: GridItem) {
let allSubgrids = {}
let subgrids = getAllSubgridsAndComponentIds($app, item.data)[0]
for (let key of subgrids) {
allSubgrids[key] = $app.subgrids?.[key]
}
return allSubgrids
}
let codeDrawer: Drawer
export async function openDrawer() {
@@ -28,12 +13,7 @@
</script>
<Drawer bind:this={codeDrawer}>
<DrawerContent title="Group manager" on:close={codeDrawer.closeDrawer}>
<GroupList
selectedGroup={{
item,
subgrids
}}
/>
<DrawerContent title="Component Groups" on:close={codeDrawer.closeDrawer}>
<GroupList {item} />
</DrawerContent>
</Drawer>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { deleteGroup } from './groupUtils'
import { deleteGroup, getGroup, updateGroup } from './groupUtils'
import { workspaceStore } from '$lib/stores'
import Cell from '$lib/components/table/Cell.svelte'
@@ -7,7 +7,7 @@
import { sendUserToast } from '$lib/toast'
import { twMerge } from 'tailwind-merge'
import { createEventDispatcher } from 'svelte'
import GroupNameEditor from './GroupNameEditor.svelte'
import GroupNameEditor from './NameEditor.svelte'
import ButtonDropdown from '$lib/components/common/button/ButtonDropdown.svelte'
import { classNames } from '$lib/utils'
@@ -32,15 +32,31 @@
<tr>
<Cell first>
<div class="flex flex-row gap-1 items-center">
<GroupNameEditor on:reloadGroups {row} />
<GroupNameEditor
kind="group"
on:update={async (e) => {
if (!$workspaceStore) return
const group = await getGroup($workspaceStore, row.path)
await updateGroup($workspaceStore, row.path, {
value: {
...group,
name: e.detail.name
}
})
dispatch('reloadGroups')
sendUserToast('Group name updated:\n' + e.detail.name)
}}
{row}
/>
{row.name}
</div>
</Cell>
<Cell last>
<div class={twMerge('flex flex-row gap-1 justify-end ')}>
<div class={twMerge('flex flex-row gap-1 justify-end z-[10000]')}>
<button on:pointerdown|stopPropagation>
<ButtonDropdown hasPadding={false}>
<ButtonDropdown target="#group_portal" hasPadding={false}>
<svelte:fragment slot="items">
<MenuItem on:click={toggleDelete}>
<div

View File

@@ -3,6 +3,7 @@
import { ChevronDown } from 'lucide-svelte'
import { isOpenStore } from './store'
import { createEventDispatcher, onMount } from 'svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
export let title: string
export let prefix: string | undefined = undefined
@@ -11,6 +12,8 @@
export let toggleClasses = ''
export let contentWrapperClasses = ''
export let isOpen = false
export let tooltip: string | undefined = undefined
export let documentationLink: string | undefined = undefined
const dispatch = createEventDispatcher()
@@ -37,6 +40,13 @@
<slot name="title">
{title}
</slot>
{#if tooltip}
<Tooltip
class="ml-1"
documentationLink="https://github.com/windmill-labs/windmill-custom-component-template"
>{tooltip}</Tooltip
>
{/if}
</h1>
<ChevronDown class="rotate-0 duration-300 {isOpen ? '!rotate-180' : ''}" />
</button>
@@ -50,6 +60,13 @@
<slot name="title">
{title}
</slot>
{#if tooltip}
<Tooltip
class="ml-1"
documentationLink="https://github.com/windmill-labs/windmill-custom-component-template"
>{tooltip}</Tooltip
>
{/if}
</h1>
<div class="px-2">
<slot />

View File

@@ -1,13 +1,11 @@
<script lang="ts">
import { getGroup, updateGroup } from './groupUtils'
import { workspaceStore } from '$lib/stores'
import Button from '$lib/components/common/button/Button.svelte'
import { Popup } from '$lib/components/common'
import { Pen } from 'lucide-svelte'
import { createEventDispatcher } from 'svelte'
import { sendUserToast } from '$lib/toast'
export let kind: string
export let row: {
name: string
path: string
@@ -16,6 +14,11 @@
let editedName = row.name
const dispatch = createEventDispatcher()
function onkeydown(e) {
if (e.key === 'Enter') {
dispatch('update', { path: row.path, name: editedName })
}
}
</script>
<Popup
@@ -31,25 +34,13 @@
</Button>
</svelte:fragment>
<div class="flex flex-col w-80 gap-2">
<div class="leading-6 font-semibold text-xs">Edit group name</div>
<div class="leading-6 font-semibold text-xs">Edit {kind} name</div>
<div class="flex flex-row gap-2">
<input bind:value={editedName} />
<input on:keydown={onkeydown} bind:value={editedName} />
<Button
color="dark"
size="xs"
on:click={async () => {
if (!$workspaceStore) return
const group = await getGroup($workspaceStore, row.path)
await updateGroup($workspaceStore, row.path, {
value: {
...group,
name: editedName
}
})
dispatch('reloadGroups')
close(null)
sendUserToast('Group name updated:\n' + editedName)
}}
on:click={() => dispatch('update', { path: row.path, name: editedName }) && close(null)}
>
Update
</Button>

View File

@@ -559,6 +559,7 @@ export const quickStyleProperties: Record<
container: containerDefaultProps,
button: buttonDefaultProps
},
customcomponent: {},
htmlcomponent: {
container: [
layoutGrouping,

View File

@@ -6,7 +6,7 @@
import Portal from 'svelte-portal'
export let hasPadding: boolean = true
export let target: string | undefined = undefined
const [popperRef, popperContent] = createPopperActions({ placement: 'auto' })
const popperOptions: PopperOptions<{}> = {
@@ -38,7 +38,7 @@
</MenuButton>
</span>
<Portal>
<Portal {target}>
<div use:popperContent={popperOptions} class="z-[6000]">
<Transition
show={open}

View File

@@ -0,0 +1,2 @@
(function(){"use strict";try{if(typeof document<"u"){var o=document.createElement("style");o.appendChild(document.createTextNode(".mycomponent{font-family:Inter,system-ui,Avenir,Helvetica,Arial,sans-serif;line-height:1.5;font-weight:400;color-scheme:light dark;color:#ffffffde;background-color:#999;font-synthesis:none;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;-webkit-text-size-adjust:100%}.mycomponent a{font-weight:500;color:#646cff;text-decoration:inherit}.mycomponent a:hover{color:#535bf2}.mycomponent h1{font-size:3.2em;line-height:1.1}.mycomponent button{border-radius:8px;border:1px solid transparent;padding:.6em 1.2em;font-size:1em;font-weight:500;font-family:inherit;background-color:#1a1a1a;cursor:pointer;transition:border-color .25s}.mycomponent button:hover{border-color:#646cff}.mycomponent>button:focus,.mycomponent>button:focus-visible{outline:4px auto -webkit-focus-ring-color}@media (prefers-color-scheme: light){.mycomponent{color:#213547;background-color:#fff}.mycomponent a:hover{color:#747bff}.mycomponent button{background-color:#f9f9f9}}.mycomponent{margin:0 auto;text-align:center}.mycomponent .card{padding:2em}")),document.head.appendChild(o)}}catch(e){console.error("vite-plugin-css-injected-by-js",e)}})();
var wmc=function(t,e,r){"use strict";const w="";function i({passSetters:n,setOutput:m,renderInit:u}){const[d,l]=e.useState(u),[s,c]=e.useState(void 0);return e.useEffect(()=>{n({onInput:a=>{c(a)},onRender:l})},[l,n]),d?e.createElement(e.Fragment,null,e.createElement("h1",null,"Minimal Component"),e.createElement("pre",null,JSON.stringify(s)),e.createElement("button",{style:{fontSize:"12px",marginBottom:"10px"},onClick:()=>{m("hello world")}},"set output")):e.createElement(e.Fragment,null)}function o(n){r.createRoot(document.getElementById(n.id)).render(e.createElement(e.StrictMode,null,e.createElement(i,{passSetters:n.passSetters,setOutput:n.setOutput,renderInit:n.render})))}return window.windmill===void 0&&(window.windmill={}),window.windmill.mycomponent=o,t.customComponent=o,Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),t}({},React,ReactDOM);