feat(frontend): add menu component (#2721)

* feat(frontend): add menu component

* wip

* feat(frontend): menu component done

* feat(frontend): Fix styling + handle menu items everywhere

* feat(frontend): Fix styling + handle menu items everywhere
This commit is contained in:
Faton Ramadani
2023-11-30 12:39:18 +01:00
committed by GitHub
parent a0563d0064
commit 64cc375cbc
18 changed files with 460 additions and 7 deletions
@@ -14,6 +14,7 @@
}
export let items: Item[] | (() => Item[]) = []
export let justifyEnd: boolean = true
function computeItems(): Item[] {
if (typeof items === 'function') {
@@ -24,7 +25,7 @@
}
</script>
<Menu placement="bottom-end" justifyEnd on:close on:open>
<Menu placement="bottom-end" {justifyEnd} on:close on:open>
<div slot="trigger">
{#if $$slots.buttonReplacement}
<slot name="buttonReplacement" />
@@ -33,6 +33,7 @@
export let render: boolean
export let errorHandledByComponent: boolean | undefined = false
export let extraKey: string | undefined = undefined
export let isMenuItem: boolean = false
export let controls: { left: () => boolean; right: () => boolean | string } | undefined =
undefined
@@ -178,11 +179,18 @@
{#key css}
<Button
on:pointerdown={(e) => e.stopPropagation()}
btnClasses={twMerge(css?.button?.class ?? '', 'wm-button')}
btnClasses={twMerge(
css?.button?.class ?? '',
isMenuItem ? 'flex items-center justify-start' : '',
isMenuItem ? '!border-0' : '',
'wm-button'
)}
variant={isMenuItem ? 'border' : 'contained'}
style={css?.button?.style}
wrapperClasses={twMerge(
css?.container?.class ?? '',
resolvedConfig.fillContainer ? 'w-full h-full' : '',
isMenuItem ? 'w-full' : '',
'wm-button-container'
)}
wrapperStyle={css?.container?.style}
@@ -0,0 +1,143 @@
<script lang="ts">
import { getContext } from 'svelte'
import { twMerge } from 'tailwind-merge'
import { initConfig, initOutput } from '../../editor/appUtils'
import { components, type ButtonComponent } from '../../editor/component'
import type {
AppViewerContext,
BaseAppComponent,
ComponentCustomCSS,
RichConfigurations
} from '../../types'
import { initCss } from '../../utils'
import ResolveConfig from '../helpers/ResolveConfig.svelte'
import InitializeComponent from '../helpers/InitializeComponent.svelte'
import { AlignWrapper } from '../helpers'
import { Button } from '$lib/components/common'
import { loadIcon } from '../icon'
import ResolveStyle from '../helpers/ResolveStyle.svelte'
import Menu from '$lib/components/common/menu/MenuV2.svelte'
import { AppButton } from '../buttons'
export let id: string
export let configuration: RichConfigurations
export let customCss: ComponentCustomCSS<'menucomponent'> | undefined = undefined
export let render: boolean
export let horizontalAlignment: 'left' | 'center' | 'right' | undefined = undefined
export let verticalAlignment: 'top' | 'center' | 'bottom' | undefined = undefined
export let menuItems: (BaseAppComponent & ButtonComponent)[]
const resolvedConfig = initConfig(
components['menucomponent'].initialData.configuration,
configuration
)
const { app, worldStore } = getContext<AppViewerContext>('AppViewerContext')
initOutput($worldStore, id, {
result: undefined,
loading: false
})
//used so that we can count number of outputs setup for first refresh
initOutput($worldStore, id, {})
let beforeIconComponent: any
let afterIconComponent: any
let css = initCss($app.css?.menucomponent, customCss)
$: resolvedConfig.beforeIcon && handleBeforeIcon()
$: resolvedConfig.afterIcon && handleAfterIcon()
async function handleBeforeIcon() {
if (resolvedConfig.beforeIcon) {
beforeIconComponent = await loadIcon(resolvedConfig.beforeIcon)
}
}
async function handleAfterIcon() {
if (resolvedConfig.afterIcon) {
afterIconComponent = await loadIcon(resolvedConfig.afterIcon)
}
}
</script>
<InitializeComponent {id} />
{#each Object.keys(components['menucomponent'].initialData.configuration) as key (key)}
<ResolveConfig
{id}
{key}
bind:resolvedConfig={resolvedConfig[key]}
configuration={configuration[key]}
/>
{/each}
{#each Object.keys(css ?? {}) as key (key)}
<ResolveStyle
{id}
{customCss}
{key}
bind:css={css[key]}
componentStyle={$app.css?.menucomponent}
/>
{/each}
{#if render}
<AlignWrapper {horizontalAlignment} {verticalAlignment}>
<Menu placement="bottom-end" justifyEnd={false} on:close on:open>
<div slot="trigger">
<Button
on:pointerdown={(e) => e.stopPropagation()}
btnClasses={twMerge(
css?.button?.class,
'wm-button',
'wm-download-button',
resolvedConfig.fillContainer ? 'w-full h-full' : ''
)}
wrapperClasses={twMerge(
'wm-button-container',
'wm-download-button-container',
resolvedConfig.fillContainer ? 'w-full h-full' : ''
)}
style={css?.button?.style}
size={resolvedConfig.size}
color={resolvedConfig.color}
nonCaptureEvent
>
<span class="truncate inline-flex gap-2 items-center">
{#if resolvedConfig.beforeIcon && beforeIconComponent}
<svelte:component this={beforeIconComponent} size={14} />
{/if}
{#if resolvedConfig.label && resolvedConfig.label?.length > 0}
<div>{resolvedConfig.label}</div>
{/if}
{#if resolvedConfig.afterIcon && afterIconComponent}
<svelte:component this={afterIconComponent} size={14} />
{/if}
</span>
</Button>
</div>
<div class="flex flex-col w-full p-1 gap-2">
{#if menuItems.length > 0}
{#each menuItems as actionButton, actionIndex (actionButton?.id)}
{#if actionButton.type == 'buttoncomponent'}
<AppButton
extraKey={'idx' + actionIndex}
{render}
id={actionButton.id}
customCss={actionButton.customCss}
configuration={actionButton.configuration}
recomputeIds={actionButton.recomputeIds}
componentInput={actionButton.componentInput}
noWFull={false}
isMenuItem={true}
/>
{/if}
{/each}
{/if}
</div>
</Menu>
</AlignWrapper>
{/if}
@@ -183,6 +183,9 @@
if (c.type === 'tablecomponent') {
r.push(...c.actionButtons.map((x) => ({ input: x.componentInput, id: x.id })))
}
if (c.type === 'menucomponent') {
r.push(...c.menuItems.map((x) => ({ input: x.componentInput, id: x.id })))
}
return r
.filter((x) => x.input)
.map(async (o) => {
@@ -17,6 +17,7 @@
$: componentSettings = findComponentSettings($app, $selectedComponent?.[0])
$: tableActionSettings = findTableActionSettings($app, $selectedComponent?.[0])
$: menuItemsSettings = findMenuItemsSettings($app, $selectedComponent?.[0])
function findTableActionSettings(app: App, id: string | undefined) {
return allItems(app.grid, app.subgrids)
@@ -33,6 +34,21 @@
.find((x) => x)
}
function findMenuItemsSettings(app: App, id: string | undefined) {
return allItems(app.grid, app.subgrids)
.map((x) => {
if (x?.data?.type === 'menucomponent') {
if (x?.data?.menuItems) {
const menuItem = x.data.menuItems.find((x) => x.id === id)
if (menuItem) {
return { item: { data: menuItem, id: menuItem.id }, parent: x.data.id }
}
}
}
})
.find((x) => x)
}
const dispatch = createEventDispatcher()
</script>
@@ -65,6 +81,26 @@
}}
/>
{/key}
{:else if menuItemsSettings}
{#key menuItemsSettings?.item?.id}
<ComponentPanel
noGrid
bind:componentSettings={menuItemsSettings}
duplicateMoveAllowed={false}
onDelete={() => {
if (menuItemsSettings) {
const parent = findGridItem($app, menuItemsSettings.parent)
if (!parent) return
if (parent.data.type === 'menucomponent') {
parent.data.menuItems = parent.data.menuItems.filter(
(x) => x.id !== menuItemsSettings?.item.id
)
}
}
}}
/>
{/key}
{:else if hiddenInlineScript}
{@const id = BG_PREFIX + hiddenInlineScript.index}
{#key id}
@@ -64,6 +64,8 @@ export function dfs(
item.data.actionButtons.find((x) => id == x.id)
) {
return [item.id, id]
} else if (item.data.type == 'menucomponent' && item.data.menuItems.find((x) => id == x.id)) {
return [item.id, id]
} else {
for (let i = 0; i < (item.data.numberOfSubgrids ?? 0); i++) {
const res = dfs(subgrids[`${item.id}-${i}`], id, subgrids)
@@ -157,6 +159,9 @@ export function allsubIds(app: App, parentId: string): string[] {
if (item.data.type === 'tablecomponent') {
subIds.push(...item.data.actionButtons?.map((x) => x.id))
}
if (item.data.type === 'menucomponent') {
subIds.push(...item.data.menuItems?.map((x) => x.id))
}
}
return subIds
}
@@ -171,6 +176,8 @@ export function getNextGridItemId(app: App): string {
const allIds = allItems(app.grid, app.subgrids).flatMap((x) => {
if (x.data.type === 'tablecomponent') {
return [x.id, ...x.data.actionButtons.map((x) => x.id)]
} else if (x.data.type === 'menucomponent') {
return [x.id, ...x.data.menuItems.map((x) => x.id)]
} else {
return [x.id]
}
@@ -302,6 +309,7 @@ export function appComponentFromType<T extends keyof typeof components>(
customCss: ccomponents[type].customCss as any,
recomputeIds: init.recomputeIds ? [] : undefined,
actionButtons: init.actionButtons ? [] : undefined,
menuItems: init.menuItems ? [] : undefined,
numberOfSubgrids: init.numberOfSubgrids,
horizontalAlignment: init.horizontalAlignment,
verticalAlignment: init.verticalAlignment,
@@ -377,6 +385,16 @@ export function copyComponent(
id: x.id.replace(`${item.id}_`, `${id}_`)
})) ?? []
}
} else if (item.data.type === 'menucomponent') {
return {
...item.data,
id,
menuItems:
item.data.menuItems.map((x) => ({
...x,
id: x.id.replace(`${item.id}_`, `${id}_`)
})) ?? []
}
} else {
return { ...item.data, id }
}
@@ -409,6 +427,11 @@ export function getAllSubgridsAndComponentIds(
if (component.type === 'tablecomponent') {
components.push(...component.actionButtons?.map((x) => x.id))
}
if (component.type === 'menucomponent') {
components.push(...component.menuItems?.map((x) => x.id))
}
if (app.subgrids && component.numberOfSubgrids) {
for (let i = 0; i < component.numberOfSubgrids; i++) {
const key = `${component.id}-${i}`
@@ -64,6 +64,7 @@
import AppAggridTableEe from '../../components/display/table/AppAggridTableEe.svelte'
import AppCustomComponent from '../../components/display/AppCustomComponent.svelte'
import AppStatCard from '../../components/display/AppStatCard.svelte'
import AppMenu from '../../components/display/AppMenu.svelte'
export let component: AppComponent
export let selected: boolean
@@ -685,6 +686,16 @@
customCss={component.customCss}
{render}
/>
{:else if component.type === 'menucomponent'}
<AppMenu
id={component.id}
verticalAlignment={component.verticalAlignment}
horizontalAlignment={component.horizontalAlignment}
configuration={component.configuration}
customCss={component.customCss}
menuItems={component.menuItems}
{render}
/>
{/if}
</div>
</div>
@@ -41,7 +41,8 @@ import {
PanelTopInactive,
ListIcon,
Heading1,
FileBarChart
FileBarChart,
Menu
} from 'lucide-svelte'
import type {
Aligned,
@@ -164,6 +165,9 @@ export type SelectStepComponent = BaseComponent<'selectstepcomponent'>
export type CarouselListComponent = BaseComponent<'carousellistcomponent'>
export type StatisticCardComponent = BaseComponent<'statcomponent'>
export type MenuComponent = BaseComponent<'menucomponent'> & {
menuItems: (BaseAppComponent & ButtonComponent & GridItem)[]
}
export type TypedComponent =
| DisplayComponent
@@ -226,6 +230,7 @@ export type TypedComponent =
| PlotlyComponentV2
| ChartJsComponentV2
| StatisticCardComponent
| MenuComponent
export type AppComponent = BaseAppComponent & TypedComponent
@@ -275,6 +280,7 @@ export interface InitialAppComponent extends Partial<Aligned> {
numberOfSubgrids?: number
recomputeIds?: boolean
actionButtons?: boolean
menuItems?: boolean
tabs?: string[]
panes?: number[]
conditions?: AppInputSpec<'boolean', boolean>[]
@@ -2790,6 +2796,56 @@ This is a paragraph.
} as const
}
}
},
menucomponent: {
name: 'Menu',
icon: Menu,
documentationLink: `${documentationBaseUrl}/menu`,
dims: '1:1-1:2' as AppComponentDimensions,
customCss: {
button: { style: '', class: '' }
},
initialData: {
...defaultAlignement,
componentInput: undefined,
configuration: {
label: {
type: 'static',
fieldType: 'text',
value: '' as string
},
color: {
fieldType: 'select',
type: 'static',
selectOptions: selectOptions.buttonColorOptions,
value: 'light'
},
size: {
fieldType: 'select',
type: 'static',
selectOptions: selectOptions.buttonSizeOptions,
value: 'xs'
},
fillContainer: {
fieldType: 'boolean',
type: 'static',
value: false
},
beforeIcon: {
type: 'static',
value: 'Menu',
fieldType: 'icon-select'
},
afterIcon: {
type: 'static',
value: undefined,
fieldType: 'icon-select'
}
},
menuItems: true
}
}
} as const
@@ -65,7 +65,8 @@ const display: ComponentSet = {
'jobidlogcomponent',
'jobidflowstatuscomponent',
'customcomponent',
'statcomponent'
'statcomponent',
'menucomponent'
]
} as const
@@ -745,5 +745,8 @@ export const quickStyleProperties: Record<
selectedTab: [typographyGrouping, sizeGrouping]
},
selectstepcomponent: {},
statcomponent: {}
statcomponent: {},
menucomponent: {
button: buttonDefaultProps
}
}
@@ -8,6 +8,8 @@
import OutputHeader from './components/OutputHeader.svelte'
import TableActionsOutput from './components/TableActionsOutput.svelte'
import { BG_PREFIX } from '../../utils'
// @ts-ignore
import MenuItemsOutput from './components/MenuItemsOutput.svelte'
export let gridItem: GridItem
export let first: boolean = false
@@ -43,4 +45,5 @@
/>
<SubGridOutput {name} {expanded} {subGrids} parentId={gridItem.id} />
<TableActionsOutput {gridItem} />
<MenuItemsOutput {gridItem} />
</OutputHeader>
@@ -0,0 +1,16 @@
<script lang="ts">
import type { GridItem } from '$lib/components/apps/types'
import TableActionOutput from './TableActionOutput.svelte'
export let gridItem: GridItem
</script>
<div class="my-1">
{#if gridItem.data.type === 'menucomponent' && gridItem.data.menuItems.length > 0}
<div class="ml-2 border-l">
{#each gridItem.data.menuItems as action, index}
<TableActionOutput id={action.id} first={index === 0} />
{/each}
</div>
{/if}
</div>
@@ -90,6 +90,14 @@
}
}
if (item?.data.type === 'menucomponent') {
for (let c of item.data.menuItems) {
let old = c.id
c.id = c.id.replace(id + '_', newId + '_')
propagateRename(old, c.id)
}
}
$app = $app
$selectedComponent = [newId]
@@ -102,6 +110,13 @@
renameComponent(from, to, c)
}
}
if (data.type === 'menucomponent') {
for (let c of data.menuItems) {
renameComponent(from, to, c)
}
}
let componentInput = data.componentInput
if (componentInput?.type == 'connected') {
if (componentInput.connection?.componentId === from) {
@@ -234,8 +249,10 @@
</div>
</div>
<div
class="border-b {open ? 'h-full' : 'h-0 overflow-hidden invisible'} {$connectingInput.hoveredComponent ===
id && !$selectedComponent?.includes(id)
class="border-b {open
? 'h-full'
: 'h-0 overflow-hidden invisible'} {$connectingInput.hoveredComponent === id &&
!$selectedComponent?.includes(id)
? ' bg-orange-100/40'
: ''}"
>
@@ -31,3 +31,16 @@
{/if}
{/each}
{/if}
{#if gridItem?.data?.type === 'menucomponent'}
{#each gridItem.data.menuItems as actionButton, index (index)}
{#if actionButton?.id === $selectedComponentInEditor || actionButton?.id + '_transformer' === $selectedComponentInEditor}
<InlineScriptEditorPanel
componentType={actionButton.type}
id={actionButton.id}
transformer={$selectedComponentInEditor?.endsWith('_transformer')}
bind:componentInput={actionButton.componentInput}
/>
{/if}
{/each}
{/if}
@@ -65,6 +65,21 @@ function processGridItemRunnable(gridItem: GridItem, list: AppScriptsList): AppS
)
})
}
if (component.type === 'menucomponent') {
component.menuItems?.forEach((menuItem) => {
if (menuItem.componentInput?.type !== 'runnable') {
return
}
processRunnable(
menuItem.componentInput.runnable,
menuItem.componentInput.transformer,
menuItem.id,
list
)
})
}
if (componentInput?.type === 'runnable') {
processRunnable(componentInput.runnable, componentInput.transformer, gridItem.id, list)
}
@@ -36,6 +36,7 @@
import ComponentControl from './ComponentControl.svelte'
import GridAgGridLicenseKey from './GridAgGridLicenseKey.svelte'
import ComponentPanelDataSource from './ComponentPanelDataSource.svelte'
import MenuItems from './MenuItems.svelte'
export let componentSettings: { item: GridItem; parent: string | undefined } | undefined =
undefined
@@ -342,6 +343,8 @@
/>
{:else if componentSettings.item.data.type === 'tablecomponent' && Array.isArray(componentSettings.item.data.actionButtons)}
<TableActions id={component.id} bind:components={componentSettings.item.data.actionButtons} />
{:else if componentSettings.item.data.type === 'menucomponent' && Array.isArray(componentSettings.item.data.menuItems)}
<MenuItems id={component.id} bind:components={componentSettings.item.data.menuItems} />
{/if}
{#if Object.values(initialConfiguration).length > 0}
@@ -0,0 +1,91 @@
<script lang="ts">
import { Badge } from '$lib/components/common'
import Button from '$lib/components/common/button/Button.svelte'
import { getNextId } from '$lib/components/flows/idUtils'
import { classNames } from '$lib/utils'
import { getContext } from 'svelte'
import type { AppViewerContext, BaseAppComponent } from '../../types'
import { appComponentFromType } from '../appUtils'
import type { ButtonComponent } from '../component'
import PanelSection from './common/PanelSection.svelte'
import { Plus, Trash } from 'lucide-svelte'
export let components: (BaseAppComponent & ButtonComponent)[]
export let id: string
const { selectedComponent, app, errorByComponent } =
getContext<AppViewerContext>('AppViewerContext')
function addComponent() {
const actionId = getNextId(components.map((x) => x.id.split('_')[1]))
const newComponent = {
...appComponentFromType('buttoncomponent')(`${id}_${actionId}`),
recomputeIds: []
}
components = [...components, newComponent]
$app = $app
}
function deleteComponent(cid: string) {
components = components.filter((x) => x.id !== cid)
delete $errorByComponent[cid]
$selectedComponent = [id]
$app = $app
}
</script>
<PanelSection title={`Menu items`}>
{#if components.length == 0}
<span class="text-xs text-tertiary">No action buttons</span>
{/if}
{#each components as component}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class={classNames(
'w-full text-xs font-bold gap-1 truncate py-1.5 px-2 cursor-pointer transition-all justify-between flex items-center border border-gray-3 rounded-md',
'bg-surface border-gray-300 hover:bg-gray-100 focus:bg-gray-100 text-secondary',
$selectedComponent?.includes(component.id) ? 'outline outline-blue-500 bg-red-400' : ''
)}
on:click={() => {
$selectedComponent = [component.id]
}}
on:keypress
>
<Badge color="dark-indigo">
{component.id}
</Badge>
<div>
{#if component.type == 'buttoncomponent'}
Button
{/if}
</div>
<div>
<Button
variant="border"
color="red"
on:click={() => deleteComponent(component.id)}
startIcon={{ icon: Trash }}
iconOnly
/>
</div>
</div>
{/each}
<div class="w-full flex gap-2">
<Button
btnClasses="gap-1 flex items-center text-sm text-tertiary"
wrapperClasses="w-full"
color="light"
variant="border"
size="xs"
on:click={() => addComponent()}
title="Add Button"
startIcon={{ icon: Plus }}
>
Add
</Button>
</div>
</PanelSection>
+10
View File
@@ -276,6 +276,16 @@ export function getAllScriptNames(app: App): string[] {
})
}
if (gridItem.data.type === 'menucomponent') {
gridItem.data.menuItems.forEach((menuItem) => {
if (menuItem.componentInput?.type === 'runnable') {
if (menuItem.componentInput.runnable?.type === 'runnableByName') {
acc.push(menuItem.componentInput.runnable.name)
}
}
})
}
return acc
}, [] as string[])