From 072547a2418a30c33116a146114f8fae2cf9bfa5 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Mon, 22 Sep 2025 20:30:05 +0500 Subject: [PATCH 1/5] beta edit tags functionality --- .../src/comps/comps/tagsComp/tagsCompView.tsx | 459 ++++++++++++++---- 1 file changed, 361 insertions(+), 98 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/tagsComp/tagsCompView.tsx b/client/packages/lowcoder/src/comps/comps/tagsComp/tagsCompView.tsx index 1f2a178c9..27dcbf89a 100644 --- a/client/packages/lowcoder/src/comps/comps/tagsComp/tagsCompView.tsx +++ b/client/packages/lowcoder/src/comps/comps/tagsComp/tagsCompView.tsx @@ -1,6 +1,6 @@ import styled from "styled-components"; -import React, { useContext } from "react"; -import { Tag } from "antd"; +import React, { useContext, useMemo, useRef, useState } from "react"; +import { Tag, Input, Button, Space, Tooltip, Typography, App } from "antd"; import { EditorContext } from "comps/editorState"; import { PresetStatusColorTypes } from "antd/es/_util/colors"; import { hashToNum } from "util/stringUtils"; @@ -15,168 +15,431 @@ import { Section, sectionNames } from "lowcoder-design"; import { NameConfig } from "@lowcoder-ee/comps/generators/withExposing"; import { hiddenPropertyView, showDataLoadingIndicatorsPropertyView } from "@lowcoder-ee/comps/utils/propertyUtils"; import { withExposingConfigs } from "@lowcoder-ee/comps/generators/withExposing"; +import { PlusOutlined, DeleteOutlined, EditOutlined } from "@ant-design/icons"; + +const { Text } = Typography; + +type TagOption = { + label: string; + colorType?: "default" | "preset" | "custom"; + presetColor?: any; + color?: string; + textColor?: string; + border?: string; + borderWidth?: string; + borderStyle?: string; + radius?: string; + margin?: string; + padding?: string; + width?: string; + icon?: React.ReactNode | string; +}; const colors = PresetStatusColorTypes; -// These functions are used for individual tag styling -function getTagColor(tagText : any, tagOptions: any[]) { - const foundOption = tagOptions.find((option: { label: any; }) => option.label === tagText); +/** ---------- Styling Helpers ---------- */ +function getTagColor(tagText: string, tagOptions: TagOption[]) { + const foundOption = tagOptions.find((option) => option.label === tagText); if (foundOption) { - if (foundOption.colorType === "default") { - return undefined; - } else if (foundOption.colorType === "preset") { - return foundOption.presetColor; - } else if (foundOption.colorType === "custom") { - return undefined; - } + if (foundOption.colorType === "default") return undefined; + if (foundOption.colorType === "preset") return foundOption.presetColor; + if (foundOption.colorType === "custom") return undefined; return foundOption.color; } const index = Math.abs(hashToNum(tagText)) % colors.length; return colors[index]; } -const getTagStyle = (tagText: any, tagOptions: any[], baseStyle: any = {}) => { - const foundOption = tagOptions.find((option: { label: any; }) => option.label === tagText); - +const getTagStyle = (tagText: string, tagOptions: TagOption[], baseStyle: any = {}) => { + const foundOption = tagOptions.find((option) => option.label === tagText); + + const applyBorderFromBase = (style: any) => { + if (baseStyle.borderWidth && baseStyle.border && baseStyle.borderStyle) { + style.border = `${baseStyle.borderWidth} ${baseStyle.borderStyle} ${baseStyle.border}`; + } + }; + if (foundOption) { - // If colorType is "default", use ONLY component styles if (foundOption.colorType === "default") { const style: any = { ...baseStyle }; - if (baseStyle.borderWidth && baseStyle.border && baseStyle.borderStyle) { - style.border = `${baseStyle.borderWidth} ${baseStyle.borderStyle} ${baseStyle.border}`; - } + applyBorderFromBase(style); return style; } - + const style: any = { ...baseStyle }; - + if (foundOption.colorType === "custom") { style.backgroundColor = foundOption.color; style.color = foundOption.textColor; } - - let borderStyle = foundOption.borderStyle || "none"; - let borderWidth = foundOption.borderWidth || "0px"; - let borderColor = foundOption.border || "none"; - - if (borderStyle !== "none") { - style.border = `${borderWidth} ${borderStyle} ${borderColor}`; - } else { - style.border = "none"; - } - - if (foundOption.radius) { - style.borderRadius = foundOption.radius; - } - - if (foundOption.margin) { - style.margin = foundOption.margin; - } - - if (foundOption.padding) { - style.padding = foundOption.padding; - } - - if (foundOption.width) { - style.width = foundOption.width; - } - + + const borderStyle = foundOption.borderStyle || "none"; + const borderWidth = foundOption.borderWidth || "0px"; + const borderColor = foundOption.border || "none"; + style.border = borderStyle !== "none" ? `${borderWidth} ${borderStyle} ${borderColor}` : "none"; + + if (foundOption.radius) style.borderRadius = foundOption.radius; + if (foundOption.margin) style.margin = foundOption.margin; + if (foundOption.padding) style.padding = foundOption.padding; + if (foundOption.width) style.width = foundOption.width; + return style; } const style: any = { ...baseStyle }; - if (baseStyle.borderWidth && baseStyle.border && baseStyle.borderStyle) { - style.border = `${baseStyle.borderWidth} ${baseStyle.borderStyle} ${baseStyle.border}`; - } + applyBorderFromBase(style); return style; }; +/** ---------- Component ---------- */ const multiTags = (function () { - - const StyledTag = styled(Tag)<{ $style: any, $customStyle: any }>` + const StyledTag = styled(Tag)<{ $style: any; $customStyle: any }>` display: flex; justify-content: center; align-items: center; min-width: fit-content; - width: ${(props) => props.$customStyle?.width || 'auto'}; - max-width: 100px; + width: ${(props) => props.$customStyle?.width || "auto"}; + max-width: 100%; background: ${(props) => props.$customStyle?.backgroundColor || props.$style?.background}; color: ${(props) => props.$customStyle?.color || props.$style?.text}; border-radius: ${(props) => props.$customStyle?.borderRadius || props.$style?.borderRadius}; - border: ${(props) => props.$customStyle?.border || props.$style?.border || '1px solid #d9d9d9'}; + border: ${(props) => props.$customStyle?.border || props.$style?.border || "1px solid #d9d9d9"}; padding: ${(props) => props.$customStyle?.padding || props.$style?.padding}; margin: ${(props) => props.$customStyle?.margin || props.$style?.margin}; - font-size: ${(props) => props.$style?.textSize || '8px'}; + font-size: ${(props) => props.$style?.textSize || "12px"}; font-weight: ${(props) => props.$style?.fontWeight}; cursor: pointer; + user-select: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; `; - const StyledTagContainer = styled.div` + const StyledTagWrap = styled.div` display: flex; - gap: 5px; - padding: 5px; + flex-wrap: wrap; + gap: 6px; + padding: 6px; + `; + + const TopBar = styled.div` + display: flex; + align-items: center; + gap: 8px; + padding: 4px 6px 0; `; const childrenMap = { - options: TagsCompOptionsControl, - style: styleControl(InputLikeStyle, 'style'), + options: TagsCompOptionsControl, // initial tags (PropertyView) + style: styleControl(InputLikeStyle, "style"), onEvent: ButtonEventHandlerControl, + allowEdit: BoolCodeControl, // enable runtime CRUD controls + // extra toggles to fine-tune runtime behavior + preventDuplicates: BoolCodeControl, + allowEmptyEdits: BoolCodeControl, + // local constraints + maxTags: BoolCodeControl, // you can wire a numeric control if available in your control set; BoolCodeControl is used to keep code simple here (treat truthy as number below) + }; + + // Helper to normalize an optional "maxTags" BoolCodeControl into a number (false => unlimited) + const toMax = (val: any): number | undefined => { + if (val === false || val === undefined || val === null) return undefined; + if (typeof val === "number" && !Number.isNaN(val) && val > 0) return val; + // if BoolCodeControl returns true, you can set a sensible default cap + if (val === true) return 50; + return undefined; }; return new UICompBuilder(childrenMap, (props) => { - const handleClickEvent = useCompClickEventHandler({onEvent: props.onEvent}); + const { message } = App.useApp?.() || { message: { success: () => {}, error: () => {}, warning: () => {} } as any }; + + // This hook returns a callable we can also use to fire *custom* events with payloads. + const handleClickEvent = useCompClickEventHandler({ onEvent: props.onEvent }); + + // ---- Local Runtime State ---- + const [runtimeOptions, setRuntimeOptions] = useState(() => [...props.options]); + const [dirty, setDirty] = useState(false); + + // inline editing state + const [editingIndex, setEditingIndex] = useState(null); + const [editingValue, setEditingValue] = useState(""); + + // adding state + const [isAdding, setIsAdding] = useState(false); + const [addingValue, setAddingValue] = useState(""); + + const preventDuplicates = !!props.preventDuplicates; + const allowEmptyEdits = !!props.allowEmptyEdits; + const maxTags = toMax(props.maxTags); + + + // what we display: if allowEdit => runtimeOptions, else static props.options + const displayOptions = props.allowEdit ? runtimeOptions : props.options; + + // ---------- Event firing helper (so platform users can persist if they want) ---------- + // We’ll try to fire both a specific event (add/edit/delete) and a generic "change". + const fireEvent = (type: "add" | "edit" | "delete" | "change" | "click", payload: any) => { + try { + // specific event + if (props.onEvent) { + (props.onEvent as any)(type, payload); + } + } catch {} + try { + // generic change event (except when it's a click) + if (type !== "click" && props.onEvent) { + (props.onEvent as any)("change", { value: payload?.value, meta: payload }); + } + } catch {} + }; + + // ---------- Validation ---------- + const normalize = (s: string) => s.trim(); + const exists = (label: string, omitIndex?: number) => { + const L = normalize(label); + return runtimeOptions.some((t, i) => (omitIndex !== undefined ? i !== omitIndex : true) && normalize(t.label) === L); + }; + + // ---------- CRUD Handlers ---------- + const addTag = (raw: string) => { + const label = normalize(raw); + if (!label) { + message?.warning?.("Please enter a tag name."); + return; + } + if (maxTags && runtimeOptions.length >= maxTags) { + message?.warning?.(`Maximum ${maxTags} tags allowed.`); + return; + } + if (preventDuplicates && exists(label)) { + message?.warning?.("Duplicate tag."); + return; + } + const newTag: TagOption = { + label, + colorType: "default", + icon: "/icon:solid/tag", + presetColor: "blue", + color: "#1890ff", + textColor: "#ffffff", + border: "", + borderWidth: "", + borderStyle: "solid", + radius: "", + margin: "", + padding: "", + width: "", + }; + const next = [...runtimeOptions, newTag]; + setRuntimeOptions(next); + setAddingValue(""); + setIsAdding(false); + setDirty(true); + fireEvent("add", { label, value: next }); + }; + + const startEdit = (index: number, current: string) => { + setEditingIndex(index); + setEditingValue(current); + }; + + const confirmEdit = () => { + if (editingIndex === null) return; + const val = normalize(editingValue); + if (!val && !allowEmptyEdits) { + // cancel instead of clearing to empty + setEditingIndex(null); + setEditingValue(""); + return; + } + if (preventDuplicates && exists(val, editingIndex)) { + message?.warning?.("Duplicate tag."); + return; + } + const prev = runtimeOptions[editingIndex]?.label ?? ""; + const next = [...runtimeOptions]; + next[editingIndex] = { ...next[editingIndex], label: val }; + setRuntimeOptions(next); + setEditingIndex(null); + setEditingValue(""); + setDirty(true); + fireEvent("edit", { from: prev, to: val, index: editingIndex, value: next }); + }; + + const cancelEdit = () => { + setEditingIndex(null); + setEditingValue(""); + }; + + const deleteTag = (index: number) => { + const removed = runtimeOptions[index]?.label; + const next = runtimeOptions.filter((_, i) => i !== index); + setRuntimeOptions(next); + setDirty(true); + fireEvent("delete", { removed, index, value: next }); + }; + + + + // When users click a tag (not the edit/delete button), still bubble a "click" event with payload + const onTagClick = (tag: TagOption, idx: number) => { + fireEvent("click", { tag, index: idx, value: displayOptions }); + // also preserve your previous click wiring (if someone configured onClick in property view) + handleClickEvent?.({ tag, index: idx, value: displayOptions }); + }; return ( - - {props.options.map((tag, index) => { - - const tagColor = getTagColor(tag.label, props.options); - const tagIcon = tag.icon; - const tagStyle = getTagStyle(tag.label, props.options, props.style); - - return ( - - {tag.label} - - ); - })} - - ); + <> + {props.allowEdit && ( + + {!isAdding ? ( + + + + + + ) : ( + + setAddingValue(e.target.value)} + onPressEnter={() => addTag(addingValue)} + autoFocus + style={{ minWidth: 120 }} + /> + + + {maxTags && ( + + {runtimeOptions.length}/{maxTags} + + )} + + )} + + )} + + + {displayOptions.map((tag, index) => { + const tagColor = getTagColor(tag.label, displayOptions); + const tagStyle = getTagStyle(tag.label, displayOptions, props.style); + const isEditing = props.allowEdit && editingIndex === index; + + return ( + + {isEditing ? ( + setEditingValue(e.target.value)} + onPressEnter={confirmEdit} + onBlur={confirmEdit} + style={{ minWidth: 80 }} + autoFocus + /> + ) : ( + onTagClick(tag, index)} + > + {tag.label} + + )} + + {props.allowEdit && ( + + {!isEditing && ( + + - - - ) : ( - - setAddingValue(e.target.value)} - onPressEnter={() => addTag(addingValue)} - autoFocus - style={{ minWidth: 120 }} - /> - - - {maxTags && ( - - {runtimeOptions.length}/{maxTags} - - )} - - )} - + {tag.label} + + ) : ( + tag.label + )} + + ); + })} + + {/* Draft chip appears only while typing; press Enter to commit, Esc to cancel */} + {props.allowEdit && draft && ( + + {draft} + )} - - - {displayOptions.map((tag, index) => { - const tagColor = getTagColor(tag.label, displayOptions); - const tagStyle = getTagStyle(tag.label, displayOptions, props.style); - const isEditing = props.allowEdit && editingIndex === index; - - return ( - - {isEditing ? ( - setEditingValue(e.target.value)} - onPressEnter={confirmEdit} - onBlur={confirmEdit} - style={{ minWidth: 80 }} - autoFocus - /> - ) : ( - onTagClick(tag, index)} - > - {tag.label} - - )} - - {props.allowEdit && ( - - {!isEditing && ( - -