Files
windmill/frontend/src/lib/components/apps/editor/appUtils.ts
T
Faton Ramadani 64cc375cbc 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
2023-11-30 12:39:18 +01:00

783 lines
19 KiB
TypeScript

import type {
App,
BaseAppComponent,
ConnectingInput,
EditorBreakpoint,
FocusedGrid,
GeneralAppInput,
GridItem
} from '../types'
import {
ccomponents,
components,
getRecommendedDimensionsByComponent,
type AppComponent,
type BaseComponent,
type InitialAppComponent,
type TypedComponent
} from './component'
import { gridColumns } from '../gridUtils'
import { allItems } from '../utils'
import type { Output, World } from '../rx'
import gridHelp from '../svelte-grid/utils/helper'
import type { FilledItem } from '../svelte-grid/types'
import type { EvalAppInput, StaticAppInput } from '../inputType'
import { get, type Writable } from 'svelte/store'
import { deepMergeWithPriority } from '$lib/utils'
import { sendUserToast } from '$lib/toast'
import { getNextId } from '$lib/components/flows/idUtils'
import { enterpriseLicense } from '$lib/stores'
export function findComponentSettings(app: App, id: string | undefined) {
if (!id) return undefined
if (app?.grid) {
const gridItem = app.grid.find((x) => x.data?.id === id)
if (gridItem) {
return { item: gridItem, parent: undefined }
}
}
if (app?.subgrids) {
for (const key of Object.keys(app.subgrids ?? {})) {
const gridItem = app.subgrids[key].find((x) => x.data?.id === id)
if (gridItem) {
return { item: gridItem, parent: key }
}
}
}
return undefined
}
export function dfs(
grid: GridItem[],
id: string,
subgrids: Record<string, GridItem[]>
): string[] | undefined {
if (!grid) {
return undefined
}
for (const item of grid) {
if (item.id === id) {
return [id]
} else if (
item.data.type == 'tablecomponent' &&
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)
if (res) {
return [item.id, ...res]
}
}
}
}
return undefined
}
export function selectId(
e: PointerEvent,
id: string,
selectedComponent: Writable<string[] | undefined>,
app: App
) {
;(document?.activeElement as HTMLElement)?.blur()
if (e.shiftKey) {
selectedComponent.update((old) => {
if (old && old?.[0]) {
if (findGridItemParentGrid(app, old[0]) != findGridItemParentGrid(app, id)) {
sendUserToast('Cannot multi select items from different grids', true)
return old
}
}
if (old == undefined) {
return [id]
}
if (old.includes(id)) {
return old
}
return [...old, id]
})
} else {
if (get(selectedComponent)?.includes(id)) {
return
} else {
selectedComponent.set([id])
}
}
}
export function connectOutput(
connectingInput: Writable<ConnectingInput>,
typ: TypedComponent['type'],
id: string,
spath: string
) {
if (get(connectingInput).opened) {
let splitted = spath?.split('.')
let componentId = typ == 'containercomponent' ? splitted?.[0] : id
let path = typ == 'containercomponent' ? splitted?.[1] : spath
connectingInput.set(connectInput(get(connectingInput), componentId, path, typ))
}
}
function findGridItemById(
root: GridItem[],
subGrids: Record<string, GridItem[]> | undefined,
id: string
): GridItem | undefined {
for (const gridItem of allItems(root, subGrids)) {
if (gridItem.id === id) {
return gridItem
}
}
return undefined
}
export function findGridItemParentGrid(app: App, id: string): string | undefined {
const gridItem = app.grid.find((x) => x.id === id)
if (gridItem || app.subgrids === undefined) {
return undefined
} else {
for (const key of Object.keys(app.subgrids ?? {})) {
const subGrid = app.subgrids[key]
const gridItemIdx = subGrid.findIndex((x) => x.id === id)
if (gridItemIdx > -1) {
return key
}
}
}
}
export function allsubIds(app: App, parentId: string): string[] {
let item = findGridItem(app, parentId)
if (!item?.data.numberOfSubgrids) {
let subIds = [parentId]
if (item) {
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
}
return getAllSubgridsAndComponentIds(app, item?.data)[1]
}
export function findGridItem(app: App, id: string): GridItem | undefined {
return findGridItemById(app.grid, app.subgrids, id)
}
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]
}
})
return getNextId(allIds)
}
export function getAllRecomputeIdsForComponent(app: App, id: string | undefined) {
if (!app || !id) return []
const items = allItems(app.grid, app.subgrids)
const recomputedBy: string[] = []
items.forEach((item) => {
if (item.data.type === 'buttoncomponent') {
if (item.data.recomputeIds?.includes(id)) {
recomputedBy.push(item.id)
}
}
})
return recomputedBy
}
export function createNewGridItem(
grid: GridItem[],
id: string,
data: AppComponent,
columns?: Record<number, any>
): GridItem {
const newComponent = {
fixed: false,
x: 0,
y: 0
}
let newData: AppComponent = JSON.parse(JSON.stringify(data))
newData.id = id
const newItem: GridItem = {
data: newData,
id: id
}
gridColumns.forEach((column) => {
if (!columns) {
const rec = getRecommendedDimensionsByComponent(newData.type, column)
newItem[column] = {
...newComponent,
w: rec.w,
h: rec.h
}
} else {
newItem[column] = columns[column]
}
const position = gridHelp.findSpace(newItem, grid, column) as { x: number; y: number }
newItem[column] = { ...newItem[column], ...position }
})
return newItem
}
export function getGridItems(app: App, focusedGrid: FocusedGrid | undefined): GridItem[] {
if (!focusedGrid) {
return app.grid
} else {
const { parentComponentId, subGridIndex } = focusedGrid
const key = `${parentComponentId}-${subGridIndex ?? 0}`
return app?.subgrids?.[key] ?? []
}
}
function cleanseValue(key: string, value: { type: 'eval' | 'static'; value?: any; expr?: string }) {
if (!value) {
return [key, undefined]
}
if (value.type === 'static') {
return [key, { type: value.type, value: value.value }]
} else {
return [key, { type: value.type, expr: value.expr }]
}
}
export function cleanseOneOfConfiguration(
configuration: Record<string, Record<string, GeneralAppInput & (StaticAppInput | EvalAppInput)>>
) {
return Object.fromEntries(
Object.entries(configuration).map(([key, val]) => [
key,
Object.fromEntries(Object.entries(val).map(([key, val]) => cleanseValue(key, val)))
])
)
}
export function appComponentFromType<T extends keyof typeof components>(
type: T,
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
const configuration = Object.fromEntries(
Object.entries(init.configuration).map(([key, value]) => {
if (value.type != 'oneOf') {
return cleanseValue(key, value)
} else {
return [
key,
{
type: value.type,
selected: value.selected,
configuration: cleanseOneOfConfiguration(value.configuration)
}
]
}
})
)
return {
type,
//TODO remove tooltip from there
configuration: deepMergeWithPriority(configuration, overrideConfiguration ?? {}),
componentInput: init.componentInput,
panes: init.panes,
tabs: init.tabs,
conditions: init.conditions,
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,
id,
...(extra ?? {})
}
}
}
export function insertNewGridItem(
app: App,
builddata: (id: string) => AppComponent,
focusedGrid: FocusedGrid | undefined,
columns?: Record<string, any>,
keepId?: string
): string {
const id = keepId ?? getNextGridItemId(app)
const data = builddata(id)
if (data.type == 'aggridcomponentee' && !get(enterpriseLicense)) {
sendUserToast('AgGrid Enterprise Edition require Windmill Enterprise Edition', true)
throw Error('AgGrid Enterprise Edition require Windmill Enterprise Edition')
}
if (!app.subgrids) {
app.subgrids = {}
}
// We only want to set subgrids when we are not moving
if (!keepId) {
for (let i = 0; i < (data.numberOfSubgrids ?? 0); i++) {
app.subgrids[`${id}-${i}`] = []
}
}
const key = focusedGrid
? `${focusedGrid?.parentComponentId}-${focusedGrid?.subGridIndex ?? 0}`
: undefined
if (key && app.subgrids[key] === undefined) {
// If ever the subgrid is undefined, we want to make sure it is defined
app.subgrids[key] = []
}
let grid = focusedGrid ? app.subgrids[key!] : app.grid
const newItem = createNewGridItem(grid, id, data, columns)
grid.push(newItem)
return id
}
export function copyComponent(
app: App,
item: GridItem,
parentGrid: FocusedGrid | undefined,
subgrids: Record<string, GridItem[]>,
alreadyVisited: string[]
) {
if (alreadyVisited.includes(item.id)) {
return
} else {
alreadyVisited.push(item.id)
}
const newItem = insertNewGridItem(
app,
(id) => {
if (item.data.type === 'tablecomponent') {
return {
...item.data,
id,
actionButtons:
item.data.actionButtons.map((x) => ({
...x,
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 }
}
},
parentGrid,
Object.fromEntries(gridColumns.map((column) => [column, item[column]]))
)
for (let i = 0; i < (item?.data?.numberOfSubgrids ?? 0); i++) {
subgrids[`${item.id}-${i}`].forEach((subgridItem) => {
copyComponent(
app,
subgridItem,
{ parentComponentId: newItem, subGridIndex: i },
subgrids,
alreadyVisited
)
})
}
return newItem
}
export function getAllSubgridsAndComponentIds(
app: App,
component: AppComponent
): [string[], string[]] {
const subgrids: string[] = []
let components: string[] = [component.id]
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}`
const subgrid = app.subgrids[key]
if (subgrid) {
subgrids.push(key)
for (const item of subgrid) {
let [recSubgrids, recComponents] = getAllSubgridsAndComponentIds(app, item.data)
subgrids.push(...recSubgrids)
components.push(...recComponents)
}
}
}
}
return [subgrids, components]
}
export function getAllGridItems(app: App): GridItem[] {
return app.grid.concat(Object.values(app.subgrids ?? {}).flat())
}
export function deleteGridItem(
app: App,
component: AppComponent,
parent: string | undefined
): string[] {
let [subgrids, components] = getAllSubgridsAndComponentIds(app, component)
if (app.subgrids) {
subgrids.forEach((id) => {
delete app.subgrids![id]
})
}
if (parent) {
app.subgrids &&
(app.subgrids[parent] = app.subgrids[parent].filter((item) => item.id !== component?.id))
} else {
app.grid = app.grid.filter((item) => item.id !== component?.id)
}
return components
}
type AvailableSpace = {
left: number
right: number
top: number
bottom: number
}
export function findAvailableSpace(
grid: GridItem[],
gridItem: GridItem,
editorBreakpoint: EditorBreakpoint,
parentGridItem: GridItem | undefined = undefined
): AvailableSpace | undefined {
if (gridItem) {
const breakpoint = editorBreakpoint === 'sm' ? 3 : 12
const maxHeight = parentGridItem ? parentGridItem[breakpoint].h - 1 : 16
const maxWidth = 12
const availableSpace = {
left: 0,
right: 0,
top: 0,
bottom: 0
}
const items = grid.map((item) => {
return {
id: item.id,
x: item[breakpoint].x,
y: item[breakpoint].y,
w: item[breakpoint].w,
h: item[breakpoint].h
}
})
const item = items.find((item) => item.id === gridItem.id)
if (!item) {
return availableSpace
}
if (item.x > 0) {
for (let x = item.x - 1; x >= 0; x--) {
const itemToCheck = { ...item, x, w: 1 }
const isItemInWay = items.some((item) => isOverlapping(item, itemToCheck))
if (isItemInWay) {
break
} else {
availableSpace.left++
}
}
}
if (item.x + item.w < maxWidth) {
for (let x = item.x + item.w; x < maxWidth; x++) {
const itemToCheck = { ...item, x, w: 1 }
const isItemInWay = items.some((item) => isOverlapping(item, itemToCheck))
if (isItemInWay) {
break
} else {
availableSpace.right++
}
}
}
if (item.y > 0) {
for (let y = item.y - 1; y >= 0; y--) {
const itemToCheck = { ...item, h: 1, y }
const isItemInWay = items.some((item) => isOverlapping(item, itemToCheck))
if (isItemInWay) {
break
} else {
availableSpace.top++
}
}
}
if (item.y + item.h < maxHeight) {
for (let y = item.y + item.h; y < maxHeight; y++) {
const itemToCheck = { ...item, h: 1, y }
const isItemInWay = items.some((item) => isOverlapping(item, itemToCheck))
if (isItemInWay) {
break
} else {
availableSpace.bottom++
}
}
}
return availableSpace
}
}
function isOverlapping(item1: any, item2: any) {
return (
item1.x < item2.x + item2.w &&
item1.x + item1.w > item2.x &&
item1.y < item2.y + item2.h &&
item1.y + item1.h > item2.y
)
}
type Outputtable<Type> = {
-readonly [Property in keyof Type]: Output<Type[Property]>
}
export function initOutput<I extends Record<string, any>>(
world: World,
id: string,
init: I
): Outputtable<I> {
if (!world) {
return {} as any
}
return Object.fromEntries(
Object.entries(init).map(([key, value]) => [key, world.newOutput(id, key, value)])
) as Outputtable<I>
}
export function initConfig<
T extends Record<
string,
| StaticAppInput
| EvalAppInput
| {
type: 'oneOf'
selected: string
configuration: Record<string, Record<string, StaticAppInput | EvalAppInput>>
}
>
>(
r: T,
configuration?: Record<
string,
| StaticAppInput
| {
type: 'oneOf'
selected: string
configuration: Record<string, Record<string, StaticAppInput | EvalAppInput | boolean>>
}
| any
>
): {
[Property in keyof T]: T[Property] extends StaticAppInput
? T[Property]['value'] | undefined
: T[Property] extends { type: 'oneOf' }
? {
type: 'oneOf'
selected: keyof T[Property]['configuration']
configuration: {
[Choice in keyof T[Property]['configuration']]: {
[IT in keyof T[Property]['configuration'][Choice]]: T[Property]['configuration'][Choice][IT] extends StaticAppInput
? T[Property]['configuration'][Choice][IT]['value'] | undefined
: undefined
}
}
}
: undefined
} {
return JSON.parse(
JSON.stringify(
Object.fromEntries(
Object.entries(r).map(([key, value]) =>
value.type == 'static'
? [
key,
configuration?.[key]?.type == 'static' ? configuration?.[key]?.['value'] : undefined
]
: value.type == 'oneOf'
? [
key,
{
selected: value.selected,
type: 'oneOf',
configuration: Object.fromEntries(
Object.entries(value.configuration).map(([choice, config]) => {
const conf = initConfig(config, configuration?.[key]?.configuration?.[choice])
Object.entries(config).forEach(([innerKey, innerValue]) => {
if (innerValue.type === 'static' && !(innerKey in conf)) {
conf[innerKey] = innerValue.value
}
})
return [choice, conf]
})
)
}
]
: [key, undefined]
)
) as any
)
)
}
export function expandGriditem(
grid: GridItem[],
id: string,
$breakpoint: EditorBreakpoint,
parentGridItem: GridItem | undefined = undefined
) {
const gridComponent = grid.find((item) => item.id === id)
if (!gridComponent) return
const availableSpace = findAvailableSpace(grid, gridComponent, $breakpoint, parentGridItem)
if (!availableSpace) {
return
}
const { left, right, top, bottom } = availableSpace
const width = $breakpoint === 'sm' ? 3 : 12
const item = gridComponent[width]
item.x = item.x - left
item.y = item.y - top
item.w = item.w + left + right
item.h = item.h + top + bottom
}
export function sortGridItemsPosition<T>(
gridItems: FilledItem<T>[],
width: number
): FilledItem<T>[] {
return gridItems.sort((a: FilledItem<T>, b: FilledItem<T>) => {
const aX = a[width].x
const aY = a[width].y
const bX = b[width].x
const bY = b[width].y
if (aY < bY) {
return -1
} else if (aY > bY) {
return 1
} else {
if (aX < bX) {
return -1
} else if (aX > bX) {
return 1
} else {
return 0
}
}
})
}
export function connectInput(
connectingInput: ConnectingInput,
componentId: string,
path: string,
componentType?: TypedComponent['type']
): ConnectingInput {
if (connectingInput) {
if (connectingInput.onConnect) {
connectingInput.onConnect({ componentId, path })
}
connectingInput = {
opened: false,
input: {
connection: {
componentId,
path,
componentType
},
type: 'connected'
},
hoveredComponent: undefined,
onConnect: undefined
}
}
return connectingInput
}
export function recursivelyFilterKeyInJSON(
json: object,
search: string,
extraSearch?: string | undefined
): object {
if (json === null || json === undefined || typeof json != 'object') {
return json
}
if (!search || search == '') {
return json
}
let filteredJSON = {}
Object.keys(json).forEach((key) => {
if (
key.toLowerCase().includes(search.toLowerCase()) ||
extraSearch?.toLowerCase().includes(search.toLowerCase()) ||
(typeof json[key] === 'string' && json[key].toLowerCase().includes(search.toLowerCase())) ||
(typeof json[key] === 'number' &&
json[key].toString().toLowerCase().includes(search.toLowerCase()))
) {
filteredJSON[key] = json[key]
} else if (typeof json[key] === 'object') {
const res = recursivelyFilterKeyInJSON(json[key], search, extraSearch)
if (Object.keys(res ?? {}).length !== 0) {
filteredJSON[key] = res
}
}
})
return filteredJSON
}