diff --git a/.github/workflows/docker-images.yml b/.github/workflows/docker-images.yml index d075f1fdce..be06cf1a4b 100644 --- a/.github/workflows/docker-images.yml +++ b/.github/workflows/docker-images.yml @@ -80,18 +80,21 @@ jobs: # Image names ALLINONE_IMAGE_NAMES=lowcoderorg/lowcoder-ce:${IMAGE_TAG} FRONTEND_IMAGE_NAMES=lowcoderorg/lowcoder-ce-frontend:${IMAGE_TAG} + FRONTEND_EE_IMAGE_NAMES=lowcoderorg/lowcoder-enterprise-frontend:${IMAGE_TAG} APISERVICE_IMAGE_NAMES=lowcoderorg/lowcoder-ce-api-service:${IMAGE_TAG} NODESERVICE_IMAGE_NAMES=lowcoderorg/lowcoder-ce-node-service:${IMAGE_TAG} if [[ "${IS_LATEST}" == "true" ]]; then ALLINONE_IMAGE_NAMES="lowcoderorg/lowcoder-ce:latest,${ALLINONE_IMAGE_NAMES}" FRONTEND_IMAGE_NAMES="lowcoderorg/lowcoder-ce-frontend:latest,${FRONTEND_IMAGE_NAMES}" + FRONTEND_EE_IMAGE_NAMES="lowcoderorg/lowcoder-enterprise-frontend:latest,${FRONTEND_EE_IMAGE_NAMES}" APISERVICE_IMAGE_NAMES="lowcoderorg/lowcoder-ce-api-service:latest,${APISERVICE_IMAGE_NAMES}" NODESERVICE_IMAGE_NAMES="lowcoderorg/lowcoder-ce-node-service:latest,${NODESERVICE_IMAGE_NAMES}" fi; echo "ALLINONE_IMAGE_NAMES=${ALLINONE_IMAGE_NAMES}" >> "${GITHUB_ENV}" echo "FRONTEND_IMAGE_NAMES=${FRONTEND_IMAGE_NAMES}" >> "${GITHUB_ENV}" + echo "FRONTEND_EE_IMAGE_NAMES=${FRONTEND_EE_IMAGE_NAMES}" >> "${GITHUB_ENV}" echo "APISERVICE_IMAGE_NAMES=${APISERVICE_IMAGE_NAMES}" >> "${GITHUB_ENV}" echo "NODESERVICE_IMAGE_NAMES=${NODESERVICE_IMAGE_NAMES}" >> "${GITHUB_ENV}" @@ -146,6 +149,24 @@ jobs: push: true tags: ${{ env.FRONTEND_IMAGE_NAMES }} + - name: Build and push the enterprise edition frontend image + if: ${{ env.BUILD_FRONTEND == 'true' }} + uses: docker/build-push-action@v6 + env: + NODE_ENV: production + with: + file: ./deploy/docker/Dockerfile + target: lowcoder-enterprise-frontend + build-args: | + REACT_APP_ENV=production + REACT_APP_EDITION=enterprise + REACT_APP_COMMIT_ID="dev #${{ env.SHORT_SHA }}" + platforms: | + linux/amd64 + linux/arm64 + push: true + tags: ${{ env.FRONTEND_EE_IMAGE_NAMES }} + - name: Build and push the node service image if: ${{ env.BUILD_NODESERVICE == 'true' }} uses: docker/build-push-action@v6 diff --git a/client/VERSION b/client/VERSION index fbafd6b600..5b013b97d6 100644 --- a/client/VERSION +++ b/client/VERSION @@ -1 +1 @@ -2.7.2 \ No newline at end of file +2.7.6 \ No newline at end of file diff --git a/client/package.json b/client/package.json index 12f93a4aa7..2367285358 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "lowcoder-frontend", - "version": "2.7.2", + "version": "2.7.6", "type": "module", "private": true, "workspaces": [ @@ -15,6 +15,7 @@ "start:ee": "REACT_APP_EDITION=enterprise yarn workspace lowcoder start", "translate": "node --loader ts-node/esm ./scripts/translate.js", "build": "yarn node ./scripts/build.js", + "build:ee": "REACT_APP_EDITION=enterprise yarn node ./scripts/build.js", "test": "jest && yarn workspace lowcoder-comps test", "prepare": "yarn workspace lowcoder prepare", "build:core": "yarn workspace lowcoder-core build", @@ -56,7 +57,7 @@ "shelljs": "^0.8.5", "svgo": "^3.0.0", "ts-node": "^10.4.0", - "typescript": "^4.8.4", + "typescript": "^5.6.2", "whatwg-fetch": "^3.6.2" }, "lint-staged": { @@ -80,6 +81,7 @@ "@types/styled-components": "^5.1.34", "antd-mobile": "^5.34.0", "chalk": "4", + "dompurify": "^3.3.1", "flag-icons": "^7.2.1", "number-precision": "^1.6.0", "react-countup": "^6.5.3", diff --git a/client/packages/lowcoder-comps/package.json b/client/packages/lowcoder-comps/package.json index 613c8ee3db..6d61b820fc 100644 --- a/client/packages/lowcoder-comps/package.json +++ b/client/packages/lowcoder-comps/package.json @@ -1,6 +1,6 @@ { "name": "lowcoder-comps", - "version": "2.7.2", + "version": "2.7.6", "type": "module", "license": "MIT", "dependencies": { diff --git a/client/packages/lowcoder-comps/src/comps/barChartComp/barChartComp.tsx b/client/packages/lowcoder-comps/src/comps/barChartComp/barChartComp.tsx index df7fc06232..0998492ae3 100644 --- a/client/packages/lowcoder-comps/src/comps/barChartComp/barChartComp.tsx +++ b/client/packages/lowcoder-comps/src/comps/barChartComp/barChartComp.tsx @@ -61,6 +61,8 @@ BarChartTmpComp = withViewFn(BarChartTmpComp, (comp) => { const [chartSize, setChartSize] = useState(); const firstResize = useRef(true); const theme = useContext(ThemeContext); + const [chartKey, setChartKey] = useState(0); + const prevRaceMode = useRef(); const defaultChartTheme = { color: chartColorPalette, backgroundColor: "#fff", @@ -73,6 +75,17 @@ BarChartTmpComp = withViewFn(BarChartTmpComp, (comp) => { log.error('theme chart error: ', error); } + + // Detect race mode changes and force chart recreation + const currentRaceMode = comp.children.chartConfig?.children?.comp?.children?.race?.getView(); + useEffect(() => { + if (prevRaceMode.current !== undefined && prevRaceMode.current !== currentRaceMode) { + // Force chart recreation when race mode changes + setChartKey(prev => prev + 1); + } + prevRaceMode.current = currentRaceMode; + }, [currentRaceMode]); + const triggerClickEvent = async (dispatch: any, action: CompAction) => { await getPromiseAfterDispatch( dispatch, @@ -160,7 +173,6 @@ BarChartTmpComp = withViewFn(BarChartTmpComp, (comp) => { useResizeDetector({ targetRef: containerRef, onResize: ({width, height}) => { - console.log('barChart - resize'); if (width && height) { setChartSize({ w: width, h: height }); } @@ -176,11 +188,13 @@ BarChartTmpComp = withViewFn(BarChartTmpComp, (comp) => { return (
(echartsCompRef.current = e)} style={{ height: "100%" }} - notMerge - lazyUpdate + notMerge={!currentRaceMode} + lazyUpdate={!currentRaceMode} opts={{ locale: getEchartsLocale() }} + theme={themeConfig} option={option} mode={mode} /> diff --git a/client/packages/lowcoder-comps/src/comps/barChartComp/barChartUtils.ts b/client/packages/lowcoder-comps/src/comps/barChartComp/barChartUtils.ts index 72abe79f77..84b4ea05c6 100644 --- a/client/packages/lowcoder-comps/src/comps/barChartComp/barChartUtils.ts +++ b/client/packages/lowcoder-comps/src/comps/barChartComp/barChartUtils.ts @@ -201,6 +201,15 @@ export function getEchartsConfig( animationEasing: 'linear', animationEasingUpdate: 'linear', } + } else { + // Ensure proper animation settings when race is disabled + config = { + ...config, + animationDuration: 1000, + animationDurationUpdate: 1000, + animationEasing: 'cubicOut', + animationEasingUpdate: 'cubicOut', + } } if (props.data.length <= 0) { // no data @@ -333,6 +342,21 @@ export function getEchartsConfig( animationDurationUpdate: 300 }, } + } else { + // Reset axis animations when race is disabled + config = { + ...config, + xAxis: { + ...config.xAxis, + animationDuration: undefined, + animationDurationUpdate: undefined + }, + yAxis: { + ...config.yAxis, + animationDuration: undefined, + animationDurationUpdate: undefined + }, + } } } // console.log("Echarts transformedData and config", transformedData, config); diff --git a/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/barChartConfig.tsx b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/barChartConfig.tsx index dd7a369934..d1450007a6 100644 --- a/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/barChartConfig.tsx +++ b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/barChartConfig.tsx @@ -53,7 +53,7 @@ export const BarChartConfig = (function () { type: "bar", subtype: props.type, realtimeSort: props.race, - seriesLayoutBy: props.race?'column':undefined, + seriesLayoutBy: props.race?'column':'row', label: { show: props.showLabel, position: "top", diff --git a/client/packages/lowcoder-comps/src/comps/calendarComp/calendarComp.tsx b/client/packages/lowcoder-comps/src/comps/calendarComp/calendarComp.tsx index 43ddfbaf30..9bf7a4a6c7 100644 --- a/client/packages/lowcoder-comps/src/comps/calendarComp/calendarComp.tsx +++ b/client/packages/lowcoder-comps/src/comps/calendarComp/calendarComp.tsx @@ -268,6 +268,7 @@ let CalendarBasicComp = (function () { const ref = createRef(); const editEvent = useRef(); const initData = useRef(false); + const clickTimeout = useRef(null); const [form] = Form.useForm(); const [left, setLeft] = useState(undefined); const [licensed, setLicensed] = useState(props.licenseKey !== ""); @@ -370,6 +371,15 @@ let CalendarBasicComp = (function () { initData.current = true; } }, [JSON.stringify(initialEvents), comp?.children?.comp?.children?.initialData]); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (clickTimeout.current) { + clearTimeout(clickTimeout.current); + } + }; + }, []); const resources = useMemo(() => props.resources.value, [props.resources.value]); @@ -850,22 +860,30 @@ let CalendarBasicComp = (function () { handleEventDataChange, ]); + const handleSingleClick = useCallback(() => { + // Prevent double click from triggering the event + // Use a timeout to debounce rapid clicks + if (clickTimeout.current) { + clearTimeout(clickTimeout.current); + clickTimeout.current = null; + return; // This was a double click, don't trigger + } + + clickTimeout.current = setTimeout(() => { + props.onEvent('click'); + clickTimeout.current = null; + }, 150); // Small delay to catch double clicks + }, [props.onEvent]); + const handleDbClick = useCallback(() => { - const event = props.updatedEventsData.find( - (item: EventType) => item.id === editEvent.current?.id - ) as EventType; if (!props.editable || !editEvent.current) { return; } - if (event) { - showModal(event, true); + if (onEventVal && onEventVal.some((e: any) => e.name === 'doubleClick')) { + // Check if 'doubleClick' is included in the array + props.onEvent('doubleClick'); } else { - if (onEventVal && onEventVal.some((e: any) => e.name === 'doubleClick')) { - // Check if 'doubleClick' is included in the array - props.onEvent('doubleClick'); - } else { - showModal(editEvent.current as EventType, false); - } + showModal(editEvent.current as EventType, false); } }, [ editEvent, @@ -974,6 +992,9 @@ let CalendarBasicComp = (function () { allDaySlot={props.showAllDay} eventContent={renderEventContent} select={(info) => handleCreate(info)} + dateClick={() => { + handleSingleClick(); + }} eventClick={(info) => { const event = events.find( (item: EventInput) => item.id === info.event.id @@ -982,6 +1003,7 @@ let CalendarBasicComp = (function () { setTimeout(() => { editEvent.current = undefined; }, 500); + handleSingleClick(); }} moreLinkClick={(info) => { let left = 0; diff --git a/client/packages/lowcoder-comps/src/comps/lineChartComp/lineChartComp.tsx b/client/packages/lowcoder-comps/src/comps/lineChartComp/lineChartComp.tsx index 032607625b..ed2f1654e2 100644 --- a/client/packages/lowcoder-comps/src/comps/lineChartComp/lineChartComp.tsx +++ b/client/packages/lowcoder-comps/src/comps/lineChartComp/lineChartComp.tsx @@ -174,6 +174,7 @@ LineChartTmpComp = withViewFn(LineChartTmpComp, (comp) => { notMerge lazyUpdate opts={{ locale: getEchartsLocale() }} + theme={themeConfig} option={option} mode={mode} /> diff --git a/client/packages/lowcoder-comps/src/comps/pieChartComp/pieChartComp.tsx b/client/packages/lowcoder-comps/src/comps/pieChartComp/pieChartComp.tsx index aaa5f01984..b52c99846a 100644 --- a/client/packages/lowcoder-comps/src/comps/pieChartComp/pieChartComp.tsx +++ b/client/packages/lowcoder-comps/src/comps/pieChartComp/pieChartComp.tsx @@ -194,6 +194,7 @@ PieChartTmpComp = withViewFn(PieChartTmpComp, (comp) => { notMerge lazyUpdate opts={{ locale: getEchartsLocale() }} + theme={themeConfig} option={option} mode={mode} /> @@ -302,7 +303,7 @@ let PieChartComp = withExposingConfigs(PieChartTmpComp, [ export const PieChartCompWithDefault = withDefault(PieChartComp, { - xAxisKey: "date", + xAxisKey: "name", series: [ { dataIndex: genRandomKey(), diff --git a/client/packages/lowcoder-comps/src/comps/pieChartComp/pieChartUtils.ts b/client/packages/lowcoder-comps/src/comps/pieChartComp/pieChartUtils.ts index 5453933397..c72fe4a32d 100644 --- a/client/packages/lowcoder-comps/src/comps/pieChartComp/pieChartUtils.ts +++ b/client/packages/lowcoder-comps/src/comps/pieChartComp/pieChartUtils.ts @@ -172,15 +172,7 @@ export function getEchartsConfig( } }, tooltip: props.tooltip && { - trigger: "axis", - axisPointer: { - type: "line", - lineStyle: { - color: "rgba(0,0,0,0.2)", - width: 2, - type: "solid" - } - } + trigger: "item", }, grid: { ...gridPos, diff --git a/client/packages/lowcoder-comps/src/comps/scatterChartComp/scatterChartComp.tsx b/client/packages/lowcoder-comps/src/comps/scatterChartComp/scatterChartComp.tsx index c7fd7da9cd..527a4ca2d7 100644 --- a/client/packages/lowcoder-comps/src/comps/scatterChartComp/scatterChartComp.tsx +++ b/client/packages/lowcoder-comps/src/comps/scatterChartComp/scatterChartComp.tsx @@ -175,6 +175,7 @@ ScatterChartTmpComp = withViewFn(ScatterChartTmpComp, (comp) => { notMerge lazyUpdate opts={{ locale: getEchartsLocale() }} + theme={themeConfig} option={option} mode={mode} /> diff --git a/client/packages/lowcoder-core/lib/index.js b/client/packages/lowcoder-core/lib/index.js index f972c7ba4b..719756c00b 100644 --- a/client/packages/lowcoder-core/lib/index.js +++ b/client/packages/lowcoder-core/lib/index.js @@ -1692,11 +1692,12 @@ class CodeNode extends AbstractNode { // if query is dependent on itself, mark as ready if (pathsArr?.[0] === options?.queryName) return; + // TODO: check if this is needed after removing lazy load // wait for lazy loaded comps to load before executing query on page load - if (value && !Object.keys(value).length && paths.size) { - isFetching = true; - ready = false; - } + // if (value && !Object.keys(value).length && paths.size) { + // isFetching = true; + // ready = false; + // } if (_.has(value, IS_FETCHING_FIELD)) { isFetching = isFetching || value.isFetching === true; } diff --git a/client/packages/lowcoder-core/src/eval/codeNode.tsx b/client/packages/lowcoder-core/src/eval/codeNode.tsx index 2b67e7bbfa..e2e69fab1b 100644 --- a/client/packages/lowcoder-core/src/eval/codeNode.tsx +++ b/client/packages/lowcoder-core/src/eval/codeNode.tsx @@ -176,11 +176,12 @@ export class CodeNode extends AbstractNode> { // if query is dependent on itself, mark as ready if (pathsArr?.[0] === options?.queryName) return; + // TODO: check if this is needed after removing lazy load // wait for lazy loaded comps to load before executing query on page load - if (value && !Object.keys(value).length && paths.size) { - isFetching = true; - ready = false; - } + // if (value && !Object.keys(value).length && paths.size) { + // isFetching = true; + // ready = false; + // } if (_.has(value, IS_FETCHING_FIELD)) { isFetching = isFetching || value.isFetching === true; } diff --git a/client/packages/lowcoder-design/src/components/Dropdown.tsx b/client/packages/lowcoder-design/src/components/Dropdown.tsx index b2a9d27663..55bd8b8303 100644 --- a/client/packages/lowcoder-design/src/components/Dropdown.tsx +++ b/client/packages/lowcoder-design/src/components/Dropdown.tsx @@ -159,16 +159,6 @@ export function Dropdown(props: DropdownProps) { const { placement = "right" } = props; const valueInfoMap = _.fromPairs(props.options.map((option) => [option.value, option])); - useEffect(() => { - const dropdownElems = document.querySelectorAll("div.ant-dropdown ul.ant-dropdown-menu"); - for (let index = 0; index < dropdownElems.length; index++) { - const element = dropdownElems[index]; - element.style.maxHeight = "300px"; - element.style.overflowY = "scroll"; - element.style.minWidth = "150px"; - element.style.paddingRight = "10px"; - } - }, []); return ( diff --git a/client/packages/lowcoder-design/src/components/Switch.tsx b/client/packages/lowcoder-design/src/components/Switch.tsx index 576d304112..83dbfba5b6 100644 --- a/client/packages/lowcoder-design/src/components/Switch.tsx +++ b/client/packages/lowcoder-design/src/components/Switch.tsx @@ -52,6 +52,24 @@ const SwitchStyle: any = styled.input` border-radius: 20px; background-color: #ffffff; } + + &:disabled { + background-color: #e0e0e0; + opacity: 0.6; + cursor: not-allowed; + } + + &:disabled::before { + background-color: #cccccc; + } + + &:disabled:checked { + background-color: #a0a0a0; + } + + &:disabled:hover { + cursor: not-allowed; + } `; const SwitchDiv = styled.div<{ @@ -104,16 +122,18 @@ const JsIconGray = styled(jsIconGray)` interface SwitchProps extends Omit, "value" | "onChange"> { value: boolean; onChange: (value: boolean) => void; + disabled?: boolean; } export const Switch = (props: SwitchProps) => { - const { value, onChange, ...inputChanges } = props; + const { value, onChange, disabled, ...inputChanges } = props; return ( props.onChange(!props.value)} + checked={value} + onClick={() => onChange(!value)} onChange={() => {}} + disabled={disabled} {...inputChanges} /> ); @@ -154,15 +174,17 @@ export const SwitchWrapper = (props: { export function TacoSwitch(props: { label: string; checked: boolean; - onChange: (checked: boolean) => void; + disabled?: boolean; + onChange?: (checked: boolean) => void; }) { return ( { - props.onChange(value); + props.onChange ? props.onChange(value) : null; }} value={props.checked} + disabled={props.disabled} /> ); diff --git a/client/packages/lowcoder-design/src/components/colorSelect/colorUtils.ts b/client/packages/lowcoder-design/src/components/colorSelect/colorUtils.ts index 94621fcb16..d0f0e7d6f6 100644 --- a/client/packages/lowcoder-design/src/components/colorSelect/colorUtils.ts +++ b/client/packages/lowcoder-design/src/components/colorSelect/colorUtils.ts @@ -75,6 +75,26 @@ const isValidColor = (str?: string) => { return colord(str).isValid(); }; +const isTransparentColor = (color?: string) => { + if (!color) return true; + + // Check for common transparent values + if (color === 'transparent' || color === '') return true; + + // Check if it's a valid color with alpha = 0 + try { + const colorObj = colord(color); + if (colorObj.isValid()) { + return colorObj.alpha() === 0; + } + } catch (e) { + // If colord can't parse it, consider it transparent + return true; + } + + return false; +}; + export const isDarkColor = (colorStr: string) => { return brightnessCompare(colorStr, 0.75); }; @@ -122,4 +142,4 @@ export const darkenColor = (colorStr: string, intensity: number) => { return color.darken(intensity).toHex().toUpperCase(); }; -export { toRGBA, toHex, alphaOfRgba, isValidColor, isValidGradient }; +export { toRGBA, toHex, alphaOfRgba, isValidColor, isValidGradient, isTransparentColor }; diff --git a/client/packages/lowcoder-design/src/components/customSelect.tsx b/client/packages/lowcoder-design/src/components/customSelect.tsx index 2f13f0db8e..72864178ad 100644 --- a/client/packages/lowcoder-design/src/components/customSelect.tsx +++ b/client/packages/lowcoder-design/src/components/customSelect.tsx @@ -20,7 +20,8 @@ const SelectWrapper = styled.div<{ $border?: boolean }>` padding: ${(props) => (props.$border ? "0px" : "0 0 0 12px")}; height: 100%; align-items: center; - margin-right: 8px; + margin-right: 10px; + padding-right: 5px; background-color: #fff; .ant-select-selection-item { @@ -46,9 +47,9 @@ const SelectWrapper = styled.div<{ $border?: boolean }>` } .ant-select-arrow { - width: 20px; - height: 20px; - right: 8px; + width: 17px; + height: 17px; + right: 10px; top: 0; bottom: 0; margin: auto; diff --git a/client/packages/lowcoder-design/src/components/option.tsx b/client/packages/lowcoder-design/src/components/option.tsx index 4e62301f83..d35ee0be11 100644 --- a/client/packages/lowcoder-design/src/components/option.tsx +++ b/client/packages/lowcoder-design/src/components/option.tsx @@ -9,7 +9,7 @@ import { CSS } from "@dnd-kit/utilities"; import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { ConstructorToComp, MultiCompConstructor } from "lowcoder-core"; import { ReactComponent as WarnIcon } from "icons/v1/icon-warning-white.svg"; -import { DndContext } from "@dnd-kit/core"; +import { DndContext, DragEndEvent } from "@dnd-kit/core"; import { restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { ActiveTextColor, GreyTextColor } from "constants/style"; import { trans } from "i18n/design"; @@ -225,12 +225,12 @@ function Option>(props: { } return -1; }; - const handleDragEnd = (e: { active: { id: string }; over: { id: string } | null }) => { + const handleDragEnd = (e: DragEndEvent) => { if (!e.over) { return; } - const fromIndex = findIndex(e.active.id); - const toIndex = findIndex(e.over.id); + const fromIndex = findIndex(String(e.active.id)); + const toIndex = findIndex(String(e.over.id)); if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) { return; } diff --git a/client/packages/lowcoder-design/src/components/toolTip.tsx b/client/packages/lowcoder-design/src/components/toolTip.tsx index 3a6b53843d..e12b478215 100644 --- a/client/packages/lowcoder-design/src/components/toolTip.tsx +++ b/client/packages/lowcoder-design/src/components/toolTip.tsx @@ -210,6 +210,7 @@ export const TutorialsTooltip = ({ step, backProps, skipProps, + closeProps, primaryProps, tooltipProps, isLastStep, @@ -219,7 +220,7 @@ export const TutorialsTooltip = ({ {step.title && {step.title}} - + diff --git a/client/packages/lowcoder-design/src/icons/index.tsx b/client/packages/lowcoder-design/src/icons/index.tsx index 75f0d8a687..3b9f775e3c 100644 --- a/client/packages/lowcoder-design/src/icons/index.tsx +++ b/client/packages/lowcoder-design/src/icons/index.tsx @@ -358,6 +358,7 @@ export { ReactComponent as VideoCameraStreamCompIconSmall } from "./v2/camera-st export { ReactComponent as VideoScreenshareCompIconSmall } from "./v2/screen-share-stream-s.svg"; // new export { ReactComponent as SignatureCompIconSmall } from "./v2/signature-s.svg"; export { ReactComponent as StepCompIconSmall } from "./v2/steps-s.svg"; +export { ReactComponent as TagsCompIconSmall } from "./v2/tags-s.svg" export { ReactComponent as CandlestickChartCompIconSmall } from "./v2/candlestick-chart-s.svg"; // new @@ -471,6 +472,7 @@ export { ReactComponent as SignatureCompIcon } from "./v2/signature-m.svg"; export { ReactComponent as GanttCompIcon } from "./v2/gantt-chart-m.svg"; export { ReactComponent as KanbanCompIconSmall } from "./v2/kanban-s.svg"; export { ReactComponent as KanbanCompIcon } from "./v2/kanban-m.svg"; +export { ReactComponent as TagsCompIcon } from "./v2/tags-l.svg"; export { ReactComponent as CandlestickChartCompIcon } from "./v2/candlestick-chart-m.svg"; export { ReactComponent as FunnelChartCompIcon } from "./v2/funnel-chart-m.svg"; diff --git a/client/packages/lowcoder-design/src/icons/v2/tags-l.svg b/client/packages/lowcoder-design/src/icons/v2/tags-l.svg new file mode 100644 index 0000000000..cd1d0368c3 --- /dev/null +++ b/client/packages/lowcoder-design/src/icons/v2/tags-l.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/client/packages/lowcoder-design/src/icons/v2/tags-s.svg b/client/packages/lowcoder-design/src/icons/v2/tags-s.svg new file mode 100644 index 0000000000..d45fcb0aa8 --- /dev/null +++ b/client/packages/lowcoder-design/src/icons/v2/tags-s.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/client/packages/lowcoder-sdk-webpack-bundle/package.json b/client/packages/lowcoder-sdk-webpack-bundle/package.json index 267fb8fb36..9b40d63052 100644 --- a/client/packages/lowcoder-sdk-webpack-bundle/package.json +++ b/client/packages/lowcoder-sdk-webpack-bundle/package.json @@ -1,7 +1,7 @@ { "name": "lowcoder-sdk-webpack-bundle", "description": "", - "version": "2.7.2", + "version": "2.7.6", "main": "index.jsx", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", diff --git a/client/packages/lowcoder-sdk/package.json b/client/packages/lowcoder-sdk/package.json index 7cc5d5ea49..c933a88316 100644 --- a/client/packages/lowcoder-sdk/package.json +++ b/client/packages/lowcoder-sdk/package.json @@ -1,6 +1,6 @@ { "name": "lowcoder-sdk", - "version": "2.7.2", + "version": "2.7.6", "type": "module", "files": [ "src", diff --git a/client/packages/lowcoder/.eslintrc b/client/packages/lowcoder/.eslintrc index 0cdf4f9409..da5d6faec2 100644 --- a/client/packages/lowcoder/.eslintrc +++ b/client/packages/lowcoder/.eslintrc @@ -1,6 +1,9 @@ { "extends": ["react-app"], "plugins": ["only-ascii"], + "parserOptions": { + "warnOnUnsupportedTypeScriptVersion": false + }, "rules": { // "react-hooks/exhaustive-deps": "off" //"only-ascii/only-ascii": "error" diff --git a/client/packages/lowcoder/hocuspocus-server.js b/client/packages/lowcoder/hocuspocus-server.js new file mode 100644 index 0000000000..a6f773a4af --- /dev/null +++ b/client/packages/lowcoder/hocuspocus-server.js @@ -0,0 +1,84 @@ +#!/usr/bin/env node + +/** + * Hocuspocus (Yjs WebSocket) Server for ChatBox v2 + * + * Provides real-time shared state via Yjs documents served over WebSocket. + * Each application room maps to a Yjs document. Presence is handled through + * Hocuspocus awareness on the client side. + * + * Env vars: + * PORT - HTTP/WebSocket port (default 3006) + * HOST - Bind address (default 0.0.0.0) + * HOCUSPOCUS_SECRET - Optional shared secret for token auth + * + * Usage: node hocuspocus-server.js + */ + +import { Server } from "@hocuspocus/server"; + +const PORT = parseInt(process.env.PORT || "3006", 10); +const HOST = process.env.HOST || "0.0.0.0"; +const SECRET = process.env.HOCUSPOCUS_SECRET || ""; + +function writeJson(response, statusCode, payload) { + response.writeHead(statusCode, { "Content-Type": "application/json" }); + response.end(JSON.stringify(payload)); +} + +const server = new Server({ + name: "lowcoder-hocuspocus", + quiet: true, + address: HOST, + port: PORT, + + async onListen() { + console.log(`[hocuspocus] listening on ws://${HOST}:${PORT}`); + }, + + async onRequest({ request, response }) { + if (request.url === "/health") { + writeJson(response, 200, { + status: "ok", + host: HOST, + port: PORT, + auth: SECRET ? "enabled" : "disabled", + }); + return; + } + + if (request.url === "/") { + writeJson(response, 200, { + name: "lowcoder-hocuspocus", + websocket: `ws://${HOST}:${PORT}`, + health: "/health", + }); + } + }, + + async onAuthenticate({ token, documentName }) { + if (!SECRET) { + return; + } + + if (token !== SECRET) { + console.warn(`[hocuspocus] rejected connection for ${documentName}: invalid token`); + throw new Error("Unauthorized"); + } + }, + + async onConnect({ documentName, socketId }) { + console.log(`[hocuspocus] connect socket=${socketId} document=${documentName}`); + }, + + async onDisconnect({ documentName, socketId }) { + console.log(`[hocuspocus] disconnect socket=${socketId} document=${documentName}`); + }, +}); + +try { + await server.listen(); +} catch (error) { + console.error("[hocuspocus] failed to start", error); + process.exit(1); +} diff --git a/client/packages/lowcoder/package.json b/client/packages/lowcoder/package.json index 9aac99d87b..7c2899bfcc 100644 --- a/client/packages/lowcoder/package.json +++ b/client/packages/lowcoder/package.json @@ -1,6 +1,6 @@ { "name": "lowcoder", - "version": "2.7.2", + "version": "2.7.6", "private": true, "type": "module", "main": "src/index.sdk.ts", @@ -21,15 +21,17 @@ "@codemirror/lang-json": "^6.0.1", "@codemirror/lang-sql": "^6.5.4", "@codemirror/search": "^6.5.5", - "@dnd-kit/core": "^5.0.1", + "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^7.0.0", - "@dnd-kit/sortable": "^6.0.0", - "@dnd-kit/utilities": "^3.1.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@fortawesome/fontawesome-svg-core": "^6.5.1", "@fortawesome/free-brands-svg-icons": "^6.5.1", "@fortawesome/free-regular-svg-icons": "^6.5.1", "@fortawesome/free-solid-svg-icons": "^6.5.1", "@fortawesome/react-fontawesome": "latest", + "@hocuspocus/provider": "^3.4.4", + "@hocuspocus/server": "^3.4.4", "@jsonforms/core": "^3.5.1", "@lottiefiles/dotlottie-react": "^0.13.0", "@manaflair/redux-batch": "^1.0.0", @@ -59,11 +61,14 @@ "coolshapes-react": "lowcoder-org/coolshapes-react", "copy-to-clipboard": "^3.3.3", "core-js": "^3.25.2", + "cors": "^2.8.6", "dayjs": "^1.11.13", + "dnd-kit-sortable-tree": "^0.1.73", "echarts": "^5.4.3", "echarts-for-react": "^3.0.2", "echarts-wordcloud": "^2.1.0", "eslint4b-prebuilt-2": "^7.32.0", + "express": "^5.2.1", "file-saver": "^2.0.5", "github-markdown-css": "^5.1.0", "hotkeys-js": "^3.8.7", @@ -125,9 +130,11 @@ "web-vitals": "^2.1.0", "ws": "^8.18.3", "xlsx": "^0.18.5", + "y-indexeddb": "^9.0.12", "y-protocols": "^1.0.6", "y-websocket": "^3.0.0", - "yjs": "^13.6.27" + "yjs": "^13.6.27", + "zod": "^3.25.76" }, "scripts": { "supportedBrowsers": "yarn dlx browserslist-useragent-regexp --allowHigherVersions '>0.2%,not dead,not op_mini all,chrome >=69'", @@ -153,7 +160,7 @@ "http-proxy-middleware": "^2.0.6", "rollup-plugin-terser": "^7.0.2", "rollup-plugin-visualizer": "^5.9.2", - "typescript": "^4.8.4", + "typescript": "^5.6.2", "vite": "^4.5.5", "vite-plugin-checker": "^0.5.1", "vite-plugin-dynamic-import": "^1.5.0", diff --git a/client/packages/lowcoder/src/api/applicationApi.ts b/client/packages/lowcoder/src/api/applicationApi.ts index 8ed818b371..9e5234a69a 100644 --- a/client/packages/lowcoder/src/api/applicationApi.ts +++ b/client/packages/lowcoder/src/api/applicationApi.ts @@ -70,6 +70,11 @@ export interface ApplicationResp extends ApiResponse { data: ApplicationDetail; } +export interface ApplicationPublishRequest { + commitMessage?: string; + tag: string; +} + interface GrantAppPermissionReq { applicationId: string; role: ApplicationRoleType; @@ -171,8 +176,13 @@ class ApplicationApi extends Api { return Api.put(ApplicationApi.updateApplicationURL(applicationId), rest); } - static publishApplication(request: PublishApplicationPayload): AxiosPromise { - return Api.post(ApplicationApi.publishApplicationURL(request.applicationId)); + static publishApplication( + request: PublishApplicationPayload + ): AxiosPromise { + return Api.post( + ApplicationApi.publishApplicationURL(request.applicationId), + request?.request + ); } static getApplicationDetail(request: FetchAppInfoPayload): AxiosPromise { diff --git a/client/packages/lowcoder/src/api/commonSettingApi.ts b/client/packages/lowcoder/src/api/commonSettingApi.ts index b2f746454b..226617d96c 100644 --- a/client/packages/lowcoder/src/api/commonSettingApi.ts +++ b/client/packages/lowcoder/src/api/commonSettingApi.ts @@ -123,7 +123,7 @@ export function isThemeColorKey(key: string) { case "padding": case "gridColumns": case "textSize": - case "lineHeight": + case "lineHeight": return true; } return false; diff --git a/client/packages/lowcoder/src/api/datasourceApi.ts b/client/packages/lowcoder/src/api/datasourceApi.ts index 1be29e6469..278015a124 100644 --- a/client/packages/lowcoder/src/api/datasourceApi.ts +++ b/client/packages/lowcoder/src/api/datasourceApi.ts @@ -187,6 +187,10 @@ export class DatasourceApi extends Api { return Api.get(DatasourceApi.url + `/listByOrg?orgId=${orgId}`, {...res}); } + static getDatasourceById(id: string): AxiosPromise> { + return Api.get(`${DatasourceApi.url}/${id}`); + } + static createDatasource( datasourceConfig: Partial ): AxiosPromise> { diff --git a/client/packages/lowcoder/src/api/userApi.ts b/client/packages/lowcoder/src/api/userApi.ts index 5955071a84..a65a72338c 100644 --- a/client/packages/lowcoder/src/api/userApi.ts +++ b/client/packages/lowcoder/src/api/userApi.ts @@ -63,10 +63,13 @@ export type GetCurrentUserResponse = GenericApiResponse; export interface GetMyOrgsResponse extends ApiResponse { data: { data: Array<{ - orgId: string; - orgName: string; - createdAt?: number; - updatedAt?: number; + isCurrentOrg: boolean; + orgView: { + orgId: string; + orgName: string; + createdAt?: number; + updatedAt?: number; + }; }>; pageNum: number; pageSize: number; diff --git a/client/packages/lowcoder/src/app-env.d.ts b/client/packages/lowcoder/src/app-env.d.ts index 5205d43a84..9953e80e09 100644 --- a/client/packages/lowcoder/src/app-env.d.ts +++ b/client/packages/lowcoder/src/app-env.d.ts @@ -41,6 +41,8 @@ declare var REACT_APP_ENV: string; declare var REACT_APP_BUILD_ID: string; declare var REACT_APP_LOG_LEVEL: string; declare var REACT_APP_SERVER_IPS: string; +declare var REACT_APP_HOCUSPOCUS_URL: string; +declare var REACT_APP_HOCUSPOCUS_SECRET: string; declare var REACT_APP_BUNDLE_TYPE: "sdk" | "app"; declare var REACT_APP_DISABLE_JS_SANDBOX: string; declare var REACT_APP_BUNDLE_BUILTIN_PLUGIN: string; diff --git a/client/packages/lowcoder/src/base/codeEditor/completion/exposingCompletionSource.tsx b/client/packages/lowcoder/src/base/codeEditor/completion/exposingCompletionSource.tsx index 056edb7bba..8a3b943537 100644 --- a/client/packages/lowcoder/src/base/codeEditor/completion/exposingCompletionSource.tsx +++ b/client/packages/lowcoder/src/base/codeEditor/completion/exposingCompletionSource.tsx @@ -19,7 +19,7 @@ export class ExposingCompletionSource extends CompletionSource { return null; } const matchPath = context.matchBefore( - /(?:[A-Za-z_$][\w$]*(?:\[\s*(?:\d+|(["'])(?:[^\1\\]|\\.)*?\1)\s*\])*\.)*(?:[A-Za-z_$][\w$]*)?/ + /(?:[A-Za-z_$][\w$]*(?:\[\s*(?:\d+|'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")\s*\])*\.)*(?:[A-Za-z_$][\w$]*)?/ ); if (!matchPath) { return null; diff --git a/client/packages/lowcoder/src/base/codeEditor/extensions.tsx b/client/packages/lowcoder/src/base/codeEditor/extensions.tsx index 3682cf4e04..cd36cb9c28 100644 --- a/client/packages/lowcoder/src/base/codeEditor/extensions.tsx +++ b/client/packages/lowcoder/src/base/codeEditor/extensions.tsx @@ -27,7 +27,7 @@ import { foldKeymap, indentOnInput, } from "@codemirror/language"; -import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands"; +import { defaultKeymap, history, historyKeymap, insertTab, indentLess, indentMore } from "@codemirror/commands"; import { highlightSelectionMatches, searchKeymap } from "@codemirror/search"; import { Diagnostic, linter, lintKeymap } from "@codemirror/lint"; import { type EditorState, Prec } from "@codemirror/state"; @@ -282,7 +282,20 @@ export function useFocusExtension(onFocus?: (focused: boolean) => void): [Extens } function indentWithTabExtension(open?: boolean) { - return open ?? true ? keymap.of([indentWithTab]) : []; + if (!(open ?? true)) return []; + return keymap.of([ + { + key: "Tab", + run: (view: EditorView) => { + const { main } = view.state.selection; + if (!main.empty && main.from !== main.to) { + return indentMore(view); + } + return insertTab(view); + }, + }, + { key: "Shift-Tab", run: indentLess }, + ]); } export function lineNoExtension(showLineNumber?: boolean) { @@ -493,26 +506,26 @@ export function useExtensions(props: CodeEditorProps) { basicSetup, defaultTheme, highlightJsExt, - autocompletionExtension, focusExtension, lineNoExt, languageExt, onChangeExt, placeholderExt, indentWithTabExt, + autocompletionExtension, tooltipExt, lintExt, iconExt, ], [ highlightJsExt, - autocompletionExtension, focusExtension, lineNoExt, languageExt, onChangeExt, placeholderExt, indentWithTabExt, + autocompletionExtension, tooltipExt, lintExt, iconExt, diff --git a/client/packages/lowcoder/src/components/DraggableTree/DroppableMenuItem.tsx b/client/packages/lowcoder/src/components/DraggableTree/DroppableMenuItem.tsx index 7c9eac729f..29ebb14b09 100644 --- a/client/packages/lowcoder/src/components/DraggableTree/DroppableMenuItem.tsx +++ b/client/packages/lowcoder/src/components/DraggableTree/DroppableMenuItem.tsx @@ -67,6 +67,9 @@ export default function DraggableMenuItem(props: IDraggableMenuItemProps) { const canDropBefore = checkDroppableFlag(item.canDropBefore, activeNode?.data); const canDropAfter = checkDroppableFlag(item.canDropAfter, activeNode?.data); + const lastChildNode = items[items.length - 1]; + const canDropAfterLastChild = checkDroppableFlag(lastChildNode?.canDropAfter, activeNode?.data); + const dropData: IDropData = { targetListSize: items.length, targetPath: dropInAsSub ? [...path, 0] : [...path.slice(0, -1), path[path.length - 1] + 1], @@ -136,6 +139,15 @@ export default function DraggableMenuItem(props: IDraggableMenuItemProps) { /> ))} + {activeNode && canDropAfterLastChild && ( +
+ +
+ )}
)} diff --git a/client/packages/lowcoder/src/components/PermissionDialog/AppPermissionDialog.tsx b/client/packages/lowcoder/src/components/PermissionDialog/AppPermissionDialog.tsx index 69a9afe883..be9abcc463 100644 --- a/client/packages/lowcoder/src/components/PermissionDialog/AppPermissionDialog.tsx +++ b/client/packages/lowcoder/src/components/PermissionDialog/AppPermissionDialog.tsx @@ -12,6 +12,7 @@ import { fetchApplicationPermissions, updateAppPermission, updateAppPermissionInfo, + publishApplication, } from "../../redux/reduxActions/applicationActions"; import { PermissionItemsType } from "./PermissionList"; import { trans } from "../../i18n"; @@ -29,19 +30,62 @@ import { StyledLoading } from "./commonComponents"; import { PermissionRole } from "./Permission"; import { messageInstance } from "lowcoder-design/src/components/GlobalInstances"; import { default as Divider } from "antd/es/divider"; -import { SocialShareButtons } from "components/SocialShareButtons"; +import { default as Form } from "antd/es/form"; +import { Typography } from "antd"; +import StepModal from "../StepModal"; +import { AddIcon } from "icons"; +import { GreyTextColor } from "constants/style"; +import { VersionDataForm } from "@lowcoder-ee/pages/common/versionDataForm"; -export const AppPermissionDialog = React.memo((props: { - applicationId: string; - visible: boolean; - onVisibleChange: (visible: boolean) => void; -}) => { - const { applicationId } = props; - const dispatch = useDispatch(); - const appPermissionInfo = useSelector(getAppPermissionInfo); +const BottomWrapper = styled.div` + margin: 12px 16px 0 16px; + display: flex; + justify-content: space-between; +`; + +const AddPermissionButton = styled(TacoButton)` + &, + &:hover, + &:focus { + border: none; + box-shadow: none; + padding: 0; + display: flex; + align-items: center; + font-size: 14px; + line-height: 14px; + background: #ffffff; + transition: unset; + } + + svg { + margin-right: 4px; + } - const { appType } = useContext(ExternalEditorContext); - const isModule = appType === AppTypeEnum.Module; + &:hover { + color: #315efb; + + svg g path { + fill: #315efb; + } + } +`; + +export const AppPermissionDialog = React.memo( + (props: { + applicationId: string; + visible: boolean; + onVisibleChange: (visible: boolean) => void; + publishedVersion?: string | undefined; + }) => { + const [form] = Form.useForm(); + const { appType } = useContext(ExternalEditorContext); + const isModule = appType === AppTypeEnum.Module; + const { applicationId, publishedVersion } = props; + + const dispatch = useDispatch(); + const appPermissionInfo = useSelector(getAppPermissionInfo); + const [activeStepKey, setActiveStepKey] = useState("permission"); useEffect(() => { dispatch(fetchApplicationPermissions({ applicationId: applicationId })); @@ -80,76 +124,169 @@ export const AppPermissionDialog = React.memo((props: { } } - return ( - { - if (!appPermissionInfo) { - return ; - } - return ( - <> - - - {list} - - ); - }} - supportRoles={[ - { label: trans("share.viewer"), value: PermissionRole.Viewer }, - { - label: trans("share.editor"), - value: PermissionRole.Editor, - }, - { - label: trans("share.owner"), - value: PermissionRole.Owner, - }, - ]} - permissionItems={permissions} - addPermission={(userIds, groupIds, role, onSuccess) => - ApplicationApi.grantAppPermission({ - applicationId: applicationId, - userIds: userIds, - groupIds: groupIds, - role: role as any, - }) - .then((resp) => { - if (validateResponse(resp)) { - dispatch(fetchApplicationPermissions({ applicationId: applicationId })); - onSuccess(); - } - }) - .catch((e) => { - messageInstance.error(trans("home.addPermissionErrorMessage", { message: e.message })); - }) - } - updatePermission={(permissionId, role) => - dispatch( - updateAppPermission({ - applicationId: applicationId, - role: role as ApplicationRoleType, - permissionId: permissionId, - }) - ) - } - deletePermission={(permissionId) => - dispatch( - deleteAppPermission({ - applicationId: applicationId, - permissionId: permissionId, - }) - ) - } - /> - ); -}); + return ( + { + setActiveStepKey("permission"); + props.onVisibleChange(false); + }} + showOkButton={true} + showBackLink={true} + showCancelButton={true} + width="440px" + onStepChange={setActiveStepKey} + activeStepKey={activeStepKey} + steps={[ + { + key: "permission", + titleRender: () => null, + bodyRender: (modalProps) => ( + { + if (!appPermissionInfo) { + return ; + } + return <>{list}; + }} + supportRoles={[ + { + label: trans("share.viewer"), + value: PermissionRole.Viewer, + }, + { + label: trans("share.editor"), + value: PermissionRole.Editor, + }, + { + label: trans("share.owner"), + value: PermissionRole.Owner, + }, + ]} + permissionItems={permissions} + addPermission={(userIds, groupIds, role, onSuccess) => + ApplicationApi.grantAppPermission({ + applicationId: applicationId, + userIds: userIds, + groupIds: groupIds, + role: role as any, + }) + .then((resp) => { + if (validateResponse(resp)) { + dispatch( + fetchApplicationPermissions({ + applicationId: applicationId, + }) + ); + onSuccess(); + } + }) + .catch((e) => { + messageInstance.error( + trans("home.addPermissionErrorMessage", { + message: e.message, + }) + ); + }) + } + updatePermission={(permissionId, role) => + dispatch( + updateAppPermission({ + applicationId: applicationId, + role: role as ApplicationRoleType, + permissionId: permissionId, + }) + ) + } + deletePermission={(permissionId) => + dispatch( + deleteAppPermission({ + applicationId: applicationId, + permissionId: permissionId, + }) + ) + } + viewFooterRender={(primaryModelProps, props) => ( + + } + onClick={() => { + props.next(); + }} + > + {trans("home.addMember")} + + + { + primaryModelProps.next(); + }} + > + {trans("event.next") + " "} + + + )} + primaryModelProps={modalProps} + /> + ), + footerRender: () => null, + }, + { + key: "versions", + titleRender: () => trans("home.versions"), + bodyRender: () => ( + + ), + footerRender: (modalProps) => ( + + { + modalProps.back(); + }} + > + {trans("back")} + + { + form.validateFields().then(() => { + dispatch( + publishApplication({ + applicationId: applicationId, + request: form.getFieldsValue(), + }) + ); + modalProps.back(); + props.onVisibleChange(false); + }); + }} + > + {trans("queryLibrary.publish")} + + + ), + }, + ]} + /> + ); + } +); const InviteInputBtn = styled.div` display: flex; @@ -196,8 +333,16 @@ function AppShareView(props: { applicationId: string; permissionInfo: AppPermissionInfo; isModule: boolean; + form: any; + publishedVersion?: string; }) { - const { applicationId, permissionInfo, isModule } = props; + const { + applicationId, + permissionInfo, + isModule, + form, + publishedVersion, + } = props; const [isPublic, setPublic] = useState(permissionInfo.publicToAll); const [isPublicToMarketplace, setPublicToMarketplace] = useState(permissionInfo.publicToMarketplace); const dispatch = useDispatch(); @@ -207,11 +352,20 @@ function AppShareView(props: { useEffect(() => { setPublicToMarketplace(permissionInfo.publicToMarketplace); }, [permissionInfo.publicToMarketplace]); - const inviteLink = window.location.origin + APPLICATION_VIEW_URL(props.applicationId, "view"); return (
- + + + - {isPublic && + {isPublic && ( { messageInstance.error(e.message); }); - } } - label={isModule ? trans("home.moduleMarketplaceMessage") : trans("home.appMarketplaceMessage")} /> - } - { isPublicToMarketplace && <>
- {trans("home.marketplaceGoodPublishing")} -
} + }} + label={ + isModule + ? trans("home.moduleMarketplaceMessage") + : trans("home.appMarketplaceMessage") + } + /> + + )} + {isPublicToMarketplace && isPublic && ( +
+ + {trans("home.marketplaceGoodPublishing")} + + +
+ )} - {isPublic && } + {isPublic && } + - {isPublic && - <> - - - - } + - +
+ + {trans("home.publishVersionDescription")} + +
); } diff --git a/client/packages/lowcoder/src/components/PermissionDialog/DatasourcePermissionDialog.tsx b/client/packages/lowcoder/src/components/PermissionDialog/DatasourcePermissionDialog.tsx index 9bad678a13..4f11a03d70 100644 --- a/client/packages/lowcoder/src/components/PermissionDialog/DatasourcePermissionDialog.tsx +++ b/client/packages/lowcoder/src/components/PermissionDialog/DatasourcePermissionDialog.tsx @@ -13,6 +13,45 @@ import { DatasourceRole } from "../../api/datasourcePermissionApi"; import { getDataSourcePermissionInfo } from "../../redux/selectors/datasourceSelectors"; import { StyledLoading } from "./commonComponents"; import { PermissionRole } from "./Permission"; +import { getUser } from "../../redux/selectors/usersSelectors"; +import styled from "styled-components"; +import { TacoButton } from "components/button"; +import { AddIcon } from "icons"; +import { GreyTextColor } from "constants/style"; + +const BottomWrapper = styled.div` + margin: 12px 16px 0 16px; + display: flex; + justify-content: flex-start; +`; + +const AddPermissionButton = styled(TacoButton)` + &, + &:hover, + &:focus { + border: none; + box-shadow: none; + padding: 0; + display: flex; + align-items: center; + font-size: 14px; + line-height: 14px; + background: #ffffff; + transition: unset; + } + + svg { + margin-right: 4px; + } + + &:hover { + color: #315efb; + + svg g path { + fill: #315efb; + } + } +`; export const DatasourcePermissionDialog = (props: { datasourceId: string; @@ -22,6 +61,7 @@ export const DatasourcePermissionDialog = (props: { const { datasourceId } = props; const dispatch = useDispatch(); const permissionInfo = useSelector(getDataSourcePermissionInfo)[datasourceId]; + const user = useSelector(getUser); useEffect(() => { dispatch(fetchDatasourcePermissions({ datasourceId: datasourceId })); @@ -75,12 +115,27 @@ export const DatasourcePermissionDialog = (props: { { label: trans("share.datasourceOwner"), value: PermissionRole.Owner }, ]} permissionItems={permissions} + contextType="organization" + organizationId={user.currentOrgId} viewBodyRender={(list) => { if (!permissionInfo) { return ; } return list; }} + viewFooterRender={(_primaryModelProps, stepProps) => ( + + } + onClick={() => { + stepProps.next(); + }} + > + {trans("home.addMember")} + + + )} addPermission={(userIds, groupIds, role, onSuccess) => { dispatch( grantDatasourcePermission( diff --git a/client/packages/lowcoder/src/components/PermissionDialog/Permission.tsx b/client/packages/lowcoder/src/components/PermissionDialog/Permission.tsx index 6425d3afc6..f6fffc37d3 100644 --- a/client/packages/lowcoder/src/components/PermissionDialog/Permission.tsx +++ b/client/packages/lowcoder/src/components/PermissionDialog/Permission.tsx @@ -27,7 +27,7 @@ import { EmptyContent } from "pages/common/styledComponent"; import { trans } from "i18n"; import { PermissionItem } from "./PermissionList"; import { currentApplication } from "@lowcoder-ee/redux/selectors/applicationSelector"; -import { fetchAvailableGroupsMembers } from "@lowcoder-ee/util/pagination/axios"; +import { fetchAvailableGroupsMembers, fetchAvailableOrgGroupsMembers } from "@lowcoder-ee/util/pagination/axios"; const AddAppUserContent = styled.div` display: flex; @@ -186,6 +186,13 @@ const AddRoleSelect = styled(StyledRoleSelect)<{ $isVisible: boolean }>` display: ${(props) => (props.$isVisible ? "unset" : "none")}; `; +type PermissionContextType = "application" | "organization"; + +type PermissionContextProps = { + contextType: PermissionContextType; + organizationId?: string; +}; + type AddAppOptionView = { type: ApplicationPermissionType; id: string; @@ -274,7 +281,7 @@ function PermissionTagRender(props: CustomTagProps) { color={value} closable={closable} onClose={onClose} - style={{ marginRight: 3 }} + style={{ marginRight: 3, display: "flex", alignItems: "center" }} > {label} @@ -294,8 +301,10 @@ const PermissionSelector = (props: { user: User; filterItems: PermissionItem[]; supportRoles: { label: string; value: PermissionRole }[]; + contextType: PermissionContextType; + organizationId?: string; }) => { - const { selectedItems, setSelectRole, setSelectedItems, user } = props; + const { selectedItems, setSelectRole, setSelectedItems, user, contextType, organizationId } = props; const [roleSelectVisible, setRoleSelectVisible] = useState(false); const selectRef = useRef(null); const [optionViews, setOptionViews] = useState() @@ -305,42 +314,41 @@ const PermissionSelector = (props: { const debouncedUserSearch = useCallback( debounce((searchTerm: string) => { - if (!application) return; - setIsLoading(true); - fetchAvailableGroupsMembers(application.applicationId, searchTerm).then(res => { - if(res.success) { - setOptionViews(getPermissionOptionView(res.data, props.filterItems)) - } - setIsLoading(false); - }).catch(() => { + + if (contextType === "application" && application) { + fetchAvailableGroupsMembers(application.applicationId, searchTerm).then(res => { + if(res.success) { + setOptionViews(getPermissionOptionView(res.data, props.filterItems)) + } + setIsLoading(false); + }).catch(() => { + setIsLoading(false); + }); + } else if (contextType === "organization" && organizationId) { + fetchAvailableOrgGroupsMembers(organizationId, searchTerm).then(res => { + if(res.success) { + setOptionViews(getPermissionOptionView(res.data || [], props.filterItems)) + } + setIsLoading(false); + }).catch(() => { + setIsLoading(false); + }); + } else { setIsLoading(false); - }); + } }, 500), - [application, props.filterItems] + [application, props.filterItems, contextType, organizationId] ); useEffect(() => { debouncedUserSearch(searchValue); - return () => { debouncedUserSearch.cancel(); }; }, [searchValue, debouncedUserSearch]); - useEffect(() => { - if (!application) return; - - setIsLoading(true); - fetchAvailableGroupsMembers(application.applicationId, "").then(res => { - if(res.success) { - setOptionViews(getPermissionOptionView(res.data, props.filterItems)) - } - setIsLoading(false); - }).catch(() => { - setIsLoading(false); - }); - }, [application, props.filterItems]); + useEffect(() => { setRoleSelectVisible(selectedItems.length > 0); @@ -425,8 +433,8 @@ export const Permission = (props: { supportRoles: { label: string; value: PermissionRole }[]; onCancel: () => void; addPermission: (userIds: string[], groupIds: string[], role: string) => void; -}) => { - const { onCancel } = props; +} & PermissionContextProps) => { + const { onCancel, contextType = "application", organizationId } = props; const user = useSelector(getUser); const [selectRole, setSelectRole] = useState("viewer"); const [selectedItems, setSelectedItems] = useState([]); @@ -443,6 +451,8 @@ export const Permission = (props: { user={user} filterItems={props.filterItems} supportRoles={props.supportRoles} + contextType={contextType} + organizationId={organizationId} /> diff --git a/client/packages/lowcoder/src/components/PermissionDialog/PermissionDialog.tsx b/client/packages/lowcoder/src/components/PermissionDialog/PermissionDialog.tsx index b3645f7887..d43ba4e318 100644 --- a/client/packages/lowcoder/src/components/PermissionDialog/PermissionDialog.tsx +++ b/client/packages/lowcoder/src/components/PermissionDialog/PermissionDialog.tsx @@ -2,44 +2,7 @@ import React, { ReactNode, useState } from "react"; import { PermissionItemsType, PermissionList } from "./PermissionList"; import StepModal from "../StepModal"; import { trans } from "../../i18n"; -import { TacoButton } from "components/button"; -import { AddIcon } from "icons"; -import { GreyTextColor } from "constants/style"; import { Permission, PermissionRole } from "./Permission"; -import styled from "styled-components"; - -const BottomWrapper = styled.div` - margin: 12px 16px 0 16px; - display: flex; -`; - -const AddPermissionButton = styled(TacoButton)` - &, - &:hover, - &:focus { - border: none; - box-shadow: none; - padding: 0; - display: flex; - align-items: center; - font-size: 14px; - line-height: 14px; - background: #ffffff; - transition: unset; - } - - svg { - margin-right: 4px; - } - - &:hover { - color: #315efb; - - svg g path { - fill: #315efb; - } - } -`; export const PermissionDialog = (props: { title: string; @@ -47,6 +10,7 @@ export const PermissionDialog = (props: { visible: boolean; onVisibleChange: (visible: boolean) => void; viewBodyRender?: (list: ReactNode) => ReactNode; + viewFooterRender?: (primaryModelProps: any, props: any) => ReactNode; permissionItems: PermissionItemsType; supportRoles: { label: string; value: PermissionRole }[]; addPermission: ( @@ -57,9 +21,22 @@ export const PermissionDialog = (props: { ) => void; updatePermission: (permissionId: string, role: string) => void; deletePermission: (permissionId: string) => void; + primaryModelProps?: {}; + contextType?: "application" | "organization"; + organizationId?: string; }) => { - const { supportRoles, permissionItems, visible, onVisibleChange, addPermission, viewBodyRender } = - props; + const { + supportRoles, + permissionItems, + visible, + onVisibleChange, + addPermission, + viewBodyRender, + viewFooterRender, + primaryModelProps, + contextType, + organizationId, + } = props; const [activeStepKey, setActiveStepKey] = useState("view"); return ( @@ -85,26 +62,10 @@ export const PermissionDialog = (props: { ) : ( ), - footerRender: (props) => ( - - } - onClick={() => { - props.next(); - }} - > - {trans("home.addMember")} - - onVisibleChange(false)} - style={{ marginLeft: "auto", width: "76px", height: "28px" }} - > - {trans("finish") + " "} - - - ), + footerRender: (props) => + viewFooterRender + ? viewFooterRender(primaryModelProps, props) + : null, }, { key: "add", @@ -117,9 +78,11 @@ export const PermissionDialog = (props: { addPermission={(userIds, groupIds, role) => addPermission(userIds, groupIds, role, props.back) } + contextType={contextType || "application"} + organizationId={organizationId} /> ), - footerRender: (props) => null, + footerRender: () => null, }, ]} /> diff --git a/client/packages/lowcoder/src/components/PermissionDialog/PermissionList.tsx b/client/packages/lowcoder/src/components/PermissionDialog/PermissionList.tsx index f29ef424e5..dd44f247c4 100644 --- a/client/packages/lowcoder/src/components/PermissionDialog/PermissionList.tsx +++ b/client/packages/lowcoder/src/components/PermissionDialog/PermissionList.tsx @@ -165,7 +165,7 @@ export const PermissionList = (props: { }) => ( <> - {trans("home.memberPermissionList")} + {`${trans("memberSettings.title")}:`} {props.permissionItems.map((item, index) => ( diff --git a/client/packages/lowcoder/src/components/PermissionDialog/commonComponents.tsx b/client/packages/lowcoder/src/components/PermissionDialog/commonComponents.tsx index f5a9f884ad..8a06b39cad 100644 --- a/client/packages/lowcoder/src/components/PermissionDialog/commonComponents.tsx +++ b/client/packages/lowcoder/src/components/PermissionDialog/commonComponents.tsx @@ -9,8 +9,10 @@ export const StyledRoleSelect = styled(CustomSelect)` right: 0; } - .ant-select-selector { - border: none !important; + .ant-select .ant-select-selector { + margin-right: 0 !important; + padding: 0 !important; + padding: 12px !important; } .ant-select:hover { diff --git a/client/packages/lowcoder/src/components/StepModal.tsx b/client/packages/lowcoder/src/components/StepModal.tsx index 13d08319b3..0bc88bf6da 100644 --- a/client/packages/lowcoder/src/components/StepModal.tsx +++ b/client/packages/lowcoder/src/components/StepModal.tsx @@ -25,7 +25,8 @@ export interface StepModalProps extends CustomModalProps { export default function StepModal(props: StepModalProps) { const { steps, activeStepKey, onStepChange, ...modalProps } = props; const [current, setCurrent] = useState(steps[0]?.key); - const currentStepIndex = steps.findIndex((i) => i.key === activeStepKey ?? current); + const currentStepKey = activeStepKey ?? current; + const currentStepIndex = steps.findIndex((i) => i.key === currentStepKey); const currentStep = currentStepIndex >= 0 ? steps[currentStepIndex] : null; const handleChangeStep = (key: string) => { diff --git a/client/packages/lowcoder/src/components/table/EditableCell.tsx b/client/packages/lowcoder/src/components/table/EditableCell.tsx index 42d7123457..4bc15fedc5 100644 --- a/client/packages/lowcoder/src/components/table/EditableCell.tsx +++ b/client/packages/lowcoder/src/components/table/EditableCell.tsx @@ -54,6 +54,9 @@ export type EditViewFn = (props: { value: T; onChange: (value: T) => void; onChangeEnd: () => void; + onCommit?: (value: T) => void; + onCancel?: () => void; + onImmediateSave?: (value: T) => void; otherProps?: Record; }) => ReactNode; @@ -151,11 +154,11 @@ function EditableCellComp(props: EditableCellProps) { [] ); - const onChangeEnd = useCallback(() => { + const commitValue = useCallback((finalValue: T | null) => { if (!mountedRef.current) return; - + setIsEditing(false); - const newValue = _.isNil(tmpValue) || _.isEqual(tmpValue, baseValue) ? null : tmpValue; + const newValue = _.isNil(finalValue) || _.isEqual(finalValue, baseValue) ? null : finalValue; dispatch( changeChildAction( "changeValue", @@ -163,14 +166,47 @@ function EditableCellComp(props: EditableCellProps) { false ) ); - if(!_.isEqual(tmpValue, value)) { + if(!_.isEqual(finalValue, value)) { + onTableEvent?.('columnEdited'); + } + }, [dispatch, baseValue, value, onTableEvent, setIsEditing]); + + const onChangeEnd = useCallback(() => { + commitValue(tmpValue); + }, [commitValue, tmpValue]); + + const onCommit = useCallback((nextValue: T) => { + if (!mountedRef.current) return; + setTmpValue(nextValue); + commitValue(nextValue); + }, [commitValue]); + + const onCancel = useCallback(() => { + if (!mountedRef.current) return; + setIsEditing(false); + setTmpValue(value); + }, [setIsEditing, value]); + + const onImmediateSave = useCallback((newValue: T) => { + if (!mountedRef.current) return; + + setTmpValue(newValue); + const changeValue = _.isNil(newValue) || _.isEqual(newValue, baseValue) ? null : newValue; + dispatch( + changeChildAction( + "changeValue", + changeValue, + false + ) + ); + if(!_.isEqual(newValue, value)) { onTableEvent?.('columnEdited'); } - }, [dispatch, tmpValue, baseValue, value, onTableEvent, setIsEditing]); + }, [dispatch, baseValue, value, onTableEvent]); const editView = useMemo( - () => editViewFn?.({ value, onChange, onChangeEnd, otherProps }) ?? <>, - [editViewFn, value, onChange, onChangeEnd, otherProps] + () => editViewFn?.({ value, onChange, onChangeEnd, onCommit, onCancel, onImmediateSave, otherProps }) ?? <>, + [editViewFn, value, onChange, onChangeEnd, onCommit, onCancel, onImmediateSave, otherProps] ); const enterEditFn = useCallback(() => { @@ -225,4 +261,4 @@ function EditableCellComp(props: EditableCellProps) { ); } -export const EditableCell = React.memo(EditableCellComp) as typeof EditableCellComp; \ No newline at end of file +export const EditableCell = React.memo(EditableCellComp) as typeof EditableCellComp; diff --git a/client/packages/lowcoder/src/comps/comps/autoCompleteComp/autoCompleteComp.tsx b/client/packages/lowcoder/src/comps/comps/autoCompleteComp/autoCompleteComp.tsx index 2b33bf3766..0339aea4de 100644 --- a/client/packages/lowcoder/src/comps/comps/autoCompleteComp/autoCompleteComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/autoCompleteComp/autoCompleteComp.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useEffect, useState, useCallback } from "react"; +import { ReactNode, useEffect, useState, useCallback, useRef } from "react"; import { Input, Section, sectionNames } from "lowcoder-design"; import { BoolControl } from "comps/controls/boolControl"; import { styleControl } from "comps/controls/styleControl"; @@ -148,12 +148,19 @@ let AutoCompleteCompBase = (function () { const [activationFlag, setActivationFlag] = useState(false); const [searchtext, setsearchtext] = useState(props.value.value); const [validateState, setvalidateState] = useState({}); + + // Use simple refs like text input components + const changeRef = useRef(false); + const touchRef = useRef(false); // 是否中文环境 const [chineseEnv, setChineseEnv] = useState(getDayJSLocale() === "zh-cn"); useEffect(() => { - setsearchtext(props.value.value); + // Only update local state from props if user hasn't touched the input + if (!touchRef.current) { + setsearchtext(props.value.value); + } activationFlag && setvalidateState(textInputValidate(getTextInputValidate())); }, [ @@ -247,19 +254,27 @@ let AutoCompleteCompBase = (function () { props.valueInItems.onChange(false); setvalidateState(textInputValidate(getTextInputValidate())); setsearchtext(value); + changeRef.current = true; + touchRef.current = true; + + // Update parent value immediately to prevent sync issues props.value.onChange(value); props.onEvent("change"); + if(!Boolean(value)) { props.selectedOption.onChange({}); } }, [props.valueInItems, getTextInputValidate, props.value, props.onEvent, props.selectedOption]); const handleSelect = useCallback((data: string, option: any) => { - setsearchtext(option[valueOrLabel]); + const selectedValue = option[valueOrLabel]; + setsearchtext(selectedValue); props.valueInItems.onChange(true); - props.value.onChange(option[valueOrLabel]); + props.value.onChange(selectedValue); props.selectedOption.onChange(option); props.onEvent("submit"); + changeRef.current = true; + touchRef.current = true; }, [valueOrLabel, props.valueInItems, props.value, props.onEvent, props.selectedOption]); const handleFocus = useCallback(() => { @@ -268,6 +283,7 @@ let AutoCompleteCompBase = (function () { }, [props.onEvent]); const handleBlur = useCallback(() => { + touchRef.current = false; props.onEvent("blur"); }, [props.onEvent]); diff --git a/client/packages/lowcoder/src/comps/comps/buttonComp/buttonComp.tsx b/client/packages/lowcoder/src/comps/comps/buttonComp/buttonComp.tsx index a6d6f88890..bd019ab66c 100644 --- a/client/packages/lowcoder/src/comps/comps/buttonComp/buttonComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/buttonComp/buttonComp.tsx @@ -1,4 +1,4 @@ -import { BoolCodeControl, StringControl } from "comps/controls/codeControl"; +import { BoolCodeControl, StringControl, NumberControl } from "comps/controls/codeControl"; import { dropdownControl } from "comps/controls/dropdownControl"; import { ButtonEventHandlerControl } from "comps/controls/eventHandlerControl"; import { IconControl } from "comps/controls/iconControl"; @@ -137,7 +137,8 @@ const childrenMap = { disabledStyle: DisabledButtonStyleControl, animationStyle: styleControl(AnimationStyle, 'animationStyle'), viewRef: RefControl, - tooltip: StringControl + tooltip: StringControl, + tabIndex: NumberControl }; type ChildrenType = NewChildren>; @@ -162,8 +163,12 @@ const ButtonPropertyView = React.memo((props: { disabledPropertyView(props.children), hiddenPropertyView(props.children), loadingPropertyView(props.children), + props.children.tabIndex.propertyView({ label: trans("prop.tabIndex") }), ] - : props.children.form.getPropertyView()} + : [ + props.children.form.getPropertyView(), + props.children.tabIndex.propertyView({ label: trans("prop.tabIndex") }), + ]} )} @@ -222,6 +227,7 @@ const ButtonView = React.memo((props: ToViewReturn) => { (!isDefault(props.type) && getForm(editorState, props.form)?.disableSubmit()) } onClick={handleClick} + tabIndex={typeof props.tabIndex === 'number' ? props.tabIndex : undefined} > {props.prefixIcon && {props.prefixIcon}} { diff --git a/client/packages/lowcoder/src/comps/comps/buttonComp/buttonCompConstants.tsx b/client/packages/lowcoder/src/comps/comps/buttonComp/buttonCompConstants.tsx index b878eabbdb..a7c8e48dcd 100644 --- a/client/packages/lowcoder/src/comps/comps/buttonComp/buttonCompConstants.tsx +++ b/client/packages/lowcoder/src/comps/comps/buttonComp/buttonCompConstants.tsx @@ -11,27 +11,28 @@ export function getButtonStyle(buttonStyle: ButtonStyleType, disabledStyle: Disa const hoverColor = buttonStyle.background && genHoverColor(buttonStyle.background); const activeColor = buttonStyle.background && genActiveColor(buttonStyle.background); return css` - & { + &&& { border-radius: ${buttonStyle.radius}; border-width:${buttonStyle.borderWidth}; margin: ${buttonStyle.margin}; padding: ${buttonStyle.padding}; rotate: ${buttonStyle.rotation}; + --antd-wave-shadow-color: ${buttonStyle.border}; + border-color: ${buttonStyle.border}; + color: ${buttonStyle.text}; + font-size: ${buttonStyle.textSize}; + font-weight: ${buttonStyle.textWeight}; + font-family: ${buttonStyle.fontFamily}; + font-style: ${buttonStyle.fontStyle}; + text-transform:${buttonStyle.textTransform}; + text-decoration:${buttonStyle.textDecoration}; + border-radius: ${buttonStyle.radius}; + margin: ${buttonStyle.margin}; + padding: ${buttonStyle.padding}; + &:not(:disabled) { - --antd-wave-shadow-color: ${buttonStyle.border}; - border-color: ${buttonStyle.border}; - color: ${buttonStyle.text}; - font-size: ${buttonStyle.textSize}; - font-weight: ${buttonStyle.textWeight}; - font-family: ${buttonStyle.fontFamily}; - font-style: ${buttonStyle.fontStyle}; - text-transform:${buttonStyle.textTransform}; - text-decoration:${buttonStyle.textDecoration}; background: ${buttonStyle.background}; - border-radius: ${buttonStyle.radius}; - margin: ${buttonStyle.margin}; - padding: ${buttonStyle.padding}; - + &:hover, &:focus { color: ${buttonStyle.text}; @@ -48,13 +49,13 @@ export function getButtonStyle(buttonStyle: ButtonStyleType, disabledStyle: Disa : buttonStyle.border} !important; } } - - /* Disabled state styling */ &:disabled, - &.ant-btn-disabled { - color: ${disabledStyle.disabledText}; - background: ${disabledStyle.disabledBackground}; - cursor: not-allowed; + &.ant-btn-disabled, + &[disabled] { + background: ${disabledStyle.disabledBackground} !important; + cursor: not-allowed !important; + color: ${disabledStyle.disabledText || buttonStyle.text} !important; + border-color: ${disabledStyle.disabledBorder || buttonStyle.border} !important; } } `; diff --git a/client/packages/lowcoder/src/comps/comps/buttonComp/linkComp.tsx b/client/packages/lowcoder/src/comps/comps/buttonComp/linkComp.tsx index 43cb5959a3..b96fe77ea2 100644 --- a/client/packages/lowcoder/src/comps/comps/buttonComp/linkComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/buttonComp/linkComp.tsx @@ -1,6 +1,6 @@ import { default as Button } from "antd/es/button"; import { ButtonCompWrapper, buttonRefMethods } from "comps/comps/buttonComp/buttonCompConstants"; -import { BoolCodeControl, StringControl } from "comps/controls/codeControl"; +import { BoolCodeControl, StringControl, NumberControl } from "comps/controls/codeControl"; import { ButtonEventHandlerControl } from "comps/controls/eventHandlerControl"; import { styleControl } from "comps/controls/styleControl"; import { AnimationStyle, AnimationStyleType, LinkStyle, LinkStyleType } from "comps/controls/styleControlConstants"; @@ -91,6 +91,7 @@ const LinkTmpComp = (function () { prefixIcon: IconControl, suffixIcon: IconControl, viewRef: RefControl, + tabIndex: NumberControl, }; return new UICompBuilder(childrenMap, (props) => { // chrome86 bug: button children should not contain only empty span @@ -105,6 +106,7 @@ const LinkTmpComp = (function () { disabled={props.disabled} onClick={() => props.onEvent("click")} type={"link"} + tabIndex={typeof props.tabIndex === 'number' ? props.tabIndex : undefined} > {hasChildren && ( @@ -131,6 +133,7 @@ const LinkTmpComp = (function () { {hiddenPropertyView(children)} {loadingPropertyView(children)} {showDataLoadingIndicatorsPropertyView(children)} + {children.tabIndex.propertyView({ label: trans("prop.tabIndex") })}
{children.prefixIcon.propertyView({ label: trans("button.prefixIcon") })} diff --git a/client/packages/lowcoder/src/comps/comps/buttonComp/scannerComp.tsx b/client/packages/lowcoder/src/comps/comps/buttonComp/scannerComp.tsx index a4ecd85a6c..8900ea914c 100644 --- a/client/packages/lowcoder/src/comps/comps/buttonComp/scannerComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/buttonComp/scannerComp.tsx @@ -6,7 +6,7 @@ import { buttonRefMethods, ButtonStyleControl, } from "comps/comps/buttonComp/buttonCompConstants"; -import { BoolCodeControl, StringControl } from "comps/controls/codeControl"; +import { BoolCodeControl, StringControl, NumberControl } from "comps/controls/codeControl"; import { ScannerEventHandlerControl } from "comps/controls/eventHandlerControl"; import { withDefault } from "comps/generators"; import { UICompBuilder } from "comps/generators/uiCompBuilder"; @@ -30,7 +30,7 @@ import React, { useState, useContext, } from "react"; -import { arrayStringExposingStateControl } from "comps/controls/codeStateControl"; +import { arrayStringExposingStateControl, stringExposingStateControl } from "comps/controls/codeStateControl"; import { BoolControl } from "comps/controls/boolControl"; import { RefControl } from "comps/controls/refControl"; import { EditorContext } from "comps/editorState"; @@ -120,6 +120,7 @@ const BarcodeScannerComponent = React.lazy( const ScannerTmpComp = (function () { const childrenMap = { data: arrayStringExposingStateControl("data"), + value: stringExposingStateControl("value"), text: withDefault(StringControl, trans("scanner.text")), continuous: BoolControl, uniqueData: withDefault(BoolControl, true), @@ -128,6 +129,7 @@ const ScannerTmpComp = (function () { disabled: BoolCodeControl, style: ButtonStyleControl, viewRef: RefControl, + tabIndex: NumberControl, }; return new UICompBuilder(childrenMap, (props) => { const [showModal, setShowModal] = useState(false); @@ -149,17 +151,27 @@ const ScannerTmpComp = (function () { }, [success, showModal]); const continuousValue = useRef([]); + const seenSetRef = useRef>(new Set()); const handleUpdate = (err: any, result: any) => { if (result) { if (props.continuous) { - continuousValue.current = [...continuousValue.current, result.text]; + const scannedText: string = result.text; + if (props.uniqueData && seenSetRef.current.has(scannedText)) { + return; + } + continuousValue.current = [...continuousValue.current, scannedText]; + if (props.uniqueData) { + seenSetRef.current.add(scannedText); + } const val = props.uniqueData ? [...new Set(continuousValue.current)] : continuousValue.current; + props.value.onChange(scannedText); props.data.onChange(val); props.onEvent("success"); } else { + props.value.onChange(result.text); props.data.onChange([result.text]); setShowModal(false); setSuccess(true); @@ -199,10 +211,12 @@ const ScannerTmpComp = (function () { ref={props.viewRef} $buttonStyle={props.style} disabled={props.disabled} + tabIndex={typeof props.tabIndex === 'number' ? props.tabIndex : undefined} onClick={() => { props.onEvent("click"); setShowModal(true); continuousValue.current = []; + seenSetRef.current = new Set(); }} > {props.text} @@ -284,6 +298,7 @@ const ScannerTmpComp = (function () { {disabledPropertyView(children)} {hiddenPropertyView(children)} {showDataLoadingIndicatorsPropertyView(children)} + {children.tabIndex.propertyView({ label: trans("prop.tabIndex") })}
{children.continuous.propertyView({ @@ -314,6 +329,7 @@ const ScannerTmpComp = (function () { export const ScannerComp = withExposingConfigs(ScannerTmpComp, [ new NameConfig("data", trans("data")), + new NameConfig("value", trans("value")), new NameConfig("text", trans("button.textDesc")), ...CommonNameConfig, ]); diff --git a/client/packages/lowcoder/src/comps/comps/buttonComp/toggleButtonComp.tsx b/client/packages/lowcoder/src/comps/comps/buttonComp/toggleButtonComp.tsx index 654ec6659d..ce82c9ff51 100644 --- a/client/packages/lowcoder/src/comps/comps/buttonComp/toggleButtonComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/buttonComp/toggleButtonComp.tsx @@ -1,4 +1,4 @@ -import { BoolCodeControl, StringControl } from "comps/controls/codeControl"; +import { BoolCodeControl, StringControl, NumberControl } from "comps/controls/codeControl"; import { withDefault } from "comps/generators"; import { UICompBuilder } from "comps/generators/uiCompBuilder"; import { @@ -68,6 +68,7 @@ const ToggleTmpComp = (function () { showBorder: withDefault(BoolControl, true), viewRef: RefControl, tooltip: StringControl, + tabIndex: NumberControl, }; return new UICompBuilder(childrenMap, (props) => { const text = props.showText @@ -92,6 +93,7 @@ const ToggleTmpComp = (function () { props.onEvent("change"); props.value.onChange(!props.value.value); }} + tabIndex={typeof props.tabIndex === 'number' ? props.tabIndex : undefined} > {props.iconPosition === "right" && text} {{props.value.value ? props.trueIcon : props.falseIcon}} @@ -117,6 +119,7 @@ const ToggleTmpComp = (function () { {hiddenPropertyView(children)} {loadingPropertyView(children)} {showDataLoadingIndicatorsPropertyView(children)} + {children.tabIndex.propertyView({ label: trans("prop.tabIndex") })}
{children.showText.propertyView({ label: trans("toggleButton.showText") })} diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/ChatBoxContext.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/ChatBoxContext.tsx new file mode 100644 index 0000000000..67702f1e7f --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/ChatBoxContext.tsx @@ -0,0 +1,84 @@ +import { createContext, useContext } from "react"; +import type { ChatRoom, OnlineUser, PendingRoomInvite } from "./store"; +import type { + ChatBoxContainerStyleType, + ChatBoxSidebarStyleType, + ChatBoxHeaderStyleType, + ChatBoxMessageStyleType, + ChatBoxInputStyleType, + AnimationStyleType, +} from "comps/controls/styleControlConstants"; + +type ChatEventName = + | "messageSent" + | "startTyping" + | "stopTyping" + | "roomSwitch" + | "roomLeave" + | "roomCreate" + | "inviteSend" + | "inviteAccept" + | "inviteDecline"; + +interface ExposedState { + value: string; + onChange: (v: string) => void; +} + +export interface ChatBoxContextValue { + // Data + messages: any[]; + rooms: ChatRoom[]; + currentRoomId: string; + currentRoom: ChatRoom | null; + currentUserId: string; + currentUserName: string; + typingUsers: any[]; + onlineUsers: OnlineUser[]; + pendingInvites: PendingRoomInvite[]; + isAiThinking: boolean; + + // Exposed state + chatTitle: ExposedState; + messageText: ExposedState; + lastSentMessageText: ExposedState; + + // UI config + showHeader: boolean; + showRoomsPanel: boolean; + roomsPanelWidth: string; + allowRoomCreation: boolean; + allowRoomSearch: boolean; + style: ChatBoxContainerStyleType; + animationStyle: AnimationStyleType; + sidebarStyle: ChatBoxSidebarStyleType; + headerStyle: ChatBoxHeaderStyleType; + messageStyle: ChatBoxMessageStyleType; + inputStyle: ChatBoxInputStyleType; + + // Events + onEvent: (event: ChatEventName) => any; + + // Room actions + onRoomSwitch: (roomId: string) => void; + onRoomLeave: (roomId: string) => void; + onRoomCreate: ( + name: string, + type: "public" | "private" | "llm", + description?: string, + llmQueryName?: string, + ) => void; + onInviteSend: (toUserId: string) => void; + onInviteAccept: (inviteId: string) => void; + onInviteDecline: (inviteId: string) => void; +} + +export const ChatBoxContext = createContext(null); + +export function useChatBox(): ChatBoxContextValue { + const ctx = useContext(ChatBoxContext); + if (!ctx) { + throw new Error("useChatBox must be used within a ChatBoxProvider"); + } + return ctx; +} diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/README.md b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/README.md deleted file mode 100644 index 9353f42708..0000000000 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/README.md +++ /dev/null @@ -1,992 +0,0 @@ -# ChatBoxComponent - Developer Guide - -**A comprehensive real-time chat component for Lowcoder with local and collaborative modes** - ---- - -## 📋 **Table of Contents** - -1. [Project Status & Architecture](#project-status--architecture) -2. [Component Structure](#component-structure) -3. [Features Implemented](#features-implemented) -4. [Development Setup](#development-setup) -5. [Testing & Debugging](#testing--debugging) -6. [Architecture Deep Dive](#architecture-deep-dive) -7. [API Reference](#api-reference) -8. [Future Enhancements](#future-enhancements) -9. [Known Issues & Limitations](#known-issues--limitations) -10. [Contributing Guidelines](#contributing-guidelines) - ---- - -## 🎯 **Project Status & Architecture** - -### **Current Status: ✅ PRODUCTION READY** - -The ChatBoxComponent is **fully functional** with real-time synchronization, local persistence, and dynamic room management. All major features are implemented and tested. - -### **High-Level Architecture** - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ ChatBoxComponent (React) │ -│ ┌─────────────────────────┐ │ -│ │ useChatManager │ │ -│ │ (React Hook) │ │ -│ └─────────────────────────┘ │ -└─────────────────────────┬───────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ HybridChatManager │ -│ (Provider Coordination Layer) │ -│ ┌─────────────────────┐ ┌─────────────────────────────────┐ │ -│ │ ALASqlProvider │ │ YjsPluvProvider │ │ -│ │ (Local Storage) │ │ (Real-time Collaboration) │ │ -│ │ │ │ │ │ -│ │ • SQLite-like DB │ │ • Yjs CRDT Documents │ │ -│ │ • Local Persistence │ │ • WebSocket Synchronization │ │ -│ │ • Cross-tab Sharing │ │ • Real-time Presence │ │ -│ └─────────────────────┘ │ • Typing Indicators │ │ -│ └─────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### **🔧 Recent Major Fixes** - -**Critical Issues Resolved:** -- **✅ WebSocket Server**: Fixed import issues with `y-websocket` server utilities -- **✅ Connection Management**: Proper lifecycle handling with status monitoring -- **✅ Memory Management**: Reference counting for shared Yjs documents -- **✅ Observer Cleanup**: Fixed memory leaks in event subscription handling -- **✅ Cross-browser Sync**: Real-time synchronization across multiple browsers - ---- - -## 📝 **Component Structure** - -### **File Organization** - -``` -chatBoxComponent/ -├── README.md # This file - comprehensive developer guide -├── index.ts # Main module exports -├── chatBoxComp.tsx # React component implementation -├── yjs-websocket-server.js # WebSocket server for real-time sync -├── yjs-websocket-server.cjs # CommonJS version of server -│ -├── hooks/ -│ └── useChatManager.ts # Main React hook for chat functionality -│ -├── managers/ -│ └── HybridChatManager.ts # Orchestrates local/collaborative providers -│ -├── providers/ -│ ├── ChatDataProvider.ts # Abstract interface + base implementation -│ ├── ALASqlProvider.ts # Local storage with SQLite-like features -│ └── YjsPluvProvider.ts # Real-time collaboration with Yjs + WebSocket -│ -├── types/ -│ └── chatDataTypes.ts # TypeScript definitions and utilities -│ -└── server/ # (Optional) Advanced server configurations -``` - -### **Component Hierarchy** - -``` -ChatBoxComp (Main React Component) - │ - ├── useChatManager (Hook) - │ │ - │ └── HybridChatManager (Manager) - │ │ - │ ├── ALASqlProvider (Local) - │ └── YjsPluvProvider (Collaborative) - │ - ├── Chat UI Components - │ ├── Message List - │ ├── Input Area - │ ├── Room Sidebar - │ └── User Management - │ - └── WebSocket Server (External) - └── yjs-websocket-server.js -``` - ---- - -## ✅ **Features Implemented** - -### **Core Chat Features** -- ✅ **Message Exchange**: Send/receive text messages in real-time -- ✅ **User Management**: Multiple users with unique IDs and display names -- ✅ **Typing Indicators**: Live typing status across users -- ✅ **Message Persistence**: Messages survive page refreshes and app restarts -- ✅ **Cross-tab Synchronization**: Real-time sync between browser tabs - -### **Room Management System** -- ✅ **Dynamic Room Creation**: Users can create public/private rooms -- ✅ **Room Discovery**: Browse and join available rooms -- ✅ **Room Switching**: Seamlessly move between different chat rooms -- ✅ **Participant Tracking**: Live participant counts and user lists -- ✅ **Permission System**: Configurable room access controls - -### **Storage & Synchronization** -- ✅ **Local Mode**: ALASql-based local persistence (works offline) -- ✅ **Collaborative Mode**: Yjs CRDT + WebSocket real-time sync -- ✅ **Hybrid Mode**: Automatic fallback between collaborative and local -- ✅ **Cross-device Sync**: Real-time synchronization across devices/browsers - -### **Developer Experience** -- ✅ **Provider Architecture**: Clean abstraction for different storage backends -- ✅ **TypeScript Support**: Comprehensive type definitions -- ✅ **Error Handling**: Graceful degradation and error recovery -- ✅ **Debugging Tools**: Extensive console logging for troubleshooting -- ✅ **Memory Management**: Proper cleanup and resource management - ---- - -## 🚀 **Development Setup** - -### **Prerequisites** - -```bash -# Required software -Node.js >= 16.0.0 -Yarn >= 1.22.0 (preferred over npm) -Lowcoder development environment -``` - -### **Installation & Setup** - -```bash -# 1. Navigate to the Lowcoder client directory -cd client/packages/lowcoder - -# 2. Install dependencies (if not already done) -yarn install - -# 3. Verify chatBoxComponent is integrated -# Check that the component is registered in: -# - src/comps/uiCompRegistry.ts -# - src/comps/index.tsx -``` - -### **Starting Development** - -```bash -# Terminal 1: Start the main development server -cd client/packages/lowcoder -yarn start - -# Terminal 2: Start WebSocket server for real-time features -cd client/packages/lowcoder -node yjs-websocket-server.js # ES modules (recommended) -# OR -node yjs-websocket-server.cjs # CommonJS (fallback) -``` - -### **Component Integration** - -The ChatBoxComponent is already integrated into Lowcoder. To use it: - -1. **In Lowcoder Editor**: Drag "ChatBox" component from the component panel -2. **Configure Properties**: - - **Mode**: `local`, `collaborative`, or `hybrid` - - **User ID**: Unique identifier for the user - - **User Name**: Display name for the user - - **Room ID**: Chat room identifier - - **Server URL**: WebSocket server URL (for collaborative mode) - ---- - -## 🧪 **Testing & Debugging** - -### **🚀 Quick Testing Guide** - -#### **Step 1: Start the WebSocket Server** - -Choose either ES modules (.js) or CommonJS (.cjs) version: - -```bash -# Method 1: ES modules (recommended) -cd client/packages/lowcoder -node yjs-websocket-server.js - -# Method 2: CommonJS (alternative) -cd client/packages/lowcoder -node yjs-websocket-server.cjs -``` - -**Expected Output:** -``` -🚀 Starting Yjs WebSocket Server... -📡 Server will run on: ws://localhost:3001 -🔌 WebSocket server created -✅ Yjs WebSocket Server is running! -📡 WebSocket endpoint: ws://localhost:3001 -🏥 Health check: http://localhost:3001/health -``` - -#### **🔥 Step 2: Test Real-time Multi-Browser Synchronization** - -1. **First Browser Tab/Window:** - ``` - - Add ChatBox component - - Set Mode: "Collaborative (Real-time)" - - Set User ID: "alice_123" - - Set User Name: "Alice" - - Set Room ID: "test_room" - ``` - -2. **Second Browser Tab/Window (or different browser):** - ``` - - Add ChatBox component - - Set Mode: "Collaborative (Real-time)" - - Set User ID: "bob_456" - - Set User Name: "Bob" - - Set Room ID: "test_room" (SAME!) - ``` - -3. **Send Messages:** - ``` - - Alice sends: "Hello from Alice!" - - Bob sends: "Hi Alice, this is Bob!" - - Messages should appear INSTANTLY in both browsers - ``` - -#### **✅ Expected Console Logs (Success Indicators):** - -**YjsPluvProvider Logs:** -``` -[YjsPluvProvider] 🚀 CONNECT called with config: {mode: "collaborative", ...} -[YjsPluvProvider] 📄 Creating new Y.Doc for room: test_room -[YjsPluvProvider] 🔗 Creating WebSocket connection... -[YjsPluvProvider] 📡 URL: ws://localhost:3001 -[YjsPluvProvider] 🏠 Room: test_room -[YjsPluvProvider] ✅ Created new Y.Doc and WebSocket provider -[YjsPluvProvider] 📡 WebSocket status changed: connected -[YjsPluvProvider] ✅ WebSocket connected - real-time sync enabled! -[YjsPluvProvider] 🔄 Document sync status: synced -``` - -**Message Synchronization Logs:** -``` -[YjsPluvProvider] 📤 SENDING MESSAGE: -[YjsPluvProvider] 💬 Text: Hello from Alice! -[YjsPluvProvider] 👤 Author: Alice (alice_123) -[YjsPluvProvider] 🏠 Room: test_room -[YjsPluvProvider] ✅ MESSAGE STORED in Yjs map -[YjsPluvProvider] 🔔 MESSAGES MAP CHANGED! -[YjsPluvProvider] 🆕 NEW MESSAGE DETECTED: -[YjsPluvProvider] ✅ Notified subscribers for room: test_room -``` - -#### **🧪 Advanced Testing Scenarios** - -**Test 1: Multiple Devices/Browsers** -1. Open the app in **Chrome, Firefox, and Safari** -2. Use the **same Room ID** in all browsers -3. Send messages from each browser -4. Verify **instant synchronization** across ALL browsers - -**Test 2: Network Resilience** -1. **Disconnect WiFi** while chatting -2. **Send messages** (should queue locally) -3. **Reconnect WiFi** -4. Verify **messages sync** when connection restored - -**Test 3: Server Restart** -1. **Stop WebSocket server** (Ctrl+C) -2. **Send messages** (should work locally) -3. **Restart server** (`node yjs-websocket-server.js`) -4. Verify **automatic reconnection** and sync - -**Test 4: Multiple Rooms** -1. Open **4 browser tabs** -2. Tabs 1-2 use Room ID: "room_alpha" -3. Tabs 3-4 use Room ID: "room_beta" -4. Send messages in both rooms -5. Verify **room isolation** (messages only sync within same room) - -### **🔍 Debug Mode & Logging** - -```javascript -// Enable detailed logging by checking browser console -// All operations are logged with prefixes: - -// 🟢 YjsPluvProvider logs -[YjsPluvProvider] 🚀 CONNECT called... -[YjsPluvProvider] 📄 Creating new Y.Doc for room... -[YjsPluvProvider] 🔗 Creating WebSocket connection... -[YjsPluvProvider] ✅ WebSocket connected... - -// 🟡 HybridChatManager logs -[HybridChatManager] 🏠 Creating room from request... -[HybridChatManager] 🔍 Getting available rooms... -[HybridChatManager] 🚪 User joining room... - -// 🔵 ALASqlProvider logs (fallback) -[ALASqlProvider] 📦 Local storage operations... -``` - -### **🔧 Development Commands** - -```bash -# Navigate to working directory -cd client/packages/lowcoder - -# Start WebSocket server (choose one) -node yjs-websocket-server.js # ES modules -node yjs-websocket-server.cjs # CommonJS - -# Start development server (in separate terminal) -yarn start - -# Build for production -yarn build - -# Health check WebSocket server -curl http://localhost:3001/health -``` - -### **🐛 Common Issues & Solutions** - -**Problem: "Failed to setup Yjs connection"** -- **Solution**: Ensure WebSocket server is running on port 3001 -- Check firewall/antivirus settings -- Try using `.cjs` version if `.js` fails - -**Problem: Messages not syncing between tabs** -- **Solution**: Verify both tabs use **exactly the same Room ID** -- Check browser console for connection logs -- Ensure mode is set to "Collaborative" -- Restart WebSocket server - -**Problem: WebSocket connection fails** -- **Solution**: Check if port 3001 is available -- Try different port: `PORT=3002 node yjs-websocket-server.js` -- Update serverUrl in chat config to match - -**Problem: Import/Export errors** -- **Solution**: Ensure `y-websocket` package is installed: `yarn add y-websocket` -- Check Node.js version (requires Node 16+) -- Try deleting `node_modules` and running `yarn install` - -### **🚑 Health Checks** - -```bash -# Check WebSocket server health -curl http://localhost:3001/health - -# Expected response: -{ - "status": "healthy", - "uptime": "00:05:32", - "connections": 2, - "rooms": ["test_room", "general"] -} -``` - ---- - -## 🏗 **Architecture Deep Dive** - -### **🔄 Data Flow Architecture** - -#### **Real-time Synchronization Flow** -``` -┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ -│ Browser A │───▶│ WebSocket Server│◀───│ Browser B │ -│ YjsProvider │ │ (Port 3001) │ │ YjsProvider │ -└─────────────────┘ └─────────────────┘ └─────────────────┘ - │ │ │ - ▼ ▼ ▼ -┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ -│ Yjs Y.Doc (A) │───▶│ Shared Y.Doc │◀───│ Yjs Y.Doc (B) │ -│ • messages │ │ • messages │ │ • messages │ -│ • rooms │ │ • rooms │ │ • rooms │ -│ • presence │ │ • presence │ │ • presence │ -└─────────────────┘ └─────────────────┘ └─────────────────┘ - │ │ - ▼ ▼ -┌─────────────────┐ ┌─────────────────┐ -│ ChatBox UI (A) │ │ ChatBox UI (B) │ -│ (React) │ │ (React) │ -└─────────────────┘ └─────────────────┘ -``` - -#### **Provider Switching Logic** -``` -HybridChatManager Decision Tree: - -┌──────────────────────────┐ -│ Component Initialization │ -└────────────┬─────────────┘ - │ - ▼ -┌──────────────────────────┐ -│ Mode == 'collaborative'? │ -└───────┬─────────────────┘ - │ YES │ NO - ▼ ▼ -┌──────────────┐ ┌────────────────┐ -│ Try YjsProvider │ │ Use ALASqlProvider │ -└───────┬───────┘ └────────────────┘ - │ - ▼ -┌──────────────────────────┐ -│ WebSocket connection OK? │ -└───────┬─────────────────┘ - │ FAIL │ SUCCESS - ▼ ▼ -┌────────────────┐ ┌──────────────────────────┐ -│ Fallback to │ │ Use YjsProvider │ -│ ALASqlProvider │ │ (Real-time mode) │ -└────────────────┘ └──────────────────────────┘ -``` - -### **📦 Data Models** - -#### **UnifiedMessage Interface** -```typescript -interface UnifiedMessage { - // Core identification - id: string; // Unique message ID - text: string; // Message content - timestamp: number; // Unix timestamp - - // User information - authorId: string; // User ID who sent message - authorName: string; // Display name of user - - // Room association - roomId: string; // Which room this message belongs to - - // Status tracking - status: 'sending' | 'sent' | 'failed' | 'synced'; - messageType: 'text' | 'file' | 'system' | 'action'; - - // Real-time collaboration metadata - yjsId?: string; // Yjs document reference - version?: number; // Conflict resolution version - metadata?: Record; // Extensible metadata -} -``` - -#### **UnifiedRoom Interface** -```typescript -interface UnifiedRoom { - // Core identification - id: string; // Unique room ID - name: string; // Display name - type: 'private' | 'public' | 'group'; - - // Participant management - participants: string[]; // Array of user IDs - admins: string[]; // Array of admin user IDs - creator: string; // User ID who created room - - // Room state - isActive: boolean; // Whether room is active - lastActivity: number; // Last message timestamp - createdAt: number; // Room creation time - updatedAt: number; // Last room update - - // Optional settings - description?: string; // Room description - maxParticipants?: number; // Participant limit -} -``` - -### **⚙️ Provider Architecture** - -#### **ChatDataProvider Interface** -The `ChatDataProvider` interface ensures consistent API across different storage backends: - -```typescript -interface ChatDataProvider { - // Connection management - connect(config: ConnectionConfig): Promise>; - disconnect(): Promise>; - getConnectionState(): ConnectionState; - isConnected(): boolean; - - // Core operations - sendMessage(message: Omit): Promise>; - getMessages(roomId: string, limit?: number): Promise>; - createRoom(room: Omit): Promise>; - getRooms(userId?: string): Promise>; - - // Real-time subscriptions - subscribeToRoom(roomId: string, callback: (event: ChatEvent) => void): UnsubscribeFunction; - subscribeToPresence(roomId: string, callback: (users: UserPresence[]) => void): UnsubscribeFunction; - subscribeToTyping(roomId: string, callback: (typingUsers: TypingState[]) => void): UnsubscribeFunction; -} -``` - -#### **Provider Implementations** - -**ALASqlProvider (Local Storage)** -- **Purpose**: Offline-capable local storage using SQLite-like syntax -- **Features**: Persistence across browser sessions, cross-tab synchronization -- **Best for**: Offline mode, local development, fallback when network fails -- **Storage**: Browser IndexedDB via ALASql - -**YjsPluvProvider (Real-time Collaboration)** -- **Purpose**: Real-time multi-user synchronization using Yjs CRDTs -- **Features**: Conflict-free merge resolution, real-time presence, typing indicators -- **Best for**: Multi-user collaboration, real-time sync across devices -- **Storage**: In-memory with WebSocket server persistence - -### **🚀 Performance Optimizations** - -#### **Memory Management** -- **Reference Counting**: Shared Yjs documents with automatic cleanup when unused -- **Observer Cleanup**: Proper event listener removal prevents memory leaks -- **Connection Pooling**: Reuse WebSocket connections across component instances -- **Subscription Tracking**: Automatic cleanup of event subscriptions on unmount - -#### **Network Optimizations** -- **Connection Persistence**: WebSocket connections survive component re-renders -- **Automatic Reconnection**: Smart retry logic with exponential backoff -- **Fallback Handling**: Seamless switch to local mode when server unavailable -- **Batch Operations**: Minimize WebSocket message frequency - -#### **UI Performance** -- **Message Virtualization**: Efficient rendering of large message lists -- **Optimistic Updates**: Immediate UI updates with server reconciliation -- **Debounced Typing**: Reduce typing indicator network traffic -- **State Normalization**: Efficient React re-rendering patterns - ---- - -## 📚 **API Reference** - -### **useChatManager Hook** - -Main React hook for chat functionality: - -```typescript -const { - // Connection state - isConnected, - connectionState, - - // Core chat operations - sendMessage, - messages, - - // Room management - currentRoom, - joinedRooms, - createRoom, - joinRoom, - leaveRoom, - - // Real-time features - onlineUsers, - typingUsers, - startTyping, - stopTyping, - - // Lifecycle - connect, - disconnect -} = useChatManager({ - userId: 'user_123', - userName: 'John Doe', - applicationId: 'my_app', - roomId: 'general', - mode: 'collaborative', // 'local' | 'collaborative' | 'hybrid' - autoConnect: true -}); -``` - -### **Component Properties** - -```typescript -interface ChatBoxProps { - // User configuration - userId: string; // Unique user identifier - userName: string; // Display name for user - applicationId: string; // App identifier for data isolation - - // Room configuration - roomId: string; // Initial room to join - mode: 'local' | 'collaborative' | 'hybrid'; - - // Server configuration (for collaborative mode) - serverUrl?: string; // WebSocket server URL - - // UI configuration - autoHeight: boolean; // Adjust height automatically - showTypingIndicators: boolean; // Display typing indicators - showOnlineUsers: boolean; // Display online user list - - // Room management - allowRoomCreation: boolean; // Enable room creation - allowRoomJoining: boolean; // Enable room joining - showAvailableRooms: boolean; // Show room browser - maxRoomsDisplay: number; // Limit room list size - - // Event handlers - onEvent: (event: EventType) => void; -} -``` - -### **Error Handling** - -```typescript -// All operations return OperationResult -interface OperationResult { - success: boolean; - data?: T; - error?: string; - timestamp: number; -} - -// Usage example -const result = await sendMessage('Hello world!'); -if (!result.success) { - console.error('Failed to send message:', result.error); - // Handle error appropriately -} -``` - -### **Usage Examples** - -#### **Basic Local Chat** -```typescript -// Simple local chat setup - -``` - -#### **Real-time Collaborative Chat** -```typescript -// Real-time collaborative chat with room management - -``` - -#### **Hybrid Mode with Fallback** -```typescript -// Hybrid mode - tries collaborative, falls back to local - -``` - ---- - -## 🔮 **Future Enhancements** - -### **Planned Features** - -#### **Short-term (Next Sprint)** -- 🔴 **File Attachments**: Support for images, documents, and media -- 🔴 **Message Reactions**: Emoji reactions and message threading -- 🔴 **Message Search**: Full-text search across message history -- 🔴 **User Mentions**: @mention functionality with notifications - -#### **Medium-term (Next Quarter)** -- 🟡 **Voice Messages**: Audio recording and playback -- 🟡 **Video Chat Integration**: WebRTC peer-to-peer video calls -- 🟡 **Message Encryption**: End-to-end encryption for private rooms -- 🟡 **Push Notifications**: Browser notifications for new messages - -#### **Long-term (Future Releases)** -- 🟢 **AI Integration**: Smart suggestions and chatbot support -- 🟢 **Advanced Moderation**: Automated content filtering and user moderation -- 🟢 **Analytics Dashboard**: Usage metrics and chat analytics -- 🟢 **Mobile SDK**: React Native component for mobile apps - -### **Technical Debt & Improvements** - -#### **Performance** -- **Message Pagination**: Implement virtual scrolling for large chat histories -- **Image Optimization**: Automatic image compression and lazy loading -- **Bundle Optimization**: Reduce component bundle size with code splitting - -#### **Developer Experience** -- **Storybook Integration**: Interactive component documentation -- **Unit Test Coverage**: Increase test coverage to 90%+ -- **E2E Testing**: Automated browser testing for multi-user scenarios -- **Performance Monitoring**: Real-time performance metrics and alerting - ---- - -## ⚠️ **Known Issues & Limitations** - -### **Current Limitations** - -1. **File Attachments**: Not yet implemented - text messages only -2. **Message History**: Limited to 1000 messages per room (configurable) -3. **User Presence**: Basic online/offline - no rich presence status -4. **Mobile Support**: Optimized for desktop, mobile experience needs improvement -5. **Scalability**: WebSocket server not production-ready (single instance) - -### **Browser Compatibility** - -| Browser | Status | Notes | -|---------|--------| ----- | -| Chrome 90+ | ✅ Full Support | Recommended browser | -| Firefox 85+ | ✅ Full Support | All features working | -| Safari 14+ | ✅ Full Support | WebSocket limitations on iOS | -| Edge 90+ | ✅ Full Support | Chromium-based versions | -| IE 11 | ❌ Not Supported | Missing WebSocket and ES6 features | - -### **Production Considerations** - -1. **WebSocket Server**: Current server is for development only - - **Solution**: Deploy production-grade WebSocket infrastructure - - **Alternatives**: Consider Socket.io, Pusher, or Ably for production - -2. **Data Persistence**: Yjs server doesn't persist data between restarts - - **Solution**: Implement Redis or database backend for persistence - -3. **Authentication**: No built-in authentication mechanism - - **Solution**: Integrate with your app's authentication system - -4. **Rate Limiting**: No protection against message spam - - **Solution**: Implement server-side rate limiting - ---- - -## 🤝 **Contributing Guidelines** - -### **Development Workflow** - -```bash -# 1. Create feature branch -git checkout -b feature/message-reactions - -# 2. Make incremental changes -# - Follow small, testable implementations -# - Update types in chatDataTypes.ts first -# - Implement in providers -# - Update HybridChatManager -# - Add UI components -# - Update tests - -# 3. Test thoroughly -yarn test -yarn build - -# 4. Test real-time features -node yjs-websocket-server.js -# Test in multiple browsers - -# 5. Update documentation -# - Update this README.md -# - Add inline code comments -# - Update API documentation -``` - -### **Code Standards** - -#### **TypeScript Guidelines** -- **Strict Types**: Always use proper TypeScript types, avoid `any` -- **Interface First**: Define interfaces before implementation -- **Error Handling**: Use `OperationResult` for all async operations -- **Null Safety**: Handle null/undefined cases explicitly - -#### **React Best Practices** -- **Hooks**: Prefer hooks over class components -- **Memoization**: Use `useMemo`/`useCallback` for expensive operations -- **State Management**: Keep state as local as possible -- **Error Boundaries**: Implement error boundaries for chat components - -#### **Testing Requirements** -- **Unit Tests**: Test individual functions and providers -- **Integration Tests**: Test provider interactions and data flow -- **E2E Tests**: Test real-time synchronization across browsers -- **Performance Tests**: Measure memory usage and WebSocket efficiency - -### **Architecture Decisions** - -#### **Provider Pattern** -The provider pattern allows easy switching between storage backends: -- **Benefits**: Clean abstraction, testability, extensibility -- **Trade-offs**: Additional complexity, potential over-engineering -- **Alternatives**: Direct implementation without abstraction - -#### **Yjs for Real-time Sync** -Yjs provides conflict-free replicated data types (CRDTs): -- **Benefits**: Automatic conflict resolution, offline support, mature library -- **Trade-offs**: Learning curve, bundle size, WebSocket dependency -- **Alternatives**: Socket.io with manual conflict resolution, OT algorithms - -#### **Hybrid Manager Approach** -HybridChatManager coordinates multiple providers: -- **Benefits**: Graceful degradation, mode switching, unified API -- **Trade-offs**: Additional complexity, potential sync issues -- **Alternatives**: Single provider with mode configuration - -### **Performance Guidelines** - -1. **Memory Management**: Always clean up subscriptions and observers -2. **Network Efficiency**: Batch operations when possible -3. **UI Responsiveness**: Use virtualization for large message lists -4. **Error Recovery**: Implement reconnection and retry logic - -### **How Things Are Connected** - -#### **Data Flow Overview** -``` -User Action (Send Message) - ↓ -ChatBoxComponent (React UI) - ↓ -useChatManager Hook - ↓ -HybridChatManager - ↓ -Active Provider (ALASql OR YjsPluvProvider) - ↓ -Storage Layer (IndexedDB OR WebSocket + Yjs) - ↓ -Real-time Updates - ↓ -Observer Callbacks - ↓ -React State Updates - ↓ -UI Re-render with New Message -``` - -#### **Component Integration Points** -1. **Component Registration**: `src/comps/uiCompRegistry.ts` and `src/comps/index.tsx` -2. **Event System**: Uses Lowcoder's event handling system for user interactions -3. **Styling**: Integrates with Lowcoder's design system and theming -4. **State Management**: Uses React hooks with proper cleanup - -#### **Real-time Synchronization Chain** -1. **Message Sent**: User types and sends message -2. **Local Update**: Immediate UI update (optimistic) -3. **Provider Storage**: Message stored in active provider -4. **WebSocket Broadcast**: If collaborative mode, sent to server -5. **Remote Updates**: Other clients receive via WebSocket -6. **Yjs Integration**: CRDT merge resolution if conflicts -7. **Observer Triggers**: Yjs observers fire for remote changes -8. **State Sync**: React state updated with remote messages -9. **UI Update**: New messages appear in other browsers - ---- - -## 🎉 **Success Metrics - ACHIEVED ✅** - -- [x] **Real-time synchronization** across multiple browsers/devices -- [x] **WebSocket connection** with robust error handling -- [x] **Message persistence** with local and collaborative storage -- [x] **Room management** with dynamic creation and joining -- [x] **Typing indicators** and user presence tracking -- [x] **Provider architecture** with clean abstraction layers -- [x] **Memory management** with proper cleanup and reference counting -- [x] **Cross-browser compatibility** tested on major browsers -- [x] **Developer experience** with comprehensive TypeScript support -- [x] **Production readiness** with error handling and fallback mechanisms - ---- - -## 📖 **What's Done and What's Remaining** - -### **✅ COMPLETED FEATURES** - -#### **Core Architecture (100% Complete)** -- **Provider Pattern**: Clean abstraction layer for different storage backends -- **HybridChatManager**: Intelligent provider coordination and fallback -- **TypeScript Integration**: Full type safety and interface definitions -- **Error Handling**: Comprehensive error recovery and user feedback - -#### **Local Storage (100% Complete)** -- **ALASqlProvider**: SQLite-like local persistence -- **Cross-tab Sync**: Shared data between browser tabs -- **Offline Support**: Works without network connection -- **Data Persistence**: Survives browser restarts - -#### **Real-time Collaboration (100% Complete)** -- **YjsPluvProvider**: CRDT-based real-time synchronization -- **WebSocket Server**: Functional server for development -- **Multi-browser Sync**: Real-time updates across devices -- **Presence System**: User online status and typing indicators -- **Memory Management**: Proper cleanup and reference counting - -#### **Room Management (100% Complete)** -- **Dynamic Room Creation**: Users can create new rooms -- **Room Discovery**: Browse and join available rooms -- **Permission System**: Configurable access controls -- **Participant Tracking**: Live user counts and lists - -#### **UI Components (100% Complete)** -- **Message Interface**: Clean, responsive chat UI -- **Room Sidebar**: Room navigation and management -- **Typing Indicators**: Live typing status display -- **User Management**: Online user lists and presence - -### **🔄 WHAT'S REMAINING (Future Enhancements)** - -#### **Feature Enhancements (Not Critical)** -- **File Attachments**: Image and document sharing -- **Message Reactions**: Emoji reactions and threading -- **Voice Messages**: Audio recording capabilities -- **Video Integration**: WebRTC video calling -- **Message Search**: Full-text search functionality - -#### **Production Hardening (Environment-Specific)** -- **Production WebSocket Server**: Scalable server infrastructure -- **Authentication Integration**: Connect to existing auth systems -- **Rate Limiting**: Anti-spam protection -- **Data Persistence**: Server-side message storage -- **Performance Monitoring**: Real-time metrics and alerting - -#### **Developer Tools (Nice-to-Have)** -- **Storybook Documentation**: Interactive component docs -- **Automated Testing**: Comprehensive test suite -- **Performance Profiling**: Memory and network monitoring -- **Mobile Optimization**: Enhanced mobile experience - -### **🎯 CURRENT STATUS: PRODUCTION READY** - -The ChatBoxComponent is **fully functional and ready for production use** in Lowcoder applications. All core features are implemented and tested: - -- ✅ **Real-time messaging** works across multiple browsers -- ✅ **Local persistence** maintains data integrity -- ✅ **Room management** provides full multi-room support -- ✅ **Error handling** ensures graceful degradation -- ✅ **Developer experience** includes comprehensive documentation - -The remaining items are **enhancements** rather than requirements, making this component suitable for immediate integration into production Lowcoder environments. - ---- - -**Status**: ✅ **PRODUCTION READY** - -The ChatBoxComponent provides a complete real-time chat solution with local persistence, collaborative synchronization, dynamic room management, and comprehensive developer tooling. Ready for integration into production Lowcoder applications. diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/chatBoxComp.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/chatBoxComp.tsx index 013f545577..0fa68d56cc 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/chatBoxComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/chatBoxComp.tsx @@ -1,1449 +1,358 @@ -import { ScrollBar, Section, sectionNames } from "lowcoder-design"; -import styled, { css } from "styled-components"; -import { UICompBuilder } from "../../generators"; -import { NameConfig, NameConfigHidden, withExposingConfigs } from "../../generators/withExposing"; -import { withMethodExposing } from "../../generators/withMethodExposing"; -import { TextStyle, TextStyleType, AnimationStyle, AnimationStyleType } from "comps/controls/styleControlConstants"; +import React, { useContext } from "react"; +import { Section, sectionNames } from "lowcoder-design"; +import { UICompBuilder, withDefault } from "../../generators"; +import { + NameConfig, + NameConfigHidden, + withExposingConfigs, +} from "../../generators/withExposing"; +import { stringExposingStateControl } from "comps/controls/codeStateControl"; +import { BoolControl } from "comps/controls/boolControl"; +import { StringControl, jsonArrayControl } from "comps/controls/codeControl"; +import { AutoHeightControl } from "comps/controls/autoHeightControl"; +import { eventHandlerControl } from "comps/controls/eventHandlerControl"; +import { styleControl } from "comps/controls/styleControl"; +import { + AnimationStyle, + ChatBoxContainerStyle, + ChatBoxSidebarStyle, + ChatBoxHeaderStyle, + ChatBoxMessageStyle, + ChatBoxInputStyle, +} from "comps/controls/styleControlConstants"; import { hiddenPropertyView } from "comps/utils/propertyUtils"; -import React, { useContext, useEffect, useRef, useMemo, useState } from "react"; import { EditorContext } from "comps/editorState"; -import { ToViewReturn } from "../../generators/multi"; -import { useCompClickEventHandler } from "../../utils/useCompClickEventHandler"; -import { Button, Input, Modal, Form, Radio, Space, Typography, Divider, Badge, Tooltip, Popconfirm } from "antd"; -import { PlusOutlined, SearchOutlined, GlobalOutlined, LockOutlined, UserOutlined, CheckCircleOutlined, LogoutOutlined } from "@ant-design/icons"; -import { useChatManager } from "./hooks/useChatManager"; -import { UnifiedMessage } from "./types/chatDataTypes"; -import { chatCompChildrenMap, ChatCompChildrenType } from "./chatUtils"; - -// // Event options for the chat component -// const EventOptions = [clickEvent, doubleClickEvent] as const; +import { trans } from "i18n"; -// Chat component styling -const ChatContainer = styled.div<{ - $styleConfig: TextStyleType; - $animationStyle: AnimationStyleType; -}>` - height: 100%; - display: flex; - overflow: hidden; - border-radius: ${(props) => props.$styleConfig.radius || "4px"}; - border: ${(props) => props.$styleConfig.borderWidth || "1px"} solid ${(props) => props.$styleConfig.border || "#e0e0e0"}; - background: ${(props) => props.$styleConfig.background || "#ffffff"}; - font-family: ${(props) => props.$styleConfig.fontFamily || "Inter, sans-serif"}; - ${(props) => props.$animationStyle} -`; - -const LeftPanel = styled.div<{ $width: string }>` - width: ${(props) => props.$width}; - border-right: 1px solid #f0f0f0; - display: flex; - flex-direction: column; - background: #fafbfc; - position: relative; - - &::before { - content: ''; - position: absolute; - top: 0; - right: 0; - bottom: 0; - width: 1px; - background: linear-gradient(180deg, transparent 0%, #e6f7ff 50%, transparent 100%); - opacity: 0.5; - } -`; +import { ChatBoxView } from "./components/ChatBoxView"; +import { ChatBoxContext } from "./ChatBoxContext"; +import type { ChatRoom, PendingRoomInvite } from "./store"; -const RightPanel = styled.div` - flex: 1; - display: flex; - flex-direction: column; - min-width: 0; -`; +// ─── Events ────────────────────────────────────────────────────────────────── -const ChatHeader = styled.div<{ $styleConfig: TextStyleType }>` - padding: 16px; - border-bottom: 1px solid #e0e0e0; - background: ${(props) => props.$styleConfig.background || "#ffffff"}; - font-size: ${(props) => props.$styleConfig.textSize || "16px"}; - font-weight: ${(props) => props.$styleConfig.textWeight || "600"}; - color: ${(props) => props.$styleConfig.text || "#1a1a1a"}; -`; - -const RoomsSection = styled.div` - flex: 1; - overflow-y: auto; - margin-bottom: 8px; - padding: 0 8px; -`; - -const RoomItem = styled.div<{ $isActive?: boolean; $styleConfig: TextStyleType }>` - padding: 10px 12px; - margin-bottom: 6px; - border-radius: 8px; - cursor: pointer; - background: ${(props) => props.$isActive ? props.$styleConfig.links || "#1890ff" : "#ffffff"}; - color: ${(props) => props.$isActive ? "#ffffff" : props.$styleConfig.text || "#262626"}; - font-size: ${(props) => props.$styleConfig.textSize || "13px"}; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - border: 1px solid ${(props) => props.$isActive ? "transparent" : "#f0f0f0"}; - box-shadow: ${(props) => props.$isActive ? "0 3px 8px rgba(24, 144, 255, 0.15)" : "0 1px 2px rgba(0, 0, 0, 0.04)"}; - position: relative; - overflow: hidden; - - &:hover { - background: ${(props) => props.$isActive ? props.$styleConfig.links || "#1890ff" : "#fafafa"}; - transform: translateY(-1px); - box-shadow: ${(props) => props.$isActive ? "0 4px 10px rgba(24, 144, 255, 0.2)" : "0 3px 8px rgba(0, 0, 0, 0.1)"}; - border-color: ${(props) => props.$isActive ? "transparent" : "#d9d9d9"}; - } - - &:active { - transform: translateY(0); - } -`; - -const ChatArea = styled.div` - flex: 1; - overflow-y: auto; - padding: 16px; - display: flex; - flex-direction: column; - gap: 12px; -`; - -const MessageBubble = styled.div<{ $isOwn: boolean; $styleConfig: TextStyleType }>` - max-width: 70%; - padding: 12px 16px; - border-radius: 18px; - align-self: ${(props) => props.$isOwn ? "flex-end" : "flex-start"}; - background: ${(props) => props.$isOwn ? (props.$styleConfig.links || "#007bff") : "#f1f3f4"}; - color: ${(props) => props.$isOwn ? "#ffffff" : (props.$styleConfig.text || "#333")}; - font-size: ${(props) => props.$styleConfig.textSize || "14px"}; - word-wrap: break-word; -`; - -const MessageInput = styled.div` - padding: 16px; - border-top: 1px solid #e0e0e0; - display: flex; - gap: 8px; - align-items: center; -`; - -const InputField = styled.textarea<{ $styleConfig: TextStyleType }>` - flex: 1; - padding: 12px 16px; - border: 1px solid #e0e0e0; - border-radius: 20px; - resize: none; - max-height: 100px; - min-height: 40px; - font-family: ${(props) => props.$styleConfig.fontFamily || "Inter, sans-serif"}; - font-size: ${(props) => props.$styleConfig.textSize || "14px"}; - color: ${(props) => props.$styleConfig.text || "#333"}; - outline: none; - - &:focus { - border-color: ${(props) => props.$styleConfig.links || "#007bff"}; - } -`; - -const SendButton = styled.button<{ $styleConfig: TextStyleType }>` - padding: 8px 16px; - background: ${(props) => props.$styleConfig.links || "#007bff"}; - color: #ffffff; - border: none; - border-radius: 20px; - cursor: pointer; - font-size: ${(props) => props.$styleConfig.textSize || "14px"}; - font-weight: 500; - transition: background-color 0.2s; - - &:hover { - opacity: 0.9; - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } -`; - -const EmptyState = styled.div<{ $styleConfig: TextStyleType }>` - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - height: 100%; - color: ${(props) => props.$styleConfig.text || "#666"}; - font-size: ${(props) => props.$styleConfig.textSize || "14px"}; - text-align: center; - gap: 8px; -`; +const ChatEvents = [ + { + label: trans("chatBox.messageSent"), + value: "messageSent", + description: trans("chatBox.messageSentDesc"), + }, + { + label: trans("chatBox.startTyping"), + value: "startTyping", + description: trans("chatBox.startTypingDesc"), + }, + { + label: trans("chatBox.stopTyping"), + value: "stopTyping", + description: trans("chatBox.stopTypingDesc"), + }, + { + label: trans("chatBox.roomSwitch"), + value: "roomSwitch", + description: trans("chatBox.roomSwitchDesc"), + }, + { + label: trans("chatBox.roomLeave"), + value: "roomLeave", + description: trans("chatBox.roomLeaveDesc"), + }, + { + label: trans("chatBox.roomCreate"), + value: "roomCreate", + description: trans("chatBox.roomCreateDesc"), + }, + { + label: trans("chatBox.inviteSend"), + value: "inviteSend", + description: trans("chatBox.inviteSendDesc"), + }, + { + label: trans("chatBox.inviteAccept"), + value: "inviteAccept", + description: trans("chatBox.inviteAcceptDesc"), + }, + { + label: trans("chatBox.inviteDecline"), + value: "inviteDecline", + description: trans("chatBox.inviteDeclineDesc"), + }, +] as const; + +// ─── Children map ──────────────────────────────────────────────────────────── + +const childrenMap = { + // ── Chat content ───────────────────────────────────────────────── + chatTitle: stringExposingStateControl("chatTitle", trans("chatBox.chatTitleDefault")), + showHeader: withDefault(BoolControl, true), + messages: jsonArrayControl([]), + currentUserId: withDefault(StringControl, "user_1"), + currentUserName: withDefault(StringControl, trans("chatBox.currentUserNameDefault")), + typingUsers: jsonArrayControl([]), + isAiThinking: withDefault(BoolControl, false), + lastSentMessageText: stringExposingStateControl("lastSentMessageText", ""), + messageText: stringExposingStateControl("messageText", ""), + + // ── Rooms panel ────────────────────────────────────────────────── + rooms: jsonArrayControl([]), + currentRoomId: withDefault(StringControl, ""), + pendingInvites: jsonArrayControl([]), + onlineUsers: jsonArrayControl([]), + showRoomsPanel: withDefault(BoolControl, true), + roomsPanelWidth: withDefault(StringControl, "240px"), + allowRoomCreation: withDefault(BoolControl, true), + allowRoomSearch: withDefault(BoolControl, true), + + // ── Exposed state written on user interactions ──────────────────── + pendingRoomId: stringExposingStateControl("pendingRoomId", ""), + newRoomName: stringExposingStateControl("newRoomName", ""), + newRoomType: stringExposingStateControl("newRoomType", "public"), + newRoomDescription: stringExposingStateControl("newRoomDescription", ""), + newRoomLlmQuery: stringExposingStateControl("newRoomLlmQuery", ""), + inviteTargetUserId: stringExposingStateControl("inviteTargetUserId", ""), + pendingInviteId: stringExposingStateControl("pendingInviteId", ""), + + // ── Style / layout ──────────────────────────────────────────────── + autoHeight: AutoHeightControl, + onEvent: eventHandlerControl(ChatEvents), + style: styleControl(ChatBoxContainerStyle, "style"), + animationStyle: styleControl(AnimationStyle, "animationStyle"), + sidebarStyle: styleControl(ChatBoxSidebarStyle, "sidebarStyle"), + headerStyle: styleControl(ChatBoxHeaderStyle, "headerStyle"), + messageStyle: styleControl(ChatBoxMessageStyle, "messageStyle"), + inputStyle: styleControl(ChatBoxInputStyle, "inputStyle"), +}; -const ConnectionStatus = styled.div<{ $connected: boolean; $styleConfig: TextStyleType }>` - padding: 8px 16px; - background: ${(props) => props.$connected ? "#d4edda" : "#f8d7da"}; - color: ${(props) => props.$connected ? "#155724" : "#721c24"}; - font-size: 12px; - text-align: center; - border-bottom: 1px solid #e0e0e0; -`; +// ─── Property panel ────────────────────────────────────────────────────────── -const TypingIndicator = styled.div<{ $styleConfig: TextStyleType }>` - padding: 8px 16px; - font-size: 12px; - color: #666; - font-style: italic; - opacity: 0.8; - border-bottom: 1px solid #e0e0e0; - background: #f9f9f9; - - .typing-dots { - display: inline-block; - margin-left: 8px; - } - - .typing-dots span { - display: inline-block; - background-color: #bbb; - border-radius: 50%; - width: 4px; - height: 4px; - margin: 0 1px; - animation: typing 1.4s infinite ease-in-out both; - } - - .typing-dots span:nth-child(1) { animation-delay: -0.32s; } - .typing-dots span:nth-child(2) { animation-delay: -0.16s; } - .typing-dots span:nth-child(3) { animation-delay: 0s; } - - @keyframes typing { - 0%, 80%, 100% { - transform: scale(0); - opacity: 0.3; - } - 40% { - transform: scale(1); - opacity: 1; - } - } -`; +const ChatBoxPropertyView = React.memo((props: { children: any }) => { + const { children } = props; + const editorMode = useContext(EditorContext).editorModeStatus; -// Property view component -const ChatPropertyView = React.memo((props: { - children: ChatCompChildrenType -}) => { - const editorContext = useContext(EditorContext); - const editorModeStatus = useMemo(() => editorContext.editorModeStatus, [editorContext.editorModeStatus]); + return ( + <> +
+ {children.chatTitle.propertyView({ + label: trans("chatBox.chatTitleLabel"), + tooltip: trans("chatBox.chatTitleTooltip"), + })} + {children.messages.propertyView({ + label: trans("chatBox.messagesLabel"), + tooltip: trans("chatBox.messagesTooltip"), + })} + {children.currentUserId.propertyView({ + label: trans("chatBox.currentUserIdLabel"), + tooltip: trans("chatBox.currentUserIdTooltip"), + })} + {children.currentUserName.propertyView({ + label: trans("chatBox.currentUserNameLabel"), + tooltip: trans("chatBox.currentUserNameTooltip"), + })} +
- const basicSection = useMemo(() => ( -
- {props.children.chatName.propertyView({ - label: "Chat Name", - tooltip: "Name displayed in the chat header" - })} - {props.children.userId.propertyView({ - label: "User ID", - tooltip: "Unique identifier for the current user" - })} - {props.children.userName.propertyView({ - label: "User Name", - tooltip: "Display name for the current user" - })} - {props.children.applicationId.propertyView({ - label: "Application ID", - tooltip: "Unique identifier for this Lowcoder application - all chat components with the same Application ID can discover each other's rooms" - })} - {props.children.roomId.propertyView({ - label: "Initial Room", - tooltip: "Default room to join when the component loads (within the application scope)" - })} - {props.children.mode.propertyView({ - label: "Sync Mode", - tooltip: "Choose how messages are synchronized: Collaborative (real-time), Hybrid (local + real-time), or Local only" - })} -
- ), [props.children]); +
+ {children.showRoomsPanel.propertyView({ label: trans("chatBox.showRoomsPanelLabel") })} + {children.roomsPanelWidth.propertyView({ + label: trans("chatBox.panelWidthLabel"), + tooltip: trans("chatBox.panelWidthTooltip"), + })} + {children.rooms.propertyView({ + label: trans("chatBox.roomsLabel"), + tooltip: trans("chatBox.roomsTooltip"), + })} + {children.currentRoomId.propertyView({ + label: trans("chatBox.currentRoomIdLabel"), + tooltip: trans("chatBox.currentRoomIdTooltip"), + })} + {children.pendingInvites.propertyView({ + label: trans("chatBox.pendingInvitesLabel"), + tooltip: trans("chatBox.pendingInvitesTooltip"), + })} + {children.allowRoomCreation.propertyView({ label: trans("chatBox.allowRoomCreationLabel") })} + {children.allowRoomSearch.propertyView({ label: trans("chatBox.allowRoomSearchLabel") })} +
- const roomManagementSection = useMemo(() => ( -
- {props.children.allowRoomCreation.propertyView({ - label: "Allow Room Creation", - tooltip: "Allow users to create new chat rooms" - })} - {props.children.allowRoomJoining.propertyView({ - label: "Allow Room Joining", - tooltip: "Allow users to join existing rooms" - })} - {props.children.roomPermissionMode.propertyView({ - label: "Permission Mode", - tooltip: "Control how users can join rooms" - })} - {props.children.showAvailableRooms.propertyView({ - label: "Show Available Rooms", - tooltip: "Display list of available rooms to join" - })} - {props.children.maxRoomsDisplay.propertyView({ - label: "Max Rooms to Display", - tooltip: "Maximum number of rooms to show in the list" - })} -
- ), [props.children]); +
+ {children.typingUsers.propertyView({ + label: trans("chatBox.typingUsersLabel"), + tooltip: trans("chatBox.typingUsersTooltip"), + })} + {children.isAiThinking.propertyView({ + label: trans("chatBox.aiIsThinkingLabel"), + tooltip: trans("chatBox.aiIsThinkingTooltip"), + })} + {children.onlineUsers.propertyView({ + label: trans("chatBox.onlineUsersLabel"), + tooltip: trans("chatBox.onlineUsersTooltip"), + })} +
- const interactionSection = useMemo(() => - ["logic", "both"].includes(editorModeStatus) && ( -
- {hiddenPropertyView(props.children)} - {props.children.onEvent.getPropertyView()} +
+ {children.showHeader.propertyView({ label: trans("chatBox.showHeaderLabel") })}
- ), [editorModeStatus, props.children]); - const layoutSection = useMemo(() => - ["layout", "both"].includes(editorModeStatus) && ( - <> -
- {props.children.autoHeight.getPropertyView()} - {props.children.leftPanelWidth.propertyView({ - label: "Left Panel Width", - tooltip: "Width of the rooms/people panel (e.g., 300px, 25%)" - })} - {props.children.showRooms.propertyView({ - label: "Show Rooms" - })} -
-
- {props.children.style.getPropertyView()} -
-
- {props.children.animationStyle.getPropertyView()} + {["logic", "both"].includes(editorMode) && ( +
+ {hiddenPropertyView(children)} + {children.onEvent.getPropertyView()}
- - ), [editorModeStatus, props.children]); - - return ( - <> - {basicSection} - {roomManagementSection} - {interactionSection} - {layoutSection} + )} + + {["layout", "both"].includes(editorMode) && ( + <> +
+ {children.autoHeight.getPropertyView()} +
+
+ {children.style.getPropertyView()} +
+
+ {children.sidebarStyle.getPropertyView()} +
+
+ {children.headerStyle.getPropertyView()} +
+
+ {children.messageStyle.getPropertyView()} +
+
+ {children.inputStyle.getPropertyView()} +
+
+ {children.animationStyle.getPropertyView()} +
+ + )} ); }); -// Handler for joinUser method -const handleJoinUser = async ( - comp: any, - userId: string, - userName: string, -) => { - try { - // Update the component's internal state with user credentials - comp.children.userId.getView().onChange(userId); - comp.children.userName.getView().onChange(userName); - - console.log('[ChatBox] 👤 User joined as:', { userId, userName }); - - // The chat manager will automatically reconnect with new credentials - // due to the useEffect that watches for userId/userName changes - return true; - } catch (error) { - console.error('[ChatBox] 💥 Error joining as user:', error); - return false; - } -}; - -// Main view component -const ChatBoxView = React.memo((props: ToViewReturn) => { - const [currentMessage, setCurrentMessage] = useState(""); - const [joinedRooms, setJoinedRooms] = useState([]); - const [searchableRooms, setSearchableRooms] = useState([]); - const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); - const [showSearchResults, setShowSearchResults] = useState(false); - const [searchResults, setSearchResults] = useState([]); - const [isSearching, setIsSearching] = useState(false); - const [createRoomForm] = Form.useForm(); - const handleClickEvent = useCompClickEventHandler({onEvent: props.onEvent}); - const chatAreaRef = useRef(null); - const searchTimeoutRef = useRef(null); - - // Helper function to trigger custom events - const triggerEvent = (eventName: string) => { - if (props.onEvent) { - props.onEvent(eventName); - } - }; - - // Initialize chat manager - const modeValue = props.mode as 'local' | 'collaborative' | 'hybrid'; - - // Only auto-connect if userId and userName are provided in configuration - const shouldAutoConnect = !!(props.userId.value && props.userName.value); - - const chatManager = useChatManager({ - userId: props.userId.value, - userName: props.userName.value, - applicationId: props.applicationId.value || "lowcoder_app", - roomId: props.roomId.value || "general", - mode: modeValue, // Use mode from props - autoConnect: shouldAutoConnect, // Only auto-connect if credentials are provided - }); - - // Handle reconnection when userId or userName changes (for public users) - useEffect(() => { - if (props.userId.value && props.userName.value) { - if (chatManager.isConnected) { - // Disconnect and let the chat manager reconnect with new credentials - chatManager.disconnect().then(() => { - console.log('[ChatBox] 🔄 Reconnecting with new user credentials'); - }); - } else { - // If not connected and we have credentials, trigger connection - console.log('[ChatBox] 🔌 Connecting with user credentials'); - } - } - }, [props.userId.value, props.userName.value]); - - // Chat event handlers - useEffect(() => { - if (chatManager.isConnected) { - triggerEvent("connected"); - } else if (chatManager.error) { - triggerEvent("error"); - } - }, [chatManager.isConnected, chatManager.error]); - - // Load joined rooms when connected - useEffect(() => { - const loadRooms = async () => { - if (chatManager.isConnected) { - try { - console.log('[ChatBox] 🔄 Loading joined rooms...'); - const allRooms = await chatManager.getAvailableRooms(); - console.log('[ChatBox] 🔍 getAvailableRooms result:', allRooms); - - if (!allRooms || !Array.isArray(allRooms)) { - console.warn('[ChatBox] ⚠️ getAvailableRooms returned undefined or invalid data:', allRooms); - // Keep existing joined rooms if API fails - return; - } - - // Filter to only show rooms the user is a member of - // Participants can be either strings (user IDs) or objects with id property - const userJoinedRooms = allRooms.filter((room: any) => { - if (!room.participants) { - console.log(`[ChatBox] 🔍 Room "${room.name}" has no participants - excluding`); - return false; - } - - console.log(`[ChatBox] 🔍 Checking room "${room.name}" participants:`, room.participants, 'vs current user:', props.userId.value); - console.log(`[ChatBox] 🔍 Current userName: "${props.userName.value}"`); - - const isUserInRoom = room.participants.some((p: any) => { - // Handle both string participants (just user IDs) and object participants - const participantId = typeof p === 'string' ? p : p.id; - const isMatch = participantId === props.userId.value; - console.log(`[ChatBox] 🔍 Participant ${participantId} === ${props.userId.value}? ${isMatch}`); - return isMatch; - }); - - console.log(`[ChatBox] 🔍 Room "${room.name}" - User is ${isUserInRoom ? 'MEMBER' : 'NOT MEMBER'}`); - return isUserInRoom; - }); - console.log('[ChatBox] 📋 Found joined rooms:', userJoinedRooms.map((r: any) => r.name)); - setJoinedRooms(userJoinedRooms); - } catch (error) { - console.error('[ChatBox] 💥 Failed to load joined rooms:', error); - } - } - }; - - loadRooms(); - }, [chatManager.isConnected, props.userId.value, chatManager.getAvailableRooms]); - - // Refresh joined rooms periodically - useEffect(() => { - if (!chatManager.isConnected) return; - - const refreshInterval = setInterval(async () => { - try { - console.log('[ChatBox] 🔄 Refreshing joined rooms...'); - const allRooms = await chatManager.getAvailableRooms(); - console.log('[ChatBox] 🔍 Refresh getAvailableRooms result:', allRooms); - - if (!allRooms || !Array.isArray(allRooms)) { - console.warn('[ChatBox] ⚠️ Refresh getAvailableRooms returned undefined or invalid data:', allRooms); - // Skip this refresh cycle if data is invalid - return; - } - - const userJoinedRooms = allRooms.filter((room: any) => { - if (!room.participants) return false; - - return room.participants.some((p: any) => { - // Handle both string participants (just user IDs) and object participants - const participantId = typeof p === 'string' ? p : p.id; - return participantId === props.userId.value; - }); - }); - setJoinedRooms(userJoinedRooms); - console.log('[ChatBox] 📋 Refreshed joined rooms count:', userJoinedRooms.length); - } catch (error) { - console.error('[ChatBox] 💥 Failed to refresh joined rooms:', error); - } - }, 5000); // Refresh every 5 seconds - - return () => clearInterval(refreshInterval); - }, [chatManager.isConnected, props.userId.value, chatManager.getAvailableRooms]); - - // Room management functions - const handleCreateRoom = async (values: any) => { - try { - const newRoom = await chatManager.createRoomFromRequest({ - name: values.roomName.trim(), - type: values.roomType, - description: values.description || `Created by ${props.userName.value}` - }); - - if (newRoom) { - console.log('[ChatBox] ✅ Created room:', newRoom.name); - - // Automatically join the room as the creator - const joinSuccess = await chatManager.joinRoom(newRoom.id); - - // Always add the room to joined rooms regardless of join success - // This ensures the UI works even if there are backend sync issues - const roomWithUser = { - ...newRoom, - participants: [ - ...(newRoom.participants || []), - { id: props.userId.value, name: props.userName.value } - ] - }; - - // Add to joined rooms immediately - setJoinedRooms(prev => [...prev, roomWithUser]); - - if (joinSuccess) { - console.log('[ChatBox] ✅ Creator automatically joined the room'); - console.log('[ChatBox] 📋 Created room added to joined rooms and set as active'); - } else { - console.warn('[ChatBox] ⚠️ Failed to auto-join created room, but room added to local state'); - } - - // Reset form and close modal - createRoomForm.resetFields(); - setIsCreateModalOpen(false); - } - } catch (error) { - console.error('Failed to create room:', error); - } - }; - - const handleJoinRoom = async (roomId: string) => { - try { - console.log('[ChatBox] 🚪 Attempting to join room:', roomId); - const success = await chatManager.joinRoom(roomId); - if (success) { - console.log('[ChatBox] ✅ Successfully joined room:', roomId); - - // Find the room from search results - const roomToAdd = searchResults.find((room: any) => room.id === roomId); - if (roomToAdd) { - // Add current user to participants for immediate local state update - const roomWithUser = { - ...roomToAdd, - participants: [ - ...(roomToAdd.participants || []), - { id: props.userId.value, name: props.userName.value } - ] - }; - - // Add to joined rooms immediately - setJoinedRooms(prev => [...prev, roomWithUser]); - console.log('[ChatBox] 📋 Added room to joined rooms locally'); - } - - // Remove the joined room from search results - setSearchResults(prev => prev.filter((room: any) => room.id !== roomId)); - - // Clear search state to show joined rooms - setSearchQuery(""); - setShowSearchResults(false); - - // Trigger room joined event - triggerEvent("roomJoined"); - - console.log('[ChatBox] 📋 Room join completed successfully'); - } else { - console.log('[ChatBox] ❌ Failed to join room:', roomId); - } - } catch (error) { - console.error('[ChatBox] 💥 Error joining room:', error); - } - }; - - const handleLeaveRoom = async (roomId: string) => { - try { - console.log('[ChatBox] 🚪 Attempting to leave room:', roomId); - const success = await chatManager.leaveRoom(roomId); - if (success) { - console.log('[ChatBox] ✅ Successfully left room:', roomId); - - // Remove the room from joined rooms immediately - const updatedJoinedRooms = joinedRooms.filter((room: any) => room.id !== roomId); - setJoinedRooms(updatedJoinedRooms); - - // Trigger room left event - triggerEvent("roomLeft"); - - // If user left the current room, switch to another joined room or clear chat - if (currentRoom?.id === roomId) { - if (updatedJoinedRooms.length > 0) { - await chatManager.joinRoom(updatedJoinedRooms[0].id); - } else { - // No more rooms joined, user needs to search and join a room - console.log('[ChatBox] ℹ️ No more joined rooms, user needs to search for rooms'); - } - } - } else { - console.log('[ChatBox] ❌ Failed to leave room:', roomId); - } - } catch (error) { - console.error('[ChatBox] 💥 Error leaving room:', error); - } - }; - - // Search functionality - searches all available rooms, not just joined ones - const handleSearch = async (query: string) => { - if (!query.trim()) { - setShowSearchResults(false); - setSearchResults([]); - return; - } - - setIsSearching(true); - try { - console.log('[ChatBox] 🔍 Searching for rooms:', query); - - // Get all available rooms and filter by search query - const allRooms = await chatManager.getAvailableRooms(); - console.log('[ChatBox] 🔍 Search getAvailableRooms result:', allRooms); - - if (!allRooms || !Array.isArray(allRooms)) { - console.warn('[ChatBox] ⚠️ Search getAvailableRooms returned undefined or invalid data:', allRooms); - setSearchResults([]); - setShowSearchResults(true); - return; - } - - console.log('[ChatBox] 🔍 All available rooms count:', allRooms.length); - console.log('[ChatBox] 🔍 User ID for filtering:', props.userId.value); - - // Show all public rooms that match search, regardless of current membership - const filtered = allRooms.filter((room: any) => { - console.log(`[ChatBox] 🔍 Filtering room "${room.name}" with query: "${query}"`); - - if (!query || typeof query !== 'string') { - console.warn(`[ChatBox] ⚠️ Invalid query:`, query); - return false; - } - - if (!room.name || typeof room.name !== 'string') { - console.warn(`[ChatBox] ⚠️ Invalid room name:`, room.name); - return false; - } - - const matchesSearch = room.name.toLowerCase().includes(query.toLowerCase()) || - (room.description && room.description.toLowerCase().includes(query.toLowerCase())); - - // For public rooms, show them even if user is not a member (they can join) - // For private rooms, only show if user is already a member - const canAccess = room.type === 'public' || - (room.participants && room.participants.some((p: any) => { - const participantId = typeof p === 'string' ? p : p.id; - return participantId === props.userId.value; - })); - - console.log(`[ChatBox] 🔍 Room "${room.name}" (${room.type}): query="${query}", matchesSearch=${matchesSearch}, canAccess=${canAccess}, participants:`, room.participants); - - return matchesSearch && canAccess; - }); - - console.log('[ChatBox] 🔍 Filtered rooms:', filtered.map((r: any) => ({ - name: r.name, - id: r.id, - participants: r.participants?.length || 0 - }))); - - setSearchResults(filtered); - setShowSearchResults(true); - console.log('[ChatBox] 🔍 Search results:', filtered.length, 'rooms found'); - } catch (error) { - console.error('[ChatBox] 💥 Error searching rooms:', error); - setSearchResults([]); - } finally { - setIsSearching(false); - } - }; - - const handleSearchInputChange = (e: React.ChangeEvent) => { - const query = e.target.value; - console.log(`[ChatBox] 🔍 Search input changed to: "${query}"`); - setSearchQuery(query); - - // Debounce search - if (searchTimeoutRef.current) { - clearTimeout(searchTimeoutRef.current); - } - searchTimeoutRef.current = setTimeout(() => { - console.log(`[ChatBox] 🔍 Executing debounced search with query: "${query}"`); - handleSearch(query); - }, 300); - }; - - const { - isConnected, - isLoading, - error, - currentRoom, - messages, - typingUsers, - sendMessage, - startTyping, - stopTyping - } = chatManager; - - // Message received event - useEffect(() => { - if (messages.length > 0) { - const lastMessage = messages[messages.length - 1]; - if (lastMessage && lastMessage.authorId !== props.userId.value) { - triggerEvent("messageReceived"); - } - } - }, [messages]); - - // Typing events - useEffect(() => { - if (typingUsers && typingUsers.length > 0) { - triggerEvent("typingStarted"); - } else { - triggerEvent("typingStopped"); - } - }, [typingUsers]); - - - useEffect(() => { - if (chatAreaRef.current) { - chatAreaRef.current.scrollTop = chatAreaRef.current.scrollHeight; - } - }, [messages]); - - // Typing management - const typingTimeoutRef = useRef(null); - const isTypingRef = useRef(false); - - const handleInputChange = (e: React.ChangeEvent) => { - const newValue = e.target.value; - setCurrentMessage(newValue); - - if (newValue.trim() && isConnected) { - // Only start typing if we weren't already typing - if (!isTypingRef.current) { - console.log('[ChatBox] 🖊️ Starting typing indicator'); - startTyping(); - isTypingRef.current = true; - } - - // Clear existing timeout - if (typingTimeoutRef.current) { - clearTimeout(typingTimeoutRef.current); - } - - // Set new timeout to stop typing after 2 seconds of inactivity - typingTimeoutRef.current = setTimeout(() => { - console.log('[ChatBox] 🖊️ Stopping typing indicator (timeout)'); - stopTyping(); - isTypingRef.current = false; - }, 2000); - } else if (!newValue.trim()) { - // Stop typing immediately if input is empty - if (typingTimeoutRef.current) { - clearTimeout(typingTimeoutRef.current); - typingTimeoutRef.current = null; - } - if (isTypingRef.current) { - console.log('[ChatBox] 🖊️ Stopping typing indicator (empty input)'); - stopTyping(); - isTypingRef.current = false; - } - } - }; - - const handleSendMessage = async () => { - if (currentMessage.trim()) { - // Stop typing before sending - if (typingTimeoutRef.current) { - clearTimeout(typingTimeoutRef.current); - typingTimeoutRef.current = null; - } - if (isTypingRef.current) { - console.log('[ChatBox] 🖊️ Stopping typing indicator (sending message)'); - stopTyping(); - isTypingRef.current = false; - } - - const success = await sendMessage(currentMessage.trim()); - - if (success) { - setCurrentMessage(""); - handleClickEvent(); - triggerEvent("messageSent"); - } - } - }; - - const handleKeyPress = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSendMessage(); - } - }; - - // Clean up typing timeout on unmount - useEffect(() => { - return () => { - if (typingTimeoutRef.current) { - clearTimeout(typingTimeoutRef.current); - } - if (isTypingRef.current) { - stopTyping(); - } +ChatBoxPropertyView.displayName = "ChatBoxPropertyView"; + +// ─── Component ─────────────────────────────────────────────────────────────── + +let ChatBoxTmp = (function () { + return new UICompBuilder(childrenMap, (props) => { + const messages = Array.isArray(props.messages) ? props.messages : []; + const rooms = (Array.isArray(props.rooms) ? props.rooms : []) as unknown as ChatRoom[]; + const typingUsers = Array.isArray(props.typingUsers) ? props.typingUsers : []; + const onlineUsers = Array.isArray(props.onlineUsers) ? props.onlineUsers : []; + const isAiThinking = Boolean(props.isAiThinking); + const pendingInvites = (Array.isArray(props.pendingInvites) + ? props.pendingInvites + : []) as unknown as PendingRoomInvite[]; + const currentRoom = rooms.find((r) => r.id === props.currentRoomId) ?? null; + + const contextValue = { + messages, + rooms, + currentRoomId: props.currentRoomId, + currentRoom, + currentUserId: props.currentUserId, + currentUserName: props.currentUserName, + typingUsers, + onlineUsers: onlineUsers as any, + isAiThinking, + pendingInvites, + + chatTitle: props.chatTitle, + messageText: props.messageText, + lastSentMessageText: props.lastSentMessageText, + + showHeader: props.showHeader, + showRoomsPanel: props.showRoomsPanel, + roomsPanelWidth: props.roomsPanelWidth, + allowRoomCreation: props.allowRoomCreation, + allowRoomSearch: props.allowRoomSearch, + style: props.style, + animationStyle: props.animationStyle, + sidebarStyle: props.sidebarStyle, + headerStyle: props.headerStyle, + messageStyle: props.messageStyle, + inputStyle: props.inputStyle, + + onEvent: props.onEvent, + + onRoomSwitch: (roomId: string) => { + props.pendingRoomId.onChange(roomId); + props.onEvent("roomSwitch"); + }, + onRoomLeave: (roomId: string) => { + props.pendingRoomId.onChange(roomId); + props.onEvent("roomLeave"); + }, + onRoomCreate: ( + name: string, + type: "public" | "private" | "llm", + description?: string, + llmQueryName?: string, + ) => { + props.newRoomName.onChange(name); + props.newRoomType.onChange(type); + props.newRoomDescription.onChange(description || ""); + props.newRoomLlmQuery.onChange(llmQueryName || ""); + props.onEvent("roomCreate"); + }, + onInviteSend: (toUserId: string) => { + props.inviteTargetUserId.onChange(toUserId); + props.onEvent("inviteSend"); + }, + onInviteAccept: (inviteId: string) => { + props.pendingInviteId.onChange(inviteId); + props.onEvent("inviteAccept"); + }, + onInviteDecline: (inviteId: string) => { + props.pendingInviteId.onChange(inviteId); + props.onEvent("inviteDecline"); + }, }; - }, [stopTyping]); - - // Process rooms for display - const baseRooms = showSearchResults ? searchResults : joinedRooms; - const displayRooms = baseRooms.map((room: any) => ({ - id: room.id, - name: room.name, - type: room.type, - active: currentRoom?.id === room.id, - participantCount: room.participants?.length || 0, - canJoin: showSearchResults, // Can only join rooms found through search - isSearchResult: showSearchResults - })); - - // When showing search results, we don't need to add current room - // When showing joined rooms, all rooms are already joined by definition - - - - return ( - - {/* Left Panel - Combined Content */} - - {/* Connection Status */} - {!isConnected && ( - - {isLoading ? "Connecting..." : error ? `Error: ${error}` : "Disconnected"} - - )} - -
- {props.showRooms && ( -
-
- Chat Rooms -
-
- {/* Modern Create Room Modal */} - {/* Create Room Button - Modern Design */} - {props.allowRoomCreation && ( - - )} -
- - - {/* Modern Search UI */} -
- } - value={searchQuery} - onChange={handleSearchInputChange} - loading={isSearching} - style={{ - borderRadius: '6px', - marginBottom: '8px' - }} - size="middle" - allowClear - onClear={() => { - setSearchQuery(""); - setShowSearchResults(false); - setSearchResults([]); - }} - /> - {showSearchResults && ( -
-
0 ? '4px' : '0' - }}> - - - Search Results - -
- {searchResults.length === 0 ? ( -
- No rooms match "{searchQuery}" -
- ) : ( -
- Found {searchResults.length} room{searchResults.length === 1 ? '' : 's'} matching "{searchQuery}" -
- )} -
- )} -
- - {/* Clear Search Button - Modern */} - {showSearchResults && ( -
- -
- )} - - {/* Room List */} - {displayRooms.length === 0 && isConnected && ( -
- {showSearchResults ? ( - searchQuery ? `No rooms found for "${searchQuery}"` : 'Enter a search term to find rooms' - ) : ( - <> -
- 🏠 You haven't joined any rooms yet -
-
- {props.allowRoomCreation - ? 'Create a new room or search to join existing ones' - : 'Search to find and join existing rooms' - } -
- - )} -
- )} - {displayRooms.map((room: any) => ( - { - if (!room.active) { - if (room.canJoin && props.allowRoomJoining) { - // Join a new room from search results - handleJoinRoom(room.id); - } else if (!room.canJoin) { - // Switch to an already joined room - chatManager.setCurrentRoom(room.id); - } - } - }} - style={{ - cursor: (!room.active) ? 'pointer' : 'default', - opacity: room.active ? 1 : 0.8, - transition: 'all 0.2s', - border: room.active - ? '1px solid #52c41a' - : room.isSearchResult - ? '1px solid #d1ecf1' - : '1px solid transparent', - boxShadow: room.isSearchResult - ? '0 2px 4px rgba(0, 0, 0, 0.08)' - : undefined - }} - title={ - room.active - ? 'Current room' - : room.canJoin - ? `Click to join "${room.name}"` - : `Click to switch to "${room.name}"` - } - > - {/* Room Icon and Name */} -
- {room.type === 'public' ? ( - - ) : ( - - )} -
- - {room.name} - - - {/* Room Metadata */} -
- - - - {room.participantCount} - - - - {room.active && ( - - )} - - {room.isSearchResult && !room.active && ( -
- NEW -
- )} -
-
-
- - {/* Action Buttons */} -
- {room.canJoin && props.allowRoomJoining && ( - - - - )} - - {room.active && ( - - handleLeaveRoom(room.id)} - onCancel={() => {/* setRoomToLeave(null); */}} - okText="Leave" - cancelText="Cancel" - placement="bottomRight" - okButtonProps={{ danger: true }} - > -
-
- ))} -
-
- )} -
-
- - {/* Right Panel - Chat Area */} - - -
-
-
- {props.chatName.value} -
-
- {currentRoom?.name || "Default Room"} -
-
-
- {isConnected ? ( - - ) : ( - - )} -
- -
-
-
-
- - {/* Leave Room Confirmation */} - {/* Removed Popconfirm from here as it's now integrated into the room item */} - - - {messages.length === 0 ? ( - -
💬
-
No messages yet
-
- {isConnected ? "Start the conversation!" : "Connecting to chat..."} -
-
- ) : ( - messages.map((message: UnifiedMessage) => ( - -
- {message.authorName} -
- {message.text} -
- {new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} -
-
- )) - )} -
- {/* Typing Indicators */} - {typingUsers && typingUsers.length > 0 && ( - - {typingUsers.length === 1 ? ( - - {typingUsers[0].userName} is typing - - - - - - - ) : ( - - {typingUsers.length} people are typing - - - - - - - )} - - )} - - - - - Send - - -
- - {/* Modern Create Room Modal */} - - - Create New Room - - } - open={isCreateModalOpen} - onCancel={() => { - setIsCreateModalOpen(false); - createRoomForm.resetFields(); - }} - footer={null} - width={480} - centered - destroyOnHidden - > -
- { - // if (!value || value.length < 2) return; - // - // try { - // const allRooms = await chatManager.getAvailableRooms(); - // const roomExists = allRooms.some((room: any) => - // room.name.toLowerCase() === value.toLowerCase() - // ); - // - // if (roomExists) { - // throw new Error('A room with this name already exists'); - // } - // } catch (error) { - // if (error instanceof Error && error.message.includes('already exists')) { - // throw error; - // } - // // If there's an API error, don't block the validation - // console.warn('Could not validate room name uniqueness:', error); - // } - // } - // } - ]} - > - - - - - - - - - - - - - -
-
Public Room
-
- Anyone can discover and join this room -
-
-
-
- - - -
-
Private Room
-
- Only invited members can join this room -
-
-
-
-
-
-
- - - - - - - -
-
-
- ); -}); - -// Build the component -let ChatBoxTmpComp = (function () { - return new UICompBuilder(chatCompChildrenMap, (props) => ) - .setPropertyViewFn((children) => ) + return ( + + + + ); + }) + .setPropertyViewFn((children) => ( + + )) .build(); })(); -ChatBoxTmpComp = class extends ChatBoxTmpComp { +ChatBoxTmp = class extends ChatBoxTmp { override autoHeight(): boolean { return this.children.autoHeight.getView(); } }; -// Add method exposing -ChatBoxTmpComp = withMethodExposing(ChatBoxTmpComp, [ - { - method: { - name: "joinUser", - description: "Allow users to join the chat server with their own credentials", - params: [ - { - name: "userId", - type: "string", - }, - { - name: "userName", - type: "string", - }, - ], - }, - execute: async (comp: any, values: any) => { - return await handleJoinUser(comp, values?.[0], values?.[1]); - }, - }, -]); - -export const ChatBoxComp = withExposingConfigs(ChatBoxTmpComp, [ - new NameConfig("chatName", "Chat name displayed in header"), - new NameConfig("userId", "Unique identifier for current user"), - new NameConfig("userName", "Display name for current user"), - new NameConfig("applicationId", "Application scope identifier for room discovery"), - new NameConfig("roomId", "Initial room to join within application scope"), +export const ChatBoxComp = withExposingConfigs(ChatBoxTmp, [ + new NameConfig("chatTitle", trans("chatBox.chatTitleExposed")), + new NameConfig( + "lastSentMessageText", + trans("chatBox.lastSentMessageTextExposed"), + ), + new NameConfig("messageText", trans("chatBox.messageTextExposed")), + new NameConfig("currentRoomId", trans("chatBox.currentRoomIdExposed")), + new NameConfig( + "pendingRoomId", + trans("chatBox.pendingRoomIdExposed"), + ), + new NameConfig("newRoomName", trans("chatBox.newRoomNameExposed")), + new NameConfig( + "newRoomType", + trans("chatBox.newRoomTypeExposed"), + ), + new NameConfig("newRoomDescription", trans("chatBox.newRoomDescriptionExposed")), + new NameConfig( + "newRoomLlmQuery", + trans("chatBox.newRoomLlmQueryExposed"), + ), + new NameConfig( + "inviteTargetUserId", + trans("chatBox.inviteTargetUserIdExposed"), + ), + new NameConfig( + "pendingInviteId", + trans("chatBox.pendingInviteIdExposed"), + ), NameConfigHidden, ]); diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/chatControllerComp.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/chatControllerComp.tsx deleted file mode 100644 index e289d4629d..0000000000 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/chatControllerComp.tsx +++ /dev/null @@ -1,624 +0,0 @@ -import React, { useCallback, useEffect, useRef, useState } from "react"; -import { ResizeHandle } from "react-resizable"; -import { v4 as uuidv4 } from "uuid"; -import { chatCompChildrenMap, ChatCompChildrenType, ChatPropertyView } from "./chatUtils"; -import { trans } from "i18n"; -import { ToViewReturn } from "@lowcoder-ee/comps/generators/multi"; -import Form from "antd/es/form"; -import { useCompClickEventHandler } from "@lowcoder-ee/comps/utils/useCompClickEventHandler"; -import { useChatManager, UseChatManagerReturn } from "./hooks/useChatManager"; -import { ContainerChildren, ContainerCompBuilder } from "../containerBase/containerCompBuilder"; -import { withMethodExposing } from "@lowcoder-ee/comps/generators/withMethodExposing"; -import { BackgroundColorContext } from "@lowcoder-ee/comps/utils/backgroundColorContext"; -import Drawer from "antd/es/drawer"; -import { isNumeric } from "@lowcoder-ee/util/stringUtils"; -import { gridItemCompToGridItems, InnerGrid } from "../containerComp/containerView"; -import { HintPlaceHolder } from "components/container"; -import { NameConfig, withExposingConfigs } from "@lowcoder-ee/comps/generators/withExposing"; -import { BooleanStateControl } from "@lowcoder-ee/comps/controls/codeStateControl"; -import { StringControl } from "@lowcoder-ee/comps/controls/codeControl"; -import { stateComp, withDefault } from "@lowcoder-ee/comps/generators/simpleGenerators"; -import { PositionControl } from "@lowcoder-ee/comps/controls/dropdownControl"; -import { BoolControl } from "@lowcoder-ee/comps/controls/boolControl"; -import { NewChildren } from "@lowcoder-ee/comps/generators/uiCompBuilder"; -import { changeChildAction, ConstructorToComp, DispatchType, RecordConstructorToComp } from "lowcoder-core"; -import { Layers } from "@lowcoder-ee/constants/Layers"; -import { JSONObject } from "@lowcoder-ee/util/jsonTypes"; - -const DEFAULT_SIZE = 378; -const DEFAULT_PADDING = 16; -function transToPxSize(size: string | number) { - return isNumeric(size) ? size + "px" : (size as string); -} - -const handleCreateRoom = async ( - comp: ConstructorToComp, - roomData: { - name: string, - description: string, - private: boolean, - }, -) => { - const chatManager = comp.children.chatManager.getView() as unknown as UseChatManagerReturn; - const userId = comp.children.userId.getView(); - const userName = comp.children.userName.getView(); - - try { - const newRoom = await chatManager.createRoomFromRequest({ - name: roomData.name.trim(), - type: roomData.private ? "private" : "public", - description: roomData.description || `Created by ${userName}` - }); - - if (newRoom) { - console.log('[ChatBox] ✅ Created room:', newRoom.name); - - // Automatically join the room as the creator - const joinSuccess = await chatManager.joinRoom(newRoom.id); - return joinSuccess; - } - } catch (error) { - console.error('Failed to create room:', error); - } -}; - -const handleJoinRoom = async ( - comp: ConstructorToComp, - roomId: string, -) => { - const chatManager = comp.children.chatManager.getView() as unknown as UseChatManagerReturn; - try { - const success = await chatManager.joinRoom(roomId); - if (success) { - // Note: Event will be triggered by the component's useEffect hooks - console.log('[ChatController] ✅ Successfully joined room:', roomId); - } else { - console.error('[ChatBox] ❌ Failed to join room:', roomId); - } - } catch (error) { - console.error('[ChatBox] 💥 Error joining room:', error); - } -}; - - -const handleLeaveRoom = async ( - comp: ConstructorToComp, - roomId: string, -) => { - try { - const chatManager = comp.children.chatManager.getView() as unknown as UseChatManagerReturn; - console.log('[ChatBox] 🚪 Attempting to leave room:', roomId); - - const success = await chatManager.leaveRoom(roomId); - if (success) { - // Note: Event will be triggered by the component's useEffect hooks - console.log('[ChatController] ✅ Successfully left room:', roomId); - } - return success; - } catch (error) { - console.error('[ChatBox] 💥 Error leaving room:', error); - } -}; - -const handleSetCurrentRoom = async ( - comp: ConstructorToComp, - roomId: string, -) => { - try { - const chatManager = comp.children.chatManager.getView() as unknown as UseChatManagerReturn; - await chatManager.setCurrentRoom(roomId); - } catch (error) { - console.error('Failed to set current room:', error); - } -}; - -const handleSendMessage = async ( - comp: ConstructorToComp, - currentMessage: string, -) => { - try { - const chatManager = comp.children.chatManager.getView() as unknown as UseChatManagerReturn; - if (currentMessage.trim()) { - const success = await chatManager.sendMessage(currentMessage.trim()); - if (success) { - // Note: Event will be triggered by the component's useEffect hooks - console.log('[ChatController] ✅ Message sent successfully'); - } - return success; - } - } catch (error) { - console.error('[ChatBox] 💥 Error sending message:', error); - } -}; - -const handleStartTyping = ( - comp: ConstructorToComp, -) => { - try { - const chatManager = comp.children.chatManager.getView() as unknown as UseChatManagerReturn; - chatManager.startTyping(); - } catch (error) { - console.error('[ChatBox] 💥 Error starting typing:', error); - } -}; - -const handleStopTyping = ( - comp: ConstructorToComp, -) => { - try { - const chatManager = comp.children.chatManager.getView() as unknown as UseChatManagerReturn; - chatManager.stopTyping(); - } catch (error) { - console.error('[ChatBox] 💥 Error stopping typing:', error); - } -}; - -const handleJoinUser = async ( - comp: ConstructorToComp, - userId: string, - userName: string, -) => { - try { - // Update the component's internal state with public user credentials - comp.children.userId.getView().onChange(userId); - comp.children.userName.getView().onChange(userName); - - console.log('[ChatController] 👤 Public user joined as:', { userId, userName }); - - // The chat manager will automatically reconnect with new credentials - // due to the useEffect that watches for userId/userName changes - return true; - } catch (error) { - console.error('[ChatBox] 💥 Error joining as public user:', error); - return false; - } -}; - -const childrenMap = { - ...chatCompChildrenMap, - visible: withDefault(BooleanStateControl, "false"), - width: StringControl, - height: StringControl, - placement: PositionControl, - maskClosable: withDefault(BoolControl, true), - showMask: withDefault(BoolControl, true), - rooms: stateComp([]), - messages: stateComp([]), - chatManager: stateComp({}), - participants: stateComp([]), - currentRoom: stateComp(null), - typingUsers: stateComp([]), -} - -type ChatControllerChildrenType = NewChildren>; - -const CanvasContainerID = "__canvas_container__"; - -const ChatBoxView = React.memo(( - props: ToViewReturn> & { dispatch: DispatchType }, -) => { - const { dispatch } = props; - const { items, ...otherContainerProps } = props.container; - const isTopBom = ["top", "bottom"].includes(props.placement); - const [currentMessage, setCurrentMessage] = useState(""); - const [joinedRooms, setJoinedRooms] = useState([]); - const [currentRoomParticipants, setCurrentRoomParticipants] = useState>([]); - const handleClickEvent = useCompClickEventHandler({onEvent: props.onEvent}); - - // Helper function to trigger custom events - const triggerEvent = (eventName: string) => { - if (props.onEvent) { - props.onEvent(eventName); - } - }; - - // Initialize chat manager - const modeValue = props.mode as 'local' | 'collaborative' | 'hybrid'; - - // Only initialize chat manager if userId and userName are provided - const shouldInitialize = !!(props.userId.value && props.userName.value); - - const chatManager = useChatManager({ - userId: props.userId.value, - userName: props.userName.value, - applicationId: props.applicationId.value, - roomId: props.roomId.value, - mode: modeValue, // Use mode from props - autoConnect: shouldInitialize, // Only auto-connect if credentials are provided - }); - - useEffect(() => { - if (!chatManager.isConnected) return; - - dispatch( - changeChildAction("chatManager", chatManager as any, false) - ) - }, [chatManager.isConnected]); - - const loadRooms = useCallback(async () => { - if (!chatManager.isConnected) return; - try { - const allRooms = await chatManager.getAvailableRooms(); - - if (!allRooms || !Array.isArray(allRooms)) { - // Keep existing joined rooms if API fails - return; - } - console.log('[ChatBox] 📋 Found joined rooms:', allRooms.map((r: any) => r.name)); - - setJoinedRooms(allRooms); - dispatch( - changeChildAction("rooms", allRooms as any, false) - ) - } catch (error) { - console.error('[ChatBox] 💥 Failed to load joined rooms:', error); - } - }, [chatManager.isConnected, dispatch]); - - // Load joined rooms when connected - useEffect(() => { - if (chatManager.isConnected) { - loadRooms(); - } - }, [chatManager.isConnected, props.userId.value, loadRooms]); - - // Handle reconnection when userId or userName changes - useEffect(() => { - if (props.userId.value && props.userName.value) { - if (chatManager.isConnected) { - // Disconnect and let the chat manager reconnect with new credentials - chatManager.disconnect().then(() => { - console.log('[ChatController] 🔄 Reconnecting with new user credentials'); - }); - } else { - // If not connected and we have credentials, trigger connection - console.log('[ChatController] 🔌 Connecting with user credentials'); - } - } - }, [props.userId.value, props.userName.value]); - - // Chat event handlers - useEffect(() => { - if (chatManager.isConnected) { - triggerEvent("connected"); - } else if (chatManager.error) { - triggerEvent("error"); - } - }, [chatManager.isConnected, chatManager.error]); - - // Refresh joined rooms periodically - useEffect(() => { - if (!chatManager.isConnected) return; - - const refreshInterval = setInterval(async () => { - loadRooms(); - }, 10000); // Refresh every 10 seconds - - return () => clearInterval(refreshInterval); - }, [chatManager.isConnected, props.userId.value, loadRooms]); - - const { - isConnected, - isLoading, - error, - currentRoom, - messages, - typingUsers, - sendMessage, - startTyping, - stopTyping, - getRoomParticipants - } = chatManager; - - useEffect(() => { - if (!isConnected) return; - - dispatch( - changeChildAction("messages", messages as any, false) - ) - }, [isConnected, messages]); - - // Load participants when current room changes - useEffect(() => { - const loadParticipants = async () => { - if (currentRoom && getRoomParticipants) { - try { - const participants = await getRoomParticipants(currentRoom.id); - setCurrentRoomParticipants(participants); - console.log('[ChatController] 👥 Loaded participants for room:', currentRoom.name, participants); - } catch (error) { - console.error('[ChatController] Failed to load participants:', error); - } - } - }; - - loadParticipants(); - }, [currentRoom, getRoomParticipants]); - - // Update participants state - useEffect(() => { - if (!chatManager.isConnected) return; - - dispatch( - changeChildAction("participants", currentRoomParticipants as any, false) - ); - }, [currentRoomParticipants]); - - // Update currentRoom state - useEffect(() => { - if (!chatManager.isConnected) return; - - dispatch( - changeChildAction("currentRoom", currentRoom as any, false) - ); - - // Trigger room joined event when currentRoom changes to a new room - if (currentRoom) { - triggerEvent("roomJoined"); - } - }, [currentRoom]); - - // Update typingUsers state - useEffect(() => { - if (!chatManager.isConnected) return; - - dispatch( - changeChildAction("typingUsers", typingUsers as any, false) - ); - }, [typingUsers]); - - // Message events - useEffect(() => { - if (messages.length > 0) { - const lastMessage = messages[messages.length - 1]; - if (lastMessage) { - if (lastMessage.authorId === props.userId.value) { - // Message sent by current user - triggerEvent("messageSent"); - } else { - // Message received from another user - triggerEvent("messageReceived"); - } - } - } - }, [messages, props.userId.value]); - - // Typing events - useEffect(() => { - if (typingUsers && typingUsers.length > 0) { - triggerEvent("typingStarted"); - } else { - triggerEvent("typingStopped"); - } - }, [typingUsers]); - - return ( - - {/* */} - - document.querySelector(`#${CanvasContainerID}`) || document.body - } - footer={null} - width={transToPxSize(props.width || DEFAULT_SIZE)} - height={ - !props.autoHeight - ? transToPxSize(props.height || DEFAULT_SIZE) - : "" - } - onClose={(e: any) => { - props.visible.onChange(false); - }} - afterOpenChange={(visible: any) => { - if (!visible) { - props.onEvent("close"); - } - }} - zIndex={Layers.drawer} - maskClosable={props.maskClosable} - mask={props.showMask} - > - - - - ); -}); - -let ChatControllerComp = new ContainerCompBuilder( - childrenMap, - (props, dispatch) => -) - .setPropertyViewFn((children) => ) - .build(); - -ChatControllerComp = class extends ChatControllerComp { - autoHeight(): boolean { - return false; - } - - -}; - -ChatControllerComp = withMethodExposing(ChatControllerComp, [ - { - method: { - name: "createRoom", - params: [ - { - name: "name", - type: "string", - }, - { - name: "description", - type: "string", - }, - { - name: "private", - type: "boolean", - }, - ], - }, - execute: async (comp: ConstructorToComp, values: any) => { - handleCreateRoom(comp, { - name: values?.[0], - private: values?.[1], - description: values?.[2], - }); - }, - }, - { - method: { - name: "setCurrentRoom", - params: [ - { - name: "roomId", - type: "string", - }, - ], - }, - execute: async (comp: ConstructorToComp, values: any) => { - handleSetCurrentRoom(comp, values?.[0]); - }, - }, - { - method: { - name: "joinRoom", - params: [ - { - name: "roomId", - type: "string", - }, - ], - }, - execute: async (comp: ConstructorToComp, values: any) => { - handleJoinRoom(comp, values?.[0]); - }, - }, - { - method: { - name: "leaveRoom", - params: [ - { - name: "roomId", - type: "string", - }, - ], - }, - execute: async (comp: ConstructorToComp, values: any) => { - handleLeaveRoom(comp, values?.[0]); - }, - }, - { - method: { - name: "startTyping", - params: [], - }, - execute: async (comp: ConstructorToComp, values: any) => { - handleStartTyping(comp); - }, - }, - { - method: { - name: "stopTyping", - params: [], - }, - execute: async (comp: ConstructorToComp, values: any) => { - handleStopTyping(comp); - }, - }, - { - method: { - name: "sendMessage", - params: [ - { - name: "message", - type: "string", - }, - ], - }, - execute: async (comp: ConstructorToComp, values: any) => { - handleSendMessage(comp, values?.[0]); - }, - }, - { - method: { - name: "getRoomParticipants", - description: "Get participants of a room with their ID and name", - params: [ - { - name: "roomId", - type: "string", - }, - ], - }, - execute: async (comp: ConstructorToComp, values: any) => { - const chatManager = comp.children.chatManager.getView() as any; - if (chatManager && chatManager.getRoomParticipants) { - return await chatManager.getRoomParticipants(values?.[0]); - } - return []; - }, - }, - { - method: { - name: "joinUser", - description: "Allow users to join the chat server with their own credentials", - params: [ - { - name: "userId", - type: "string", - }, - { - name: "userName", - type: "string", - }, - ], - }, - execute: async (comp: ConstructorToComp, values: any) => { - return await handleJoinUser(comp, values?.[0], values?.[1]); - }, - }, -]); - -ChatControllerComp = withExposingConfigs(ChatControllerComp, [ - new NameConfig("chatName", trans("chatBox.chatName")), - new NameConfig("rooms", trans("chatBox.rooms")), - new NameConfig("messages", trans("chatBox.messages")), - new NameConfig("participants", trans("chatBox.participants")), - new NameConfig("currentRoom", trans("chatBox.currentRoom")), - new NameConfig("typingUsers", trans("chatBox.typingUsers")), - new NameConfig("allowRoomCreation", trans("chatBox.allowRoomCreation")), - new NameConfig("allowRoomJoining", trans("chatBox.allowRoomJoining")), - new NameConfig("roomPermissionMode", trans("chatBox.roomPermissionMode")), - new NameConfig("userId", trans("chatBox.userId")), - new NameConfig("userName", trans("chatBox.userName")), -]); - -export { ChatControllerComp }; diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/chatUtils.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/chatUtils.tsx deleted file mode 100644 index 3f0fa5e396..0000000000 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/chatUtils.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { AutoHeightControl } from "@lowcoder-ee/comps/controls/autoHeightControl"; -import { BoolControl } from "@lowcoder-ee/comps/controls/boolControl"; -import { StringControl } from "@lowcoder-ee/comps/controls/codeControl"; -import { stringExposingStateControl } from "@lowcoder-ee/comps/controls/codeStateControl"; -import { dropdownControl } from "@lowcoder-ee/comps/controls/dropdownControl"; -import { clickEvent, doubleClickEvent, eventHandlerControl } from "@lowcoder-ee/comps/controls/eventHandlerControl"; -import { styleControl } from "@lowcoder-ee/comps/controls/styleControl"; -import { AnimationStyle, TextStyle } from "@lowcoder-ee/comps/controls/styleControlConstants"; -import { EditorContext } from "@lowcoder-ee/comps/editorState"; -import { withDefault } from "@lowcoder-ee/comps/generators/simpleGenerators"; -import { NewChildren } from "@lowcoder-ee/comps/generators/uiCompBuilder"; -import { hiddenPropertyView } from "@lowcoder-ee/comps/utils/propertyUtils"; -import { RecordConstructorToComp } from "lowcoder-core"; -import { ScrollBar, Section, sectionNames } from "lowcoder-design"; -import React, { useContext, useMemo } from "react"; -import { trans } from "i18n"; - -// Event options for the chat component -const EventOptions = [ - clickEvent, - doubleClickEvent, - { label: trans("chatBox.connected"), value: "connected", description: trans("chatBox.connectedDesc") }, - { label: trans("chatBox.disconnected"), value: "disconnected", description: trans("chatBox.disconnectedDesc") }, - { label: trans("chatBox.messageReceived"), value: "messageReceived", description: trans("chatBox.messageReceivedDesc") }, - { label: trans("chatBox.messageSent"), value: "messageSent", description: trans("chatBox.messageSentDesc") }, - { label: trans("chatBox.userJoined"), value: "userJoined", description: trans("chatBox.userJoinedDesc") }, - { label: trans("chatBox.userLeft"), value: "userLeft", description: trans("chatBox.userLeftDesc") }, - { label: trans("chatBox.typingStarted"), value: "typingStarted", description: trans("chatBox.typingStartedDesc") }, - { label: trans("chatBox.typingStopped"), value: "typingStopped", description: trans("chatBox.typingStoppedDesc") }, - { label: trans("chatBox.roomJoined"), value: "roomJoined", description: trans("chatBox.roomJoinedDesc") }, - { label: trans("chatBox.roomLeft"), value: "roomLeft", description: trans("chatBox.roomLeftDesc") }, - { label: trans("chatBox.error"), value: "error", description: trans("chatBox.errorDesc") }, -] as const; - -// Define the component's children map -export const chatCompChildrenMap = { - chatName: stringExposingStateControl("chatName", "Chat Room"), - userId: stringExposingStateControl("userId", "user_1"), - userName: stringExposingStateControl("userName", "User"), - applicationId: stringExposingStateControl("applicationId", "lowcoder_app"), - roomId: stringExposingStateControl("roomId", "general"), - mode: dropdownControl([ - { label: "🌐 Collaborative (Real-time)", value: "collaborative" }, - { label: "🔀 Hybrid (Local + Real-time)", value: "hybrid" }, - { label: "📱 Local Only", value: "local" } - ], "collaborative"), - - // Room Management Configuration - allowRoomCreation: withDefault(BoolControl, true), - allowRoomJoining: withDefault(BoolControl, true), - roomPermissionMode: dropdownControl([ - { label: "🌐 Open (Anyone can join public rooms)", value: "open" }, - { label: "🔐 Invite Only (Admin invitation required)", value: "invite" }, - { label: "👤 Admin Only (Only admins can manage)", value: "admin" } - ], "open"), - showAvailableRooms: withDefault(BoolControl, true), - maxRoomsDisplay: withDefault(StringControl, "10"), - - // UI Configuration - leftPanelWidth: withDefault(StringControl, "200px"), - showRooms: withDefault(BoolControl, true), - autoHeight: AutoHeightControl, - onEvent: eventHandlerControl(EventOptions), - style: styleControl(TextStyle, 'style'), - animationStyle: styleControl(AnimationStyle, 'animationStyle'), -}; - -export type ChatCompChildrenType = NewChildren>; - -// Property view component -export const ChatPropertyView = React.memo((props: { - children: ChatCompChildrenType -}) => { - const editorContext = useContext(EditorContext); - const editorModeStatus = useMemo(() => editorContext.editorModeStatus, [editorContext.editorModeStatus]); - - const basicSection = useMemo(() => ( -
- {props.children.chatName.propertyView({ - label: "Chat Name", - tooltip: "Name displayed in the chat header" - })} - {props.children.userId.propertyView({ - label: "User ID", - tooltip: "Unique identifier for the current user" - })} - {props.children.userName.propertyView({ - label: "User Name", - tooltip: "Display name for the current user" - })} - {props.children.applicationId.propertyView({ - label: "Application ID", - tooltip: "Unique identifier for this Lowcoder application - all chat components with the same Application ID can discover each other's rooms" - })} - {props.children.roomId.propertyView({ - label: "Initial Room", - tooltip: "Default room to join when the component loads (within the application scope)" - })} - {props.children.mode.propertyView({ - label: "Sync Mode", - tooltip: "Choose how messages are synchronized: Collaborative (real-time), Hybrid (local + real-time), or Local only" - })} -
- ), [props.children]); - - const roomManagementSection = useMemo(() => ( -
- {props.children.allowRoomCreation.propertyView({ - label: "Allow Room Creation", - tooltip: "Allow users to create new chat rooms" - })} - {props.children.allowRoomJoining.propertyView({ - label: "Allow Room Joining", - tooltip: "Allow users to join existing rooms" - })} - {props.children.roomPermissionMode.propertyView({ - label: "Permission Mode", - tooltip: "Control how users can join rooms" - })} - {props.children.showAvailableRooms.propertyView({ - label: "Show Available Rooms", - tooltip: "Display list of available rooms to join" - })} - {props.children.maxRoomsDisplay.propertyView({ - label: "Max Rooms to Display", - tooltip: "Maximum number of rooms to show in the list" - })} -
- ), [props.children]); - - const interactionSection = useMemo(() => - ["logic", "both"].includes(editorModeStatus) && ( -
- {hiddenPropertyView(props.children)} - {props.children.onEvent.getPropertyView()} -
- ), [editorModeStatus, props.children]); - - const layoutSection = useMemo(() => - ["layout", "both"].includes(editorModeStatus) && ( - <> -
- {props.children.autoHeight.getPropertyView()} - {props.children.leftPanelWidth.propertyView({ - label: "Left Panel Width", - tooltip: "Width of the rooms/people panel (e.g., 300px, 25%)" - })} - {props.children.showRooms.propertyView({ - label: "Show Rooms" - })} -
-
- {props.children.style.getPropertyView()} -
-
- {props.children.animationStyle.getPropertyView()} -
- - ), [editorModeStatus, props.children]); - - return ( - <> - {basicSection} - {roomManagementSection} - {interactionSection} - {layoutSection} - - ); -}); \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/components/ChatBoxView.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/components/ChatBoxView.tsx new file mode 100644 index 0000000000..de55234736 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/components/ChatBoxView.tsx @@ -0,0 +1,125 @@ +import React, { useMemo, useState } from "react"; +import { + Wrapper, + ChatPanelContainer, + ChatHeaderBar, + OnlineCountBadge, + OnlineCountDot, +} from "../styles"; +import { MessageList } from "./MessageList"; +import { InputBar } from "./InputBar"; +import { RoomPanel } from "./RoomPanel"; +import { CreateRoomModal } from "./CreateRoomModal"; +import { InviteUserModal } from "./InviteUserModal"; +import { useChatBox } from "../ChatBoxContext"; +import type { ChatRoom } from "../store"; +import { trans } from "i18n"; + +export const ChatBoxView = React.memo(() => { + const ctx = useChatBox(); + const [createModalOpen, setCreateModalOpen] = useState(false); + const [inviteModalOpen, setInviteModalOpen] = useState(false); + + const headerTitle = ctx.currentRoom + ? ctx.currentRoom.name + : ctx.chatTitle.value; + + const roomOnlineCount = useMemo(() => { + if (!ctx.currentRoomId) return 0; + return ctx.onlineUsers.filter( + (u) => u.currentRoomId === ctx.currentRoomId && u.userId !== ctx.currentUserId, + ).length + 1; + }, [ctx.onlineUsers, ctx.currentRoomId, ctx.currentUserId]); + + return ( + + {/* ── Rooms sidebar ───────────────────────────────────────── */} + {ctx.showRoomsPanel && ( + setCreateModalOpen(true)} + onInviteModalOpen={ + ctx.currentRoom?.type === "private" + ? () => setInviteModalOpen(true) + : undefined + } + /> + )} + + {/* ── Chat area ───────────────────────────────────────────── */} + + {ctx.showHeader && ( + +
+
+ {headerTitle} +
+ {ctx.currentRoom?.description && ( +
+ {ctx.currentRoom.description} +
+ )} +
+ {ctx.currentRoomId && roomOnlineCount > 0 && ( + + + {trans("chatBox.onlineCount", { count: roomOnlineCount })} + + )} +
+ )} + + + + { + ctx.lastSentMessageText.onChange(text); + ctx.onEvent("messageSent"); + }} + onStartTyping={() => ctx.onEvent("startTyping")} + onStopTyping={() => ctx.onEvent("stopTyping")} + onDraftChange={(text) => ctx.messageText.onChange(text)} + inputStyle={ctx.inputStyle} + /> +
+ + {/* ── Modals ──────────────────────────────────────────────── */} + setCreateModalOpen(false)} + onCreateRoom={async (name, type, description, llmQueryName) => { + ctx.onRoomCreate(name, type, description, llmQueryName); + const placeholder: ChatRoom = { + id: "__pending__", + name, + type, + description: description || null, + members: [ctx.currentUserId], + createdBy: ctx.currentUserId, + createdAt: Date.now(), + llmQueryName: llmQueryName || null, + }; + return placeholder; + }} + onRoomCreatedEvent={() => {}} + /> + + setInviteModalOpen(false)} + currentRoom={ctx.currentRoom} + onSendInvite={async (toUserId) => { + ctx.onInviteSend(toUserId); + return true; + }} + /> +
+ ); +}); + +ChatBoxView.displayName = "ChatBoxView"; diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/components/CreateRoomModal.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/components/CreateRoomModal.tsx new file mode 100644 index 0000000000..a0d1276ae9 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/components/CreateRoomModal.tsx @@ -0,0 +1,226 @@ +import React, { useCallback, useState } from "react"; +import { Modal, Form, Input, Radio, Button, Space, Alert, Segmented } from "antd"; +import { + PlusOutlined, + GlobalOutlined, + LockOutlined, + RobotOutlined, + ThunderboltOutlined, +} from "@ant-design/icons"; +import type { ChatRoom } from "../store"; +import { trans } from "i18n"; + +export interface CreateRoomModalProps { + open: boolean; + onClose: () => void; + onCreateRoom: ( + name: string, + type: "public" | "private" | "llm", + description?: string, + llmQueryName?: string, + ) => Promise; + onRoomCreatedEvent: () => void; +} + +type RoomMode = "normal" | "llm"; + +export const CreateRoomModal = React.memo((props: CreateRoomModalProps) => { + const { open, onClose, onCreateRoom, onRoomCreatedEvent } = props; + const [form] = Form.useForm(); + const [roomMode, setRoomMode] = useState("normal"); + + const handleModeChange = useCallback((val: string | number) => { + setRoomMode(val as RoomMode); + // Reset visibility when switching modes + form.setFieldValue("roomType", val === "llm" ? "llm" : "public"); + }, [form]); + + const handleFinish = useCallback( + async (values: { + roomName: string; + roomType: "public" | "private" | "llm"; + description?: string; + llmQueryName?: string; + }) => { + const type: "public" | "private" | "llm" = + roomMode === "llm" ? "llm" : values.roomType; + + const room = await onCreateRoom( + values.roomName.trim(), + type, + values.description, + roomMode === "llm" ? values.llmQueryName?.trim() : undefined, + ); + + if (room) { + form.resetFields(); + setRoomMode("normal"); + onClose(); + onRoomCreatedEvent(); + } + }, + [onCreateRoom, form, onClose, onRoomCreatedEvent, roomMode], + ); + + const handleCancel = useCallback(() => { + onClose(); + form.resetFields(); + setRoomMode("normal"); + }, [onClose, form]); + + return ( + + {/* Room mode selector */} +
+
+ {trans("chatBox.roomTypeLabel")} +
+ + + {trans("chatBox.normalRoomLabel")} +
+ ), + value: "normal", + }, + { + label: ( +
+ + {trans("chatBox.aiRoomLabel")} +
+ ), + value: "llm", + }, + ]} + /> + + + {roomMode === "llm" && ( + } + style={{ + marginBottom: 16, + background: "#faf5ff", + border: "1px solid #e9d5ff", + borderRadius: 8, + }} + message={ + + {trans("chatBox.aiRoomStrongLabel")}{" "} + {trans("chatBox.aiRoomMessage")} + + } + /> + )} + +
+ + + + + + + + + {roomMode === "normal" && ( + + + + {trans("chatBox.publicRoomsLabel")} + + + {trans("chatBox.privateRoomsLabel")} + + + + )} + + {roomMode === "llm" && ( + + {trans("chatBox.queryNameLabel")}{" "} + + ({trans("chatBox.queryNameHint")}) + + + } + rules={[{ required: true, message: trans("chatBox.queryNameRequired") }]} + extra={ + + {trans("chatBox.queryNameExtraPrefix")}{" "} + conversationHistory,{" "} + prompt, and{" "} + roomId {trans("chatBox.queryNameExtraSuffix")} + + } + > + } + /> + + )} + + + + + + + +
+
+ ); +}); + +CreateRoomModal.displayName = "CreateRoomModal"; diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/components/InputBar.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/components/InputBar.tsx new file mode 100644 index 0000000000..c1eb6cf20b --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/components/InputBar.tsx @@ -0,0 +1,107 @@ +import React, { useCallback, useRef, useState } from "react"; +import { Button } from "antd"; +import { SendOutlined } from "@ant-design/icons"; +import type { ChatBoxInputStyleType } from "comps/controls/styleControlConstants"; +import { InputBarContainer, StyledTextArea } from "../styles"; +import { trans } from "i18n"; + +export interface InputBarProps { + onSend: (text: string) => void; + onStartTyping: () => void; + onStopTyping: () => void; + onDraftChange: (text: string) => void; + inputStyle?: ChatBoxInputStyleType; +} + +export const InputBar = React.memo((props: InputBarProps) => { + const { onSend, onStartTyping, onStopTyping, onDraftChange, inputStyle } = props; + const [draft, setDraft] = useState(""); + const typingTimeoutRef = useRef | null>(null); + const isTypingRef = useRef(false); + + const clearTypingTimeout = useCallback(() => { + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + typingTimeoutRef.current = null; + } + }, []); + + const handleStopTyping = useCallback(() => { + clearTypingTimeout(); + if (isTypingRef.current) { + isTypingRef.current = false; + onStopTyping(); + } + }, [onStopTyping, clearTypingTimeout]); + + const handleSend = useCallback(() => { + if (!draft.trim()) return; + handleStopTyping(); + onSend(draft.trim()); + setDraft(""); + onDraftChange(""); + }, [draft, onSend, handleStopTyping, onDraftChange]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }, + [handleSend], + ); + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value; + setDraft(value); + onDraftChange(value); + + if (!value.trim()) { + handleStopTyping(); + return; + } + + if (!isTypingRef.current) { + isTypingRef.current = true; + onStartTyping(); + } + + clearTypingTimeout(); + typingTimeoutRef.current = setTimeout(() => { + handleStopTyping(); + }, 2000); + }, + [onStartTyping, handleStopTyping, clearTypingTimeout, onDraftChange], + ); + + const sendBtnStyle: React.CSSProperties = inputStyle ? { + backgroundColor: inputStyle.sendButtonBackground, + borderColor: inputStyle.sendButtonBackground, + color: inputStyle.sendButtonIcon, + } : {}; + + return ( + + + + + + + + + )} + + ); +}); + +InviteUserModal.displayName = "InviteUserModal"; diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/components/MessageList.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/components/MessageList.tsx new file mode 100644 index 0000000000..92ac29e171 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/components/MessageList.tsx @@ -0,0 +1,208 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { Tooltip } from "antd"; +import { CopyOutlined, CheckOutlined, RobotOutlined } from "@ant-design/icons"; +import dayjs from "dayjs"; +import { parseMessageTimestamp, formatChatTime } from "util/dateTimeUtils"; +import { LLM_BOT_AUTHOR_ID } from "../store"; +import type { ChatBoxMessageStyleType } from "comps/controls/styleControlConstants"; +import { trans } from "i18n"; +import { + MessagesArea, + MessageWrapper, + Bubble, + BubbleMeta, + BubbleTime, + EmptyChat, + TypingIndicatorWrapper, + TypingDots, + TypingLabel, + AiBubbleWrapper, + AiBadge, + AiBubble, + AiCopyButton, + LlmLoadingBubble, +} from "../styles"; + +function readField(msg: any, ...keys: string[]): string { + for (const k of keys) { + if (msg[k] != null && msg[k] !== "") return String(msg[k]); + } + return ""; +} + +// ── AI message bubble with copy button ─────────────────────────────────────── + +const AiMessageBubble = React.memo( + ({ text, authorName, ts }: { text: string; authorName: string; ts: dayjs.Dayjs | null }) => { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(text).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1800); + }); + }, [text]); + + return ( + + + + {authorName} + +
+ + {text} + + + + {copied ? ( + + ) : ( + + )} + + +
+ {ts && ( + + {formatChatTime(ts)} + + )} +
+ ); + }, +); + +AiMessageBubble.displayName = "AiMessageBubble"; + +// ── Main component ─────────────────────────────────────────────────────────── + +export interface MessageListProps { + messages: any[]; + typingUsers: any[]; + currentUserId: string; + isAiThinking?: boolean; + messageStyle?: ChatBoxMessageStyleType; +} + +export const MessageList = React.memo((props: MessageListProps) => { + const { messages, typingUsers, currentUserId, isAiThinking = false, messageStyle } = props; + const containerRef = useRef(null); + + useEffect(() => { + if (containerRef.current) { + containerRef.current.scrollTo({ + top: containerRef.current.scrollHeight, + behavior: "smooth", + }); + } + }, [messages.length, isAiThinking]); + + return ( + + {messages.length === 0 ? ( + +
💬
+
{trans("chatBox.noMessagesYet")}
+
{trans("chatBox.startConversation")}
+
+ ) : ( + messages.map((msg, idx) => { + const id = readField(msg, "id", "_id") || `msg_${idx}`; + const text = readField(msg, "text", "message", "content"); + const authorId = readField( + msg, + "authorId", + "userId", + "author_id", + "sender", + ); + const authorName = + readField( + msg, + "authorName", + "userName", + "author_name", + "senderName", + ) || authorId; + const ts = parseMessageTimestamp(msg); + const authorType = msg.authorType || msg.role || ""; + + const isAssistant = + authorType === "assistant" || + authorId === LLM_BOT_AUTHOR_ID; + const isOwn = !isAssistant && authorId === currentUserId; + + if (isAssistant) { + return ( + + ); + } + + return ( + + {authorName} + {text} + {ts && ( + + {formatChatTime(ts)} + + )} + + ); + }) + )} + + {isAiThinking && ( + + + + {trans("chatBox.aiThinking")} + + + + + + + + )} + + {typingUsers.length > 0 && ( + + + + + + + + {typingUsers.length === 1 + ? trans("chatBox.singleUserTyping", { + userName: + typingUsers[0].userName || + typingUsers[0].userId || + trans("chatBox.someoneLabel"), + }) + : trans("chatBox.multipleUsersTyping", { count: typingUsers.length })} + + + )} + +
+ ); +}); + +MessageList.displayName = "MessageList"; diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/components/RoomPanel.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/components/RoomPanel.tsx new file mode 100644 index 0000000000..685ed386ef --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/components/RoomPanel.tsx @@ -0,0 +1,397 @@ +import React, { useMemo, useState } from "react"; +import { Button, Input, Tooltip, Popconfirm } from "antd"; +import { + PlusOutlined, + SearchOutlined, + GlobalOutlined, + LockOutlined, + LogoutOutlined, + RobotOutlined, + MailOutlined, + UserAddOutlined, + TeamOutlined, +} from "@ant-design/icons"; +import type { ChatRoom, OnlineUser } from "../store"; +import { + RoomPanelContainer, + RoomPanelHeader, + RoomListContainer, + RoomItemStyled, + LlmRoomBadge, + OnlinePresenceSection, + OnlinePresenceLabel, + OnlineUserItem, + OnlineAvatar, + OnlineDot, + OnlineUserName, +} from "../styles"; +import { useChatBox } from "../ChatBoxContext"; +import { trans } from "i18n"; + +export interface RoomPanelProps { + onCreateModalOpen: () => void; + onInviteModalOpen?: () => void; +} + +export const RoomPanel = React.memo((props: RoomPanelProps) => { + const { onCreateModalOpen, onInviteModalOpen } = props; + const { + rooms, + currentRoomId, + currentUserId, + currentUserName, + allowRoomCreation, + allowRoomSearch, + roomsPanelWidth, + pendingInvites, + onlineUsers, + sidebarStyle, + onRoomSwitch, + onRoomLeave, + onInviteAccept, + onInviteDecline, + } = useChatBox(); + + // Users in the current room (from Pluv presence), plus self + const roomOnlineUsers = useMemo(() => { + const peers = onlineUsers.filter( + (u) => u.currentRoomId === currentRoomId && u.userId !== currentUserId, + ); + const self: OnlineUser = { + userId: currentUserId, + userName: currentUserName, + currentRoomId, + }; + return currentRoomId ? [self, ...peers] : peers; + }, [onlineUsers, currentRoomId, currentUserId, currentUserName]); + + const [searchQuery, setSearchQuery] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const [isSearchMode, setIsSearchMode] = useState(false); + + const handleSearch = (q: string) => { + setSearchQuery(q); + if (!q.trim()) { + setIsSearchMode(false); + setSearchResults([]); + return; + } + setIsSearchMode(true); + const lower = q.toLowerCase(); + setSearchResults( + rooms.filter((r) => r.name.toLowerCase().includes(lower)), + ); + }; + + const clearSearch = () => { + setSearchQuery(""); + setIsSearchMode(false); + setSearchResults([]); + }; + + const roomListItems = isSearchMode ? searchResults : rooms; + + const publicRooms = roomListItems.filter((r) => r.type === "public"); + const privateRooms = roomListItems.filter((r) => r.type === "private"); + const llmRooms = roomListItems.filter((r) => r.type === "llm"); + + const renderRoomItem = (room: ChatRoom) => { + const isActive = currentRoomId === room.id; + const isSearch = isSearchMode; + + return ( + { + if (!isActive) { + onRoomSwitch(room.id); + } + if (isSearch) { + clearSearch(); + } + }} + title={room.name} + > + {room.type === "llm" ? ( + + ) : room.type === "public" ? ( + + ) : ( + + )} + + {room.name} + + {room.type === "llm" && !isSearch && ( + + {trans("chatBox.aiShortLabel")} + + )} + {isActive && !isSearch && ( + { + e?.stopPropagation(); + onRoomLeave(room.id); + }} + onCancel={(e) => e?.stopPropagation()} + okText={trans("chatBox.leaveAction")} + cancelText={trans("chatBox.cancelAction")} + okButtonProps={{ danger: true }} + > + e.stopPropagation()} + style={{ fontSize: 12, opacity: 0.7 }} + /> + + )} + + ); + }; + + return ( + + + {trans("chatBox.roomsHeader")} +
+ {onInviteModalOpen && ( + +
+
+ + {allowRoomSearch && ( +
+ } + value={searchQuery} + onChange={(e) => handleSearch(e.target.value)} + allowClear + onClear={clearSearch} + /> +
+ )} + + {isSearchMode && ( +
+ {searchResults.length > 0 + ? trans( + searchResults.length === 1 + ? "chatBox.searchResultsCountSingle" + : "chatBox.searchResultsCountPlural", + { count: searchResults.length }, + ) + : trans("chatBox.noRoomsMatch", { searchQuery })} + +
+ )} + + {/* Pending invites section */} + {!isSearchMode && pendingInvites.length > 0 && ( +
+
+ + {trans("chatBox.pendingInvitesHeader", { count: pendingInvites.length })} +
+ {pendingInvites.map((invite) => ( +
+
+ + {invite.roomName} +
+
+ {trans("chatBox.invitedBy", { userName: invite.fromUserName })} +
+
+ + +
+
+ ))} +
+ )} + + + {roomListItems.length === 0 && !isSearchMode && ( +
+ {allowRoomCreation + ? trans("chatBox.noRoomsYetCreateOne") + : trans("chatBox.noRoomsYet")} +
+ )} + + {isSearchMode + ? roomListItems.map(renderRoomItem) + : ( + <> + {llmRooms.length > 0 && ( + <> + + {llmRooms.map(renderRoomItem)} + + )} + {publicRooms.length > 0 && ( + <> + + {publicRooms.map(renderRoomItem)} + + )} + {privateRooms.length > 0 && ( + <> + + {privateRooms.map(renderRoomItem)} + + )} + + )} +
+ + {/* ── Online Presence ─────────────────────────────────────── */} + {currentRoomId && roomOnlineUsers.length > 0 && ( + + + + {trans("chatBox.onlinePresence", { count: roomOnlineUsers.length })} + + {roomOnlineUsers.map((user) => ( + + + {(user.userName || user.userId).slice(0, 1).toUpperCase()} + + + + {user.userId === currentUserId + ? trans("chatBox.userWithYou", { userName: user.userName }) + : user.userName} + + + ))} + + )} +
+ ); +}); + +RoomPanel.displayName = "RoomPanel"; + +// ── Avatar color helper ─────────────────────────────────────────────────────── + +const AVATAR_PALETTE = [ + "#1890ff", "#52c41a", "#fa8c16", "#722ed1", + "#eb2f96", "#13c2c2", "#faad14", "#f5222d", +]; + +function avatarColor(userId: string): string { + let hash = 0; + for (let i = 0; i < userId.length; i++) { + hash = userId.charCodeAt(i) + ((hash << 5) - hash); + } + return AVATAR_PALETTE[Math.abs(hash) % AVATAR_PALETTE.length]; +} + +// ── Section label ───────────────────────────────────────────────────────────── + +const RoomSectionLabel = React.memo(({ label }: { label: string }) => ( +
+ {label} +
+)); + +RoomSectionLabel.displayName = "RoomSectionLabel"; diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/hooks/useChatManager.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/hooks/useChatManager.ts deleted file mode 100644 index cd0df73293..0000000000 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/hooks/useChatManager.ts +++ /dev/null @@ -1,617 +0,0 @@ -// React hook for managing chat data through HybridChatManager -// Provides a clean interface for ChatBoxComponent to use our data layer - -import { useEffect, useRef, useState, useCallback } from 'react'; -import { HybridChatManager, HybridChatManagerConfig, ManagerEvent } from '../managers/HybridChatManager'; -import { UnifiedMessage, UnifiedRoom, ConnectionState, ChatEvent, TypingState, CreateRoomRequest, JoinRoomRequest, RoomListFilter } from '../types/chatDataTypes'; - -// Hook configuration -export interface UseChatManagerConfig { - userId: string; - userName: string; - applicationId: string; - roomId: string; - mode?: 'local' | 'collaborative' | 'hybrid'; - autoConnect?: boolean; - dbName?: string; -} - -// Hook return type -export interface UseChatManagerReturn { - // Connection state - isConnected: boolean; - connectionState: ConnectionState; - isLoading: boolean; - error: string | null; - - // Current room data - currentRoom: UnifiedRoom | null; - messages: UnifiedMessage[]; - typingUsers: TypingState[]; - - // Operations - sendMessage: (text: string, messageType?: 'text' | 'system') => Promise; - loadMoreMessages: () => Promise; - refreshMessages: () => Promise; - - // Typing indicators - startTyping: () => Promise; - stopTyping: () => Promise; - - // Room management - setCurrentRoom: (roomId: string) => Promise; - createRoom: (name: string, type?: 'private' | 'public' | 'group') => Promise; - - // Enhanced room management - createRoomFromRequest: (request: CreateRoomRequest) => Promise; - getAvailableRooms: (filter?: RoomListFilter) => Promise; - joinRoom: (roomId: string) => Promise; - leaveRoom: (roomId: string) => Promise; - canUserJoinRoom: (roomId: string) => Promise; - getRoomParticipants: (roomId: string) => Promise>; - - // Manager access (for advanced use) - manager: HybridChatManager | null; - - // Cleanup - disconnect: () => Promise; -} - -export function useChatManager(config: UseChatManagerConfig): UseChatManagerReturn { - // State management - const [isConnected, setIsConnected] = useState(false); - const [connectionState, setConnectionState] = useState('disconnected'); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const [currentRoom, setCurrentRoom] = useState(null); - const [messages, setMessages] = useState([]); - const [typingUsers, setTypingUsers] = useState([]); - - // Manager reference - const managerRef = useRef(null); - const unsubscribeRefs = useRef<(() => void)[]>([]); - - // Initialize manager - const initializeManager = useCallback(async () => { - if (managerRef.current) { - return; // Already initialized - } - setIsLoading(true); - setError(null); - - try { - console.log(`[ChatManager] 🏗️ Initializing chat manager for user ${config.userId} in application ${config.applicationId}`); - - const managerConfig: HybridChatManagerConfig = { - mode: config.mode || 'collaborative', // Default to collaborative - userId: config.userId, - userName: config.userName, - applicationId: config.applicationId, - local: { - // Use applicationId for database scoping so all components within the same - // Lowcoder application share the same ALASql database. This enables - // cross-component room discovery while maintaining application isolation. - dbName: config.dbName || `ChatDB_App_${config.applicationId}`, - }, - // 🧪 TEST: Add collaborative config to enable YjsPluvProvider for testing - // This enables testing of the Yjs document structure (Step 1) - collaborative: { - serverUrl: 'ws://localhost:3005', // Placeholder - not used in Step 1 - roomId: config.roomId, - authToken: undefined, - autoConnect: true, - }, - autoReconnect: true, - reconnectDelay: 2000, - }; - - const manager = new HybridChatManager(managerConfig); - managerRef.current = manager; - - // Set up connection state listener - const connectionUnsub = manager.subscribeToConnection((state) => { - setConnectionState(state); - setIsConnected(state === 'connected'); - - if (state === 'failed') { - setError('Connection failed'); - } else if (state === 'connected') { - setError(null); - } - }); - unsubscribeRefs.current.push(connectionUnsub); - - // Set up manager event listener - const managerUnsub = manager.subscribeToManagerEvents((event: ManagerEvent) => { - if (event.type === 'sync_failed') { - setError(event.error || 'Sync failed'); - } - }); - unsubscribeRefs.current.push(managerUnsub); - - // Initialize the manager - const result = await manager.initialize(); - - if (!result.success) { - throw new Error(result.error || 'Failed to initialize chat manager'); - } - - // Set up initial room - await setupCurrentRoom(manager, config.roomId); - - } catch (err) { - console.error('[ChatManager] Failed to initialize chat manager:', err); - setError(err instanceof Error ? err.message : 'Unknown error'); - } finally { - setIsLoading(false); - } - }, [config.userId, config.userName, config.applicationId, config.mode, config.dbName]); - - // Setup current room and message subscription - const setupCurrentRoom = useCallback(async (manager: HybridChatManager, roomIdentifier: string) => { - try { - console.log(`[ChatManager] 🏠 Setting up room: "${roomIdentifier}" for user: ${config.userId}`); - console.log(`[ChatManager] 🏠 Application scope: ${config.applicationId}`); - - // Try to get existing room by name first - console.log(`[ChatManager] 🔍 Searching for room by name: "${roomIdentifier}"`); - let roomResult = await manager.getRoomByName(roomIdentifier); - console.log(`[ChatManager] 🔍 getRoomByName result:`, roomResult); - - if (!roomResult.success) { - // Fallback to searching by ID for backward compatibility - console.log(`[ChatManager] 🔍 Room not found by name, trying by ID: "${roomIdentifier}"`); - roomResult = await manager.getRoom(roomIdentifier); - console.log(`[ChatManager] 🔍 getRoom result:`, roomResult); - } - - if (!roomResult.success) { - // Create room if it doesn't exist - console.log(`[ChatManager] 🏗️ Creating new room: "${roomIdentifier}" as public`); - const createResult = await manager.createRoom({ - name: roomIdentifier, // Use the identifier as the name - type: 'public', // Make initial rooms public so they can be discovered - participants: [config.userId], - admins: [config.userId], - creator: config.userId, - isActive: true, - lastActivity: Date.now() - }); - - if (!createResult.success) { - throw new Error(createResult.error || 'Failed to create room'); - } - - console.log(`[ChatManager] ✅ Created room:`, createResult.data); - roomResult = createResult; - } else { - // Room exists - check if user is a participant, if not, join them - const room = roomResult.data!; - console.log(`[ChatManager] 🏠 Found existing room:`, room); - - const isUserParticipant = room.participants?.some((p: any) => { - const participantId = typeof p === 'string' ? p : p.id; - return participantId === config.userId; - }); - - console.log(`[ChatManager] 👤 User ${config.userId} is ${isUserParticipant ? 'already' : 'NOT'} a participant`); - - if (!isUserParticipant) { - console.log(`[ChatManager] 🚪 User not in room "${roomIdentifier}", attempting to join...`); - try { - await manager.joinRoom({ - roomId: room.id, - userId: config.userId, - userName: config.userName - }); - // Refresh room data after joining - roomResult = await manager.getRoom(room.id); - console.log(`[ChatManager] ✅ Successfully joined room, updated data:`, roomResult.data); - } catch (joinError) { - console.warn(`[ChatManager] ⚠️ Failed to auto-join room "${roomIdentifier}":`, joinError); - // Continue anyway - user might still be able to use the room - } - } - } - - setCurrentRoom(roomResult.data!); - - // Subscribe to room events - const roomUnsub = manager.subscribeToRoom(roomResult.data!.id, (event: ChatEvent) => { - if (event.type === 'message_added') { - setMessages(prev => { - const newMessages = [...prev, event.data as UnifiedMessage]; - return newMessages; - }); - } else if (event.type === 'message_updated') { - setMessages(prev => prev.map(msg => - msg.id === event.data.id ? { ...msg, ...event.data } : msg - )); - } else if (event.type === 'message_deleted') { - setMessages(prev => prev.filter(msg => msg.id !== event.data.messageId)); - } else if (event.type === 'typing_started') { - console.log('[ChatManager] 🖊️ User started typing:', event.data); - setTypingUsers(prev => { - const existing = prev.find(user => user.userId === event.data.userId); - if (existing) return prev; // Already typing - return [...prev, event.data]; - }); - } else if (event.type === 'typing_stopped') { - console.log('[ChatManager] 🖊️ User stopped typing:', event.data); - setTypingUsers(prev => prev.filter(user => user.userId !== event.data.userId)); - } - }); - unsubscribeRefs.current.push(roomUnsub); - - // Load initial messages - await loadMessages(manager, roomResult.data!.id); - - } catch (err) { - console.error('[ChatManager] Error in setupCurrentRoom:', err); - setError(err instanceof Error ? err.message : 'Failed to setup room'); - } - }, [config.userId]); // Remove loadMessages from dependencies to avoid circular dependency - - // Load messages for current room - const loadMessages = useCallback(async (manager: HybridChatManager, roomId: string, before?: number) => { - try { - const result = await manager.getMessages(roomId, 50, before); - - if (result.success) { - if (before) { - // Prepend older messages - setMessages(prev => [...result.data!, ...prev]); - } else { - // Set initial messages - setMessages(result.data!); - } - } else { - console.error('Failed to load messages:', result.error); - } - } catch (err) { - console.error('Error loading messages:', err); - } - }, []); - - // Send message - const sendMessage = useCallback(async (text: string, messageType: 'text' | 'system' = 'text'): Promise => { - const manager = managerRef.current; - - if (!manager || !currentRoom) { - setError('Chat not connected'); - return false; - } - - if (!text.trim()) { - return false; - } - - try { - const messageObj = { - text: text.trim(), - authorId: config.userId, - authorName: config.userName, - roomId: currentRoom.id, - messageType, - }; - - const result = await manager.sendMessage(messageObj); - - if (!result.success) { - setError(result.error || 'Failed to send message'); - return false; - } - - // Message will be added via subscription - return true; - } catch (err) { - console.error('[ChatManager] Error sending message:', err); - setError(err instanceof Error ? err.message : 'Failed to send message'); - return false; - } - }, [config.userId, config.userName, currentRoom]); - - // Load more messages (pagination) - const loadMoreMessages = useCallback(async () => { - const manager = managerRef.current; - if (!manager || !currentRoom || messages.length === 0) { - return; - } - - const oldestMessage = messages[0]; - await loadMessages(manager, currentRoom.id, oldestMessage.timestamp); - }, [currentRoom, messages, loadMessages]); - - // Refresh messages - const refreshMessages = useCallback(async () => { - const manager = managerRef.current; - if (!manager || !currentRoom) { - return; - } - - await loadMessages(manager, currentRoom.id); - }, [currentRoom, loadMessages]); - - // Set current room - const setCurrentRoomById = useCallback(async (roomId: string) => { - const manager = managerRef.current; - if (!manager) { - return; - } - - // Clean up existing room subscription - unsubscribeRefs.current.forEach(unsub => unsub()); - unsubscribeRefs.current = []; - - await setupCurrentRoom(manager, roomId); - }, [setupCurrentRoom]); - - // Create new room - const createRoom = useCallback(async (name: string, type: 'private' | 'public' | 'group' = 'private'): Promise => { - const manager = managerRef.current; - if (!manager) { - return null; - } - - try { - const result = await manager.createRoom({ - name, - type, - participants: [config.userId], - admins: [config.userId], - creator: config.userId, - isActive: true, - lastActivity: Date.now(), - }); - - if (result.success) { - return result.data!.id; - } else { - setError(result.error || 'Failed to create room'); - return null; - } - } catch (err) { - console.error('Error creating room:', err); - setError(err instanceof Error ? err.message : 'Failed to create room'); - return null; - } - }, [config.userId]); - - // Disconnect - const disconnect = useCallback(async () => { - const manager = managerRef.current; - if (!manager) { - return; - } - - // Clean up subscriptions - unsubscribeRefs.current.forEach(unsub => unsub()); - unsubscribeRefs.current = []; - - // Disconnect manager - await manager.disconnect(); - managerRef.current = null; - - // Reset state - setIsConnected(false); - setConnectionState('disconnected'); - setCurrentRoom(null); - setMessages([]); - setTypingUsers([]); - setError(null); - }, []); - - // Typing indicator functions - const startTyping = useCallback(async () => { - const manager = managerRef.current; - if (!manager || !currentRoom) return; - - try { - await manager.startTyping(currentRoom.id); - } catch (error) { - console.error('[ChatManager] Failed to start typing:', error); - } - }, [currentRoom]); - - const stopTyping = useCallback(async () => { - const manager = managerRef.current; - if (!manager || !currentRoom) return; - - try { - await manager.stopTyping(currentRoom.id); - } catch (error) { - console.error('[ChatManager] Failed to stop typing:', error); - } - }, [currentRoom]); - - // Auto-connect on mount - useEffect(() => { - if (config.autoConnect !== false) { - initializeManager(); - } - - return () => { - // Cleanup on unmount - disconnect(); - }; - }, [config.autoConnect, initializeManager]); - - // Update room when roomId changes - useEffect(() => { - if (managerRef.current && isConnected && config.roomId) { - setCurrentRoomById(config.roomId); - } - }, [config.roomId, isConnected, setCurrentRoomById]); - - // ------------------------------------------------------------ - // Cross-component message propagation (same browser tab) - // ------------------------------------------------------------ - // Each ALASqlProvider instance fires a CustomEvent on `window` when it inserts - // a new message. Listen for that here so that *other* ChatBox components that - // use a different provider instance (e.g. because they have a different - // userId) immediately receive the update without refreshing. - useEffect(() => { - const handler = (e: any) => { - const { roomId, message } = (e as CustomEvent).detail || {}; - if (!roomId || !message) return; - // Only handle messages for the current room that were not sent by *this* user - if (roomId === currentRoom?.id && message.authorId !== config.userId) { - setMessages((prev) => { - if (prev.some((m) => m.id === message.id)) return prev; // de-dupe - return [...prev, message]; - }); - } - }; - window.addEventListener("alasql-chat-message-added", handler as EventListener); - return () => window.removeEventListener("alasql-chat-message-added", handler as EventListener); - }, [currentRoom?.id, config.userId]); - - // Enhanced room management functions - const createRoomFromRequest = useCallback(async (request: CreateRoomRequest): Promise => { - const manager = managerRef.current; - if (!manager) return null; - - try { - const result = await manager.createRoomFromRequest(request, config.userId); - if (result.success) { - console.log('[useChatManager] 🏠 Created room from request:', result.data); - return result.data!; - } - setError(result.error || 'Failed to create room'); - return null; - } catch (error) { - setError(error instanceof Error ? error.message : 'Failed to create room'); - return null; - } - }, [config.userId]); - - const getAvailableRooms = useCallback(async (filter?: RoomListFilter): Promise => { - const manager = managerRef.current; - if (!manager) return []; - - try { - const result = await manager.getAvailableRooms(config.userId, filter); - if (result.success) { - return result.data!; - } - setError(result.error || 'Failed to get available rooms'); - return []; - } catch (error) { - setError(error instanceof Error ? error.message : 'Failed to get available rooms'); - return []; - } - }, [config.userId]); - - const joinRoom = useCallback(async (roomId: string): Promise => { - const manager = managerRef.current; - if (!manager) return false; - - try { - const result = await manager.joinRoom({ - roomId, - userId: config.userId, - userName: config.userName - }); - if (result.success) { - console.log('[useChatManager] 🚪 Joined room:', result.data!.name); - // Switch to the joined room - await setCurrentRoomById(roomId); - return true; - } - setError(result.error || 'Failed to join room'); - return false; - } catch (error) { - setError(error instanceof Error ? error.message : 'Failed to join room'); - return false; - } - }, [config.userId, config.userName, setCurrentRoomById]); - - const leaveRoom = useCallback(async (roomId: string): Promise => { - const manager = managerRef.current; - if (!manager) return false; - - try { - const result = await manager.leaveRoom(roomId, config.userId); - if (result.success) { - console.log('[useChatManager] 🚪 Left room:', roomId); - // If we left the current room, switch to a default room - if (currentRoom?.id === roomId) { - await setCurrentRoomById(config.roomId); // Fall back to default room - } - return true; - } - setError(result.error || 'Failed to leave room'); - return false; - } catch (error) { - setError(error instanceof Error ? error.message : 'Failed to leave room'); - return false; - } - }, [config.userId, config.roomId, currentRoom?.id, setCurrentRoomById]); - - const canUserJoinRoom = useCallback(async (roomId: string): Promise => { - const manager = managerRef.current; - if (!manager) return false; - - try { - const result = await manager.canUserJoinRoom(roomId, config.userId); - return result.success ? result.data! : false; - } catch (error) { - return false; - } - }, [config.userId]); - - const getRoomParticipants = useCallback(async (roomId: string): Promise> => { - const manager = managerRef.current; - if (!manager) return []; - - try { - const result = await manager.getRoomParticipants(roomId); - if (result.success) { - return result.data!; - } - setError(result.error || 'Failed to get room participants'); - return []; - } catch (error) { - setError(error instanceof Error ? error.message : 'Failed to get room participants'); - return []; - } - }, []); - - return { - // Connection state - isConnected, - connectionState, - isLoading, - error, - - // Current room data - currentRoom, - messages, - typingUsers, - - // Operations - sendMessage, - loadMoreMessages, - refreshMessages, - startTyping, - stopTyping, - - // Room management - setCurrentRoom: setCurrentRoomById, - createRoom, - - // Enhanced room management - createRoomFromRequest, - getAvailableRooms, - joinRoom, - leaveRoom, - canUserJoinRoom, - getRoomParticipants, - - // Manager access - manager: managerRef.current, - - // Cleanup - disconnect, - }; -} \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/index.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/index.ts deleted file mode 100644 index 62ee57d893..0000000000 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -// ChatBoxComponent Module Exports -// Provides clean access to all chat component functionality - -// Main component -export { ChatBoxComp } from './chatBoxComp'; - -// Data layer -export type { ChatDataProvider } from './providers/ChatDataProvider'; -export { BaseChatDataProvider } from './providers/ChatDataProvider'; -export { ALASqlProvider } from './providers/ALASqlProvider'; -export { YjsPluvProvider } from './providers/YjsPluvProvider'; -// export type { YjsPluvProviderConfig } from './providers/YjsPluvProvider'; - -// Management layer -export { HybridChatManager } from './managers/HybridChatManager'; -export type { HybridChatManagerConfig } from './managers/HybridChatManager'; - -// React hooks -export { useChatManager } from './hooks/useChatManager'; -export type { UseChatManagerConfig } from './hooks/useChatManager'; - -// Types and utilities -export * from './types/chatDataTypes'; \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/index.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/index.tsx index 52de413b71..6e5a36bf18 100644 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/index.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/index.tsx @@ -1 +1 @@ -export { ChatBoxComp } from "./chatBoxComp"; \ No newline at end of file +export { ChatBoxComp } from "./chatBoxComp"; diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/managers/HybridChatManager.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/managers/HybridChatManager.ts deleted file mode 100644 index 11cb44cdf3..0000000000 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/managers/HybridChatManager.ts +++ /dev/null @@ -1,668 +0,0 @@ -// Hybrid Chat Manager -// Coordinates between local (ALASql) and collaborative (Yjs+Pluv.io) providers -// Provides a unified interface for chat components - -import { ChatDataProvider, UnsubscribeFunction } from '../providers/ChatDataProvider'; -import { ALASqlProvider } from '../providers/ALASqlProvider'; -import { YjsPluvProvider } from '../providers/YjsPluvProvider'; -import { - UnifiedMessage, - UnifiedRoom, - UserPresence, - TypingState, - ConnectionConfig, - ConnectionState, - ChatEvent, - OperationResult, - ChatDataError, - ChatErrorCodes, - CreateRoomRequest, - JoinRoomRequest, - RoomMembershipUpdate, - RoomListFilter -} from '../types/chatDataTypes'; - -// Global provider cache to share instances across components with same applicationId -const globalProviderCache = new Map(); - -// Function to get or create shared ALASqlProvider for applicationId -function getSharedALASqlProvider(applicationId: string): ALASqlProvider { - const cacheKey = `alasql_${applicationId}`; - - if (!globalProviderCache.has(cacheKey)) { - console.log(`[HybridChatManager] 🏗️ Creating new shared ALASqlProvider for applicationId: ${applicationId}`); - globalProviderCache.set(cacheKey, new ALASqlProvider()); - } else { - console.log(`[HybridChatManager] ♻️ Reusing existing ALASqlProvider for applicationId: ${applicationId}`); - } - - return globalProviderCache.get(cacheKey)!; -} - -// Manager configuration -export interface HybridChatManagerConfig { - mode: 'local' | 'collaborative' | 'hybrid'; - userId: string; - userName: string; - applicationId: string; - - // Local provider config - local?: { - dbName?: string; - tableName?: string; - }; - - // Collaborative provider config - collaborative?: { - serverUrl: string; - roomId: string; - authToken?: string; - autoConnect?: boolean; - }; - - // Fallback behavior - fallbackToLocal?: boolean; - autoReconnect?: boolean; - reconnectDelay?: number; -} - -// Events emitted by the manager -export type ManagerEventType = 'provider_switched' | 'sync_started' | 'sync_completed' | 'sync_failed' | 'connection_changed'; - -export interface ManagerEvent { - type: ManagerEventType; - provider?: string; - error?: string; - timestamp: number; -} - -export type ManagerEventCallback = (event: ManagerEvent) => void; - -export class HybridChatManager { - private config: HybridChatManagerConfig; - private primaryProvider!: ChatDataProvider; // Use definite assignment assertion - private secondaryProvider?: ChatDataProvider; - private currentMode: 'local' | 'collaborative' | 'hybrid'; - - // Event management - private managerEventCallbacks: ManagerEventCallback[] = []; - private subscriptions: Map = new Map(); - - // Reconnection handling - private reconnectTimer?: NodeJS.Timeout; - private reconnectAttempts = 0; - private maxReconnectAttempts = 5; - - constructor(config: HybridChatManagerConfig) { - this.config = config; - this.currentMode = config.mode === 'collaborative' ? 'collaborative' : - config.mode === 'hybrid' ? 'hybrid' : 'local'; - - // Initialize providers based on mode - this.initializeProviders(); - this.setupProviderListeners(); - } - - private initializeProviders(): void { - // Use shared ALASqlProvider for same applicationId to enable cross-component room discovery - this.primaryProvider = getSharedALASqlProvider(this.config.applicationId); - - // Initialize collaborative provider if configured - if (this.config.mode === 'collaborative' || this.config.mode === 'hybrid') { - // Initialize YjsPluvProvider for collaborative features - if (this.config.collaborative) { - try { - this.secondaryProvider = new YjsPluvProvider(); - - // Switch primary provider for collaborative mode - if (this.config.mode === 'collaborative') { - [this.primaryProvider, this.secondaryProvider] = [this.secondaryProvider, this.primaryProvider]; - this.currentMode = 'collaborative'; - } - } catch (error) { - console.error('[HybridChatManager] ❌ FAILED to initialize collaborative provider:', error); - - if (this.config.fallbackToLocal !== false) { - console.log('[HybridChatManager] Falling back to local mode'); - this.currentMode = 'local'; - } else { - throw error; - } - } - } - } - } - - private setupProviderListeners(): void { - // Monitor primary provider connection - if (this.primaryProvider.subscribeToConnection) { - const connectionUnsub = this.primaryProvider.subscribeToConnection((state: ConnectionState) => { - // Handle connection failures for collaborative provider - if (this.currentMode === 'collaborative' && state === 'failed' && this.secondaryProvider) { - this.handleProviderFailure(); - } - - this.emitManagerEvent({ - type: 'connection_changed', - provider: this.primaryProvider.name, - timestamp: Date.now() - }); - }); - - this.addSubscription('connection', connectionUnsub); - } - } - - private async handleProviderFailure(): Promise { - if (!this.config.fallbackToLocal || !this.secondaryProvider) { - return; - } - - try { - // Switch providers - [this.primaryProvider, this.secondaryProvider] = [this.secondaryProvider, this.primaryProvider]; - this.currentMode = 'local'; - - // Emit switch event - this.emitManagerEvent({ - type: 'provider_switched', - provider: this.primaryProvider.name, - timestamp: Date.now() - }); - - // Start reconnection attempts for collaborative provider - this.startReconnectionTimer(); - - } catch (error) { - console.error('[HybridChatManager] Failed to switch providers:', error); - - this.emitManagerEvent({ - type: 'sync_failed', - error: error instanceof Error ? error.message : 'Provider switch failed', - timestamp: Date.now() - }); - } - } - - private startReconnectionTimer(): void { - if (this.reconnectTimer || !this.config.autoReconnect) { - return; - } - - const delay = Math.min( - this.config.reconnectDelay || 1000 * Math.pow(2, this.reconnectAttempts), - 30000 // Max 30 seconds - ); - - this.reconnectTimer = setTimeout(async () => { - this.reconnectTimer = undefined; - - if (this.reconnectAttempts >= this.maxReconnectAttempts) { - return; - } - - this.reconnectAttempts++; - - try { - // Try to reconnect the collaborative provider - if (this.secondaryProvider && this.secondaryProvider.name === 'YjsPluvProvider') { - const result = await this.secondaryProvider.connect({ - mode: 'collaborative', - userId: this.config.userId, - userName: this.config.userName, - realtime: this.config.collaborative || { - serverUrl: '', - roomId: '', - authToken: undefined - } - }); - - if (result.success) { - // Switch back to collaborative provider - [this.primaryProvider, this.secondaryProvider] = [this.secondaryProvider, this.primaryProvider]; - this.currentMode = 'collaborative'; - this.reconnectAttempts = 0; - - this.emitManagerEvent({ - type: 'provider_switched', - provider: this.primaryProvider.name, - timestamp: Date.now() - }); - } else { - this.startReconnectionTimer(); // Try again - } - } - } catch (error) { - this.startReconnectionTimer(); // Try again - } - }, delay); - } - - // Initialization - async initialize(): Promise> { - try { - this.emitManagerEvent({ - type: 'sync_started', - provider: this.primaryProvider.name, - timestamp: Date.now() - }); - - // Prepare connection config - const connectionConfig = { - mode: this.currentMode, - userId: this.config.userId, - userName: this.config.userName, - alasql: { - dbName: this.config.local?.dbName || `ChatDB_${this.config.userId}`, - tableName: this.config.local?.tableName - }, - realtime: this.config.collaborative ? { - roomId: this.config.collaborative.roomId, - serverUrl: this.config.collaborative.serverUrl, - authToken: this.config.collaborative.authToken - } : undefined - }; - - const result = await this.primaryProvider.connect(connectionConfig); - - if (!result.success) { - throw new Error(result.error || 'Failed to initialize primary provider'); - } - - this.emitManagerEvent({ - type: 'sync_completed', - provider: this.primaryProvider.name, - timestamp: Date.now() - }); - - return { success: true, data: undefined, timestamp: Date.now() }; - } catch (error) { - console.error('[HybridChatManager] 💥 Initialization failed:', error); - - this.emitManagerEvent({ - type: 'sync_failed', - error: error instanceof Error ? error.message : 'Initialization failed', - timestamp: Date.now() - }); - - return { - success: false, - error: error instanceof Error ? error.message : 'Initialization failed', - timestamp: Date.now() - }; - } - } - - async disconnect(): Promise> { - try { - // Clear reconnect timer - if (this.reconnectTimer) { - clearTimeout(this.reconnectTimer); - this.reconnectTimer = undefined; - } - - // Disconnect all providers - await this.primaryProvider.disconnect(); - if (this.secondaryProvider) { - await this.secondaryProvider.disconnect(); - } - - // Clean up subscriptions - this.subscriptions.forEach(subs => subs.forEach(unsub => unsub())); - this.subscriptions.clear(); - - return { success: true, timestamp: Date.now() }; - } catch (error) { - return this.handleError(error, 'disconnect'); - } - } - - // Provider management - private getActiveProvider(): ChatDataProvider { - // Always return primary provider for now - // TODO: Implement provider switching logic - return this.primaryProvider; - } - - private addSubscription(key: string, unsubscribe: UnsubscribeFunction): void { - if (!this.subscriptions.has(key)) { - this.subscriptions.set(key, []); - } - this.subscriptions.get(key)!.push(unsubscribe); - } - - private unsubscribeAll(key: string): void { - const unsubs = this.subscriptions.get(key); - if (unsubs) { - unsubs.forEach(unsub => unsub()); - this.subscriptions.delete(key); - } - } - - // Room operations (delegated to active provider) - async createRoom(room: Omit): Promise> { - return this.getActiveProvider().createRoom(room); - } - - async getRooms(userId?: string): Promise> { - return this.getActiveProvider().getRooms(userId); - } - - async getRoom(roomId: string): Promise> { - return this.getActiveProvider().getRoom(roomId); - } - - async getRoomByName(name: string): Promise> { - return this.getActiveProvider().getRoomByName(name); - } - - async updateRoom(roomId: string, updates: Partial): Promise> { - return this.getActiveProvider().updateRoom(roomId, updates); - } - - async deleteRoom(roomId: string): Promise> { - return this.getActiveProvider().deleteRoom(roomId); - } - - // Enhanced room management operations (delegated to active provider) - async createRoomFromRequest(request: CreateRoomRequest, creatorId: string): Promise> { - console.log('[HybridChatManager] 🏠 Creating room from request:', request); - return this.getActiveProvider().createRoomFromRequest(request, creatorId); - } - - async getAvailableRooms(userId: string, filter?: RoomListFilter): Promise> { - console.log('[HybridChatManager] 🔍 Getting available rooms for user:', userId, 'filter:', filter); - return this.getActiveProvider().getAvailableRooms(userId, filter); - } - - async joinRoom(request: JoinRoomRequest): Promise> { - console.log('[HybridChatManager] 🚪 User joining room:', request); - return this.getActiveProvider().joinRoom(request); - } - - async leaveRoom(roomId: string, userId: string): Promise> { - console.log('[HybridChatManager] 🚪 User leaving room:', { roomId, userId }); - return this.getActiveProvider().leaveRoom(roomId, userId); - } - - async updateRoomMembership(update: RoomMembershipUpdate): Promise> { - console.log('[HybridChatManager] 👥 Updating room membership:', update); - return this.getActiveProvider().updateRoomMembership(update); - } - - async canUserJoinRoom(roomId: string, userId: string): Promise> { - console.log('[HybridChatManager] 🔐 Checking if user can join room:', { roomId, userId }); - return this.getActiveProvider().canUserJoinRoom(roomId, userId); - } - - async getRoomParticipants(roomId: string): Promise>> { - console.log('[HybridChatManager] 👥 Getting room participants:', { roomId }); - - try { - // First get the room to access participants - const roomResult = await this.getRoom(roomId); - if (!roomResult.success || !roomResult.data) { - return { - success: false, - error: roomResult.error || 'Room not found', - timestamp: Date.now() - }; - } - - const room = roomResult.data; - const participants = room.participants || []; - - // Get participant details by looking at recent messages to extract user names - const messagesResult = await this.getMessages(roomId, 100); // Get recent messages - if (!messagesResult.success) { - // If we can't get messages, return participants with just IDs - return { - success: true, - data: participants.map(id => ({ id, name: id })), // Fallback to ID as name - timestamp: Date.now() - }; - } - - // Create a map of userId -> userName from messages - const userMap = new Map(); - messagesResult.data?.forEach(message => { - if (message.authorId && message.authorName) { - userMap.set(message.authorId, message.authorName); - } - }); - - // Build participant list with names - const participantsWithNames = participants.map(participantId => ({ - id: participantId, - name: userMap.get(participantId) || participantId // Fallback to ID if name not found - })); - - return { - success: true, - data: participantsWithNames, - timestamp: Date.now() - }; - - } catch (error) { - console.error('[HybridChatManager] Error getting room participants:', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to get room participants', - timestamp: Date.now() - }; - } - } - - // Message operations (delegated to active provider) - async sendMessage(message: Omit): Promise> { - const activeProvider = this.getActiveProvider(); - - try { - const result = await activeProvider.sendMessage(message); - return result; - } catch (error) { - console.error('[HybridChatManager] Error in sendMessage:', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to send message', - timestamp: Date.now() - }; - } - } - - async getMessages(roomId: string, limit?: number, before?: number): Promise> { - return this.getActiveProvider().getMessages(roomId, limit, before); - } - - async getMessage(messageId: string): Promise> { - return this.getActiveProvider().getMessage(messageId); - } - - async updateMessage(messageId: string, updates: Partial): Promise> { - return this.getActiveProvider().updateMessage(messageId, updates); - } - - async deleteMessage(messageId: string): Promise> { - return this.getActiveProvider().deleteMessage(messageId); - } - - // Presence operations (delegated to active provider) - async updatePresence(presence: Partial): Promise> { - return this.getActiveProvider().updatePresence(presence); - } - - async getPresence(roomId: string): Promise> { - return this.getActiveProvider().getPresence(roomId); - } - - // Typing operations (delegated to active provider) - async startTyping(roomId: string): Promise> { - return this.getActiveProvider().startTyping(roomId); - } - - async stopTyping(roomId: string): Promise> { - return this.getActiveProvider().stopTyping(roomId); - } - - // Subscription management (with cleanup tracking) - subscribeToRoom(roomId: string, callback: (event: ChatEvent) => void): UnsubscribeFunction { - const unsubscribe = this.getActiveProvider().subscribeToRoom(roomId, callback); - - // Track subscription for cleanup - if (!this.subscriptions.has(roomId)) { - this.subscriptions.set(roomId, []); - } - this.subscriptions.get(roomId)!.push(unsubscribe); - - return () => { - unsubscribe(); - const subs = this.subscriptions.get(roomId); - if (subs) { - const index = subs.indexOf(unsubscribe); - if (index > -1) { - subs.splice(index, 1); - } - if (subs.length === 0) { - this.subscriptions.delete(roomId); - } - } - }; - } - - subscribeToPresence(roomId: string, callback: (users: UserPresence[]) => void): UnsubscribeFunction { - return this.getActiveProvider().subscribeToPresence(roomId, callback); - } - - subscribeToTyping(roomId: string, callback: (typingUsers: TypingState[]) => void): UnsubscribeFunction { - return this.getActiveProvider().subscribeToTyping(roomId, callback); - } - - subscribeToConnection(callback: (state: ConnectionState) => void): UnsubscribeFunction { - return this.getActiveProvider().subscribeToConnection(callback); - } - - // Manager events - subscribeToManagerEvents(callback: ManagerEventCallback): UnsubscribeFunction { - this.managerEventCallbacks.push(callback); - - return () => { - const index = this.managerEventCallbacks.indexOf(callback); - if (index > -1) { - this.managerEventCallbacks.splice(index, 1); - } - }; - } - - private emitManagerEvent(event: ManagerEvent): void { - this.managerEventCallbacks.forEach(callback => { - try { - callback(event); - } catch (error) { - console.error('Error in manager event callback:', error); - } - }); - } - - // Utility operations - async clearRoomData(roomId: string): Promise> { - return this.getActiveProvider().clearRoomData(roomId); - } - - async exportData(): Promise> { - return this.getActiveProvider().exportData(); - } - - async importData(data: any): Promise> { - return this.getActiveProvider().importData(data); - } - - async healthCheck(): Promise> { - const primaryHealth = await this.getActiveProvider().healthCheck(); - - return { - success: true, - data: { - status: primaryHealth.data?.status || 'unknown', - details: { - mode: this.currentMode, - provider: this.getActiveProvider().name, - reconnectAttempts: this.reconnectAttempts, - primary: primaryHealth.data, - // TODO: Add secondary provider health when available - } - }, - timestamp: Date.now(), - }; - } - - // Getters - getConnectionState(): ConnectionState { - return this.getActiveProvider().getConnectionState(); - } - - isConnected(): boolean { - return this.getActiveProvider().isConnected(); - } - - getCurrentMode(): 'local' | 'collaborative' | 'hybrid' { - return this.currentMode; - } - - getConfig(): HybridChatManagerConfig { - return { ...this.config }; - } - - // Future methods for provider switching - async switchToCollaborativeMode(): Promise> { - // TODO: Implement when Yjs provider is ready - return { - success: false, - error: 'Collaborative mode not implemented yet', - timestamp: Date.now(), - }; - } - - async switchToLocalMode(): Promise> { - if (this.currentMode === 'local') { - return { success: true, timestamp: Date.now() }; - } - - this.currentMode = 'local'; - this.emitManagerEvent({ - type: 'provider_switched', - provider: 'local', - timestamp: Date.now() - }); - - return { success: true, timestamp: Date.now() }; - } - - // Sync operations (for future hybrid mode) - async syncToCollaborative(): Promise> { - // TODO: Implement data sync between providers - return { - success: false, - error: 'Sync not implemented yet', - timestamp: Date.now(), - }; - } - - // Error handling - private handleError(error: any, operation: string): OperationResult { - console.error(`HybridChatManager error in ${operation}:`, error); - - if (error instanceof ChatDataError) { - return { - success: false, - error: error.message, - timestamp: Date.now(), - }; - } - - return { - success: false, - error: error?.message || `Unknown error in ${operation}`, - timestamp: Date.now(), - }; - } -} \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/providers/ALASqlProvider.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/providers/ALASqlProvider.ts deleted file mode 100644 index a0afc568de..0000000000 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/providers/ALASqlProvider.ts +++ /dev/null @@ -1,631 +0,0 @@ -// ALASql provider implementation -// Wraps existing ALASql functionality to work with our unified interface - -import alasql from "alasql"; -import { BaseChatDataProvider } from './ChatDataProvider'; -import { - UnifiedMessage, - UnifiedRoom, - UserPresence, - ConnectionConfig, - OperationResult, - DataTransformUtils, - ChatDataError, - ChatErrorCodes, - CreateRoomRequest, - JoinRoomRequest, - RoomMembershipUpdate, - RoomListFilter -} from '../types/chatDataTypes'; - -interface ALASqlMessage { - id: string; - threadId: string; - role: string; - text: string; - timestamp: number; -} - -interface ALASqlThread { - threadId: string; - status: string; - title: string; - createdAt: number; - updatedAt: number; -} - -export class ALASqlProvider extends BaseChatDataProvider { - public readonly name = 'ALASqlProvider'; - public readonly version = '1.0.0'; - - private initialized = false; - private dbName = 'ChatDB'; - private threadsTable = 'threads'; - private messagesTable = 'messages'; - private presenceTable = 'presence'; - - constructor() { - super(); - } - - async connect(config: ConnectionConfig): Promise> { - try { - this.setConnectionState('connecting'); - this.config = config; - if (config.alasql?.dbName) { - this.dbName = config.alasql.dbName; - } - alasql.options.autocommit = true; - await this.initializeDatabase(); - this.initialized = true; - this.setConnectionState('connected'); - return this.createSuccessResult(); - } catch (error) { - this.setConnectionState('failed'); - return this.handleError(error, 'connect'); - } - } - - async disconnect(): Promise> { - try { - this.setConnectionState('disconnected'); - this.initialized = false; - return this.createSuccessResult(); - } catch (error) { - return this.handleError(error, 'disconnect'); - } - } - - private async initializeDatabase(): Promise { - try { - await alasql.promise(`CREATE LOCALSTORAGE DATABASE IF NOT EXISTS ${this.dbName}`); - await alasql.promise(`ATTACH LOCALSTORAGE DATABASE ${this.dbName}`); - await alasql.promise(`USE ${this.dbName}`); - await alasql.promise(` - CREATE TABLE IF NOT EXISTS ${this.threadsTable} ( - threadId STRING PRIMARY KEY, - status STRING, - title STRING, - createdAt NUMBER, - updatedAt NUMBER - ) - `); - await alasql.promise(` - CREATE TABLE IF NOT EXISTS ${this.messagesTable} ( - id STRING PRIMARY KEY, - threadId STRING, - role STRING, - text STRING, - timestamp NUMBER - ) - `); - await alasql.promise(` - CREATE TABLE IF NOT EXISTS ${this.presenceTable} ( - userId STRING PRIMARY KEY, - userName STRING, - status STRING, - lastSeen NUMBER, - currentRoom STRING - ) - `); - } catch (error) { - throw new ChatDataError( - 'Failed to initialize ALASql database', - ChatErrorCodes.STORAGE_ERROR, - error - ); - } - } - - private async ensureInitialized(): Promise { - if (!this.initialized) { - throw new ChatDataError( - 'Provider not initialized. Call connect() first.', - ChatErrorCodes.CONNECTION_FAILED - ); - } - } - - async createRoom(room: Omit): Promise> { - try { - await this.ensureInitialized(); - const now = Date.now(); - const roomId = `room_${room.name.replace(/[^a-zA-Z0-9_-]/g, '_')}`; - const newRoom: UnifiedRoom = { - id: roomId, - createdAt: now, - updatedAt: now, - ...room, - }; - const alaSqlThread: ALASqlThread = DataTransformUtils.toLegacyThread(newRoom); - await alasql.promise(` - INSERT INTO ${this.threadsTable} VALUES (?, ?, ?, ?, ?) - `, [alaSqlThread.threadId, alaSqlThread.status, alaSqlThread.title, alaSqlThread.createdAt, alaSqlThread.updatedAt]); - return this.createSuccessResult(newRoom); - } catch (error) { - return this.handleError(error, 'createRoom'); - } - } - - async getRooms(userId?: string): Promise> { - try { - await this.ensureInitialized(); - const result = await alasql.promise(` - SELECT * FROM ${this.threadsTable} ORDER BY updatedAt DESC - `) as ALASqlThread[]; - const rooms = (Array.isArray(result) ? result : []).map(thread => - DataTransformUtils.fromLegacyThread(thread) - ); - return this.createSuccessResult(rooms); - } catch (error) { - return this.handleError(error, 'getRooms'); - } - } - - async getRoom(roomId: string): Promise> { - try { - await this.ensureInitialized(); - const result = await alasql.promise(` - SELECT * FROM ${this.threadsTable} WHERE threadId = ? - `, [roomId]) as ALASqlThread[]; - if (!result || result.length === 0) { - throw new ChatDataError( - `Room with id ${roomId} not found`, - ChatErrorCodes.ROOM_NOT_FOUND - ); - } - const room = DataTransformUtils.fromLegacyThread(result[0]); - return this.createSuccessResult(room); - } catch (error) { - return this.handleError(error, 'getRoom'); - } - } - - async getRoomByName(name: string): Promise> { - try { - await this.ensureInitialized(); - let result = await alasql.promise(` - SELECT * FROM ${this.threadsTable} WHERE title = ? - `, [name]) as ALASqlThread[]; - if (!result || result.length === 0) { - result = await alasql.promise(` - SELECT * FROM ${this.threadsTable} WHERE title = ? - `, [`Chat Room ${name}`]) as ALASqlThread[]; - } - if (!result || result.length === 0) { - result = await alasql.promise(` - SELECT * FROM ${this.threadsTable} WHERE title LIKE ? - `, [`%${name}%`]) as ALASqlThread[]; - } - if (!result || result.length === 0) { - throw new ChatDataError( - `Room with name ${name} not found`, - ChatErrorCodes.ROOM_NOT_FOUND - ); - } - const room = DataTransformUtils.fromLegacyThread(result[0]); - return this.createSuccessResult(room); - } catch (error) { - return this.handleError(error, 'getRoomByName'); - } - } - - async updateRoom(roomId: string, updates: Partial): Promise> { - try { - await this.ensureInitialized(); - const existingResult = await this.getRoom(roomId); - if (!existingResult.success) { - return existingResult; - } - const updatedRoom: UnifiedRoom = { - ...existingResult.data!, - ...updates, - updatedAt: Date.now(), - }; - const alaSqlThread = DataTransformUtils.toLegacyThread(updatedRoom); - await alasql.promise(` - UPDATE ${this.threadsTable} - SET status = ?, title = ?, updatedAt = ? - WHERE threadId = ? - `, [alaSqlThread.status, alaSqlThread.title, alaSqlThread.updatedAt, roomId]); - return this.createSuccessResult(updatedRoom); - } catch (error) { - return this.handleError(error, 'updateRoom'); - } - } - - async deleteRoom(roomId: string): Promise> { - try { - await this.ensureInitialized(); - await alasql.promise(`DELETE FROM ${this.messagesTable} WHERE threadId = ?`, [roomId]); - await alasql.promise(`DELETE FROM ${this.threadsTable} WHERE threadId = ?`, [roomId]); - return this.createSuccessResult(); - } catch (error) { - return this.handleError(error, 'deleteRoom'); - } - } - - // Enhanced room management operations - async createRoomFromRequest(request: CreateRoomRequest, creatorId: string): Promise> { - try { - await this.ensureInitialized(); - const roomId = this.generateId(); - const now = Date.now(); - - const newRoom: UnifiedRoom = { - id: roomId, - name: request.name, - type: request.type, - participants: [creatorId], - admins: [creatorId], - creator: creatorId, - description: request.description, - maxParticipants: request.maxParticipants, - isActive: true, - lastActivity: now, - createdAt: now, - updatedAt: now, - }; - - // Convert to ALASql format and save - const legacyThread = DataTransformUtils.toLegacyThread(newRoom); - await alasql.promise(` - INSERT INTO ${this.threadsTable} VALUES (?, ?, ?, ?, ?) - `, [legacyThread.threadId, legacyThread.status, legacyThread.title, legacyThread.createdAt, legacyThread.updatedAt]); - - console.log('[ALASqlProvider] 🏠 Created room from request:', newRoom); - return this.createSuccessResult(newRoom); - } catch (error) { - return this.handleError(error, 'createRoomFromRequest'); - } - } - - async getAvailableRooms(userId: string, filter?: RoomListFilter): Promise> { - try { - await this.ensureInitialized(); - // For ALASql (local storage), all rooms are "available" to the user - // since there's no real multi-user separation - let query = `SELECT * FROM ${this.threadsTable}`; - const conditions: string[] = []; - const params: any[] = []; - - if (filter?.type) { - // Note: ALASql threads don't have type, so we'll default to private - conditions.push('status = ?'); - params.push('regular'); - } - - if (conditions.length > 0) { - query += ' WHERE ' + conditions.join(' AND '); - } - - query += ' ORDER BY updatedAt DESC'; - - const threads = await alasql.promise(query, params) as ALASqlThread[]; - const rooms = threads.map(thread => DataTransformUtils.fromLegacyThread(thread)); - - return this.createSuccessResult(rooms); - } catch (error) { - return this.handleError(error, 'getAvailableRooms'); - } - } - - async joinRoom(request: JoinRoomRequest): Promise> { - try { - // For ALASql (local), joining is just getting the room - // since there's no real multi-user management - const roomResult = await this.getRoom(request.roomId); - if (!roomResult.success) { - return roomResult; - } - - console.log(`[ALASqlProvider] 🚪 User ${request.userName} "joined" room ${roomResult.data!.name} (local only)`); - return roomResult; - } catch (error) { - return this.handleError(error, 'joinRoom'); - } - } - - async leaveRoom(roomId: string, userId: string): Promise> { - try { - // For ALASql (local), leaving is a no-op since there's no real membership - console.log(`[ALASqlProvider] 🚪 User ${userId} "left" room ${roomId} (local only)`); - return this.createSuccessResult(); - } catch (error) { - return this.handleError(error, 'leaveRoom'); - } - } - - async updateRoomMembership(update: RoomMembershipUpdate): Promise> { - try { - // For ALASql (local), membership updates are no-ops - // Just return the room as-is - const roomResult = await this.getRoom(update.roomId); - if (!roomResult.success) { - return roomResult; - } - - console.log(`[ALASqlProvider] 👥 Membership update "${update.action}" for user ${update.userId} (local only - no effect)`); - return roomResult; - } catch (error) { - return this.handleError(error, 'updateRoomMembership'); - } - } - - async canUserJoinRoom(roomId: string, userId: string): Promise> { - try { - // For ALASql (local), users can always "join" any room that exists - const roomResult = await this.getRoom(roomId); - return this.createSuccessResult(roomResult.success); - } catch (error) { - return this.handleError(error, 'canUserJoinRoom'); - } - } - - async sendMessage(message: Omit): Promise> { - try { - await this.ensureInitialized(); - const newMessage: UnifiedMessage = { - id: this.generateId(), - timestamp: Date.now(), - status: 'synced', - ...message, - }; - const alaSqlMessage = DataTransformUtils.toLegacyMessage(newMessage); - alaSqlMessage.threadId = newMessage.roomId; - await alasql.promise(` - INSERT INTO ${this.messagesTable} VALUES (?, ?, ?, ?, ?) - `, [alaSqlMessage.id, alaSqlMessage.threadId, alaSqlMessage.role, alaSqlMessage.text, alaSqlMessage.timestamp]); - this.notifyRoomSubscribers(message.roomId, { - type: 'message_added', - roomId: message.roomId, - userId: message.authorId, - data: newMessage, - timestamp: Date.now(), - }); - if (typeof window !== 'undefined' && window.dispatchEvent) { - try { - window.dispatchEvent( - new CustomEvent('alasql-chat-message-added', { - detail: { roomId: message.roomId, message: newMessage }, - }), - ); - } catch (e) { - /* Ignore if CustomEvent is not supported */ - } - } - return this.createSuccessResult(newMessage); - } catch (error) { - return this.handleError(error, 'sendMessage'); - } - } - - async getMessages(roomId: string, limit = 50, before?: number): Promise> { - try { - await this.ensureInitialized(); - let query = ` - SELECT * FROM ${this.messagesTable} - WHERE threadId = ? - `; - const params: any[] = [roomId]; - if (before) { - query += ` AND timestamp < ?`; - params.push(before); - } - query += ` ORDER BY timestamp DESC LIMIT ?`; - params.push(limit); - const result = await alasql.promise(query, params) as ALASqlMessage[]; - const messages = (Array.isArray(result) ? result : []).map(alaSqlMsg => - DataTransformUtils.fromLegacyMessage( - alaSqlMsg, - roomId, - alaSqlMsg.role === 'assistant' ? 'assistant' : this.config?.userId || 'unknown', - alaSqlMsg.role === 'assistant' ? 'Assistant' : this.config?.userName || 'User' - ) - ).reverse(); - return this.createSuccessResult(messages); - } catch (error) { - return this.handleError(error, 'getMessages'); - } - } - - async getMessage(messageId: string): Promise> { - try { - await this.ensureInitialized(); - const result = await alasql.promise(` - SELECT * FROM ${this.messagesTable} WHERE id = ? - `, [messageId]) as ALASqlMessage[]; - if (!result || result.length === 0) { - throw new ChatDataError( - `Message with id ${messageId} not found`, - ChatErrorCodes.MESSAGE_NOT_FOUND - ); - } - const alaSqlMsg = result[0]; - const message = DataTransformUtils.fromLegacyMessage( - alaSqlMsg, - alaSqlMsg.threadId, - alaSqlMsg.role === 'assistant' ? 'assistant' : this.config?.userId || 'unknown', - alaSqlMsg.role === 'assistant' ? 'Assistant' : this.config?.userName || 'User' - ); - return this.createSuccessResult(message); - } catch (error) { - return this.handleError(error, 'getMessage'); - } - } - - async updateMessage(messageId: string, updates: Partial): Promise> { - try { - await this.ensureInitialized(); - const existingResult = await this.getMessage(messageId); - if (!existingResult.success) { - return existingResult; - } - const updatedMessage: UnifiedMessage = { - ...existingResult.data!, - ...updates, - }; - const alaSqlMessage = DataTransformUtils.toLegacyMessage(updatedMessage); - await alasql.promise(` - UPDATE ${this.messagesTable} - SET text = ?, timestamp = ? - WHERE id = ? - `, [alaSqlMessage.text, alaSqlMessage.timestamp, messageId]); - this.notifyRoomSubscribers(updatedMessage.roomId, { - type: 'message_updated', - roomId: updatedMessage.roomId, - userId: updatedMessage.authorId, - data: updatedMessage, - timestamp: Date.now(), - }); - return this.createSuccessResult(updatedMessage); - } catch (error) { - return this.handleError(error, 'updateMessage'); - } - } - - async deleteMessage(messageId: string): Promise> { - try { - await this.ensureInitialized(); - const messageResult = await this.getMessage(messageId); - await alasql.promise(`DELETE FROM ${this.messagesTable} WHERE id = ?`, [messageId]); - if (messageResult.success) { - this.notifyRoomSubscribers(messageResult.data!.roomId, { - type: 'message_deleted', - roomId: messageResult.data!.roomId, - userId: messageResult.data!.authorId, - data: { messageId }, - timestamp: Date.now(), - }); - } - return this.createSuccessResult(); - } catch (error) { - return this.handleError(error, 'deleteMessage'); - } - } - - async updatePresence(presence: Partial): Promise> { - try { - await this.ensureInitialized(); - if (!presence.userId) { - throw new ChatDataError('UserId is required for presence update', ChatErrorCodes.VALIDATION_ERROR); - } - const now = Date.now(); - await alasql.promise(` - INSERT OR REPLACE INTO ${this.presenceTable} VALUES (?, ?, ?, ?, ?) - `, [ - presence.userId, - presence.userName || 'Unknown', - presence.status || 'online', - presence.lastSeen || now, - presence.currentRoom || null - ]); - return this.createSuccessResult(); - } catch (error) { - return this.handleError(error, 'updatePresence'); - } - } - - async getPresence(roomId: string): Promise> { - try { - await this.ensureInitialized(); - const result = await alasql.promise(` - SELECT * FROM ${this.presenceTable} WHERE currentRoom = ? - `, [roomId]) as any[]; - const presence = (Array.isArray(result) ? result : []).map(row => ({ - userId: row.userId, - userName: row.userName, - status: row.status, - lastSeen: row.lastSeen, - currentRoom: row.currentRoom, - })); - return this.createSuccessResult(presence); - } catch (error) { - return this.handleError(error, 'getPresence'); - } - } - - async startTyping(roomId: string): Promise> { - return this.createSuccessResult(); - } - - async stopTyping(roomId: string): Promise> { - return this.createSuccessResult(); - } - - async clearRoomData(roomId: string): Promise> { - try { - await this.ensureInitialized(); - await alasql.promise(`DELETE FROM ${this.messagesTable} WHERE threadId = ?`, [roomId]); - return this.createSuccessResult(); - } catch (error) { - return this.handleError(error, 'clearRoomData'); - } - } - - async exportData(): Promise> { - try { - await this.ensureInitialized(); - const threads = await alasql.promise(`SELECT * FROM ${this.threadsTable}`) as ALASqlThread[]; - const messages = await alasql.promise(`SELECT * FROM ${this.messagesTable}`) as ALASqlMessage[]; - return this.createSuccessResult({ - threads: Array.isArray(threads) ? threads : [], - messages: Array.isArray(messages) ? messages : [], - exportedAt: Date.now(), - provider: this.name, - version: this.version, - }); - } catch (error) { - return this.handleError(error, 'exportData'); - } - } - - async importData(data: any): Promise> { - try { - await this.ensureInitialized(); - if (!data.threads || !data.messages) { - throw new ChatDataError('Invalid import data format', ChatErrorCodes.VALIDATION_ERROR); - } - await alasql.promise(`DELETE FROM ${this.messagesTable}`); - await alasql.promise(`DELETE FROM ${this.threadsTable}`); - for (const thread of data.threads) { - await alasql.promise(` - INSERT INTO ${this.threadsTable} VALUES (?, ?, ?, ?, ?) - `, [thread.threadId, thread.status, thread.title, thread.createdAt, thread.updatedAt]); - } - for (const message of data.messages) { - await alasql.promise(` - INSERT INTO ${this.messagesTable} VALUES (?, ?, ?, ?, ?) - `, [message.id, message.threadId, message.role, message.text, message.timestamp]); - } - return this.createSuccessResult(); - } catch (error) { - return this.handleError(error, 'importData'); - } - } - - async healthCheck(): Promise> { - try { - if (!this.initialized) { - return this.createSuccessResult({ - status: 'disconnected', - details: { message: 'Provider not initialized' } - }); - } - await alasql.promise(`SELECT COUNT(*) as count FROM ${this.threadsTable}`); - return this.createSuccessResult({ - status: 'healthy', - details: { - dbName: this.dbName, - tablesCount: 3, - initialized: this.initialized, - } - }); - } catch (error) { - return this.createSuccessResult({ - status: 'unhealthy', - details: { error: error instanceof Error ? error.message : String(error) } - }); - } - } -} \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/providers/ChatDataProvider.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/providers/ChatDataProvider.ts deleted file mode 100644 index acbe0cc45e..0000000000 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/providers/ChatDataProvider.ts +++ /dev/null @@ -1,302 +0,0 @@ -// Core interface for chat data providers -// This abstraction allows us to support both local (ALASql) and collaborative (Yjs) storage - -import { - UnifiedMessage, - UnifiedRoom, - UserPresence, - TypingState, - ConnectionConfig, - ConnectionState, - ChatEvent, - OperationResult, - ChatDataError, - CreateRoomRequest, - JoinRoomRequest, - RoomMembershipUpdate, - RoomListFilter -} from '../types/chatDataTypes'; - -// Callback type for real-time subscriptions -export type ChatEventCallback = (event: ChatEvent) => void; -export type PresenceCallback = (users: UserPresence[]) => void; -export type TypingCallback = (typingUsers: TypingState[]) => void; -export type ConnectionCallback = (state: ConnectionState) => void; - -// Subscription cleanup function -export type UnsubscribeFunction = () => void; - -// Main data provider interface -export interface ChatDataProvider { - // Provider identification - readonly name: string; - readonly version: string; - - // Connection management - connect(config: ConnectionConfig): Promise>; - disconnect(): Promise>; - getConnectionState(): ConnectionState; - isConnected(): boolean; - - // Room/Thread operations - createRoom(room: Omit): Promise>; - getRooms(userId?: string): Promise>; - getRoom(roomId: string): Promise>; - getRoomByName(name: string): Promise>; - updateRoom(roomId: string, updates: Partial): Promise>; - deleteRoom(roomId: string): Promise>; - - // Enhanced room management operations - createRoomFromRequest(request: CreateRoomRequest, creatorId: string): Promise>; - getAvailableRooms(userId: string, filter?: RoomListFilter): Promise>; - joinRoom(request: JoinRoomRequest): Promise>; - leaveRoom(roomId: string, userId: string): Promise>; - updateRoomMembership(update: RoomMembershipUpdate): Promise>; - canUserJoinRoom(roomId: string, userId: string): Promise>; - - // Message operations - sendMessage(message: Omit): Promise>; - getMessages(roomId: string, limit?: number, before?: number): Promise>; - getMessage(messageId: string): Promise>; - updateMessage(messageId: string, updates: Partial): Promise>; - deleteMessage(messageId: string): Promise>; - - // Real-time subscriptions (for collaborative providers) - subscribeToRoom(roomId: string, callback: ChatEventCallback): UnsubscribeFunction; - subscribeToPresence(roomId: string, callback: PresenceCallback): UnsubscribeFunction; - subscribeToTyping(roomId: string, callback: TypingCallback): UnsubscribeFunction; - subscribeToConnection(callback: ConnectionCallback): UnsubscribeFunction; - - // Presence management - updatePresence(presence: Partial): Promise>; - getPresence(roomId: string): Promise>; - - // Typing indicators - startTyping(roomId: string): Promise>; - stopTyping(roomId: string): Promise>; - - // Utility operations - clearRoomData(roomId: string): Promise>; - exportData(): Promise>; - importData(data: any): Promise>; - - // Health check - healthCheck(): Promise>; -} - -// Base abstract class with common functionality -export abstract class BaseChatDataProvider implements ChatDataProvider { - public abstract readonly name: string; - public abstract readonly version: string; - - protected connectionState: ConnectionState = 'disconnected'; - protected config?: ConnectionConfig; - protected roomSubscriptions: Map = new Map(); - protected presenceSubscriptions: Map = new Map(); - protected typingSubscriptions: Map = new Map(); - protected connectionSubscriptions: ConnectionCallback[] = []; - - // Connection state management - getConnectionState(): ConnectionState { - return this.connectionState; - } - - isConnected(): boolean { - return this.connectionState === 'connected'; - } - - protected setConnectionState(state: ConnectionState): void { - if (this.connectionState !== state) { - this.connectionState = state; - this.notifyConnectionSubscribers(state); - } - } - - // Event subscription management - subscribeToRoom(roomId: string, callback: ChatEventCallback): UnsubscribeFunction { - if (!this.roomSubscriptions.has(roomId)) { - this.roomSubscriptions.set(roomId, []); - } - this.roomSubscriptions.get(roomId)!.push(callback); - - return () => { - const callbacks = this.roomSubscriptions.get(roomId); - if (callbacks) { - const index = callbacks.indexOf(callback); - if (index > -1) { - callbacks.splice(index, 1); - } - if (callbacks.length === 0) { - this.roomSubscriptions.delete(roomId); - } - } - }; - } - - subscribeToPresence(roomId: string, callback: PresenceCallback): UnsubscribeFunction { - if (!this.presenceSubscriptions.has(roomId)) { - this.presenceSubscriptions.set(roomId, []); - } - this.presenceSubscriptions.get(roomId)!.push(callback); - - return () => { - const callbacks = this.presenceSubscriptions.get(roomId); - if (callbacks) { - const index = callbacks.indexOf(callback); - if (index > -1) { - callbacks.splice(index, 1); - } - if (callbacks.length === 0) { - this.presenceSubscriptions.delete(roomId); - } - } - }; - } - - subscribeToTyping(roomId: string, callback: TypingCallback): UnsubscribeFunction { - if (!this.typingSubscriptions.has(roomId)) { - this.typingSubscriptions.set(roomId, []); - } - this.typingSubscriptions.get(roomId)!.push(callback); - - return () => { - const callbacks = this.typingSubscriptions.get(roomId); - if (callbacks) { - const index = callbacks.indexOf(callback); - if (index > -1) { - callbacks.splice(index, 1); - } - if (callbacks.length === 0) { - this.typingSubscriptions.delete(roomId); - } - } - }; - } - - subscribeToConnection(callback: ConnectionCallback): UnsubscribeFunction { - this.connectionSubscriptions.push(callback); - - return () => { - const index = this.connectionSubscriptions.indexOf(callback); - if (index > -1) { - this.connectionSubscriptions.splice(index, 1); - } - }; - } - - // Notify subscribers - protected notifyRoomSubscribers(roomId: string, event: ChatEvent): void { - const callbacks = this.roomSubscriptions.get(roomId); - - if (callbacks) { - callbacks.forEach(callback => { - try { - callback(event); - } catch (error) { - console.error(`Error in chat event callback:`, error); - } - }); - } else { - console.warn(`No subscribers found for room: ${roomId}`); - } - } - - protected notifyPresenceSubscribers(roomId: string, users: UserPresence[]): void { - const callbacks = this.presenceSubscriptions.get(roomId); - if (callbacks) { - callbacks.forEach(callback => { - try { - callback(users); - } catch (error) { - console.error('Error in presence callback:', error); - } - }); - } - } - - protected notifyTypingSubscribers(roomId: string, typingUsers: TypingState[]): void { - const callbacks = this.typingSubscriptions.get(roomId); - if (callbacks) { - callbacks.forEach(callback => { - try { - callback(typingUsers); - } catch (error) { - console.error('Error in typing callback:', error); - } - }); - } - } - - protected notifyConnectionSubscribers(state: ConnectionState): void { - this.connectionSubscriptions.forEach(callback => { - try { - callback(state); - } catch (error) { - console.error('Error in connection callback:', error); - } - }); - } - - // Utility methods - protected generateId(): string { - return `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - } - - protected createSuccessResult(data?: T): OperationResult { - return { - success: true, - data, - timestamp: Date.now(), - }; - } - - protected createErrorResult(error: string, details?: any): OperationResult { - return { - success: false, - error, - timestamp: Date.now(), - }; - } - - protected handleError(error: any, operation: string): OperationResult { - console.error(`${this.name} provider error in ${operation}:`, error); - - if (error instanceof ChatDataError) { - return this.createErrorResult(error.message, error.details); - } - - return this.createErrorResult( - error?.message || `Unknown error in ${operation}`, - error - ); - } - - // Abstract methods that must be implemented by concrete providers - public abstract connect(config: ConnectionConfig): Promise>; - public abstract disconnect(): Promise>; - public abstract createRoom(room: Omit): Promise>; - public abstract getRooms(userId?: string): Promise>; - public abstract getRoom(roomId: string): Promise>; - public abstract getRoomByName(name: string): Promise>; - public abstract updateRoom(roomId: string, updates: Partial): Promise>; - public abstract deleteRoom(roomId: string): Promise>; - public abstract createRoomFromRequest(request: CreateRoomRequest, creatorId: string): Promise>; - public abstract getAvailableRooms(userId: string, filter?: RoomListFilter): Promise>; - public abstract joinRoom(request: JoinRoomRequest): Promise>; - public abstract leaveRoom(roomId: string, userId: string): Promise>; - public abstract updateRoomMembership(update: RoomMembershipUpdate): Promise>; - public abstract canUserJoinRoom(roomId: string, userId: string): Promise>; - public abstract sendMessage(message: Omit): Promise>; - public abstract getMessages(roomId: string, limit?: number, before?: number): Promise>; - public abstract getMessage(messageId: string): Promise>; - public abstract updateMessage(messageId: string, updates: Partial): Promise>; - public abstract deleteMessage(messageId: string): Promise>; - public abstract updatePresence(presence: Partial): Promise>; - public abstract getPresence(roomId: string): Promise>; - public abstract startTyping(roomId: string): Promise>; - public abstract stopTyping(roomId: string): Promise>; - public abstract clearRoomData(roomId: string): Promise>; - public abstract exportData(): Promise>; - public abstract importData(data: any): Promise>; - public abstract healthCheck(): Promise>; -} \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/providers/YjsPluvProvider.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/providers/YjsPluvProvider.ts deleted file mode 100644 index 144d3caef9..0000000000 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/providers/YjsPluvProvider.ts +++ /dev/null @@ -1,903 +0,0 @@ -// YjsPluvProvider - Real-time collaborative provider using Yjs + WebSocket -// Implements ChatDataProvider interface for seamless integration - -import { ChatDataProvider, BaseChatDataProvider } from './ChatDataProvider'; -import { - UnifiedMessage, - UnifiedRoom, - UserPresence, - TypingState, - ConnectionConfig, - ConnectionState, - ChatEvent, - OperationResult, - CreateRoomRequest, - JoinRoomRequest, - RoomMembershipUpdate, - RoomListFilter -} from '../types/chatDataTypes'; -import * as Y from 'yjs'; -import { WebsocketProvider } from 'y-websocket'; - -export class YjsPluvProvider extends BaseChatDataProvider implements ChatDataProvider { - public readonly name = 'YjsPluvProvider'; - public readonly version = '1.0.0'; - - private ydoc: Y.Doc | null = null; - private messagesMap: Y.Map | null = null; - private roomsMap: Y.Map | null = null; - private presenceMap: Y.Map | null = null; - private typingMap: Y.Map | null = null; - private wsProvider: WebsocketProvider | null = null; - private docId: string | null = null; - - // Global document sharing for same browser session - private static globalDocs = new Map(); - private static globalWsProviders = new Map(); - private static docRefCounts = new Map(); - - private messagesObserver: ((event: Y.YMapEvent) => void) | null = null; - private roomsObserver: ((event: Y.YMapEvent) => void) | null = null; - private typingObserver: ((event: Y.YMapEvent) => void) | null = null; - - constructor() { - super(); - } - - async connect(config: ConnectionConfig): Promise> { - try { - this.config = config; - if (!config.realtime?.roomId) { - return this.createErrorResult('roomId is required for Yjs connection'); - } - const docId = config.realtime.roomId; - this.docId = docId; - let ydoc = YjsPluvProvider.globalDocs.get(docId); - let wsProvider = YjsPluvProvider.globalWsProviders.get(docId); - if (!ydoc) { - ydoc = new Y.Doc(); - YjsPluvProvider.globalDocs.set(docId, ydoc); - YjsPluvProvider.docRefCounts.set(docId, 1); - const wsUrl = config.realtime.serverUrl || 'ws://localhost:3005'; - wsProvider = new WebsocketProvider(wsUrl, docId, ydoc, { - connect: true, - params: { room: docId } - }); - YjsPluvProvider.globalWsProviders.set(docId, wsProvider); - } else { - const currentCount = YjsPluvProvider.docRefCounts.get(docId) || 0; - YjsPluvProvider.docRefCounts.set(docId, currentCount + 1); - } - this.ydoc = ydoc; - this.wsProvider = wsProvider || null; - this.messagesMap = this.ydoc.getMap('messages'); - this.roomsMap = this.ydoc.getMap('rooms'); - this.presenceMap = this.ydoc.getMap('presence'); - this.typingMap = this.ydoc.getMap('typing'); - this.messagesObserver = this.handleMessagesChange.bind(this); - this.roomsObserver = this.handleRoomsChange.bind(this); - this.typingObserver = this.handleTypingChange.bind(this); - this.messagesMap.observe(this.messagesObserver); - this.roomsMap.observe(this.roomsObserver); - this.typingMap.observe(this.typingObserver); - - // Set connection state immediately to allow local operations - this.setConnectionState('connected'); - - if (this.wsProvider) { - this.wsProvider.off('status', this.handleWSStatus); - this.wsProvider.off('sync', this.handleWSSync); - this.wsProvider.on('status', this.handleWSStatus.bind(this)); - this.wsProvider.on('sync', this.handleWSSync.bind(this)); - - // Update connection state based on WebSocket status - if (this.wsProvider.wsconnected) { - this.setConnectionState('connected'); - } else if (this.wsProvider.wsconnecting) { - this.setConnectionState('connecting'); - } - } - - console.log('[YjsPluvProvider] ✅ Connected successfully with docId:', docId); - return this.createSuccessResult(undefined); - } catch (error) { - this.setConnectionState('failed'); - console.error('[YjsPluvProvider] ❌ Connection failed:', error); - return this.handleError(error, 'connect'); - } - } - - private handleWSStatus(event: any) { - if (event.status === 'connected') { - this.setConnectionState('connected'); - } else if (event.status === 'connecting') { - this.setConnectionState('connecting'); - } else if (event.status === 'disconnected') { - this.setConnectionState('connected'); // Keep local operations working - } - } - - private handleWSSync(isSynced: boolean) { - // Optionally keep for debugging sync status - } - - async disconnect(): Promise> { - try { - if (this.ydoc && this.docId) { - if (this.messagesMap && this.messagesObserver) { - this.messagesMap.unobserve(this.messagesObserver); - } - if (this.roomsMap && this.roomsObserver) { - this.roomsMap.unobserve(this.roomsObserver); - } - if (this.typingMap && this.typingObserver) { - this.typingMap.unobserve(this.typingObserver); - } - const currentCount = YjsPluvProvider.docRefCounts.get(this.docId) || 1; - if (currentCount <= 1) { - const wsProvider = YjsPluvProvider.globalWsProviders.get(this.docId); - if (wsProvider) { - wsProvider.destroy(); - YjsPluvProvider.globalWsProviders.delete(this.docId); - } - YjsPluvProvider.globalDocs.delete(this.docId); - YjsPluvProvider.docRefCounts.delete(this.docId); - } else { - YjsPluvProvider.docRefCounts.set(this.docId, currentCount - 1); - } - } - this.ydoc = null; - this.messagesMap = null; - this.roomsMap = null; - this.presenceMap = null; - this.typingMap = null; - this.wsProvider = null; - this.docId = null; - this.messagesObserver = null; - this.roomsObserver = null; - this.typingObserver = null; - this.setConnectionState('disconnected'); - return this.createSuccessResult(undefined); - } catch (error) { - return this.handleError(error, 'disconnect'); - } - } - - async healthCheck(): Promise> { - try { - const isHealthy = this.ydoc !== null && this.connectionState === 'connected'; - const status = { - status: isHealthy ? 'healthy' : 'disconnected', - details: { - connectionState: this.connectionState, - yjsDocConnected: this.ydoc !== null, - mapsInitialized: this.messagesMap !== null && this.roomsMap !== null, - wsConnected: this.wsProvider?.wsconnected || false, - wsConnecting: this.wsProvider?.wsconnecting || false, - docId: this.docId, - globalDocsCount: YjsPluvProvider.globalDocs.size, - globalWsProvidersCount: YjsPluvProvider.globalWsProviders.size - } - }; - return this.createSuccessResult(status); - } catch (error) { - return this.handleError(error, 'healthCheck'); - } - } - - // Room operations - async createRoom(room: Omit): Promise> { - try { - await this.ensureConnected(); - // Use room name as deterministic ID for shared rooms - const roomId = `room_${room.name.replace(/[^a-zA-Z0-9_-]/g, '_')}`; - const newRoom: UnifiedRoom = { - id: roomId, - createdAt: Date.now(), - updatedAt: Date.now(), - ...room, - }; - this.roomsMap!.set(newRoom.id, { - id: newRoom.id, - name: newRoom.name, - type: newRoom.type, - participants: newRoom.participants, - admins: newRoom.admins, - isActive: newRoom.isActive, - createdAt: newRoom.createdAt, - updatedAt: newRoom.updatedAt, - lastActivity: newRoom.lastActivity, - }); - return this.createSuccessResult(newRoom); - } catch (error) { - return this.handleError(error, 'createRoom'); - } - } - - async getRooms(userId?: string): Promise> { - try { - await this.ensureConnected(); - const rooms: UnifiedRoom[] = []; - for (const [roomId, roomData] of this.roomsMap!.entries()) { - if (!userId || roomData.participants.includes(userId) || roomData.admins.includes(userId)) { - rooms.push({ - id: roomData.id, - name: roomData.name, - type: roomData.type, - participants: roomData.participants || [], - admins: roomData.admins || [], - creator: roomData.creator || 'unknown', - isActive: roomData.isActive ?? true, - createdAt: roomData.createdAt, - updatedAt: roomData.updatedAt, - lastActivity: roomData.lastActivity || Date.now(), - }); - } - } - return this.createSuccessResult(rooms); - } catch (error) { - return this.handleError(error, 'getRooms'); - } - } - - async getRoom(roomId: string): Promise> { - try { - await this.ensureConnected(); - const roomData = this.roomsMap!.get(roomId); - if (!roomData) { - return this.createErrorResult(`Room with id ${roomId} not found`); - } - const room: UnifiedRoom = { - id: roomData.id, - name: roomData.name, - type: roomData.type, - participants: roomData.participants || [], - admins: roomData.admins || [], - creator: roomData.creator || 'unknown', - isActive: roomData.isActive ?? true, - createdAt: roomData.createdAt, - updatedAt: roomData.updatedAt, - lastActivity: roomData.lastActivity || Date.now(), - }; - return this.createSuccessResult(room); - } catch (error) { - return this.handleError(error, 'getRoom'); - } - } - - async getRoomByName(name: string): Promise> { - try { - await this.ensureConnected(); - for (const [roomId, roomData] of this.roomsMap!.entries()) { - if (roomData.name === name) { - const room: UnifiedRoom = { - id: roomData.id, - name: roomData.name, - type: roomData.type, - participants: roomData.participants || [], - admins: roomData.admins || [], - creator: roomData.creator || 'unknown', - isActive: roomData.isActive ?? true, - createdAt: roomData.createdAt, - updatedAt: roomData.updatedAt, - lastActivity: roomData.lastActivity || Date.now(), - }; - return this.createSuccessResult(room); - } - } - return this.createErrorResult(`Room with name ${name} not found`); - } catch (error) { - return this.handleError(error, 'getRoomByName'); - } - } - - async updateRoom(roomId: string, updates: Partial): Promise> { - try { - await this.ensureConnected(); - const roomData = this.roomsMap!.get(roomId); - if (!roomData) { - return this.createErrorResult(`Room with id ${roomId} not found`); - } - const updatedRoom = { ...roomData, ...updates, updatedAt: Date.now() }; - this.roomsMap!.set(roomId, updatedRoom); - return this.createSuccessResult(updatedRoom as UnifiedRoom); - } catch (error) { - return this.handleError(error, 'updateRoom'); - } - } - - async deleteRoom(roomId: string): Promise> { - try { - await this.ensureConnected(); - this.roomsMap!.delete(roomId); - return this.createSuccessResult(undefined); - } catch (error) { - return this.handleError(error, 'deleteRoom'); - } - } - - // Enhanced room management operations - async createRoomFromRequest(request: CreateRoomRequest, creatorId: string): Promise> { - try { - await this.ensureConnected(); - const roomId = this.generateId(); - const now = Date.now(); - - const newRoom: UnifiedRoom = { - id: roomId, - name: request.name, - type: request.type, - participants: [creatorId], - admins: [creatorId], - creator: creatorId, - description: request.description, - maxParticipants: request.maxParticipants, - isActive: true, - lastActivity: now, - createdAt: now, - updatedAt: now, - }; - - this.roomsMap!.set(roomId, newRoom); - console.log('[YjsPluvProvider] 🏠 Created room from request:', newRoom); - return this.createSuccessResult(newRoom); - } catch (error) { - return this.handleError(error, 'createRoomFromRequest'); - } - } - - async getAvailableRooms(userId: string, filter?: RoomListFilter): Promise> { - try { - console.log('[YjsPluvProvider] 🔍 Getting available rooms for user:', userId); - console.log('[YjsPluvProvider] 📊 Connection state:', this.connectionState); - console.log('[YjsPluvProvider] 📄 Yjs doc available:', !!this.ydoc); - console.log('[YjsPluvProvider] 🗺️ Rooms map available:', !!this.roomsMap); - - await this.ensureConnected(); - const allRooms = Array.from(this.roomsMap!.values()); - - console.log('[YjsPluvProvider] 📋 Total rooms found:', allRooms.length); - - let filteredRooms = allRooms.filter(room => { - if (!room.isActive) return false; - if (filter?.type && room.type !== filter.type) return false; - if (filter?.userIsMember && !room.participants.includes(userId)) return false; - if (filter?.userCanJoin) { - const canJoin = room.type === 'public' || room.participants.includes(userId); - if (!canJoin) return false; - } - return true; - }); - - console.log('[YjsPluvProvider] ✅ Filtered rooms:', filteredRooms.length); - return this.createSuccessResult(filteredRooms); - } catch (error) { - console.error('[YjsPluvProvider] ❌ Error in getAvailableRooms:', error); - return this.handleError(error, 'getAvailableRooms'); - } - } - - async joinRoom(request: JoinRoomRequest): Promise> { - try { - await this.ensureConnected(); - const room = this.roomsMap!.get(request.roomId); - - if (!room) { - return this.createErrorResult(`Room ${request.roomId} not found`, 'ROOM_NOT_FOUND'); - } - - // Check if user can join - const canJoinResult = await this.canUserJoinRoom(request.roomId, request.userId); - if (!canJoinResult.success || !canJoinResult.data) { - return this.createErrorResult('User cannot join this room', 'ACCESS_DENIED'); - } - - // Add user to participants if not already there - if (!room.participants.includes(request.userId)) { - room.participants = [...room.participants, request.userId]; - room.updatedAt = Date.now(); - room.lastActivity = Date.now(); - this.roomsMap!.set(request.roomId, room); - - console.log(`[YjsPluvProvider] 🚪 User ${request.userName} joined room ${room.name}`); - } - - return this.createSuccessResult(room); - } catch (error) { - return this.handleError(error, 'joinRoom'); - } - } - - async leaveRoom(roomId: string, userId: string): Promise> { - try { - await this.ensureConnected(); - const room = this.roomsMap!.get(roomId); - - if (!room) { - return this.createErrorResult(`Room ${roomId} not found`, 'ROOM_NOT_FOUND'); - } - - // Remove user from participants and admins - room.participants = room.participants.filter((id: string) => id !== userId); - room.admins = room.admins.filter((id: string) => id !== userId); - room.updatedAt = Date.now(); - room.lastActivity = Date.now(); - - this.roomsMap!.set(roomId, room); - console.log(`[YjsPluvProvider] 🚪 User ${userId} left room ${room.name}`); - - return this.createSuccessResult(undefined); - } catch (error) { - return this.handleError(error, 'leaveRoom'); - } - } - - async updateRoomMembership(update: RoomMembershipUpdate): Promise> { - try { - await this.ensureConnected(); - const room = this.roomsMap!.get(update.roomId); - - if (!room) { - return this.createErrorResult(`Room ${update.roomId} not found`, 'ROOM_NOT_FOUND'); - } - - // Check if actor has permission (must be admin or creator) - if (!room.admins.includes(update.actorId) && room.creator !== update.actorId) { - return this.createErrorResult('Insufficient permissions', 'ACCESS_DENIED'); - } - - switch (update.action) { - case 'join': - if (!room.participants.includes(update.userId)) { - room.participants = [...room.participants, update.userId]; - } - break; - case 'leave': - case 'kick': - room.participants = room.participants.filter((id: string) => id !== update.userId); - room.admins = room.admins.filter((id: string) => id !== update.userId); - break; - case 'promote': - if (room.participants.includes(update.userId) && !room.admins.includes(update.userId)) { - room.admins = [...room.admins, update.userId]; - } - break; - case 'demote': - room.admins = room.admins.filter((id: string) => id !== update.userId); - break; - } - - room.updatedAt = Date.now(); - room.lastActivity = Date.now(); - this.roomsMap!.set(update.roomId, room); - - console.log(`[YjsPluvProvider] 👥 Membership updated - ${update.action} for user ${update.userId} in room ${room.name}`); - return this.createSuccessResult(room); - } catch (error) { - return this.handleError(error, 'updateRoomMembership'); - } - } - - async canUserJoinRoom(roomId: string, userId: string): Promise> { - try { - await this.ensureConnected(); - const room = this.roomsMap!.get(roomId); - - if (!room || !room.isActive) { - return this.createSuccessResult(false); - } - - // Check if already a member - if (room.participants.includes(userId)) { - return this.createSuccessResult(true); - } - - // Check room type permissions - if (room.type === 'public') { - // Check max participants limit - if (room.maxParticipants && room.participants.length >= room.maxParticipants) { - return this.createSuccessResult(false); - } - return this.createSuccessResult(true); - } - - if (room.type === 'private') { - // Private rooms require invitation (already handled by admin actions) - return this.createSuccessResult(false); - } - - return this.createSuccessResult(false); - } catch (error) { - return this.handleError(error, 'canUserJoinRoom'); - } - } - - // Message operations - async sendMessage(message: Omit): Promise> { - try { - await this.ensureConnected(); - const newMessage: UnifiedMessage = { - id: this.generateId(), - timestamp: Date.now(), - status: 'synced', - ...message, - }; - const messageData = { - id: newMessage.id, - text: newMessage.text, - authorId: newMessage.authorId, - authorName: newMessage.authorName, - roomId: newMessage.roomId, - timestamp: newMessage.timestamp, - status: newMessage.status, - messageType: newMessage.messageType || 'text', - metadata: newMessage.metadata || {}, - role: newMessage.role || 'user', - }; - this.messagesMap!.set(newMessage.id, messageData); - return this.createSuccessResult(newMessage); - } catch (error) { - return this.handleError(error, 'sendMessage'); - } - } - - async getMessages(roomId: string, limit?: number, before?: number): Promise> { - try { - await this.ensureConnected(); - const messages: UnifiedMessage[] = []; - for (const [messageId, messageData] of this.messagesMap!.entries()) { - if (messageData.roomId === roomId) { - if (before && messageData.timestamp >= before) { - continue; - } - const message: UnifiedMessage = { - id: messageData.id, - text: messageData.text, - authorId: messageData.authorId, - authorName: messageData.authorName, - roomId: messageData.roomId, - timestamp: messageData.timestamp, - status: messageData.status || 'synced', - messageType: messageData.messageType || 'text', - metadata: messageData.metadata || {}, - role: messageData.role || 'user', - }; - messages.push(message); - } - } - messages.sort((a, b) => a.timestamp - b.timestamp); - const limitedMessages = limit ? messages.slice(-limit) : messages; - return this.createSuccessResult(limitedMessages); - } catch (error) { - return this.handleError(error, 'getMessages'); - } - } - - async getMessage(messageId: string): Promise> { - try { - await this.ensureConnected(); - const messageData = this.messagesMap!.get(messageId); - if (!messageData) { - return this.createErrorResult(`Message with id ${messageId} not found`); - } - const message: UnifiedMessage = { - id: messageData.id, - text: messageData.text, - authorId: messageData.authorId, - authorName: messageData.authorName, - roomId: messageData.roomId, - timestamp: messageData.timestamp, - status: messageData.status || 'synced', - messageType: messageData.messageType || 'text', - metadata: messageData.metadata || {}, - role: messageData.role || 'user', - }; - return this.createSuccessResult(message); - } catch (error) { - return this.handleError(error, 'getMessage'); - } - } - - async updateMessage(messageId: string, updates: Partial): Promise> { - try { - await this.ensureConnected(); - const messageData = this.messagesMap!.get(messageId); - if (!messageData) { - return this.createErrorResult(`Message with id ${messageId} not found`); - } - const updatedMessage = { ...messageData, ...updates }; - this.messagesMap!.set(messageId, updatedMessage); - return this.createSuccessResult(updatedMessage as UnifiedMessage); - } catch (error) { - return this.handleError(error, 'updateMessage'); - } - } - - async deleteMessage(messageId: string): Promise> { - try { - await this.ensureConnected(); - this.messagesMap!.delete(messageId); - return this.createSuccessResult(undefined); - } catch (error) { - return this.handleError(error, 'deleteMessage'); - } - } - - // Presence operations - async updatePresence(presence: Partial): Promise> { - try { - await this.ensureConnected(); - if (!presence.userId) { - return this.createErrorResult('userId is required for presence update'); - } - const currentPresence = this.presenceMap!.get(presence.userId) || {}; - const updatedPresence = { ...currentPresence, ...presence }; - this.presenceMap!.set(presence.userId, updatedPresence); - return this.createSuccessResult(undefined); - } catch (error) { - return this.handleError(error, 'updatePresence'); - } - } - - async getPresence(roomId: string): Promise> { - try { - await this.ensureConnected(); - const presenceList: UserPresence[] = []; - for (const [userId, presence] of this.presenceMap!.entries()) { - if (presence.currentRoom === roomId) { - presenceList.push(presence); - } - } - return this.createSuccessResult(presenceList); - } catch (error) { - return this.handleError(error, 'getPresence'); - } - } - - // Typing operations - async startTyping(roomId: string): Promise> { - try { - await this.ensureConnected(); - - if (!this.config?.userId || !this.config?.userName) { - return this.handleError(new Error('User ID and name required for typing indicators'), 'startTyping'); - } - - const typingKey = `${roomId}_${this.config.userId}`; - const typingData: TypingState = { - userId: this.config.userId, - userName: this.config.userName, - roomId: roomId, - startTime: Date.now() - }; - - console.log('[YjsPluvProvider] 🖊️ STARTING TYPING:', typingData); - this.typingMap!.set(typingKey, { ...typingData, isTyping: true }); - - return this.createSuccessResult(undefined); - } catch (error) { - return this.handleError(error, 'startTyping'); - } - } - - async stopTyping(roomId: string): Promise> { - try { - await this.ensureConnected(); - - if (!this.config?.userId) { - return this.handleError(new Error('User ID required for typing indicators'), 'stopTyping'); - } - - const typingKey = `${roomId}_${this.config.userId}`; - console.log('[YjsPluvProvider] 🖊️ STOPPING TYPING:', this.config.userId); - this.typingMap!.delete(typingKey); - - return this.createSuccessResult(undefined); - } catch (error) { - return this.handleError(error, 'stopTyping'); - } - } - - // Utility operations - async clearRoomData(roomId: string): Promise> { - try { - await this.ensureConnected(); - const messagesToDelete = []; - for (const [messageId, messageData] of this.messagesMap!.entries()) { - if (messageData.roomId === roomId) { - messagesToDelete.push(messageId); - } - } - messagesToDelete.forEach(messageId => { - this.messagesMap!.delete(messageId); - }); - return this.createSuccessResult(undefined); - } catch (error) { - return this.handleError(error, 'clearRoomData'); - } - } - - async exportData(): Promise> { - try { - await this.ensureConnected(); - const exportData: { - version: string; - provider: string; - timestamp: number; - rooms: { [key: string]: any }; - messages: { [key: string]: any }; - presence: { [key: string]: any }; - } = { - version: this.version, - provider: this.name, - timestamp: Date.now(), - rooms: {}, - messages: {}, - presence: {} - }; - for (const [roomId, roomData] of this.roomsMap!.entries()) { - exportData.rooms[roomId] = roomData; - } - for (const [messageId, messageData] of this.messagesMap!.entries()) { - exportData.messages[messageId] = messageData; - } - for (const [userId, presenceData] of this.presenceMap!.entries()) { - exportData.presence[userId] = presenceData; - } - return this.createSuccessResult(exportData); - } catch (error) { - return this.handleError(error, 'exportData'); - } - } - - async importData(data: any): Promise> { - try { - await this.ensureConnected(); - if (!data || data.provider !== this.name) { - return this.createErrorResult('Invalid import data format'); - } - if (data.rooms) { - Object.entries(data.rooms).forEach(([roomId, roomData]: [string, any]) => { - this.roomsMap!.set(roomId, roomData); - }); - } - if (data.messages) { - Object.entries(data.messages).forEach(([messageId, messageData]: [string, any]) => { - this.messagesMap!.set(messageId, messageData); - }); - } - if (data.presence) { - Object.entries(data.presence).forEach(([userId, presenceData]: [string, any]) => { - this.presenceMap!.set(userId, presenceData); - }); - } - return this.createSuccessResult(undefined); - } catch (error) { - return this.handleError(error, 'importData'); - } - } - - // Event handlers for Yjs changes - private handleMessagesChange(event: Y.YMapEvent) { - event.changes.keys.forEach((change, key) => { - if (change.action === 'add') { - const messageData = this.messagesMap!.get(key); - if (messageData) { - this.notifyRoomSubscribers(messageData.roomId, { - type: 'message_added', - roomId: messageData.roomId, - userId: messageData.authorId, - data: { - id: messageData.id, - text: messageData.text, - authorId: messageData.authorId, - authorName: messageData.authorName, - roomId: messageData.roomId, - timestamp: messageData.timestamp, - status: messageData.status || 'synced', - messageType: messageData.messageType || 'text', - metadata: messageData.metadata || {}, - role: messageData.role || 'user', - }, - timestamp: Date.now(), - }); - } - } else if (change.action === 'update') { - const messageData = this.messagesMap!.get(key); - if (messageData) { - this.notifyRoomSubscribers(messageData.roomId, { - type: 'message_updated', - roomId: messageData.roomId, - userId: messageData.authorId, - data: { - id: messageData.id, - text: messageData.text, - authorId: messageData.authorId, - authorName: messageData.authorName, - roomId: messageData.roomId, - timestamp: messageData.timestamp, - status: messageData.status || 'synced', - messageType: messageData.messageType || 'text', - metadata: messageData.metadata || {}, - role: messageData.role || 'user', - }, - timestamp: Date.now(), - }); - } - } else if (change.action === 'delete') { - this.roomSubscriptions.forEach((callbacks, roomId) => { - this.notifyRoomSubscribers(roomId, { - type: 'message_deleted', - roomId: roomId, - data: { messageId: key }, - timestamp: Date.now(), - }); - }); - } - }); - } - - private handleRoomsChange(event: Y.YMapEvent) { - event.changes.keys.forEach((change, key) => { - if (change.action === 'add') { - const roomData = this.roomsMap!.get(key); - if (roomData) { - this.notifyRoomSubscribers(key, { - type: 'room_updated', - roomId: key, - data: roomData, - timestamp: Date.now(), - }); - } - } - }); - } - - private handleTypingChange(event: Y.YMapEvent) { - console.log('[YjsPluvProvider] 🖊️ TYPING MAP CHANGED!'); - event.changes.keys.forEach((change, key) => { - console.log(`[YjsPluvProvider] 🖊️ Typing change - Action: ${change.action}, Key: ${key}`); - if (change.action === 'add' || change.action === 'update') { - const typingData = this.typingMap!.get(key); - if (typingData) { - console.log('[YjsPluvProvider] 🖊️ TYPING STATE:', typingData); - const eventType = typingData.isTyping ? 'typing_started' : 'typing_stopped'; - this.notifyRoomSubscribers(typingData.roomId, { - type: eventType, - roomId: typingData.roomId, - userId: typingData.userId, - data: typingData, - timestamp: Date.now(), - }); - } - } else if (change.action === 'delete') { - console.log(`[YjsPluvProvider] 🖊️ Typing entry deleted for key: ${key}`); - // When typing indicator expires, notify subscribers - const parts = key.split('_'); - if (parts.length >= 2) { - const roomId = parts[0]; - const userId = parts[1]; - console.log(`[YjsPluvProvider] 🖊️ Notifying typing_stopped for user: ${userId} in room: ${roomId}`); - this.notifyRoomSubscribers(roomId, { - type: 'typing_stopped', - roomId: roomId, - userId: userId, - data: { userId, roomId, isTyping: false }, - timestamp: Date.now(), - }); - } - } - }); - } - - private async ensureConnected(): Promise { - if (!this.ydoc) { - throw new Error('YjsPluvProvider is not connected - no Yjs document available'); - } - - // Allow operations even if WebSocket is still connecting, as Yjs works locally - if (this.connectionState === 'failed' || this.connectionState === 'disconnected') { - throw new Error('YjsPluvProvider is not connected - connection state: ' + this.connectionState); - } - } -} \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/store/hocuspocusClient.tsx b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/store/hocuspocusClient.tsx new file mode 100644 index 0000000000..face724bb2 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/store/hocuspocusClient.tsx @@ -0,0 +1,262 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import * as Y from "yjs"; +import { + HocuspocusProvider, + WebSocketStatus, +} from "@hocuspocus/provider"; + +// ── Environment config ─────────────────────────────────────────────────────── + +const WS_URL = REACT_APP_HOCUSPOCUS_URL || "ws://localhost:3006"; + +const AUTH_TOKEN = REACT_APP_HOCUSPOCUS_SECRET || ""; + +type ConnectionState = "connecting" | "open" | "closed"; + +function mapWebSocketStatus(status?: WebSocketStatus): ConnectionState { + switch (status) { + case WebSocketStatus.Connected: + return "open"; + case WebSocketStatus.Connecting: + return "connecting"; + default: + return "closed"; + } +} + +// ── Context ──────────────────────────────────────────────────────────────────── + +interface HocuspocusContextValue { + provider: HocuspocusProvider; + doc: Y.Doc; +} + +const HocuspocusContext = createContext(null); + +function useHocuspocusContext(): HocuspocusContextValue { + const ctx = useContext(HocuspocusContext); + if (!ctx) { + throw new Error( + "Hocuspocus hooks must be used inside ", + ); + } + return ctx; +} + +// ── Provider component ─────────────────────────────────────────────────────── + +interface HocuspocusRoomProviderProps { + /** Document/room name — all clients with the same name share state. */ + room: string; + /** Initial presence fields to set on connect. */ + initialPresence?: Record; + /** Called when auth fails. */ + onAuthenticationFailed?: (error: unknown) => void; + children: React.ReactNode; +} + +export function HocuspocusRoomProvider({ + room, + initialPresence, + onAuthenticationFailed, + children, +}: HocuspocusRoomProviderProps) { + const initialPresenceKey = JSON.stringify(initialPresence ?? null); + + const value = useMemo(() => { + const doc = new Y.Doc(); + const provider = new HocuspocusProvider({ + url: WS_URL, + name: room, + document: doc, + token: AUTH_TOKEN || undefined, + onAuthenticationFailed: (data: unknown) => { + console.error("[Hocuspocus] Auth failed:", data); + onAuthenticationFailed?.(data); + }, + }); + + return { provider, doc }; + // Only re-create when the room name changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [room]); + + useEffect(() => { + if (initialPresenceKey !== "null") { + value.provider.setAwarenessField("user", JSON.parse(initialPresenceKey)); + } + }, [initialPresenceKey, value.provider]); + + useEffect(() => { + return () => { + value.provider.destroy(); + value.doc.destroy(); + }; + }, [value]); + + return ( + + {children} + + ); +} + +// ── Hook: useConnection ────────────────────────────────────────────────────── + +export function useConnection(): { state: ConnectionState } { + const { provider } = useHocuspocusContext(); + + const getStatus = useCallback( + () => mapWebSocketStatus(provider.configuration.websocketProvider.status), + [provider], + ); + + const [state, setState] = useState(getStatus); + + useEffect(() => { + // Sync immediately when provider changes + setState(getStatus()); + + const handleStatus = ({ status }: { status: WebSocketStatus }) => { + setState(mapWebSocketStatus(status)); + }; + + provider.on("status", handleStatus); + + return () => { + provider.off("status", handleStatus); + }; + }, [provider, getStatus]); + + return { state }; +} + +// ── Hook: useMyPresence ────────────────────────────────────────────────────── + +export function useMyPresence(): [ + Record, + (fields: Record) => void, +] { + const { provider } = useHocuspocusContext(); + + const getPresence = useCallback( + () => provider.awareness?.getLocalState()?.user ?? {}, + [provider], + ); + + const [presence, setPresence] = useState>(getPresence); + + const updatePresence = useCallback( + (fields: Record) => { + provider.setAwarenessField("user", fields); + setPresence(fields); + }, + [provider], + ); + + useEffect(() => { + const awareness = provider.awareness; + if (!awareness) return; + + const handleChange = () => { + setPresence(getPresence()); + }; + + awareness.on("change", handleChange); + + return () => { + awareness.off("change", handleChange); + }; + }, [provider, getPresence]); + + return [presence, updatePresence]; +} + +// ── Hook: useOthers ────────────────────────────────────────────────────────── + +interface OtherUser { + clientId: number; + presence: Record; +} + +export function useOthers(): OtherUser[] { + const { provider } = useHocuspocusContext(); + + const getOthers = useCallback((): OtherUser[] => { + const awareness = provider.awareness; + if (!awareness) return []; + + const localClientId = awareness.clientID; + const others: OtherUser[] = []; + + awareness.getStates().forEach((state: Record, clientId: number) => { + if (clientId === localClientId) return; + if (state?.user) { + others.push({ clientId, presence: state.user as Record }); + } + }); + + return others; + }, [provider]); + + const [others, setOthers] = useState(getOthers); + + useEffect(() => { + const awareness = provider.awareness; + if (!awareness) return; + + const handleChange = () => { + setOthers(getOthers()); + }; + + awareness.on("change", handleChange); + + return () => { + awareness.off("change", handleChange); + }; + }, [provider, getOthers]); + + return others; +} + +// ── Hook: useStorage ───────────────────────────────────────────────────────── +// Returns [snapshot, yMap] for a named top-level Y.Map on the shared doc. +// The snapshot auto-updates on any remote or local mutation. + +export function useStorage( + mapName: string, +): [Record | null, Y.Map | null] { + const { doc } = useHocuspocusContext(); + + const yMap = useMemo(() => doc.getMap(mapName), [doc, mapName]); + + const getSnapshot = useCallback( + () => (yMap ? Object.fromEntries(yMap.entries()) : null), + [yMap], + ); + + const [snapshot, setSnapshot] = useState | null>(getSnapshot); + + useEffect(() => { + if (!yMap) return; + + const handleChange = () => { + setSnapshot(getSnapshot()); + }; + + yMap.observe(handleChange); + + return () => { + yMap.unobserve(handleChange); + }; + }, [yMap, getSnapshot]); + + return [snapshot, yMap]; +} diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/store/index.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/store/index.ts new file mode 100644 index 0000000000..9cfc8465b2 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/store/index.ts @@ -0,0 +1,18 @@ +export type { + AiThinkingState, + ChatMessage, + ChatRoom, + PendingRoomInvite, + TypingUser, + OnlineUser, +} from "./types"; + +export { uid, LLM_BOT_AUTHOR_ID } from "./types"; + +export { + HocuspocusRoomProvider, + useStorage, + useMyPresence, + useOthers, + useConnection, +} from "./hocuspocusClient"; diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/store/types.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/store/types.ts new file mode 100644 index 0000000000..d26e7f2f32 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/store/types.ts @@ -0,0 +1,54 @@ +export interface ChatMessage { + id: string; + text: string; + authorId: string; + authorName: string; + timestamp: number; + authorType?: "user" | "assistant"; + [key: string]: any; +} + +export interface ChatRoom { + id: string; + name: string; + type: "public" | "private" | "llm"; + description: string | null; + members: string[]; + createdBy: string; + createdAt: number; + llmQueryName: string | null; +} + +export interface PendingRoomInvite { + id: string; + roomId: string; + roomName: string; + fromUserId: string; + fromUserName: string; + toUserId: string; + timestamp: number; +} + +export interface TypingUser { + userId: string; + userName: string; + roomId?: string; +} + +export interface OnlineUser { + userId: string; + userName: string; + currentRoomId: string | null; +} + +export interface AiThinkingState { + roomId: string; + isThinking: boolean; + timestamp: number; +} + +export const LLM_BOT_AUTHOR_ID = "__llm_bot__"; + +export function uid(): string { + return `${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; +} diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/styles.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/styles.ts new file mode 100644 index 0000000000..917cfee722 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/styles.ts @@ -0,0 +1,486 @@ +import styled from "styled-components"; +import type { + ChatBoxContainerStyleType, + ChatBoxSidebarStyleType, + ChatBoxHeaderStyleType, + ChatBoxMessageStyleType, + ChatBoxInputStyleType, + AnimationStyleType, +} from "comps/controls/styleControlConstants"; + +export const Wrapper = styled.div<{ $style: ChatBoxContainerStyleType; $anim: AnimationStyleType }>` + height: 100%; + display: flex; + overflow: hidden; + border-radius: ${(p) => p.$style.radius || "8px"}; + border: ${(p) => p.$style.borderWidth || "1px"} ${(p) => p.$style.borderStyle || "solid"} ${(p) => p.$style.border || "#e0e0e0"}; + background: ${(p) => p.$style.background || "#fff"}; + margin: ${(p) => p.$style.margin || "0"}; + padding: ${(p) => p.$style.padding || "0"}; + ${(p) => p.$anim} +`; + +export const RoomPanelContainer = styled.div<{ + $width: string; + $sidebarStyle?: ChatBoxSidebarStyleType; +}>` + width: ${(p) => p.$width}; + min-width: 160px; + border-right: 1px solid ${(p) => p.$sidebarStyle?.sidebarBorder || "#eee"}; + display: flex; + flex-direction: column; + background: ${(p) => p.$sidebarStyle?.sidebarBackground || "#fafbfc"}; + color: ${(p) => p.$sidebarStyle?.sidebarText || "inherit"}; + border-radius: ${(p) => p.$sidebarStyle?.radius || "0"}; +`; + +export const RoomPanelHeader = styled.div<{ $sidebarStyle?: ChatBoxSidebarStyleType }>` + padding: 12px; + font-weight: 600; + font-size: 13px; + color: ${(p) => p.$sidebarStyle?.sidebarText || "#555"}; + background: ${(p) => p.$sidebarStyle?.sidebarHeaderBackground || "transparent"}; + border-bottom: 1px solid ${(p) => p.$sidebarStyle?.sidebarBorder || "#eee"}; + display: flex; + align-items: center; + justify-content: space-between; +`; + +export const RoomListContainer = styled.div` + flex: 1; + overflow-y: auto; + padding: 8px; +`; + +export const RoomItemStyled = styled.div<{ + $active: boolean; + $sidebarStyle?: ChatBoxSidebarStyleType; +}>` + padding: ${(p) => p.$sidebarStyle?.padding || "8px 10px"}; + margin-bottom: 4px; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + transition: all 0.15s ease; + display: flex; + align-items: center; + gap: 6px; + background: ${(p) => + p.$active + ? p.$sidebarStyle?.sidebarActiveItemBackground || "#1890ff" + : "#fff"}; + color: ${(p) => + p.$active + ? p.$sidebarStyle?.sidebarActiveItemText || "#fff" + : p.$sidebarStyle?.sidebarText || "#333"}; + border: 1px solid ${(p) => + p.$active + ? p.$sidebarStyle?.sidebarActiveItemBackground || "#1890ff" + : "#f0f0f0"}; + + &:hover { + background: ${(p) => + p.$active + ? p.$sidebarStyle?.sidebarActiveItemBackground || "#1890ff" + : "#f5f5f5"}; + } +`; + +export const SearchResultBadge = styled.span` + font-size: 10px; + background: #e6f7ff; + color: #1890ff; + padding: 1px 6px; + border-radius: 8px; + font-weight: 500; + margin-left: auto; +`; + +export const ChatPanelContainer = styled.div` + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +`; + +export const ChatHeaderBar = styled.div<{ $headerStyle?: ChatBoxHeaderStyleType }>` + padding: ${(p) => p.$headerStyle?.padding || "12px 16px"}; + border-bottom: 1px solid ${(p) => p.$headerStyle?.headerBorder || "#eee"}; + background: ${(p) => p.$headerStyle?.headerBackground || "transparent"}; + color: ${(p) => p.$headerStyle?.headerText || "inherit"}; + display: flex; + justify-content: space-between; + align-items: center; +`; + +export const MessagesArea = styled.div<{ $messageStyle?: ChatBoxMessageStyleType }>` + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 8px; + background: ${(p) => p.$messageStyle?.messageAreaBackground || "transparent"}; +`; + +export const MessageWrapper = styled.div<{ $own: boolean }>` + display: flex; + flex-direction: column; + align-self: ${(p) => (p.$own ? "flex-end" : "flex-start")}; + max-width: 70%; +`; + +export const Bubble = styled.div<{ + $own: boolean; + $messageStyle?: ChatBoxMessageStyleType; +}>` + padding: ${(p) => p.$messageStyle?.padding || "10px 14px"}; + border-radius: ${(p) => { + const r = p.$messageStyle?.radius; + if (r && r !== "0") return r; + return p.$own ? "16px 16px 4px 16px" : "16px 16px 16px 4px"; + }}; + background: ${(p) => + p.$own + ? p.$messageStyle?.ownMessageBackground || "#1890ff" + : p.$messageStyle?.otherMessageBackground || "#f0f0f0"}; + color: ${(p) => + p.$own + ? p.$messageStyle?.ownMessageText || "#fff" + : p.$messageStyle?.otherMessageText || "#333"}; + font-size: 14px; + word-break: break-word; +`; + +export const BubbleMeta = styled.div<{ + $own: boolean; + $messageStyle?: ChatBoxMessageStyleType; +}>` + font-size: 11px; + color: ${(p) => p.$messageStyle?.messageMetaText || "inherit"}; + opacity: ${(p) => (p.$messageStyle?.messageMetaText ? 1 : 0.7)}; + margin-bottom: 2px; + text-align: ${(p) => (p.$own ? "right" : "left")}; +`; + +export const BubbleTime = styled.div<{ + $own: boolean; + $messageStyle?: ChatBoxMessageStyleType; +}>` + font-size: 10px; + color: ${(p) => p.$messageStyle?.messageMetaText || "inherit"}; + opacity: ${(p) => (p.$messageStyle?.messageMetaText ? 0.8 : 0.6)}; + margin-top: 4px; + text-align: ${(p) => (p.$own ? "right" : "left")}; +`; + +export const InputBarContainer = styled.div<{ $inputStyle?: ChatBoxInputStyleType }>` + padding: 12px 16px; + border-top: 1px solid ${(p) => p.$inputStyle?.inputAreaBorder || "#eee"}; + background: ${(p) => p.$inputStyle?.inputAreaBackground || "transparent"}; + display: flex; + gap: 8px; + align-items: flex-end; +`; + +export const StyledTextArea = styled.textarea<{ $inputStyle?: ChatBoxInputStyleType }>` + flex: 1; + padding: ${(p) => p.$inputStyle?.padding || "8px 14px"}; + border: 1px solid ${(p) => p.$inputStyle?.inputBorder || "#d9d9d9"}; + border-radius: ${(p) => p.$inputStyle?.radius || "18px"}; + background: ${(p) => p.$inputStyle?.inputBackground || "#fff"}; + color: ${(p) => p.$inputStyle?.inputText || "inherit"}; + resize: none; + min-height: 36px; + max-height: 96px; + font-size: 14px; + outline: none; + font-family: inherit; + line-height: 1.4; + &:focus { + border-color: ${(p) => p.$inputStyle?.sendButtonBackground || "#1890ff"}; + } +`; + +export const EmptyChat = styled.div` + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #999; + gap: 4px; +`; + +export const TypingIndicatorWrapper = styled.div` + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + align-self: flex-start; +`; + +export const TypingDots = styled.span` + display: inline-flex; + align-items: center; + gap: 3px; + background: #e8e8e8; + border-radius: 12px; + padding: 8px 12px; + + span { + width: 6px; + height: 6px; + border-radius: 50%; + background: #999; + animation: typingBounce 1.4s infinite ease-in-out both; + } + + span:nth-child(1) { animation-delay: 0s; } + span:nth-child(2) { animation-delay: 0.2s; } + span:nth-child(3) { animation-delay: 0.4s; } + + @keyframes typingBounce { + 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } + 40% { transform: scale(1); opacity: 1; } + } +`; + +export const TypingLabel = styled.span` + font-size: 12px; + color: #999; + font-style: italic; +`; + +export const ConnectionBanner = styled.div<{ $status: "online" | "offline" | "connecting" }>` + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: ${(p) => + p.$status === "online" ? "#52c41a" : p.$status === "offline" ? "#fa541c" : "#999"}; +`; + +export const ConnectionDot = styled.span<{ $status: "online" | "offline" | "connecting" }>` + width: 8px; + height: 8px; + border-radius: 50%; + background: ${(p) => + p.$status === "online" ? "#52c41a" : p.$status === "offline" ? "#fa541c" : "#d9d9d9"}; +`; + +// ── LLM / AI message styles ──────────────────────────────────────────────── + +export const AiBubbleWrapper = styled.div` + display: flex; + flex-direction: column; + align-self: flex-start; + max-width: 80%; + position: relative; + + &:hover .ai-copy-btn { + opacity: 1; + } +`; + +export const AiBadge = styled.span` + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 10px; + font-weight: 600; + color: #7c3aed; + background: #f3e8ff; + border-radius: 8px; + padding: 2px 7px; + margin-bottom: 4px; + align-self: flex-start; + letter-spacing: 0.4px; + text-transform: uppercase; +`; + +export const AiBubble = styled.div` + background: #faf5ff; + border: 1px solid #e9d5ff; + border-radius: 4px 16px 16px 16px; + padding: 10px 14px; + font-size: 14px; + color: #1f1f1f; + line-height: 1.6; + word-break: break-word; + + p { margin: 0 0 8px; } + p:last-child { margin-bottom: 0; } + pre { + background: #f1f5f9; + border-radius: 6px; + padding: 10px 12px; + overflow-x: auto; + font-size: 13px; + } + code { + background: #f1f5f9; + border-radius: 3px; + padding: 1px 5px; + font-size: 13px; + font-family: "Fira Mono", "Cascadia Code", monospace; + } + pre code { + background: none; + padding: 0; + } + ul, ol { padding-left: 20px; margin: 6px 0; } + li { margin-bottom: 2px; } + blockquote { + border-left: 3px solid #c084fc; + margin: 6px 0; + padding-left: 10px; + color: #666; + } + a { color: #7c3aed; } + strong { font-weight: 600; } + h1, h2, h3, h4 { margin: 8px 0 4px; font-weight: 600; } + table { border-collapse: collapse; width: 100%; margin: 6px 0; } + th, td { border: 1px solid #e9d5ff; padding: 4px 8px; } + th { background: #f3e8ff; } +`; + +export const AiCopyButton = styled.button` + position: absolute; + top: 28px; + right: -34px; + width: 26px; + height: 26px; + border: none; + background: #f3e8ff; + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.15s ease, background 0.15s ease; + color: #7c3aed; + font-size: 13px; + + &:hover { + background: #e9d5ff; + } +`; + +export const LlmLoadingBubble = styled.div` + align-self: flex-start; + background: #faf5ff; + border: 1px solid #e9d5ff; + border-radius: 4px 16px 16px 16px; + padding: 12px 16px; + display: flex; + align-items: center; + gap: 5px; + + span { + width: 7px; + height: 7px; + border-radius: 50%; + background: #c084fc; + animation: llmThink 1.4s infinite ease-in-out both; + } + span:nth-child(1) { animation-delay: 0s; } + span:nth-child(2) { animation-delay: 0.2s; } + span:nth-child(3) { animation-delay: 0.4s; } + + @keyframes llmThink { + 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } + 40% { transform: scale(1.1); opacity: 1; } + } +`; + +export const LlmRoomBadge = styled.span` + font-size: 10px; + font-weight: 600; + color: #7c3aed; + background: #f3e8ff; + border-radius: 6px; + padding: 1px 5px; + flex-shrink: 0; +`; + +// ── Online Presence styles ────────────────────────────────────────────────── + +export const OnlinePresenceSection = styled.div` + border-top: 1px solid #eee; + padding: 8px; + flex-shrink: 0; +`; + +export const OnlinePresenceLabel = styled.div` + font-size: 10px; + font-weight: 600; + color: #aaa; + letter-spacing: 0.6px; + text-transform: uppercase; + padding: 4px 2px 6px; + display: flex; + align-items: center; + gap: 6px; +`; + +export const OnlineUserItem = styled.div` + display: flex; + align-items: center; + gap: 7px; + padding: 4px 2px; + font-size: 12px; + color: #444; + overflow: hidden; +`; + +export const OnlineAvatar = styled.div<{ $color: string }>` + width: 22px; + height: 22px; + border-radius: 50%; + background: ${(p) => p.$color}; + color: #fff; + font-size: 10px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + position: relative; +`; + +export const OnlineDot = styled.span` + position: absolute; + bottom: -1px; + right: -1px; + width: 7px; + height: 7px; + border-radius: 50%; + background: #52c41a; + border: 1.5px solid #fafbfc; +`; + +export const OnlineUserName = styled.span` + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +export const OnlineCountBadge = styled.span` + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: #52c41a; + font-weight: 500; +`; + +export const OnlineCountDot = styled.span` + width: 7px; + height: 7px; + border-radius: 50%; + background: #52c41a; + display: inline-block; +`; diff --git a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/types/chatDataTypes.ts b/client/packages/lowcoder/src/comps/comps/chatBoxComponent/types/chatDataTypes.ts deleted file mode 100644 index 64aebe4757..0000000000 --- a/client/packages/lowcoder/src/comps/comps/chatBoxComponent/types/chatDataTypes.ts +++ /dev/null @@ -1,408 +0,0 @@ -// Core data types for unified chat system -// Compatible with existing ALASql structure while extensible for real-time collaboration - -export type MessageStatus = 'sending' | 'sent' | 'failed' | 'synced'; -export type MessageType = 'text' | 'file' | 'system' | 'action'; -export type RoomType = 'private' | 'public' | 'group'; -export type UserStatus = 'online' | 'away' | 'busy' | 'offline'; - -// Unified message format - backward compatible with existing MyMessage -export interface UnifiedMessage { - // Core fields (existing ALASql compatibility) - id: string; - text: string; - timestamp: number; - - // Author information for multi-user support - authorId: string; - authorName: string; - - // Room/thread association - roomId: string; - - // Message status and type - status: MessageStatus; - messageType: MessageType; - - // Real-time collaboration metadata (optional for future use) - yjsId?: string; // Yjs document reference - version?: number; // Version for conflict resolution - localId?: string; // Local optimistic ID - - // Extensibility - metadata?: Record; - - // Legacy compatibility (for existing ChatComp) - role?: "user" | "assistant"; // Maps to authorId types -} - -// Unified room format - compatible with existing thread structure -export interface UnifiedRoom { - // Core identification - id: string; - name: string; - type: RoomType; - - // Participants management - participants: string[]; // User IDs - admins: string[]; // Admin user IDs - creator: string; // User ID who created the room - - // Room settings - description?: string; // Optional room description - maxParticipants?: number; // Optional participant limit - - // State and metadata - isActive: boolean; - lastActivity: number; - createdAt: number; - updatedAt: number; - - // Real-time collaboration (optional) - yjsDocId?: string; // Yjs document ID for this room - - // Legacy compatibility - status?: "regular" | "archived"; // For existing thread system - title?: string; // Alias for name - threadId?: string; // Alias for id -} - -// User presence for real-time features -export interface UserPresence { - userId: string; - userName: string; - avatar?: string; - status: UserStatus; - lastSeen: number; - currentRoom?: string; - typingIn?: string; // Room ID where user is typing -} - -// Typing indicator state -export interface TypingState { - userId: string; - userName: string; - roomId: string; - startTime: number; -} - -// Room management interfaces -export interface CreateRoomRequest { - name: string; - type: RoomType; - description?: string; - maxParticipants?: number; - isPrivate?: boolean; -} - -export interface JoinRoomRequest { - roomId: string; - userId: string; - userName: string; -} - -export interface RoomMembershipUpdate { - roomId: string; - userId: string; - action: 'join' | 'leave' | 'promote' | 'demote' | 'kick'; - actorId: string; // Who performed the action -} - -export interface RoomListFilter { - type?: RoomType; - isActive?: boolean; - userCanJoin?: boolean; - userIsMember?: boolean; -} - -// Connection state for real-time providers -export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'failed'; - -// Chat events for real-time subscriptions -export type ChatEventType = 'message_added' | 'message_updated' | 'message_deleted' | - 'room_updated' | 'user_joined' | 'user_left' | - 'typing_started' | 'typing_stopped' | 'presence_updated'; - -export interface ChatEvent { - type: ChatEventType; - roomId: string; - userId?: string; - data: any; - timestamp: number; -} - -// Configuration types -export interface ConnectionConfig { - mode: 'local' | 'collaborative' | 'hybrid'; - userId: string; - userName: string; - - // Local storage config - alasql?: { - dbName: string; - tableName?: string; - }; - - // Real-time collaboration config (for future use) - realtime?: { - serverUrl: string; - roomId: string; - authToken?: string; - }; -} - -// Provider operation results -export interface OperationResult { - success: boolean; - data?: T; - error?: string; - timestamp: number; -} - -// DataTransformUtils - Handles conversion between different data formats -export class DataTransformUtils { - // ALASql transformations (existing) - static toALASqlMessage(message: UnifiedMessage): any { - return { - id: message.id, - role: 'user', // Map authorId to role for backward compatibility - text: message.text, - timestamp: message.timestamp, - threadId: message.roomId, // Map roomId to threadId for ALASql compatibility - authorId: message.authorId, - authorName: message.authorName, - status: message.status || 'sent', - metadata: JSON.stringify(message.metadata || {}) - }; - } - - static fromALASqlMessage(data: any): UnifiedMessage { - return { - id: data.id, - text: data.text, - authorId: data.authorId || data.userId || 'unknown', - authorName: data.authorName || data.userName || 'Unknown User', - roomId: data.threadId || data.roomId, - timestamp: data.timestamp, - status: data.status || 'sent', - messageType: 'text', - metadata: data.metadata ? JSON.parse(data.metadata) : {} - }; - } - - static toALASqlRoom(room: UnifiedRoom): any { - return { - id: room.id, - name: room.name, - type: room.type, - participants: JSON.stringify(room.participants), - admins: JSON.stringify(room.admins || []), - isActive: room.isActive, - createdAt: room.createdAt, - updatedAt: room.updatedAt, - lastActivity: room.lastActivity - }; - } - - static fromALASqlRoom(data: any): UnifiedRoom { - return { - id: data.id, - name: data.name, - type: data.type || 'private', - participants: data.participants ? JSON.parse(data.participants) : [], - admins: data.admins ? JSON.parse(data.admins) : [], - creator: data.creator || 'unknown', - isActive: data.isActive !== false, - createdAt: data.createdAt, - updatedAt: data.updatedAt, - lastActivity: data.lastActivity || data.updatedAt - }; - } - - // Yjs transformations (new) - static toYjsMessage(message: UnifiedMessage): any { - return { - id: message.id, - text: message.text, - authorId: message.authorId, - authorName: message.authorName, - roomId: message.roomId, - timestamp: message.timestamp, - status: message.status || 'sent', - messageType: message.messageType, - metadata: message.metadata || {} - }; - } - - static fromYjsMessage(data: any): UnifiedMessage { - return { - id: data.id, - text: data.text, - authorId: data.authorId, - authorName: data.authorName, - roomId: data.roomId, - timestamp: data.timestamp, - status: data.status || 'sent', - messageType: data.messageType || 'text', - metadata: data.metadata || {} - }; - } - - static toYjsRoom(room: UnifiedRoom): any { - return { - id: room.id, - name: room.name, - type: room.type, - participants: room.participants, - admins: room.admins || [], - isActive: room.isActive, - createdAt: room.createdAt, - updatedAt: room.updatedAt, - lastActivity: room.lastActivity - }; - } - - static fromYjsRoom(data: any): UnifiedRoom { - return { - id: data.id, - name: data.name, - type: data.type || 'private', - participants: data.participants || [], - admins: data.admins || [], - creator: data.creator || 'unknown', - isActive: data.isActive !== false, - createdAt: data.createdAt, - updatedAt: data.updatedAt, - lastActivity: data.lastActivity || data.updatedAt - }; - } - - static toYjsPresence(presence: UserPresence): any { - return { - userId: presence.userId, - userName: presence.userName, - status: presence.status || 'online', - lastSeen: presence.lastSeen || Date.now(), - currentRoom: presence.currentRoom, - typingIn: presence.typingIn - }; - } - - static fromYjsPresence(data: any): UserPresence { - return { - userId: data.userId, - userName: data.userName, - status: data.status || 'online', - lastSeen: data.lastSeen || Date.now(), - currentRoom: data.currentRoom, - typingIn: data.typingIn - }; - } - - // Pluv.io transformations (new) - static toPluvPresence(presence: UserPresence): any { - return { - userId: presence.userId, - userName: presence.userName, - status: presence.status || 'online', - typing: !!presence.typingIn, - lastSeen: presence.lastSeen || Date.now() - }; - } - - static fromPluvPresence(data: any): UserPresence { - return { - userId: data.userId, - userName: data.userName, - status: data.status || 'online', - lastSeen: data.lastSeen || Date.now(), - currentRoom: data.roomId - }; - } - - // Legacy ALASql thread conversions (for backward compatibility) - static fromLegacyThread(thread: any): UnifiedRoom { - return { - id: thread.threadId, - name: thread.title, - type: 'private', - participants: [], - admins: [], - creator: 'legacy_user', - isActive: thread.status !== 'archived', - lastActivity: thread.updatedAt, - createdAt: thread.createdAt, - updatedAt: thread.updatedAt, - }; - } - - static toLegacyThread(room: UnifiedRoom): any { - return { - threadId: room.id, - status: room.isActive ? 'regular' : 'archived', - title: room.name, - createdAt: room.createdAt, - updatedAt: room.updatedAt, - }; - } - - static fromLegacyMessage(msg: any, roomId: string, authorId: string, authorName: string): UnifiedMessage { - return { - id: msg.id, - text: msg.text, - authorId, - authorName, - roomId, - timestamp: msg.timestamp, - status: 'synced', - messageType: 'text', - metadata: {}, - role: msg.role === 'assistant' ? 'assistant' : 'user' - }; - } - - static toLegacyMessage(message: UnifiedMessage): any { - return { - id: message.id, - role: message.role || 'user', - text: message.text, - timestamp: message.timestamp, - threadId: message.roomId, - }; - } - - // Validation helpers - static validateMessage(data: any): boolean { - return !!(data.id && data.text && data.authorId && data.roomId && data.timestamp); - } - - static validateRoom(data: any): boolean { - return !!(data.id && data.name && data.type); - } - - static validatePresence(data: any): boolean { - return !!(data.userId && data.userName); - } -} - -// Error types for better error handling -export class ChatDataError extends Error { - constructor( - message: string, - public code: string, - public details?: any - ) { - super(message); - this.name = 'ChatDataError'; - } -} - -export enum ChatErrorCodes { - CONNECTION_FAILED = 'CONNECTION_FAILED', - OPERATION_TIMEOUT = 'OPERATION_TIMEOUT', - PERMISSION_DENIED = 'PERMISSION_DENIED', - ROOM_NOT_FOUND = 'ROOM_NOT_FOUND', - MESSAGE_NOT_FOUND = 'MESSAGE_NOT_FOUND', - VALIDATION_ERROR = 'VALIDATION_ERROR', - STORAGE_ERROR = 'STORAGE_ERROR', -} \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx index 57ac9040a1..39de2e7393 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/chatComp.tsx @@ -4,19 +4,32 @@ import { UICompBuilder } from "comps/generators"; import { NameConfig, withExposingConfigs } from "comps/generators/withExposing"; import { StringControl } from "comps/controls/codeControl"; import { arrayObjectExposingStateControl, stringExposingStateControl } from "comps/controls/codeStateControl"; +import { JSONObject } from "util/jsonTypes"; import { withDefault } from "comps/generators"; -import { BoolControl } from "comps/controls/boolControl"; -import { dropdownControl } from "comps/controls/dropdownControl"; import QuerySelectControl from "comps/controls/querySelectControl"; import { eventHandlerControl, EventConfigType } from "comps/controls/eventHandlerControl"; -import { ChatCore } from "./components/ChatCore"; +import { AutoHeightControl } from "comps/controls/autoHeightControl"; +import { ChatContainer } from "./components/ChatContainer"; +import { ChatProvider } from "./components/context/ChatContext"; import { ChatPropertyView } from "./chatPropertyView"; import { createChatStorage } from "./utils/storageFactory"; -import { QueryHandler, createMessageHandler } from "./handlers/messageHandlers"; +import { QueryHandler } from "./handlers/messageHandlers"; import { useMemo, useRef, useEffect } from "react"; import { changeChildAction } from "lowcoder-core"; import { ChatMessage } from "./types/chatTypes"; import { trans } from "i18n"; +import { TooltipProvider } from "@radix-ui/react-tooltip"; +import { styleControl } from "comps/controls/styleControl"; +import { + ChatStyle, + ChatSidebarStyle, + ChatMessagesStyle, + ChatInputStyle, + ChatSendButtonStyle, + ChatNewThreadButtonStyle, + ChatThreadItemStyle, +} from "comps/controls/styleControlConstants"; +import { AnimationStyle } from "comps/controls/styleControlConstants"; import "@assistant-ui/styles/index.css"; import "@assistant-ui/styles/markdown.css"; @@ -128,38 +141,48 @@ function generateUniqueTableName(): string { return `chat${Math.floor(1000 + Math.random() * 9000)}`; } -const ModelTypeOptions = [ - { label: trans("chat.handlerTypeQuery"), value: "query" }, - { label: trans("chat.handlerTypeN8N"), value: "n8n" }, -] as const; - export const chatChildrenMap = { - // Storage - // Storage (add the hidden property here) + // Storage (internal, hidden) _internalDbName: withDefault(StringControl, ""), + // Message Handler Configuration - handlerType: dropdownControl(ModelTypeOptions, "query"), - chatQuery: QuerySelectControl, // Only used for "query" type - modelHost: withDefault(StringControl, ""), // Only used for "n8n" type + chatQuery: QuerySelectControl, systemPrompt: withDefault(StringControl, trans("chat.defaultSystemPrompt")), - streaming: BoolControl.DEFAULT_TRUE, // UI Configuration placeholder: withDefault(StringControl, trans("chat.defaultPlaceholder")), + // Layout Configuration + autoHeight: AutoHeightControl, + leftPanelWidth: withDefault(StringControl, "250px"), + // Database Information (read-only) databaseName: withDefault(StringControl, ""), // Event Handlers onEvent: ChatEventHandlerControl, + // Style Controls - Consolidated to reduce prop count + style: styleControl(ChatStyle), // Main container + sidebarStyle: styleControl(ChatSidebarStyle), // Sidebar (includes threads & new button) + messagesStyle: styleControl(ChatMessagesStyle), // Messages area + inputStyle: styleControl(ChatInputStyle), // Input + send button area + animationStyle: styleControl(AnimationStyle), // Animations + + // Legacy style props (kept for backward compatibility, consolidated internally) + sendButtonStyle: styleControl(ChatSendButtonStyle), + newThreadButtonStyle: styleControl(ChatNewThreadButtonStyle), + threadItemStyle: styleControl(ChatThreadItemStyle), + // Exposed Variables (not shown in Property View) currentMessage: stringExposingStateControl("currentMessage", ""), - conversationHistory: stringExposingStateControl("conversationHistory", "[]"), + // Use arrayObjectExposingStateControl for proper Lowcoder pattern + // This exposes: conversationHistory.value, setConversationHistory(), clearConversationHistory(), resetConversationHistory() + conversationHistory: arrayObjectExposingStateControl("conversationHistory", [] as JSONObject[]), }; // ============================================================================ -// CLEAN CHATCOMP - USES NEW ARCHITECTURE +// CHATCOMP // ============================================================================ const ChatTmpComp = new UICompBuilder( @@ -187,64 +210,44 @@ const ChatTmpComp = new UICompBuilder( [] ); - // Create message handler based on type + // Create message handler (Query only) const messageHandler = useMemo(() => { - const handlerType = props.handlerType; - - if (handlerType === "query") { - return new QueryHandler({ - chatQuery: props.chatQuery.value, - dispatch, - streaming: props.streaming, - }); - } else if (handlerType === "n8n") { - return createMessageHandler("n8n", { - modelHost: props.modelHost, - systemPrompt: props.systemPrompt, - streaming: props.streaming - }); - } else { - // Fallback to mock handler - return createMessageHandler("mock", { - chatQuery: props.chatQuery.value, - dispatch, - streaming: props.streaming - }); - } + return new QueryHandler({ + chatQuery: props.chatQuery.value, + dispatch, + }); }, [ - props.handlerType, props.chatQuery, - props.modelHost, - props.systemPrompt, - props.streaming, dispatch, ]); // Handle message updates for exposed variable + // Using Lowcoder pattern: props.currentMessage.onChange() const handleMessageUpdate = (message: string) => { - dispatch(changeChildAction("currentMessage", message, false)); + props.currentMessage.onChange(message); // Trigger messageSent event props.onEvent("messageSent"); }; // Handle conversation history updates for exposed variable - // Handle conversation history updates for exposed variable -const handleConversationUpdate = (conversationHistory: any[]) => { - // Use utility function to create complete history with system prompt - const historyWithSystemPrompt = addSystemPromptToHistory( - conversationHistory, - props.systemPrompt - ); - - // Expose the complete history (with system prompt) for use in queries - dispatch(changeChildAction("conversationHistory", JSON.stringify(historyWithSystemPrompt), false)); - - // Trigger messageReceived event when bot responds - const lastMessage = conversationHistory[conversationHistory.length - 1]; - if (lastMessage && lastMessage.role === 'assistant') { - props.onEvent("messageReceived"); - } -}; + // Using Lowcoder pattern: props.conversationHistory.onChange() instead of dispatch(changeChildAction(...)) + const handleConversationUpdate = (messages: ChatMessage[]) => { + // Use utility function to create complete history with system prompt + const historyWithSystemPrompt = addSystemPromptToHistory( + messages, + props.systemPrompt + ); + + // Update using proper Lowcoder pattern - calling onChange on the control + // This properly updates the exposed variable and triggers reactivity + props.conversationHistory.onChange(historyWithSystemPrompt as JSONObject[]); + + // Trigger messageReceived event when bot responds + const lastMessage = messages[messages.length - 1]; + if (lastMessage && lastMessage.role === 'assistant') { + props.onEvent("messageReceived"); + } + }; // Cleanup on unmount useEffect(() => { @@ -256,27 +259,53 @@ const handleConversationUpdate = (conversationHistory: any[]) => { }; }, []); + // custom styles + const styles = { + style: props.style, + sidebarStyle: props.sidebarStyle, + messagesStyle: props.messagesStyle, + inputStyle: props.inputStyle, + sendButtonStyle: props.sendButtonStyle, + newThreadButtonStyle: props.newThreadButtonStyle, + threadItemStyle: props.threadItemStyle, + animationStyle: props.animationStyle, + }; + return ( - + + + + + ); } ) .setPropertyViewFn((children) => ) .build(); +// Override autoHeight to support AUTO/FIXED height mode +const ChatCompWithAutoHeight = class extends ChatTmpComp { + override autoHeight(): boolean { + return this.children.autoHeight.getView(); + } +}; + // ============================================================================ -// EXPORT WITH EXPOSED VARIABLES +// EXPOSED VARIABLES // ============================================================================ -export const ChatComp = withExposingConfigs(ChatTmpComp, [ +export const ChatComp = withExposingConfigs(ChatCompWithAutoHeight, [ new NameConfig("currentMessage", "Current user message"), - new NameConfig("conversationHistory", "Full conversation history as JSON array (includes system prompt for API calls)"), + // conversationHistory is now a proper array (not JSON string) - supports setConversationHistory(), clearConversationHistory(), resetConversationHistory() + new NameConfig("conversationHistory", "Full conversation history array with system prompt (use directly in API calls, no JSON.parse needed)"), new NameConfig("databaseName", "Database name for SQL queries (ChatDB_)"), ]); \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/chatCompTypes.ts b/client/packages/lowcoder/src/comps/comps/chatComp/chatCompTypes.ts index 3151bff6ad..9bb53a72a1 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/chatCompTypes.ts +++ b/client/packages/lowcoder/src/comps/comps/chatComp/chatCompTypes.ts @@ -1,19 +1,16 @@ // client/packages/lowcoder/src/comps/comps/chatComp/chatCompTypes.ts // ============================================================================ -// CLEAN CHATCOMP TYPES - SIMPLIFIED AND FOCUSED +// CHATCOMP TYPES // ============================================================================ export type ChatCompProps = { // Storage tableName: string; - // Message Handler - handlerType: "query" | "n8n"; - chatQuery: string; // Only used when handlerType === "query" - modelHost: string; // Only used when handlerType === "n8n" + // Message Handler (Query only) + chatQuery: string; systemPrompt: string; - streaming: boolean; // UI placeholder: string; diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/chatPropertyView.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/chatPropertyView.tsx index 0e2fd02901..b12aafd41d 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/chatPropertyView.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/chatPropertyView.tsx @@ -2,11 +2,12 @@ import React, { useMemo } from "react"; import { Section, sectionNames, DocLink } from "lowcoder-design"; -import { placeholderPropertyView } from "../../utils/propertyUtils"; import { trans } from "i18n"; +import { hiddenPropertyView } from "comps/utils/propertyUtils"; +import { controlItem } from "lowcoder-design"; // ============================================================================ -// CLEAN PROPERTY VIEW - FOCUSED ON ESSENTIAL CONFIGURATION +// PROPERTY VIEW // ============================================================================ export const ChatPropertyView = React.memo((props: any) => { @@ -27,56 +28,69 @@ export const ChatPropertyView = React.memo((props: any) => { {/* Message Handler Configuration */}
- {children.handlerType.propertyView({ - label: trans("chat.handlerType"), - tooltip: trans("chat.handlerTypeTooltip"), + {children.chatQuery.propertyView({ + label: trans("chat.chatQuery"), + placeholder: trans("chat.chatQueryPlaceholder"), })} - {/* Conditional Query Selection */} - {children.handlerType.getView() === "query" && ( - children.chatQuery.propertyView({ - label: trans("chat.chatQuery"), - placeholder: trans("chat.chatQueryPlaceholder"), - }) - )} - - {/* Conditional N8N Configuration */} - {children.handlerType.getView() === "n8n" && ( - children.modelHost.propertyView({ - label: trans("chat.modelHost"), - placeholder: trans("chat.modelHostPlaceholder"), - tooltip: trans("chat.modelHostTooltip"), - }) - )} - {children.systemPrompt.propertyView({ label: trans("chat.systemPrompt"), placeholder: trans("chat.systemPromptPlaceholder"), tooltip: trans("chat.systemPromptTooltip"), })} - - {children.streaming.propertyView({ - label: trans("chat.streaming"), - tooltip: trans("chat.streamingTooltip"), - })}
{/* UI Configuration */}
- {children.placeholder.propertyView({ - label: trans("chat.placeholderLabel"), - placeholder: trans("chat.defaultPlaceholder"), - tooltip: trans("chat.placeholderTooltip"), - })} + {children.placeholder.propertyView({ + label: trans("chat.placeholderLabel"), + placeholder: trans("chat.defaultPlaceholder"), + tooltip: trans("chat.placeholderTooltip"), + })} +
+ + {/* Layout Section - Height Mode & Sidebar Width */} +
+ {children.autoHeight.getPropertyView()} + {children.leftPanelWidth.propertyView({ + label: trans("chat.leftPanelWidth"), + tooltip: trans("chat.leftPanelWidthTooltip"), + })}
{/* Database Section */}
- {children.databaseName.propertyView({ - label: trans("chat.databaseName"), - tooltip: trans("chat.databaseNameTooltip"), - readonly: true - })} + {controlItem( + { filterText: trans("chat.databaseName") }, +
+
+ {trans("chat.databaseName")} +
+
+ {children.databaseName.getView() || "Not initialized"} +
+
+ {trans("chat.databaseNameTooltip")} +
+
+ )}
{/* STANDARD EVENT HANDLERS SECTION */} @@ -84,6 +98,39 @@ export const ChatPropertyView = React.memo((props: any) => { {children.onEvent.getPropertyView()}
+ {/* STYLE SECTIONS */} +
+ {children.style.getPropertyView()} +
+ +
+ {children.sidebarStyle.getPropertyView()} +
+ +
+ {children.messagesStyle.getPropertyView()} +
+ +
+ {children.inputStyle.getPropertyView()} +
+ +
+ {children.sendButtonStyle.getPropertyView()} +
+ +
+ {children.newThreadButtonStyle.getPropertyView()} +
+ +
+ {children.threadItemStyle.getPropertyView()} +
+ +
+ {children.animationStyle.getPropertyView()} +
+ ), [children]); }); diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx similarity index 63% rename from client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx rename to client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx index d5b0ce187c..689e0dc289 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx @@ -1,6 +1,6 @@ -// client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCoreMain.tsx +// client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.tsx -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { useExternalStoreRuntime, ThreadMessageLike, @@ -18,94 +18,43 @@ import { RegularThreadData, ArchivedThreadData } from "./context/ChatContext"; -import { MessageHandler, ChatMessage } from "../types/chatTypes"; -import styled from "styled-components"; +import { MessageHandler, ChatMessage, ChatCoreProps } from "../types/chatTypes"; import { trans } from "i18n"; import { universalAttachmentAdapter } from "../utils/attachmentAdapter"; +import { StyledChatContainer } from "./ChatContainerStyles"; // ============================================================================ -// STYLED COMPONENTS (same as your current ChatMain) +// CHAT CONTAINER // ============================================================================ -const ChatContainer = styled.div` - display: flex; - height: 500px; - - p { - margin: 0; - } - - .aui-thread-list-root { - width: 250px; - background-color: #fff; - padding: 10px; - } - - .aui-thread-root { - flex: 1; - background-color: #f9fafb; - } - - .aui-thread-list-item { - cursor: pointer; - transition: background-color 0.2s ease; - - &[data-active="true"] { - background-color: #dbeafe; - border: 1px solid #bfdbfe; - } - } -`; - -// ============================================================================ -// CHAT CORE MAIN - CLEAN PROPS, FOCUSED RESPONSIBILITY -// ============================================================================ - -interface ChatCoreMainProps { - messageHandler: MessageHandler; - placeholder?: string; - onMessageUpdate?: (message: string) => void; - onConversationUpdate?: (conversationHistory: ChatMessage[]) => void; - // STANDARD LOWCODER EVENT PATTERN - SINGLE CALLBACK (OPTIONAL) - onEvent?: (eventName: string) => void; -} - const generateId = () => Math.random().toString(36).substr(2, 9); -export function ChatCoreMain({ - messageHandler, - placeholder, - onMessageUpdate, - onConversationUpdate, - onEvent -}: ChatCoreMainProps) { +function ChatContainerView(props: ChatCoreProps) { const { state, actions } = useChatContext(); const [isRunning, setIsRunning] = useState(false); - console.log("RENDERING CHAT CORE MAIN"); + // callback props in refs so useEffects don't re-fire + const onConversationUpdateRef = useRef(props.onConversationUpdate); + onConversationUpdateRef.current = props.onConversationUpdate; + + const onEventRef = useRef(props.onEvent); + onEventRef.current = props.onEvent; - // Get messages for current thread const currentMessages = actions.getCurrentMessages(); - // Notify parent component of conversation changes - OPTIMIZED TIMING useEffect(() => { - // Only update conversationHistory when we have complete conversations - // Skip empty states and intermediate processing states if (currentMessages.length > 0 && !isRunning) { - onConversationUpdate?.(currentMessages); + onConversationUpdateRef.current?.(currentMessages); } }, [currentMessages, isRunning]); - // Trigger component load event on mount useEffect(() => { - onEvent?.("componentLoad"); - }, [onEvent]); + onEventRef.current?.("componentLoad"); + }, []); - // Convert custom format to ThreadMessageLike (same as your current implementation) const convertMessage = (message: ChatMessage): ThreadMessageLike => { const content: ThreadUserContentPart[] = [{ type: "text", text: message.text }]; - // Add attachment content if attachments exist if (message.attachments && message.attachments.length > 0) { for (const attachment of message.attachments) { if (attachment.content) { @@ -123,22 +72,17 @@ export function ChatCoreMain({ }; }; - // Handle new message - MUCH CLEANER with messageHandler const onNew = async (message: AppendMessage) => { const textPart = (message.content as ThreadUserContentPart[]).find( (part): part is TextContentPart => part.type === "text" ); const text = textPart?.text?.trim() ?? ""; - const completeAttachments = (message.attachments ?? []).filter( (att): att is CompleteAttachment => att.status.type === "complete" ); - const hasText = text.length > 0; - const hasAttachments = completeAttachments.length > 0; - - if (!hasText && !hasAttachments) { + if (!text && !completeAttachments.length) { throw new Error("Cannot send an empty message"); } @@ -154,9 +98,8 @@ export function ChatCoreMain({ setIsRunning(true); try { - const response = await messageHandler.sendMessage(userMessage); // Send full message object with attachments - - onMessageUpdate?.(userMessage.text); + const response = await props.messageHandler.sendMessage(userMessage); + props.onMessageUpdate?.(userMessage.text); const assistantMessage: ChatMessage = { id: generateId(), @@ -167,48 +110,34 @@ export function ChatCoreMain({ await actions.addMessage(state.currentThreadId, assistantMessage); } catch (error) { - const errorMessage: ChatMessage = { + await actions.addMessage(state.currentThreadId, { id: generateId(), role: "assistant", text: trans("chat.errorUnknown"), timestamp: Date.now(), - }; - - await actions.addMessage(state.currentThreadId, errorMessage); + }); } finally { setIsRunning(false); } }; - - // Handle edit message - CLEANER with messageHandler const onEdit = async (message: AppendMessage) => { - // Extract the first text content part (if any) const textPart = (message.content as ThreadUserContentPart[]).find( (part): part is TextContentPart => part.type === "text" ); const text = textPart?.text?.trim() ?? ""; - - // Filter only complete attachments const completeAttachments = (message.attachments ?? []).filter( (att): att is CompleteAttachment => att.status.type === "complete" ); - const hasText = text.length > 0; - const hasAttachments = completeAttachments.length > 0; - - if (!hasText && !hasAttachments) { + if (!text && !completeAttachments.length) { throw new Error("Cannot send an empty message"); } - // Find the index of the message being edited const index = currentMessages.findIndex((m) => m.id === message.parentId) + 1; - - // Build a new messages array: messages up to and including the one being edited const newMessages = [...currentMessages.slice(0, index)]; - // Build the edited user message const editedMessage: ChatMessage = { id: generateId(), role: "user", @@ -218,15 +147,12 @@ export function ChatCoreMain({ }; newMessages.push(editedMessage); - - // Update state with edited context await actions.updateMessages(state.currentThreadId, newMessages); setIsRunning(true); try { - const response = await messageHandler.sendMessage(editedMessage); // Send full message object with attachments - - onMessageUpdate?.(editedMessage.text); + const response = await props.messageHandler.sendMessage(editedMessage); + props.onMessageUpdate?.(editedMessage.text); const assistantMessage: ChatMessage = { id: generateId(), @@ -238,21 +164,18 @@ export function ChatCoreMain({ newMessages.push(assistantMessage); await actions.updateMessages(state.currentThreadId, newMessages); } catch (error) { - const errorMessage: ChatMessage = { + newMessages.push({ id: generateId(), role: "assistant", text: trans("chat.errorUnknown"), timestamp: Date.now(), - }; - - newMessages.push(errorMessage); + }); await actions.updateMessages(state.currentThreadId, newMessages); } finally { setIsRunning(false); } }; - // Thread list adapter for managing multiple threads (same as your current implementation) const threadListAdapter: ExternalStoreThreadListAdapter = { threadId: state.currentThreadId, threads: state.threadList.filter((t): t is RegularThreadData => t.status === "regular"), @@ -261,7 +184,7 @@ export function ChatCoreMain({ onSwitchToNewThread: async () => { const threadId = await actions.createThread(trans("chat.newChatTitle")); actions.setCurrentThread(threadId); - onEvent?.("threadCreated"); + props.onEvent?.("threadCreated"); }, onSwitchToThread: (threadId) => { @@ -270,25 +193,23 @@ export function ChatCoreMain({ onRename: async (threadId, newTitle) => { await actions.updateThread(threadId, { title: newTitle }); - onEvent?.("threadUpdated"); + props.onEvent?.("threadUpdated"); }, onArchive: async (threadId) => { await actions.updateThread(threadId, { status: "archived" }); - onEvent?.("threadUpdated"); + props.onEvent?.("threadUpdated"); }, onDelete: async (threadId) => { await actions.deleteThread(threadId); - onEvent?.("threadDeleted"); + props.onEvent?.("threadDeleted"); }, }; const runtime = useExternalStoreRuntime({ messages: currentMessages, - setMessages: (messages) => { - actions.updateMessages(state.currentThreadId, messages); - }, + setMessages: (messages) => actions.updateMessages(state.currentThreadId, messages), convertMessage, isRunning, onNew, @@ -305,11 +226,27 @@ export function ChatCoreMain({ return ( - + - - + + ); } +// ============================================================================ +// EXPORT +// ============================================================================ + +export const ChatContainer = ChatContainerView; diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainerStyles.ts b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainerStyles.ts new file mode 100644 index 0000000000..1f2d4580db --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainerStyles.ts @@ -0,0 +1,108 @@ +// client/packages/lowcoder/src/comps/comps/chatComp/components/ChatContainer.styles.ts + +import styled from "styled-components"; + + +export interface StyledChatContainerProps { + $autoHeight?: boolean; + $sidebarWidth?: string; + $sidebarStyle?: any; + $messagesStyle?: any; + $inputStyle?: any; + $sendButtonStyle?: any; + $newThreadButtonStyle?: any; + $threadItemStyle?: any; + $animationStyle?: any; + style?: any; +} + +export const StyledChatContainer = styled.div` + display: flex; + height: ${(props) => (props.$autoHeight ? "auto" : "100%")}; + min-height: ${(props) => (props.$autoHeight ? "300px" : "unset")}; + + /* Main container styles */ + background: ${(props) => props.style?.background || "transparent"}; + margin: ${(props) => props.style?.margin || "0"}; + padding: ${(props) => props.style?.padding || "0"}; + border: ${(props) => props.style?.borderWidth || "0"} ${(props) => props.style?.borderStyle || "solid"} ${(props) => props.style?.border || "transparent"}; + border-radius: ${(props) => props.style?.radius || "0"}; + + /* Animation styles */ + animation: ${(props) => props.$animationStyle?.animation || "none"}; + animation-duration: ${(props) => props.$animationStyle?.animationDuration || "0s"}; + animation-delay: ${(props) => props.$animationStyle?.animationDelay || "0s"}; + animation-iteration-count: ${(props) => props.$animationStyle?.animationIterationCount || "1"}; + + p { + margin: 0; + } + + /* Sidebar Styles */ + .aui-thread-list-root { + width: ${(props) => props.$sidebarWidth || "250px"}; + background-color: ${(props) => props.$sidebarStyle?.sidebarBackground || "#fff"}; + padding: 10px; + } + + .aui-thread-list-item-title { + color: ${(props) => props.$sidebarStyle?.threadText || "inherit"}; + } + + /* Messages Window Styles */ + .aui-thread-root { + flex: 1; + background-color: ${(props) => props.$messagesStyle?.messagesBackground || "#f9fafb"}; + height: auto; + } + + /* User Message Styles */ + .aui-user-message-content { + background-color: ${(props) => props.$messagesStyle?.userMessageBackground || "#3b82f6"}; + color: ${(props) => props.$messagesStyle?.userMessageText || "#ffffff"}; + } + + /* Assistant Message Styles */ + .aui-assistant-message-content { + background-color: ${(props) => props.$messagesStyle?.assistantMessageBackground || "#ffffff"}; + color: ${(props) => props.$messagesStyle?.assistantMessageText || "inherit"}; + } + + /* Input Field Styles */ + form.aui-composer-root { + background-color: ${(props) => props.$inputStyle?.inputBackground || "#ffffff"}; + color: ${(props) => props.$inputStyle?.inputText || "inherit"}; + border-color: ${(props) => props.$inputStyle?.inputBorder || "#d1d5db"}; + } + + /* Send Button Styles */ + .aui-composer-send { + background-color: ${(props) => props.$sendButtonStyle?.sendButtonBackground || "#3b82f6"} !important; + + svg { + color: ${(props) => props.$sendButtonStyle?.sendButtonIcon || "#ffffff"}; + } + } + + /* New Thread Button Styles */ + .aui-thread-list-root > button { + background-color: ${(props) => props.$newThreadButtonStyle?.newThreadBackground || "#3b82f6"} !important; + color: ${(props) => props.$newThreadButtonStyle?.newThreadText || "#ffffff"} !important; + border-color: ${(props) => props.$newThreadButtonStyle?.newThreadBackground || "#3b82f6"} !important; + } + + /* Thread item styling */ + .aui-thread-list-item { + cursor: pointer; + transition: background-color 0.2s ease; + background-color: ${(props) => props.$threadItemStyle?.threadItemBackground || "transparent"}; + color: ${(props) => props.$threadItemStyle?.threadItemText || "inherit"}; + border: 1px solid ${(props) => props.$threadItemStyle?.threadItemBorder || "transparent"}; + + &[data-active="true"] { + background-color: ${(props) => props.$threadItemStyle?.activeThreadBackground || "#dbeafe"}; + color: ${(props) => props.$threadItemStyle?.activeThreadText || "inherit"}; + border: 1px solid ${(props) => props.$threadItemStyle?.activeThreadBorder || "#bfdbfe"}; + } + } +`; diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCore.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCore.tsx deleted file mode 100644 index ad0d33e2cf..0000000000 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCore.tsx +++ /dev/null @@ -1,34 +0,0 @@ -// client/packages/lowcoder/src/comps/comps/chatComp/components/ChatCore.tsx - -import React from "react"; -import { ChatProvider } from "./context/ChatContext"; -import { ChatCoreMain } from "./ChatCoreMain"; -import { ChatCoreProps } from "../types/chatTypes"; -import { TooltipProvider } from "@radix-ui/react-tooltip"; - -// ============================================================================ -// CHAT CORE - THE SHARED FOUNDATION -// ============================================================================ - -export function ChatCore({ - storage, - messageHandler, - placeholder, - onMessageUpdate, - onConversationUpdate, - onEvent -}: ChatCoreProps) { - return ( - - - - - - ); -} \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx index 1c9af4f55b..f4823011e6 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx @@ -1,7 +1,7 @@ // client/packages/lowcoder/src/comps/comps/chatComp/components/ChatPanel.tsx -import { useMemo } from "react"; -import { ChatCore } from "./ChatCore"; +import { useMemo, useEffect } from "react"; +import { ChatPanelContainer } from "./ChatPanelContainer"; import { createChatStorage } from "../utils/storageFactory"; import { N8NHandler } from "../handlers/messageHandlers"; import { ChatPanelProps } from "../types/chatTypes"; @@ -11,7 +11,7 @@ import "@assistant-ui/styles/index.css"; import "@assistant-ui/styles/markdown.css"; // ============================================================================ -// CHAT PANEL - CLEAN BOTTOM PANEL COMPONENT +// CHAT PANEL - SIMPLIFIED BOTTOM PANEL (NO STYLING CONTROLS) // ============================================================================ export function ChatPanel({ @@ -21,24 +21,29 @@ export function ChatPanel({ streaming = true, onMessageUpdate }: ChatPanelProps) { - // Create storage instance - const storage = useMemo(() => - createChatStorage(tableName), + const storage = useMemo(() => + createChatStorage(tableName), [tableName] ); - - // Create N8N message handler - const messageHandler = useMemo(() => + + const messageHandler = useMemo(() => new N8NHandler({ modelHost, systemPrompt, streaming - }), + }), [modelHost, systemPrompt, streaming] ); + // Cleanup on unmount - delete chat data from storage + useEffect(() => { + return () => { + storage.cleanup(); + }; + }, [storage]); + return ( - ` + display: flex; + height: ${(props) => (props.autoHeight ? "auto" : "100%")}; + min-height: ${(props) => (props.autoHeight ? "300px" : "unset")}; + + p { + margin: 0; + } + + .aui-thread-list-root { + width: ${(props) => props.sidebarWidth || "250px"}; + background-color: #fff; + padding: 10px; + } + + .aui-thread-root { + flex: 1; + background-color: #f9fafb; + height: auto; + } + + .aui-thread-list-item { + cursor: pointer; + transition: background-color 0.2s ease; + + &[data-active="true"] { + background-color: #dbeafe; + border: 1px solid #bfdbfe; + } + } +`; + +// ============================================================================ +// CHAT PANEL CONTAINER - DIRECT RENDERING +// ============================================================================ + +const generateId = () => Math.random().toString(36).substr(2, 9); + +export interface ChatPanelContainerProps { + storage: any; + messageHandler: MessageHandler; + placeholder?: string; + onMessageUpdate?: (message: string) => void; +} + +function ChatPanelView({ messageHandler, placeholder, onMessageUpdate }: Omit) { + const { state, actions } = useChatContext(); + const [isRunning, setIsRunning] = useState(false); + + const currentMessages = actions.getCurrentMessages(); + + const convertMessage = (message: ChatMessage): ThreadMessageLike => { + const content: ThreadUserContentPart[] = [{ type: "text", text: message.text }]; + + return { + role: message.role, + content, + id: message.id, + createdAt: new Date(message.timestamp), + }; + }; + + const onNew = async (message: AppendMessage) => { + const textPart = (message.content as ThreadUserContentPart[]).find( + (part): part is TextContentPart => part.type === "text" + ); + + const text = textPart?.text?.trim() ?? ""; + + if (!text) { + throw new Error("Cannot send an empty message"); + } + + const userMessage: ChatMessage = { + id: generateId(), + role: "user", + text, + timestamp: Date.now(), + }; + + await actions.addMessage(state.currentThreadId, userMessage); + setIsRunning(true); + + try { + const response = await messageHandler.sendMessage(userMessage); + onMessageUpdate?.(userMessage.text); + + await actions.addMessage(state.currentThreadId, { + id: generateId(), + role: "assistant", + text: response.content, + timestamp: Date.now(), + }); + } catch (error) { + await actions.addMessage(state.currentThreadId, { + id: generateId(), + role: "assistant", + text: trans("chat.errorUnknown"), + timestamp: Date.now(), + }); + } finally { + setIsRunning(false); + } + }; + + const onEdit = async (message: AppendMessage) => { + const textPart = (message.content as ThreadUserContentPart[]).find( + (part): part is TextContentPart => part.type === "text" + ); + + const text = textPart?.text?.trim() ?? ""; + + if (!text) { + throw new Error("Cannot send an empty message"); + } + + const index = currentMessages.findIndex((m) => m.id === message.parentId) + 1; + const newMessages = [...currentMessages.slice(0, index)]; + + newMessages.push({ + id: generateId(), + role: "user", + text, + timestamp: Date.now(), + }); + + await actions.updateMessages(state.currentThreadId, newMessages); + setIsRunning(true); + + try { + const response = await messageHandler.sendMessage(newMessages[newMessages.length - 1]); + onMessageUpdate?.(text); + + newMessages.push({ + id: generateId(), + role: "assistant", + text: response.content, + timestamp: Date.now(), + }); + await actions.updateMessages(state.currentThreadId, newMessages); + } catch (error) { + newMessages.push({ + id: generateId(), + role: "assistant", + text: trans("chat.errorUnknown"), + timestamp: Date.now(), + }); + await actions.updateMessages(state.currentThreadId, newMessages); + } finally { + setIsRunning(false); + } + }; + + const threadListAdapter: ExternalStoreThreadListAdapter = { + threadId: state.currentThreadId, + threads: state.threadList.filter((t): t is RegularThreadData => t.status === "regular"), + archivedThreads: state.threadList.filter((t): t is ArchivedThreadData => t.status === "archived"), + + onSwitchToNewThread: async () => { + const threadId = await actions.createThread(trans("chat.newChatTitle")); + actions.setCurrentThread(threadId); + }, + + onSwitchToThread: (threadId) => { + actions.setCurrentThread(threadId); + }, + + onRename: async (threadId, newTitle) => { + await actions.updateThread(threadId, { title: newTitle }); + }, + + onArchive: async (threadId) => { + await actions.updateThread(threadId, { status: "archived" }); + }, + + onDelete: async (threadId) => { + await actions.deleteThread(threadId); + }, + }; + + const runtime = useExternalStoreRuntime({ + messages: currentMessages, + setMessages: (messages) => actions.updateMessages(state.currentThreadId, messages), + convertMessage, + isRunning, + onNew, + onEdit, + adapters: { + threadList: threadListAdapter, + // No attachments support for bottom panel chat + }, + }); + + if (!state.isInitialized) { + return
Loading...
; + } + + return ( + + + + + + + ); +} + +// ============================================================================ +// EXPORT - WITH PROVIDERS +// ============================================================================ + +export function ChatPanelContainer({ storage, messageHandler, placeholder, onMessageUpdate }: ChatPanelContainerProps) { + return ( + + + + + + ); +} diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/assistant-ui/thread.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/assistant-ui/thread.tsx index 616acc0879..a45e5fe147 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/assistant-ui/thread.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/assistant-ui/thread.tsx @@ -5,7 +5,7 @@ import { MessagePrimitive, ThreadPrimitive, } from "@assistant-ui/react"; - import type { FC } from "react"; + import { useMemo, type FC } from "react"; import { trans } from "i18n"; import { ArrowDownIcon, @@ -14,7 +14,6 @@ import { ChevronRightIcon, CopyIcon, PencilIcon, - RefreshCwIcon, SendHorizontalIcon, } from "lucide-react"; import { cn } from "../../utils/cn"; @@ -54,9 +53,20 @@ import { ComposerAddAttachment, ComposerAttachments, UserMessageAttachments } fr interface ThreadProps { placeholder?: string; + showAttachments?: boolean; } - export const Thread: FC = ({ placeholder = trans("chat.composerPlaceholder") }) => { + export const Thread: FC = ({ + placeholder = trans("chat.composerPlaceholder"), + showAttachments = true + }) => { + // Stable component reference so React doesn't unmount/remount on every render + const UserMessageComponent = useMemo(() => { + const Wrapper: FC = () => ; + Wrapper.displayName = "UserMessage"; + return Wrapper; + }, [showAttachments]); + return ( - + @@ -148,11 +158,18 @@ import { ComposerAddAttachment, ComposerAttachments, UserMessageAttachments } fr ); }; - const Composer: FC<{ placeholder?: string }> = ({ placeholder = trans("chat.composerPlaceholder") }) => { + const Composer: FC<{ placeholder?: string; showAttachments?: boolean }> = ({ + placeholder = trans("chat.composerPlaceholder"), + showAttachments = true + }) => { return ( - - + {showAttachments && ( + <> + + + + )} { + const UserMessage: FC<{ showAttachments?: boolean }> = ({ showAttachments = true }) => { return ( - + {showAttachments && }
@@ -273,11 +290,6 @@ import { ComposerAddAttachment, ComposerAttachments, UserMessageAttachments } fr - - - - - ); }; diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/context/ChatContext.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/context/ChatContext.tsx index 1a31222a9a..e733727f38 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/context/ChatContext.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/context/ChatContext.tsx @@ -360,7 +360,9 @@ export function ChatProvider({ children, storage }: { // Auto-initialize on mount useEffect(() => { + console.log("useEffect Inside ChatProvider", state.isInitialized, state.isLoading); if (!state.isInitialized && !state.isLoading) { + console.log("Initializing chat data..."); initialize(); } }, [state.isInitialized, state.isLoading]); diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/button.tsx b/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/button.tsx index 4406b74e67..945783c696 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/button.tsx +++ b/client/packages/lowcoder/src/comps/comps/chatComp/components/ui/button.tsx @@ -21,25 +21,25 @@ const buttonVariants = cva("aui-button", { }, }); -function Button({ - className, - variant, - size, - asChild = false, - ...props -}: React.ComponentProps<"button"> & - VariantProps & { - asChild?: boolean; - }) { +const Button = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean; + } +>(({ className, variant, size, asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : "button"; return ( ); -} +}); + +Button.displayName = "Button"; export { Button, buttonVariants }; \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts b/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts index 5e757f2314..d24e0ce84f 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts +++ b/client/packages/lowcoder/src/comps/comps/chatComp/types/chatTypes.ts @@ -63,28 +63,38 @@ export interface ChatMessage { export interface QueryHandlerConfig { chatQuery: string; dispatch: any; - streaming?: boolean; - systemPrompt?: string; - } - - // ============================================================================ - // COMPONENT PROPS (what each component actually needs) - // ============================================================================ - - export interface ChatCoreProps { - storage: ChatStorage; - messageHandler: MessageHandler; - placeholder?: string; - onMessageUpdate?: (message: string) => void; - onConversationUpdate?: (conversationHistory: ChatMessage[]) => void; - // STANDARD LOWCODER EVENT PATTERN - SINGLE CALLBACK - onEvent?: (eventName: string) => void; } - export interface ChatPanelProps { - tableName: string; - modelHost: string; - systemPrompt?: string; - streaming?: boolean; - onMessageUpdate?: (message: string) => void; - } +// ============================================================================ +// COMPONENT PROPS (what each component actually needs) +// ============================================================================ + +// Main Chat Component Props (with full styling support) +export interface ChatCoreProps { + messageHandler: MessageHandler; + placeholder?: string; + autoHeight?: boolean; + sidebarWidth?: string; + onMessageUpdate?: (message: string) => void; + onConversationUpdate?: (conversationHistory: ChatMessage[]) => void; + // STANDARD LOWCODER EVENT PATTERN - SINGLE CALLBACK + onEvent?: (eventName: string) => void; + // Style controls (only for main component) + style?: any; + sidebarStyle?: any; + messagesStyle?: any; + inputStyle?: any; + sendButtonStyle?: any; + newThreadButtonStyle?: any; + threadItemStyle?: any; + animationStyle?: any; +} + +// Bottom Panel Props (simplified, no styling controls) +export interface ChatPanelProps { + tableName: string; + modelHost: string; + systemPrompt?: string; + streaming?: boolean; + onMessageUpdate?: (message: string) => void; +} diff --git a/client/packages/lowcoder/src/comps/comps/chatComp/utils/attachmentAdapter.ts b/client/packages/lowcoder/src/comps/comps/chatComp/utils/attachmentAdapter.ts index a0f7c78e0b..9ff22d4364 100644 --- a/client/packages/lowcoder/src/comps/comps/chatComp/utils/attachmentAdapter.ts +++ b/client/packages/lowcoder/src/comps/comps/chatComp/utils/attachmentAdapter.ts @@ -5,25 +5,21 @@ import type { Attachment, ThreadUserContentPart } from "@assistant-ui/react"; +import { messageInstance } from "lowcoder-design/src/components/GlobalInstances"; + const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB + export const universalAttachmentAdapter: AttachmentAdapter = { accept: "*/*", async add({ file }): Promise { - const MAX_SIZE = 10 * 1024 * 1024; - - if (file.size > MAX_SIZE) { - return { - id: crypto.randomUUID(), - type: getAttachmentType(file.type), - name: file.name, - file, - contentType: file.type, - status: { - type: "incomplete", - reason: "error" - } - }; + if (file.size > MAX_FILE_SIZE) { + messageInstance.error( + `File "${file.name}" exceeds the 10 MB size limit (${(file.size / 1024 / 1024).toFixed(1)} MB).` + ); + throw new Error( + `File "${file.name}" exceeds the 10 MB size limit (${(file.size / 1024 / 1024).toFixed(1)} MB).` + ); } return { @@ -33,33 +29,40 @@ import type { file, contentType: file.type, status: { - type: "running", - reason: "uploading", - progress: 0 - } + type: "requires-action", + reason: "composer-send", + }, }; }, async send(attachment: PendingAttachment): Promise { - const isImage = attachment.contentType.startsWith("image/"); - - const content: ThreadUserContentPart[] = isImage - ? [{ - type: "image", - image: await fileToBase64(attachment.file) - }] - : [{ - type: "file", - data: URL.createObjectURL(attachment.file), - mimeType: attachment.file.type - }]; - + const isImage = attachment.contentType?.startsWith("image/"); + + let content: ThreadUserContentPart[]; + + try { + content = isImage + ? [{ + type: "image", + image: await fileToBase64(attachment.file), + }] + : [{ + type: "file", + data: URL.createObjectURL(attachment.file), + mimeType: attachment.file.type, + }]; + } catch (err) { + const errorMessage = `Failed to process attachment "${attachment.name}": ${err instanceof Error ? err.message : "unknown error"}`; + messageInstance.error(errorMessage); + throw new Error(errorMessage); + } + return { ...attachment, content, status: { - type: "complete" - } + type: "complete", + }, }; }, diff --git a/client/packages/lowcoder/src/comps/comps/columnLayout/columnLayout.tsx b/client/packages/lowcoder/src/comps/comps/columnLayout/columnLayout.tsx index c457ba4c06..2fc3fbdd62 100644 --- a/client/packages/lowcoder/src/comps/comps/columnLayout/columnLayout.tsx +++ b/client/packages/lowcoder/src/comps/comps/columnLayout/columnLayout.tsx @@ -209,9 +209,8 @@ const ColumnLayout = (props: ColumnLayoutProps) => { {columns.map(column => { const id = String(column.id); const childDispatch = wrapDispatch(wrapDispatch(dispatch, "containers"), id); - if(!containers[id]) return null + if(!containers[id] || column.hidden) return null const containerProps = containers[id].children; - const noOfColumns = columns.length; return ( diff --git a/client/packages/lowcoder/src/comps/comps/customComp/customComp.tsx b/client/packages/lowcoder/src/comps/comps/customComp/customComp.tsx index bd58829cba..dd9d6489f5 100644 --- a/client/packages/lowcoder/src/comps/comps/customComp/customComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/customComp/customComp.tsx @@ -45,7 +45,7 @@ const defaultCode = ` @@ -104,10 +104,11 @@ function InnerCustomComponent(props: IProps) { const methodsRef = useRef({ runQuery: async (data: any) => { - const { queryName } = data; + + const { queryName, params } = data.queryName; return getPromiseAfterDispatch( dispatch, - routeByNameAction(queryName, executeQueryAction({})) + routeByNameAction(queryName, executeQueryAction({ args: params })) ).catch((error) => Promise.resolve({})); }, diff --git a/client/packages/lowcoder/src/comps/comps/dateComp/dateComp.tsx b/client/packages/lowcoder/src/comps/comps/dateComp/dateComp.tsx index ea6f37f5a3..c6aae7ad24 100644 --- a/client/packages/lowcoder/src/comps/comps/dateComp/dateComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/dateComp/dateComp.tsx @@ -139,7 +139,6 @@ const timeValidationFields = (children: CommonChildrenType, dateType: PickerMode function validate( props: RecordConstructorToView & { value: { value: string }; - showTime: boolean; } ): { validateStatus: "success" | "warning" | "error"; @@ -763,7 +762,7 @@ export let DateRangeComp = withExposingConfigs(dateRangeControl, [ depsConfig({ name: "invalid", desc: trans("export.invalidDesc"), - depKeys: ["start", "end", "required", "minTime", "maxTime", "minDate", "maxDate", "customRule"], + depKeys: ["start", "end", "showValidationWhenEmpty", "required", "minTime", "maxTime", "minDate", "maxDate", "customRule"], func: (input) => validate({ ...input, @@ -818,4 +817,4 @@ DateRangeComp = withMethodExposing(DateRangeComp, [ comp.children.end.getView().onChange(data.end); }, }, -]); \ No newline at end of file +]); diff --git a/client/packages/lowcoder/src/comps/comps/dateComp/dateCompUtil.ts b/client/packages/lowcoder/src/comps/comps/dateComp/dateCompUtil.ts index 16bc634edf..ed0b794ecf 100644 --- a/client/packages/lowcoder/src/comps/comps/dateComp/dateCompUtil.ts +++ b/client/packages/lowcoder/src/comps/comps/dateComp/dateCompUtil.ts @@ -171,6 +171,9 @@ export const getMobileStyle = (style: DateTimeStyleType) => export const dateRefMethods = refMethods([focusMethod, blurMethod]); +export const parseInputFormats = (inputFormat?: string): string | string[] => + inputFormat?.includes(',') ? inputFormat.split(',').map(f => f.trim()) : inputFormat || ''; + export const StyledPickerPanel = styled.div<{ $style: ChildrenMultiSelectStyleType }>` diff --git a/client/packages/lowcoder/src/comps/comps/dateComp/dateRangeUIView.tsx b/client/packages/lowcoder/src/comps/comps/dateComp/dateRangeUIView.tsx index 65677b63bb..7e0c7bd5d1 100644 --- a/client/packages/lowcoder/src/comps/comps/dateComp/dateRangeUIView.tsx +++ b/client/packages/lowcoder/src/comps/comps/dateComp/dateRangeUIView.tsx @@ -1,6 +1,6 @@ import dayjs from "dayjs"; import type { DateCompViewProps } from "./dateComp"; -import { disabledDate, getStyle, StyledPickerPanel } from "comps/comps/dateComp/dateCompUtil"; +import { disabledDate, getStyle, StyledPickerPanel, parseInputFormats } from "comps/comps/dateComp/dateCompUtil"; import { useUIView } from "../../utils/useUIView"; import { checkIsMobile } from "util/commonUtils"; import React, { useContext } from "react"; @@ -68,20 +68,15 @@ export interface DateRangeUIViewProps extends DateCompViewProps { export const DateRangeUIView = (props: DateRangeUIViewProps) => { const editorState = useContext(EditorContext); + const placeholders: [string, string] = Array.isArray(props.placeholder) + ? props.placeholder + : [props.placeholder || 'Start Date', props.placeholder || 'End Date']; - // Extract or compute the placeholder values - let placeholders: [string, string]; - if (Array.isArray(props.placeholder)) { - placeholders = props.placeholder; - } else { - // Use the same placeholder for both start and end if it's a single string - placeholders = [props.placeholder || 'Start Date', props.placeholder || 'End Date']; - } return useUIView( , export const DateUIView = (props: DataUIViewProps) => { const editorState = useContext(EditorContext); - const placeholder = Array.isArray(props.placeholder) ? props.placeholder[0] : props.placeholder; + return useUIView( , validate({ ...input, @@ -640,4 +640,4 @@ TimeRangeComp = withMethodExposing(TimeRangeComp, [ comp.children.end.getView().onChange(data.end); }, }, -]); \ No newline at end of file +]); diff --git a/client/packages/lowcoder/src/comps/comps/fileComp/ImageCaptureModal.tsx b/client/packages/lowcoder/src/comps/comps/fileComp/ImageCaptureModal.tsx new file mode 100644 index 0000000000..350a1d07b9 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/fileComp/ImageCaptureModal.tsx @@ -0,0 +1,214 @@ +import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { default as Button } from "antd/es/button"; +import Dropdown from "antd/es/dropdown"; +import type { ItemType } from "antd/es/menu/interface"; +import Skeleton from "antd/es/skeleton"; +import Menu from "antd/es/menu"; +import Flex from "antd/es/flex"; +import styled from "styled-components"; +import { trans } from "i18n"; +import { CustomModal } from "lowcoder-design"; +import { CaptureResolution, RESOLUTION_CONSTRAINTS } from "./fileComp"; + +const CustomModalStyled = styled(CustomModal)` + top: 10vh; + .react-draggable { + max-width: 100%; + width: 500px; + + video { + width: 100%; + } + } +`; + +const Error = styled.div` + color: #f5222d; + height: 100px; + width: 100%; + display: flex; + align-items: center; + justify-content: center; +`; + +const Wrapper = styled.div` + img, + video, + .ant-skeleton { + width: 100%; + height: 400px; + max-height: 70vh; + position: relative; + object-fit: cover; + background-color: #000; + } + .ant-skeleton { + h3, + li { + background-color: transparent; + } + } +`; + +const ReactWebcam = React.lazy(() => import("react-webcam")); + +export const ImageCaptureModal = (props: { + showModal: boolean; + captureResolution?: CaptureResolution; + onModalClose: () => void; + onImageCapture: (image: string) => void; +}) => { + const [errMessage, setErrMessage] = useState(""); + const [selectedDeviceId, setSelectedDeviceId] = useState(null); + const [modeList, setModeList] = useState([]); + const [dropdownShow, setDropdownShow] = useState(false); + const [imgSrc, setImgSrc] = useState(); + const webcamRef = useRef(null); + + const resolution = props.captureResolution ?? "auto"; + const resolutionSize = RESOLUTION_CONSTRAINTS[resolution] ?? {}; + + const videoConstraints = useMemo(() => { + const base: MediaTrackConstraints = selectedDeviceId + ? { deviceId: { exact: selectedDeviceId } } + : { facingMode: "environment" }; + return { ...base, ...resolutionSize }; + }, [selectedDeviceId, resolutionSize]); + + useEffect(() => { + if (props.showModal) { + setImgSrc(""); + setErrMessage(""); + setSelectedDeviceId(null); + setDropdownShow(false); + } + }, [props.showModal]); + + const handleMediaErr = (err: any) => { + if (typeof err === "string") { + setErrMessage(err); + } else { + if (err.message === "getUserMedia is not implemented in this browser") { + setErrMessage(trans("scanner.errTip")); + } else { + setErrMessage(err.message); + } + } + }; + + const handleCapture = useCallback(() => { + const imageSrc = webcamRef.current?.getScreenshot?.(); + setImgSrc(imageSrc); + }, [webcamRef]); + + const getModeList = () => { + navigator.mediaDevices.enumerateDevices().then((data) => { + const videoData = data.filter((item) => item.kind === "videoinput"); + const faceModeList = videoData.map((item, index) => ({ + label: item.label || trans("scanner.camera", { index: index + 1 }), + key: item.deviceId, + })); + setModeList(faceModeList); + }); + }; + + return ( + + {!!errMessage ? ( + {errMessage} + ) : ( + props.showModal && ( + + {imgSrc ? ( + webcam + ) : ( + }> + + + )} + {imgSrc ? ( + + + + + ) : ( + + + setDropdownShow(value)} + popupRender={() => ( + { + setSelectedDeviceId(value.key); + setDropdownShow(false); + }} + /> + )} + > + + + + )} + + ) + )} + + ); +}; + +export default ImageCaptureModal; + + diff --git a/client/packages/lowcoder/src/comps/comps/fileComp/draggerUpload.tsx b/client/packages/lowcoder/src/comps/comps/fileComp/draggerUpload.tsx new file mode 100644 index 0000000000..19aef9aecc --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/fileComp/draggerUpload.tsx @@ -0,0 +1,318 @@ +import { default as AntdUpload } from "antd/es/upload"; +import { default as Button } from "antd/es/button"; +import { UploadFile, UploadChangeParam, UploadFileStatus, RcFile } from "antd/es/upload/interface"; +import { useState, useMemo } from "react"; +import styled, { css } from "styled-components"; +import { trans } from "i18n"; +import _ from "lodash"; +import { + changeValueAction, + CompAction, + multiChangeAction, +} from "lowcoder-core"; +import { hasIcon } from "comps/utils"; +import { resolveValue, resolveParsedValue, commonProps, validateFile, CaptureResolution } from "./fileComp"; +import { FileStyleType, AnimationStyleType, heightCalculator, widthCalculator } from "comps/controls/styleControlConstants"; +import { ImageCaptureModal } from "./ImageCaptureModal"; +import { v4 as uuidv4 } from "uuid"; +import { checkIsMobile } from "@lowcoder-ee/util/commonUtils"; +import { darkenColor } from "components/colorSelect/colorUtils"; + +const IconWrapper = styled.span` + display: flex; +`; + +const getDraggerStyle = (style: FileStyleType) => { + return css` + .ant-upload-drag { + border-radius: ${style.radius}; + rotate: ${style.rotation}; + margin: ${style.margin}; + padding: ${style.padding}; + width: ${widthCalculator(style.margin)}; + height: ${heightCalculator(style.margin)}; + border-width: ${style.borderWidth}; + border-style: ${style.borderStyle}; + border-color: ${style.border}; + background: ${style.background}; + transition: all 0.3s; + .ant-upload-drag-container { + .ant-upload-text { + color: ${style.text}; + font-family: ${style.fontFamily}; + font-size: ${style.textSize}; + font-weight: ${style.textWeight}; + font-style: ${style.fontStyle}; + text-decoration: ${style.textDecoration}; + text-transform: ${style.textTransform}; + } + + .ant-upload-hint { + color: ${darkenColor(style.text, 0.3)}; + font-family: ${style.fontFamily}; + font-size: calc(${style.textSize} * 0.9); + } + + .ant-upload-drag-icon { + span { + color: ${style.accent}; + } + } + } + } + + .ant-upload-list { + .ant-upload-list-item { + border-color: ${style.border}; + + .ant-upload-list-item-name { + color: ${style.text}; + } + } + } + `; +}; + +const DragAreaOverlay = styled.div` + // make it position fixed, transparent and match the parent + position: fixed; + background-color: transparent; + width: 100%; + height: 100%; + z-index: 1; + top: 0; + left: 0; +`; + +const StyledDraggerUpload = styled(AntdUpload.Dragger)<{ + $auto: boolean; + $style: FileStyleType; + $animationStyle: AnimationStyleType; +}>` + height: ${(p) => (p.$auto ? "auto" : "100%")}; + position: relative; + + /* AntD wraps dragger + list in this */ + &.ant-upload-wrapper { + display: flex; + flex-direction: column; + height: 100%; + overflow: auto; /* allows list to be visible if it grows */ + } + + /* The drag area itself */ + .ant-upload-drag { + ${(p) => + !p.$auto && + ` + flex: 1 1 auto; + min-height: 120px; + min-width: 0; + `} + position: relative; + ${(props) => props.$animationStyle} + + .ant-upload-drag-container { + .ant-upload-drag-icon { + display: flex; + justify-content: center; + } + } + } + + /* The list sits below the dragger */ + .ant-upload-list { + ${(p) => + !p.$auto && + ` + flex: 0 0 auto; + `} + position: relative; + z-index: 2; + } + + /* Apply custom styling */ + ${(props) => props.$style && getDraggerStyle(props.$style)} +`; +interface DraggerUploadProps { + value: Array; + files: any[]; + fileType: string[]; + showUploadList: boolean; + disabled: boolean; + onEvent: (eventName: string) => Promise; + style: FileStyleType; + animationStyle: AnimationStyleType; + parseFiles: boolean; + parsedValue: Array; + prefixIcon: any; + suffixIcon: any; + forceCapture: boolean; + captureResolution: CaptureResolution; + minSize: number; + maxSize: number; + maxFiles: number; + fileNamePattern: string; + uploadType: "single" | "multiple" | "directory"; + text: string; + dragHintText?: string; + dispatch: (action: CompAction) => void; + autoHeight: boolean; + tabIndex?: number; +} + +export const DraggerUpload = (props: DraggerUploadProps) => { + const { dispatch, files, style, autoHeight, animationStyle } = props; + // Track only files currently being uploaded (not yet in props.files) + const [uploadingFiles, setUploadingFiles] = useState([]); + const [showModal, setShowModal] = useState(false); + const isMobile = checkIsMobile(window.innerWidth); + + // Derive fileList from props.files (source of truth) + currently uploading files + const fileList = useMemo(() => [ + ...(files.map((f) => ({ ...f, status: "done" as const })) as UploadFile[]), + ...uploadingFiles, + ], [files, uploadingFiles]); + + const handleOnChange = (param: UploadChangeParam) => { + const currentlyUploading = param.fileList.filter((f) => f.status === "uploading"); + if (currentlyUploading.length !== 0) { + setUploadingFiles(currentlyUploading); + return; + } + + // Clear uploading state when all uploads complete + setUploadingFiles([]); + + let maxFiles = props.maxFiles; + if (props.uploadType === "single") { + maxFiles = 1; + } else if (props.maxFiles <= 0) { + maxFiles = 100; + } + + const uploadedFiles = param.fileList.filter((f) => f.status === "done"); + + if (param.file.status === "removed") { + const index = props.files.findIndex((f) => f.uid === param.file.uid); + dispatch( + multiChangeAction({ + value: changeValueAction( + [...props.value.slice(0, index), ...props.value.slice(index + 1)], + false + ), + files: changeValueAction( + [...props.files.slice(0, index), ...props.files.slice(index + 1)], + false + ), + parsedValue: changeValueAction( + [...props.parsedValue.slice(0, index), ...props.parsedValue.slice(index + 1)], + false + ), + }) + ); + props.onEvent("change"); + } else { + const unresolvedValueIdx = Math.min(props.value.length, uploadedFiles.length); + const unresolvedParsedValueIdx = Math.min(props.parsedValue.length, uploadedFiles.length); + + Promise.all([ + resolveValue(uploadedFiles.slice(unresolvedValueIdx)), + resolveParsedValue(uploadedFiles.slice(unresolvedParsedValueIdx)), + ]).then(([value, parsedValue]) => { + dispatch( + multiChangeAction({ + value: changeValueAction([...props.value, ...value].slice(-maxFiles), false), + files: changeValueAction( + uploadedFiles + .map((file) => _.pick(file, ["uid", "name", "type", "size", "lastModified"])) + .slice(-maxFiles), + false + ), + ...(props.parseFiles + ? { + parsedValue: changeValueAction( + [...props.parsedValue, ...parsedValue].slice(-maxFiles), + false + ), + } + : {}), + }) + ); + props.onEvent("change"); + props.onEvent("parse"); + }); + } + }; + + return ( + <> + validateFile(file, { + minSize: props.minSize, + maxSize: props.maxSize, + fileNamePattern: props.fileNamePattern, + })} + onChange={handleOnChange} + > +

+ {hasIcon(props.prefixIcon) ? ( + {props.prefixIcon} + ) : ( + + )} +

+

+ {props.text || trans("file.dragAreaText")} +

+

+ {props.dragHintText} +

+ {/* we need a custom overlay to add the onClick handler */} + {props.forceCapture && !isMobile && ( + { + e.preventDefault(); + e.stopPropagation(); + setShowModal(true); + }} + /> + )} + +
+ + setShowModal(false)} + onImageCapture={async (image) => { + setShowModal(false); + const res: Response = await fetch(image); + const blob: Blob = await res.blob(); + const file = new File([blob], "image.jpg", { type: "image/jpeg" }); + const fileUid = uuidv4(); + const uploadFile = { + uid: fileUid, + name: file.name, + type: file.type, + size: file.size, + lastModified: file.lastModified, + lastModifiedDate: (file as any).lastModifiedDate, + status: "done" as UploadFileStatus, + originFileObj: file as RcFile, + }; + handleOnChange({ file: uploadFile, fileList: [...fileList, uploadFile] }); + }} + /> + + ); +}; diff --git a/client/packages/lowcoder/src/comps/comps/fileComp/fileComp.tsx b/client/packages/lowcoder/src/comps/comps/fileComp/fileComp.tsx index 8df84e1584..b804c53410 100644 --- a/client/packages/lowcoder/src/comps/comps/fileComp/fileComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/fileComp/fileComp.tsx @@ -3,6 +3,7 @@ import { default as AntdUpload } from "antd/es/upload"; import { default as Dropdown } from "antd/es/dropdown"; import { UploadFile, UploadProps, UploadChangeParam, UploadFileStatus, RcFile } from "antd/es/upload/interface"; import { Buffer } from "buffer"; +import { v4 as uuidv4 } from "uuid"; import { darkenColor } from "components/colorSelect/colorUtils"; import { Section, sectionNames } from "components/Section"; import { IconControl } from "comps/controls/iconControl"; @@ -23,7 +24,7 @@ import { RecordConstructorToView, } from "lowcoder-core"; import { UploadRequestOption } from "rc-upload/lib/interface"; -import { Suspense, useCallback, useEffect, useRef, useState } from "react"; +import { Suspense, useCallback, useMemo, useRef, useState } from "react"; import styled, { css } from "styled-components"; import { JSONObject, JSONValue } from "../../../util/jsonTypes"; import { BoolControl, BoolPureControl } from "../../controls/boolControl"; @@ -41,14 +42,12 @@ import { CommonNameConfig, NameConfig, withExposingConfigs } from "../../generat import { formDataChildren, FormDataPropertyView } from "../formComp/formDataConstants"; import { messageInstance } from "lowcoder-design/src/components/GlobalInstances"; import { CustomModal } from "lowcoder-design"; - -import React, { useContext } from "react"; +import { DraggerUpload } from "./draggerUpload"; +import { ImageCaptureModal } from "./ImageCaptureModal"; +import { useContext } from "react"; import { EditorContext } from "comps/editorState"; -import type { ItemType } from "antd/es/menu/interface"; -import Skeleton from "antd/es/skeleton"; -import Menu from "antd/es/menu"; -import Flex from "antd/es/flex"; import { checkIsMobile } from "@lowcoder-ee/util/commonUtils"; +import { AutoHeightControl } from "@lowcoder-ee/comps/controls/autoHeightControl"; const FileSizeControl = codeControl((value) => { if (typeof value === "number") { @@ -98,6 +97,23 @@ const validationChildren = { minSize: FileSizeControl, maxSize: FileSizeControl, maxFiles: NumberControl, + fileNamePattern: StringControl, +}; + +export type CaptureResolution = "auto" | "1080p" | "720p" | "480p"; + +export const CaptureResolutionOptions = [ + { label: trans("file.captureResolutionAuto"), value: "auto" }, + { label: trans("file.captureResolution1080p"), value: "1080p" }, + { label: trans("file.captureResolution720p"), value: "720p" }, + { label: trans("file.captureResolution480p"), value: "480p" }, +] as const; + +export const RESOLUTION_CONSTRAINTS: Record = { + auto: {}, + "1080p": { width: 1920, height: 1080 }, + "720p": { width: 1280, height: 720 }, + "480p": { width: 640, height: 480 }, }; const commonChildren = { @@ -114,6 +130,7 @@ const commonChildren = { prefixIcon: withDefault(IconControl, "/icon:solid/arrow-up-from-bracket"), suffixIcon: IconControl, forceCapture: BoolControl, + captureResolution: dropdownControl(CaptureResolutionOptions, "auto"), ...validationChildren, }; @@ -128,9 +145,14 @@ const commonValidationFields = (children: RecordConstructorToComp & { uploadType: "single" | "multiple" | "directory"; } @@ -142,6 +164,49 @@ const commonProps = ( customRequest: (options: UploadRequestOption) => options.onSuccess && options.onSuccess({}), // Override the default upload logic and do not upload to the specified server }); +export interface FileValidationOptions { + minSize?: number; + maxSize?: number; + fileNamePattern?: string; +} + + +export const validateFile = ( + file: { name: string; size?: number }, + options: FileValidationOptions +): boolean | typeof AntdUpload.LIST_IGNORE => { + // Empty file validation + if (!file.size || file.size <= 0) { + messageInstance.error(`${file.name} ` + trans("file.fileEmptyErrorMsg")); + return AntdUpload.LIST_IGNORE; + } + + // File size validation + if ( + (!!options.minSize && file.size < options.minSize) || + (!!options.maxSize && file.size > options.maxSize) + ) { + messageInstance.error(`${file.name} ` + trans("file.fileSizeExceedErrorMsg")); + return AntdUpload.LIST_IGNORE; + } + + // File name pattern validation + if (options.fileNamePattern) { + try { + const pattern = new RegExp(options.fileNamePattern); + if (!pattern.test(file.name)) { + messageInstance.error(`${file.name} ` + trans("file.fileNamePatternErrorMsg")); + return AntdUpload.LIST_IGNORE; + } + } catch (e) { + messageInstance.error(trans("file.invalidFileNamePatternMsg", { error: String(e) })); + return AntdUpload.LIST_IGNORE; + } + } + + return true; +}; + const getStyle = (style: FileStyleType) => { return css` .ant-btn { @@ -210,45 +275,9 @@ const IconWrapper = styled.span` display: flex; `; -const CustomModalStyled = styled(CustomModal)` - top: 10vh; - .react-draggable { - max-width: 100%; - width: 500px; - - video { - width: 100%; - } - } -`; - -const Error = styled.div` - color: #f5222d; - height: 100px; - width: 100%; - display: flex; - align-items: center; - justify-content: center; -`; - -const Wrapper = styled.div` - img, - video, - .ant-skeleton { - width: 100%; - height: 400px; - max-height: 70vh; - position: relative; - object-fit: cover; - background-color: #000; - } - .ant-skeleton { - h3, - li { - background-color: transparent; - } - } -`; +const CustomModalStyled = styled(CustomModal)``; +const Error = styled.div``; +const Wrapper = styled.div``; export function resolveValue(files: UploadFile[]) { return Promise.all( @@ -289,191 +318,45 @@ export function resolveParsedValue(files: UploadFile[]) { ); } -const ReactWebcam = React.lazy(() => import("react-webcam")); - -const ImageCaptureModal = (props: { - showModal: boolean, - onModalClose: () => void; - onImageCapture: (image: string) => void; -}) => { - const [errMessage, setErrMessage] = useState(""); - const [videoConstraints, setVideoConstraints] = useState({ - facingMode: "environment", - }); - const [modeList, setModeList] = useState([]); - const [dropdownShow, setDropdownShow] = useState(false); - const [imgSrc, setImgSrc] = useState(); - const webcamRef = useRef(null); - - useEffect(() => { - if (props.showModal) { - setImgSrc(''); - setErrMessage(''); - } - }, [props.showModal]); - - const handleMediaErr = (err: any) => { - if (typeof err === "string") { - setErrMessage(err); - } else { - if (err.message === "getUserMedia is not implemented in this browser") { - setErrMessage(trans("scanner.errTip")); - } else { - setErrMessage(err.message); - } - } - }; - - const handleCapture = useCallback(() => { - const imageSrc = webcamRef.current?.getScreenshot?.(); - setImgSrc(imageSrc); - }, [webcamRef]); - - const getModeList = () => { - navigator.mediaDevices.enumerateDevices().then((data) => { - const videoData = data.filter((item) => item.kind === "videoinput"); - const faceModeList = videoData.map((item, index) => ({ - label: item.label || trans("scanner.camera", { index: index + 1 }), - key: item.deviceId, - })); - setModeList(faceModeList); - }); - }; - - return ( - - {!!errMessage ? ( - {errMessage} - ) : ( - props.showModal && ( - - {imgSrc - ? webcam - : ( - }> - - - ) - } - {imgSrc - ? ( - - - - - ) - : ( - - - setDropdownShow(value)} - popupRender={() => ( - - setVideoConstraints({ ...videoConstraints, deviceId: value.key }) - } - /> - )} - > - - - - ) - } - - ) - )} - - ) -} +// ImageCaptureModal moved to its own file for reuse const Upload = ( props: RecordConstructorToView & { uploadType: "single" | "multiple" | "directory"; text: string; + dragHintText?: string; dispatch: (action: CompAction) => void; forceCapture: boolean; + tabIndex?: number; }, ) => { const { dispatch, files, style } = props; - const [fileList, setFileList] = useState( - files.map((f) => ({ ...f, status: "done" })) as UploadFile[] - ); + // Track only files currently being uploaded (not yet in props.files) + const [uploadingFiles, setUploadingFiles] = useState([]); const [showModal, setShowModal] = useState(false); const isMobile = checkIsMobile(window.innerWidth); - useEffect(() => { - if (files.length === 0 && fileList.length !== 0) { - setFileList([]); - } - }, [files]); + // Derive fileList from props.files (source of truth) + currently uploading files + const fileList = useMemo(() => [ + ...(files.map((f) => ({ ...f, status: "done" as const })) as UploadFile[]), + ...uploadingFiles, + ], [files, uploadingFiles]); + // chrome86 bug: button children should not contain only empty span const hasChildren = hasIcon(props.prefixIcon) || !!props.text || hasIcon(props.suffixIcon); const handleOnChange = (param: UploadChangeParam) => { - const uploadingFiles = param.fileList.filter((f) => f.status === "uploading"); + const currentlyUploading = param.fileList.filter((f) => f.status === "uploading"); // the onChange callback will be executed when the state of the antd upload file changes. // so make a trick logic: the file list with loading will not be processed - if (uploadingFiles.length !== 0) { - setFileList(param.fileList); + if (currentlyUploading.length !== 0) { + setUploadingFiles(currentlyUploading); return; } + // Clear uploading state when all uploads complete + setUploadingFiles([]); + let maxFiles = props.maxFiles; if (props.uploadType === "single") { maxFiles = 1; @@ -534,8 +417,6 @@ const Upload = ( props.onEvent("parse"); }); } - - setFileList(uploadedFiles.slice(-maxFiles)); }; return ( @@ -546,31 +427,25 @@ const Upload = ( {...commonProps(props)} $style={style} fileList={fileList} - beforeUpload={(file) => { - if (!file.size || file.size <= 0) { - messageInstance.error(`${file.name} ` + trans("file.fileEmptyErrorMsg")); - return AntdUpload.LIST_IGNORE; - } - - if ( - (!!props.minSize && file.size < props.minSize) || - (!!props.maxSize && file.size > props.maxSize) - ) { - messageInstance.error(`${file.name} ` + trans("file.fileSizeExceedErrorMsg")); - return AntdUpload.LIST_IGNORE; - } - return true; - }} + beforeUpload={(file) => validateFile(file, { + minSize: props.minSize, + maxSize: props.maxSize, + fileNamePattern: props.fileNamePattern, + })} onChange={handleOnChange} > -
{children.fileType.propertyView({ @@ -665,6 +566,10 @@ let FileTmpComp = new UICompBuilder(childrenMap, (props, dispatch) => { label: trans("file.forceCapture"), tooltip: trans("file.forceCaptureTooltip") })} + {children.forceCapture.getView() && children.captureResolution.propertyView({ + label: trans("file.captureResolution"), + tooltip: trans("file.captureResolutionTooltip"), + })} {children.showUploadList.propertyView({ label: trans("file.showUploadList") })} {children.parseFiles.propertyView({ label: trans("file.parseFiles"), @@ -685,7 +590,15 @@ let FileTmpComp = new UICompBuilder(childrenMap, (props, dispatch) => { )) .build(); -FileTmpComp = withMethodExposing(FileTmpComp, [ + class FileImplComp extends FileTmpComp { + override autoHeight(): boolean { + // Both button and dragArea modes should respect the autoHeight setting + const h = this.children.autoHeight.getView(); + return h; + } + } + +const FileWithMethods = withMethodExposing(FileImplComp, [ { method: { name: "clearValue", @@ -701,9 +614,43 @@ FileTmpComp = withMethodExposing(FileTmpComp, [ }) ), }, + { + method: { + name: "clearValueAt", + description: trans("file.clearValueAtDesc"), + params: [{ name: "index", type: "number" }], + }, + execute: (comp, params) => { + const index = params[0] as number; + const value = comp.children.value.getView(); + const files = comp.children.files.getView(); + const parsedValue = comp.children.parsedValue.getView(); + + if (index < 0 || index >= files.length) { + return; + } + + comp.dispatch( + multiChangeAction({ + value: changeValueAction( + [...value.slice(0, index), ...value.slice(index + 1)], + false + ), + files: changeValueAction( + [...files.slice(0, index), ...files.slice(index + 1)], + false + ), + parsedValue: changeValueAction( + [...parsedValue.slice(0, index), ...parsedValue.slice(index + 1)], + false + ), + }) + ); + }, + }, ]); -export const FileComp = withExposingConfigs(FileTmpComp, [ +export const FileComp = withExposingConfigs(FileWithMethods, [ new NameConfig("value", trans("file.filesValueDesc")), new NameConfig( "files", diff --git a/client/packages/lowcoder/src/comps/comps/formComp/createForm.tsx b/client/packages/lowcoder/src/comps/comps/formComp/createForm.tsx index 6d4f2fc9ad..46ca12d17c 100644 --- a/client/packages/lowcoder/src/comps/comps/formComp/createForm.tsx +++ b/client/packages/lowcoder/src/comps/comps/formComp/createForm.tsx @@ -28,7 +28,7 @@ import log from "loglevel"; import { Datasource } from "@lowcoder-ee/constants/datasourceConstants"; import DataSourceIcon from "components/DataSourceIcon"; import { messageInstance } from "lowcoder-design/src/components/GlobalInstances"; -import { DndContext } from "@dnd-kit/core"; +import { DndContext, DragEndEvent } from "@dnd-kit/core"; import { SortableContext, useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; @@ -599,7 +599,7 @@ const CreateFormBody = (props: { onCreate: CreateHandler }) => { setItems(initItems); }, [dataSourceTypeConfig, tableStructure, form]); - const handleDragEnd = useCallback((e: { active: { id: string }; over: { id: string } | null }) => { + const handleDragEnd = useCallback((e: DragEndEvent) => { if (!e.over) { return; } diff --git a/client/packages/lowcoder/src/comps/comps/formComp/formComp.tsx b/client/packages/lowcoder/src/comps/comps/formComp/formComp.tsx index e0fce300cf..bece24fe23 100644 --- a/client/packages/lowcoder/src/comps/comps/formComp/formComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/formComp/formComp.tsx @@ -285,7 +285,8 @@ let FormTmpComp = class extends FormBaseComp implements IForm { } traverseFormItems(consumer: (item: GridItemComp) => boolean) { return traverseCompTree(this.getCompTree(), (item) => { - return item.children.comp.children.formDataKey ? consumer(item as GridItemComp) : true; + const hasFormDataKey = item.children.comp.children.hasOwnProperty("formDataKey"); + return hasFormDataKey ? consumer(item as GridItemComp) : true; }); } validateFormItems() { @@ -334,7 +335,9 @@ let FormTmpComp = class extends FormBaseComp implements IForm { name: "setValue", getParams: (t) => { // use component name when formDataKey is empty - const key = t.children.comp.children.formDataKey?.getView() || t.children.name.getView(); + const formDataKey = t.children.comp.children.formDataKey?.getView(); + const componentName = t.children.name.getView(); + const key = formDataKey || componentName; const value = newData[key]; return value !== undefined ? [value as EvalParamType] : undefined; }, @@ -343,7 +346,9 @@ let FormTmpComp = class extends FormBaseComp implements IForm { name: "setRange", getParams: (t) => { // use component name when formDataKey is empty - const key = t.children.comp.children.formDataKey?.getView() || t.children.name.getView(); + const formDataKey = t.children.comp.children.formDataKey?.getView(); + const componentName = t.children.name.getView(); + const key = formDataKey || componentName; const value = newData[key] ? newData[key] : undefined; return value !== undefined ? [value as EvalParamType] : undefined; }, @@ -382,20 +387,28 @@ let FormTmpComp = class extends FormBaseComp implements IForm { switch (action.type) { case CompActionTypes.UPDATE_NODES_V2: { const ret = super.reduce(action); - // When the initial value changes, update the form if (ret.children.initialData !== this.children.initialData) { // FIXME: kill setTimeout ? setTimeout(() => { - this.dispatch( - customAction( - { - type: "setData", - initialData: (action.value["initialData"] as ValueAndMsg).value || {}, - }, - false - ) - ); - }); + const newInitialData = (action.value["initialData"] as ValueAndMsg) + .value; + // only setData when initialData has explicit keys. + if ( + newInitialData && + typeof newInitialData === "object" && + Object.keys(newInitialData).length > 0 + ) { + this.dispatch( + customAction( + { + type: "setData", + initialData: newInitialData, + }, + false + ) + ); + } + }, 1000); } return ret; } @@ -547,4 +560,4 @@ export function defaultFormData(compName: string, nameGenerator: NameGenerator): showFooter: true, }, }; -} +} \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/formComp/generate/index.tsx b/client/packages/lowcoder/src/comps/comps/formComp/generate/index.tsx index 7fdffe2b7a..c5b8705fd3 100644 --- a/client/packages/lowcoder/src/comps/comps/formComp/generate/index.tsx +++ b/client/packages/lowcoder/src/comps/comps/formComp/generate/index.tsx @@ -12,6 +12,8 @@ export function getDataSourceTypeConfig( switch (dataSourceType) { case "mysql": return mysqlConfig; + case "mariadb": + return mysqlConfig; case "postgres": return postgreSqlConfig; case "mssql": diff --git a/client/packages/lowcoder/src/comps/comps/gridLayoutComp/canvasView.tsx b/client/packages/lowcoder/src/comps/comps/gridLayoutComp/canvasView.tsx index c5bb0cced9..d830a8e867 100644 --- a/client/packages/lowcoder/src/comps/comps/gridLayoutComp/canvasView.tsx +++ b/client/packages/lowcoder/src/comps/comps/gridLayoutComp/canvasView.tsx @@ -327,7 +327,7 @@ export const CanvasView = React.memo((props: ContainerBaseProps) => { bgColor={bgColor} radius="0px" emptyRows={defaultRowCount} - minHeight={defaultMinHeight} + minHeight={!isModule ? defaultMinHeight : undefined} extraHeight={defaultRowCount === DEFAULT_ROW_COUNT ? rootContainerExtraHeight : undefined } /> @@ -368,7 +368,7 @@ export const CanvasView = React.memo((props: ContainerBaseProps) => { bgColor={bgColor} positionParams={positionParams} emptyRows={defaultRowCount} - minHeight={defaultMinHeight} + minHeight={!isModule ? defaultMinHeight : undefined} extraHeight={defaultRowCount === DEFAULT_ROW_COUNT ? rootContainerExtraHeight : undefined} /> diff --git a/client/packages/lowcoder/src/comps/comps/jsonComp/jsonLottieComp.tsx b/client/packages/lowcoder/src/comps/comps/jsonComp/jsonLottieComp.tsx index 466c37e9f2..92f3559368 100644 --- a/client/packages/lowcoder/src/comps/comps/jsonComp/jsonLottieComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/jsonComp/jsonLottieComp.tsx @@ -10,7 +10,7 @@ import { styleControl } from "comps/controls/styleControl"; import { AnimationStyle, LottieStyle } from "comps/controls/styleControlConstants"; import { trans } from "i18n"; import { Section, sectionNames } from "lowcoder-design"; -import { useContext, lazy, useEffect, useState } from "react"; +import { useContext, lazy, useEffect, useState, useCallback } from "react"; import { stateComp, UICompBuilder, withDefault } from "../../generators"; import { NameConfig, @@ -23,9 +23,10 @@ import { AssetType, IconscoutControl } from "@lowcoder-ee/comps/controls/iconsco import { DotLottie } from "@lottiefiles/dotlottie-react"; import { AutoHeightControl } from "@lowcoder-ee/comps/controls/autoHeightControl"; import { useResizeDetector } from "react-resize-detector"; -import { eventHandlerControl } from "@lowcoder-ee/comps/controls/eventHandlerControl"; +import { eventHandlerControl, clickEvent, doubleClickEvent } from "@lowcoder-ee/comps/controls/eventHandlerControl"; import { withMethodExposing } from "@lowcoder-ee/comps/generators/withMethodExposing"; import { changeChildAction } from "lowcoder-core"; +import { useCompClickEventHandler } from "@lowcoder-ee/comps/utils/useCompClickEventHandler"; // const Player = lazy( // () => import('@lottiefiles/react-lottie-player') @@ -128,6 +129,8 @@ const ModeOptions = [ ] as const; const EventOptions = [ + clickEvent, + doubleClickEvent, { label: trans("jsonLottie.load"), value: "load", description: trans("jsonLottie.load") }, { label: trans("jsonLottie.play"), value: "play", description: trans("jsonLottie.play") }, { label: trans("jsonLottie.pause"), value: "pause", description: trans("jsonLottie.pause") }, @@ -160,6 +163,10 @@ let JsonLottieTmpComp = (function () { }; return new UICompBuilder(childrenMap, (props, dispatch) => { const [dotLottie, setDotLottie] = useState(null); + const handleClickEvent = useCompClickEventHandler({ onEvent: props.onEvent }); + const handleClick = useCallback(() => { + handleClickEvent(); + }, [handleClickEvent]); const setLayoutAndResize = () => { const align = props.align.split(','); @@ -244,6 +251,7 @@ let JsonLottieTmpComp = (function () { padding: `${props.container.padding}`, rotate: props.container.rotation, }} + onClick={handleClick} > null); + const childrenMap = { label: StringControl, hidden: BoolCodeControl, action: LayoutActionComp, + collapsed: CollapsedControl, // tree editor collapsed state itemKey: valueComp(""), icon: IconControl, }; type ChildrenType = ToInstanceType & { items: InstanceType; + collapsed: InstanceType; }; /** @@ -73,6 +79,14 @@ export class LayoutMenuItemComp extends MultiBaseComp { getItemKey() { return this.children.itemKey.getView(); } + + getCollapsed(): boolean { + return this.children.collapsed.getView(); + } + + setCollapsed(collapsed: boolean) { + this.children.collapsed.dispatchChangeValueAction(collapsed); + } } const LayoutMenuItemCompMigrate = migrateOldData(LayoutMenuItemComp, (oldData: any) => { diff --git a/client/packages/lowcoder/src/comps/comps/layout/mobileTabLayout.tsx b/client/packages/lowcoder/src/comps/comps/layout/mobileTabLayout.tsx index c1a04c14ea..8ae653ffa4 100644 --- a/client/packages/lowcoder/src/comps/comps/layout/mobileTabLayout.tsx +++ b/client/packages/lowcoder/src/comps/comps/layout/mobileTabLayout.tsx @@ -5,22 +5,23 @@ import { manualOptionsControl } from "comps/controls/optionsControl"; import { BoolCodeControl, StringControl, jsonControl, NumberControl } from "comps/controls/codeControl"; import { IconControl } from "comps/controls/iconControl"; import styled from "styled-components"; -import React, { Suspense, useContext, useEffect, useMemo, useState } from "react"; +import React, { Suspense, useContext, useEffect, useMemo, useState, useCallback } from "react"; import { registerLayoutMap } from "comps/comps/uiComp"; import { AppSelectComp } from "comps/comps/layout/appSelectComp"; import { NameAndExposingInfo } from "comps/utils/exposingTypes"; import { ConstructorToComp, ConstructorToDataType } from "lowcoder-core"; import { CanvasContainer } from "comps/comps/gridLayoutComp/canvasView"; import { CanvasContainerID } from "constants/domLocators"; +import { PreviewContainerID } from "constants/domLocators"; import { EditorContainer, EmptyContent } from "pages/common/styledComponent"; import { Layers } from "constants/Layers"; import { ExternalEditorContext } from "util/context/ExternalEditorContext"; import { default as Skeleton } from "antd/es/skeleton"; import { hiddenPropertyView } from "comps/utils/propertyUtils"; import { dropdownControl } from "@lowcoder-ee/comps/controls/dropdownControl"; -import { DataOption, DataOptionType, ModeOptions, menuItemStyleOptions, mobileNavJsonMenuItems } from "./navLayoutConstants"; +import { DataOption, DataOptionType, menuItemStyleOptions, mobileNavJsonMenuItems, MobileModeOptions, MobileMode, HamburgerPositionOptions, DrawerPlacementOptions } from "./navLayoutConstants"; import { styleControl } from "@lowcoder-ee/comps/controls/styleControl"; -import { NavLayoutItemActiveStyle, NavLayoutItemActiveStyleType, NavLayoutItemHoverStyle, NavLayoutItemHoverStyleType, NavLayoutItemStyle, NavLayoutItemStyleType, NavLayoutStyle, NavLayoutStyleType } from "@lowcoder-ee/comps/controls/styleControlConstants"; +import { HamburgerButtonStyle, DrawerContainerStyle, NavLayoutItemActiveStyle, NavLayoutItemActiveStyleType, NavLayoutItemHoverStyle, NavLayoutItemHoverStyleType, NavLayoutItemStyle, NavLayoutItemStyleType, NavLayoutStyle, NavLayoutStyleType } from "@lowcoder-ee/comps/controls/styleControlConstants"; import Segmented from "antd/es/segmented"; import { controlItem } from "components/control"; import { check } from "@lowcoder-ee/util/convertUtils"; @@ -30,10 +31,13 @@ import { ThemeContext } from "@lowcoder-ee/comps/utils/themeContext"; import { AlignCenter } from "lowcoder-design"; import { AlignLeft } from "lowcoder-design"; import { AlignRight } from "lowcoder-design"; +import { Drawer } from "lowcoder-design"; import { LayoutActionComp } from "./layoutActionComp"; import { defaultTheme } from "@lowcoder-ee/constants/themeConstants"; import { clickEvent, eventHandlerControl } from "@lowcoder-ee/comps/controls/eventHandlerControl"; import { childrenToProps } from "@lowcoder-ee/comps/generators/multi"; +import { useAppPathParam } from "util/hooks"; +import { ALL_APPLICATIONS_URL } from "constants/routesURL"; const TabBar = React.lazy(() => import("antd-mobile/es/components/tab-bar")); const TabBarItem = React.lazy(() => @@ -65,6 +69,154 @@ const TabLayoutViewContainer = styled.div<{ flex-direction: column; `; +const HamburgerButton = styled.button<{ + $size: string; + $position: string; // bottom-right | bottom-left | top-right | top-left + $zIndex: number; + $background?: string; + $borderColor?: string; + $radius?: string; + $margin?: string; + $padding?: string; + $borderWidth?: string; +}>` + position: fixed; + ${(props) => (props.$position.includes('bottom') ? 'bottom: 16px;' : 'top: 16px;')} + ${(props) => (props.$position.includes('right') ? 'right: 16px;' : 'left: 16px;')} + width: ${(props) => props.$size}; + height: ${(props) => props.$size}; + border-radius: ${(props) => props.$radius || '50%'}; + border: ${(props) => props.$borderWidth || '1px'} solid ${(props) => props.$borderColor || 'rgba(0,0,0,0.1)'}; + background: ${(props) => props.$background || 'white'}; + margin: ${(props) => props.$margin || '0px'}; + padding: ${(props) => props.$padding || '0px'}; + display: flex; + align-items: center; + justify-content: center; + z-index: ${(props) => props.$zIndex}; + cursor: pointer; + box-shadow: 0 6px 16px rgba(0,0,0,0.15); +`; + +const BurgerIcon = styled.div<{ + $lineColor?: string; +}>` + width: 60%; + height: 2px; + background: ${(p) => p.$lineColor || '#333'}; + position: relative; + &::before, &::after { + content: ''; + position: absolute; + left: 0; + width: 100%; + height: 2px; + background: inherit; + } + &::before { top: -6px; } + &::after { top: 6px; } +`; + +const IconWrapper = styled.div<{ + $iconColor?: string; +}>` + display: inline-flex; + align-items: center; + justify-content: center; + svg { + color: ${(p) => p.$iconColor || 'inherit'}; + fill: ${(p) => p.$iconColor || 'currentColor'}; + } +`; + +const DrawerContent = styled.div<{ + $background: string; + $padding?: string; + $borderColor?: string; + $borderWidth?: string; + $margin?: string; +}>` + background: ${(p) => p.$background}; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + padding: ${(p) => p.$padding || '12px'}; + margin: ${(p) => p.$margin || '0px'}; + box-sizing: border-box; + border: ${(p) => p.$borderWidth || '1px'} solid ${(p) => p.$borderColor || 'transparent'}; +`; + +const DrawerHeader = styled.div` + display: flex; + justify-content: flex-end; + align-items: center; +`; + +const DrawerCloseButton = styled.button<{ + $color: string; +}>` + background: transparent; + border: none; + cursor: pointer; + color: ${(p) => p.$color}; + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 16px; +`; + +const DrawerList = styled.div<{ + $itemStyle: NavLayoutItemStyleType; + $hoverStyle: NavLayoutItemHoverStyleType; + $activeStyle: NavLayoutItemActiveStyleType; +}>` + display: flex; + flex-direction: column; + gap: 8px; + + .drawer-item { + display: flex; + align-items: center; + gap: 8px; + background-color: ${(p) => p.$itemStyle.background}; + color: ${(p) => p.$itemStyle.text}; + font-size: ${(p) => p.$itemStyle.textSize}; + font-family: ${(p) => p.$itemStyle.fontFamily}; + font-style: ${(p) => p.$itemStyle.fontStyle}; + font-weight: ${(p) => p.$itemStyle.textWeight}; + text-decoration: ${(p) => p.$itemStyle.textDecoration}; + border-radius: ${(p) => p.$itemStyle.radius}; + border: 1px solid ${(p) => p.$itemStyle.border}; + margin: ${(p) => p.$itemStyle.margin}; + padding: ${(p) => p.$itemStyle.padding}; + cursor: pointer; + user-select: none; + } + .drawer-item:hover { + background-color: ${(p) => p.$hoverStyle.background}; + color: ${(p) => p.$hoverStyle.text}; + border: 1px solid ${(p) => p.$hoverStyle.border}; + font-size: ${(p) => p.$hoverStyle.textSize || p.$itemStyle.textSize}; + font-family: ${(p) => p.$hoverStyle.fontFamily || p.$itemStyle.fontFamily}; + font-style: ${(p) => p.$hoverStyle.fontStyle || p.$itemStyle.fontStyle}; + font-weight: ${(p) => p.$hoverStyle.textWeight || p.$itemStyle.textWeight}; + text-decoration: ${(p) => p.$hoverStyle.textDecoration || p.$itemStyle.textDecoration}; + } + .drawer-item.active { + background-color: ${(p) => p.$activeStyle.background}; + color: ${(p) => p.$activeStyle.text}; + border: 1px solid ${(p) => p.$activeStyle.border}; + font-size: ${(p) => p.$activeStyle.textSize || p.$itemStyle.textSize}; + font-family: ${(p) => p.$activeStyle.fontFamily || p.$itemStyle.fontFamily}; + font-style: ${(p) => p.$activeStyle.fontStyle || p.$itemStyle.fontStyle}; + font-weight: ${(p) => p.$activeStyle.textWeight || p.$itemStyle.textWeight}; + text-decoration: ${(p) => p.$activeStyle.textDecoration || p.$itemStyle.textDecoration}; + } +`; + const TabBarWrapper = styled.div<{ $readOnly: boolean, $canvasBg: string, @@ -116,23 +268,44 @@ const StyledTabBar = styled(TabBar)<{ .adm-tab-bar-item-icon, .adm-tab-bar-item-title { color: ${(props) => props.$tabStyle.text}; } - .adm-tab-bar-item-icon, { + .adm-tab-bar-item-icon { font-size: ${(props) => props.$navIconSize}; } .adm-tab-bar-item { background-color: ${(props) => props.$tabItemStyle?.background}; color: ${(props) => props.$tabItemStyle?.text}; + font-size: ${(props) => props.$tabItemStyle?.textSize}; + font-family: ${(props) => props.$tabItemStyle?.fontFamily}; + font-style: ${(props) => props.$tabItemStyle?.fontStyle}; + font-weight: ${(props) => props.$tabItemStyle?.textWeight}; + text-decoration: ${(props) => props.$tabItemStyle?.textDecoration}; border-radius: ${(props) => props.$tabItemStyle?.radius} !important; border: ${(props) => `1px solid ${props.$tabItemStyle?.border}`}; margin: ${(props) => props.$tabItemStyle?.margin}; padding: ${(props) => props.$tabItemStyle?.padding}; + + .adm-tab-bar-item-title { + font-size: ${(props) => props.$tabItemStyle?.textSize}; + font-family: ${(props) => props.$tabItemStyle?.fontFamily}; + font-style: ${(props) => props.$tabItemStyle?.fontStyle}; + font-weight: ${(props) => props.$tabItemStyle?.textWeight}; + text-decoration: ${(props) => props.$tabItemStyle?.textDecoration}; + } } .adm-tab-bar-item:hover { background-color: ${(props) => props.$tabItemHoverStyle?.background} !important; color: ${(props) => props.$tabItemHoverStyle?.text} !important; border: ${(props) => `1px solid ${props.$tabItemHoverStyle?.border}`}; + + .adm-tab-bar-item-title { + font-size: ${(props) => props.$tabItemHoverStyle?.textSize || props.$tabItemStyle?.textSize}; + font-family: ${(props) => props.$tabItemHoverStyle?.fontFamily || props.$tabItemStyle?.fontFamily}; + font-style: ${(props) => props.$tabItemHoverStyle?.fontStyle || props.$tabItemStyle?.fontStyle}; + font-weight: ${(props) => props.$tabItemHoverStyle?.textWeight || props.$tabItemStyle?.textWeight}; + text-decoration: ${(props) => props.$tabItemHoverStyle?.textDecoration || props.$tabItemStyle?.textDecoration}; + } } .adm-tab-bar-item.adm-tab-bar-item-active { @@ -141,6 +314,13 @@ const StyledTabBar = styled(TabBar)<{ .adm-tab-bar-item-icon, .adm-tab-bar-item-title { color: ${(props) => props.$tabItemActiveStyle.text}; } + .adm-tab-bar-item-title { + font-size: ${(props) => props.$tabItemActiveStyle?.textSize || props.$tabItemStyle?.textSize}; + font-family: ${(props) => props.$tabItemActiveStyle?.fontFamily || props.$tabItemStyle?.fontFamily}; + font-style: ${(props) => props.$tabItemActiveStyle?.fontStyle || props.$tabItemStyle?.fontStyle}; + font-weight: ${(props) => props.$tabItemActiveStyle?.textWeight || props.$tabItemStyle?.textWeight}; + text-decoration: ${(props) => props.$tabItemActiveStyle?.textDecoration || props.$tabItemStyle?.textDecoration}; + } } `; @@ -287,6 +467,73 @@ const TabOptionComp = (function () { .build(); })(); +function renderDataSection(children: any): any { + return ( +
+ {children.dataOptionType.propertyView({ + radioButton: true, + type: "oneline", + })} + {children.dataOptionType.getView() === DataOption.Manual + ? children.tabs.propertyView({}) + : children.jsonItems.propertyView({ + label: "Json Data", + })} +
+ ); +} + +function renderEventHandlersSection(children: any): any { + return ( +
+ {children.onEvent.getPropertyView()} +
+ ); +} + +function renderHamburgerLayoutSection(children: any): any { + const drawerPlacement = children.drawerPlacement.getView(); + return ( + <> + {children.hamburgerIcon.propertyView({ label: "Menu Icon" })} + {children.drawerCloseIcon.propertyView({ label: "Close Icon" })} + {children.hamburgerPosition.propertyView({ label: "Hamburger Position" })} + {children.hamburgerSize.propertyView({ label: "Hamburger Size" })} + {children.drawerPlacement.propertyView({ label: "Drawer Placement" })} + {(drawerPlacement === 'top' || drawerPlacement === 'bottom') && + children.drawerHeight.propertyView({ label: "Drawer Height" })} + {(drawerPlacement === 'left' || drawerPlacement === 'right') && + children.drawerWidth.propertyView({ label: "Drawer Width" })} + {children.shadowOverlay.propertyView({ label: "Shadow Overlay" })} + {children.backgroundImage.propertyView({ + label: `Background Image`, + placeholder: 'https://temp.im/350x400', + })} + + ); +} + +function renderVerticalLayoutSection(children: any): any { + return ( + <> + {children.backgroundImage.propertyView({ + label: `Background Image`, + placeholder: 'https://temp.im/350x400', + })} + {children.showSeparator.propertyView({label: trans("navLayout.mobileNavVerticalShowSeparator")})} + {children.tabBarHeight.propertyView({label: trans("navLayout.mobileNavBarHeight")})} + {children.navIconSize.propertyView({label: trans("navLayout.mobileNavIconSize")})} + {children.maxWidth.propertyView({label: trans("navLayout.mobileNavVerticalMaxWidth")})} + {children.verticalAlignment.propertyView({ + label: trans("navLayout.mobileNavVerticalOrientation"), + radioButton: true + })} + + ); +} + + + let MobileTabLayoutTmp = (function () { const childrenMap = { onEvent: eventHandlerControl(EventOptions), @@ -311,6 +558,16 @@ let MobileTabLayoutTmp = (function () { jsonTabs: manualOptionsControl(TabOptionComp, { initOptions: [], }), + // Mode & hamburger/drawer config + menuMode: dropdownControl(MobileModeOptions, MobileMode.Vertical), + hamburgerIcon: IconControl, + drawerCloseIcon: IconControl, + hamburgerPosition: dropdownControl(HamburgerPositionOptions, "bottom-right"), + hamburgerSize: withDefault(StringControl, "56px"), + drawerPlacement: dropdownControl(DrawerPlacementOptions, "right"), + drawerHeight: withDefault(StringControl, "60%"), + drawerWidth: withDefault(StringControl, "250px"), + shadowOverlay: withDefault(BoolCodeControl, true), backgroundImage: withDefault(StringControl, ""), tabBarHeight: withDefault(StringControl, "56px"), navIconSize: withDefault(StringControl, "32px"), @@ -321,47 +578,38 @@ let MobileTabLayoutTmp = (function () { navItemStyle: styleControl(NavLayoutItemStyle, 'navItemStyle'), navItemHoverStyle: styleControl(NavLayoutItemHoverStyle, 'navItemHoverStyle'), navItemActiveStyle: styleControl(NavLayoutItemActiveStyle, 'navItemActiveStyle'), + hamburgerButtonStyle: styleControl(HamburgerButtonStyle, 'hamburgerButtonStyle'), + drawerContainerStyle: styleControl(DrawerContainerStyle, 'drawerContainerStyle'), }; return new MultiCompBuilder(childrenMap, (props, dispatch) => { return null; }) .setPropertyViewFn((children) => { - const [styleSegment, setStyleSegment] = useState('normal') + const [styleSegment, setStyleSegment] = useState('normal'); + const isHamburgerMode = children.menuMode.getView() === MobileMode.Hamburger; + return ( -
-
- {children.dataOptionType.propertyView({ - radioButton: true, - type: "oneline", - })} - { - children.dataOptionType.getView() === DataOption.Manual - ? children.tabs.propertyView({}) - : children.jsonItems.propertyView({ - label: "Json Data", - }) - } -
-
- { children.onEvent.getPropertyView() } -
+ <> + {renderDataSection(children)} + {renderEventHandlersSection(children)}
- {children.backgroundImage.propertyView({ - label: `Background Image`, - placeholder: 'https://temp.im/350x400', - })} - { children.showSeparator.propertyView({label: trans("navLayout.mobileNavVerticalShowSeparator")})} - {children.tabBarHeight.propertyView({label: trans("navLayout.mobileNavBarHeight")})} - {children.navIconSize.propertyView({label: trans("navLayout.mobileNavIconSize")})} - {children.maxWidth.propertyView({label: trans("navLayout.mobileNavVerticalMaxWidth")})} - {children.verticalAlignment.propertyView( - { label: trans("navLayout.mobileNavVerticalOrientation"),radioButton: true } - )} -
-
- { children.navStyle.getPropertyView() } + {children.menuMode.propertyView({ label: "Mode", radioButton: true })} + {isHamburgerMode + ? renderHamburgerLayoutSection(children) + : renderVerticalLayoutSection(children)}
-
+ {!isHamburgerMode && ( +
+ {children.navStyle.getPropertyView()} +
+ )} + + {isHamburgerMode && ( +
+ {children.hamburgerButtonStyle.getPropertyView()} +
+ )} +
{controlItem({}, ( setStyleSegment(k as MenuItemStyleOptionValue)} /> ))} - {styleSegment === 'normal' && ( - children.navItemStyle.getPropertyView() - )} - {styleSegment === 'hover' && ( - children.navItemHoverStyle.getPropertyView() - )} - {styleSegment === 'active' && ( - children.navItemActiveStyle.getPropertyView() - )} + {styleSegment === 'normal' && children.navItemStyle.getPropertyView()} + {styleSegment === 'hover' && children.navItemHoverStyle.getPropertyView()} + {styleSegment === 'active' && children.navItemActiveStyle.getPropertyView()}
-
+ {isHamburgerMode && ( +
+ {children.drawerContainerStyle.getPropertyView()} +
+ )} + ); }) .build(); @@ -388,7 +635,9 @@ let MobileTabLayoutTmp = (function () { MobileTabLayoutTmp = withViewFn(MobileTabLayoutTmp, (comp) => { const [tabIndex, setTabIndex] = useState(0); + const [drawerVisible, setDrawerVisible] = useState(false); const { readOnly } = useContext(ExternalEditorContext); + const pathParam = useAppPathParam(); const navStyle = comp.children.navStyle.getView(); const navItemStyle = comp.children.navItemStyle.getView(); const navItemHoverStyle = comp.children.navItemHoverStyle.getView(); @@ -396,14 +645,32 @@ MobileTabLayoutTmp = withViewFn(MobileTabLayoutTmp, (comp) => { const backgroundImage = comp.children.backgroundImage.getView(); const jsonItems = comp.children.jsonItems.getView(); const dataOptionType = comp.children.dataOptionType.getView(); + const menuMode = comp.children.menuMode.getView(); + const hamburgerPosition = comp.children.hamburgerPosition.getView(); + const hamburgerSize = comp.children.hamburgerSize.getView(); + const hamburgerIconComp = comp.children.hamburgerIcon; + const drawerCloseIconComp = comp.children.drawerCloseIcon; + const hamburgerButtonStyle = comp.children.hamburgerButtonStyle.getView(); + const drawerPlacement = comp.children.drawerPlacement.getView(); + const drawerHeight = comp.children.drawerHeight.getView(); + const drawerWidth = comp.children.drawerWidth.getView(); + const shadowOverlay = comp.children.shadowOverlay.getView(); const tabBarHeight = comp.children.tabBarHeight.getView(); const navIconSize = comp.children.navIconSize.getView(); const maxWidth = comp.children.maxWidth.getView(); const verticalAlignment = comp.children.verticalAlignment.getView(); const showSeparator = comp.children.showSeparator.getView(); + const drawerContainerStyle = comp.children.drawerContainerStyle.getView(); const bgColor = (useContext(ThemeContext)?.theme || defaultTheme).canvas; const onEvent = comp.children.onEvent.getView(); + const getContainer = useCallback(() => + document.querySelector(`#${PreviewContainerID}`) || + document.querySelector(`#${CanvasContainerID}`) || + document.body, + [] + ); + useEffect(() => { comp.children.jsonTabs.dispatchChangeValueAction({ manual: jsonItems as unknown as Array> @@ -455,6 +722,21 @@ MobileTabLayoutTmp = withViewFn(MobileTabLayoutTmp, (comp) => { backgroundStyle = `center / cover url('${backgroundImage}') no-repeat, ${backgroundStyle}`; } + const navigateToApp = (nextIndex: number) => { + if (dataOptionType === DataOption.Manual) { + const selectedTab = tabViews[nextIndex]; + if (selectedTab) { + const url = [ + ALL_APPLICATIONS_URL, + pathParam.applicationId, + pathParam.viewMode, + nextIndex, + ].join("/"); + selectedTab.children.action.act(url); + } + } + }; + const tabBarView = ( { : undefined, }))} selectedKey={tabIndex + ""} - onChange={(key) => setTabIndex(Number(key))} + onChange={(key) => { + const nextIndex = Number(key); + setTabIndex(nextIndex); + // push URL with query/hash params + navigateToApp(nextIndex); + }} readOnly={!!readOnly} canvasBg={bgColor} tabStyle={{ @@ -488,11 +775,111 @@ MobileTabLayoutTmp = withViewFn(MobileTabLayoutTmp, (comp) => { /> ); + const containerTabBarHeight = menuMode === MobileMode.Hamburger ? '0px' : tabBarHeight; + + const hamburgerButton = ( + setDrawerVisible(true)} + > + {hamburgerIconComp.toJsonValue() ? ( + + {hamburgerIconComp.getView()} + + ) : ( + + )} + + ); + + const drawerView = ( + }> + setDrawerVisible(false)} + placement={drawerPlacement as any} + mask={shadowOverlay} + maskClosable={true} + closable={false} + styles={{ body: { padding: 0 } } as any} + getContainer={getContainer} + width={ + (drawerPlacement === 'left' || drawerPlacement === 'right') + ? (drawerWidth as any) + : undefined + } + height={ + (drawerPlacement === 'top' || drawerPlacement === 'bottom') + ? (drawerHeight as any) + : undefined + } + > + + + setDrawerVisible(false)} + > + {drawerCloseIconComp.toJsonValue() + ? drawerCloseIconComp.getView() + : ×} + + + + {tabViews.map((tab, index) => ( +
{ + setTabIndex(index); + setDrawerVisible(false); + onEvent('click'); + navigateToApp(index); + }} + > + {tab.children.icon.toJsonValue() ? ( + {tab.children.icon.getView()} + ) : null} + {tab.children.label.getView()} +
+ ))} +
+
+
+
+ ); + if (readOnly) { return ( - + {appView} - {tabBarView} + {menuMode === MobileMode.Hamburger ? ( + <> + {hamburgerButton} + {drawerView} + + ) : ( + tabBarView + )} ); } @@ -500,7 +887,14 @@ MobileTabLayoutTmp = withViewFn(MobileTabLayoutTmp, (comp) => { return ( {appView} - {tabBarView} + {menuMode === MobileMode.Hamburger ? ( + <> + {hamburgerButton} + {drawerView} + + ) : ( + tabBarView + )} ); }); diff --git a/client/packages/lowcoder/src/comps/comps/layout/navLayout.tsx b/client/packages/lowcoder/src/comps/comps/layout/navLayout.tsx index 2f07839ca2..4a7e2b355f 100644 --- a/client/packages/lowcoder/src/comps/comps/layout/navLayout.tsx +++ b/client/packages/lowcoder/src/comps/comps/layout/navLayout.tsx @@ -17,7 +17,8 @@ import { EditorContainer, EmptyContent } from "pages/common/styledComponent"; import { useCallback, useEffect, useMemo, useState } from "react"; import styled from "styled-components"; import { isUserViewMode, useAppPathParam } from "util/hooks"; -import { BoolCodeControl, StringControl, jsonControl } from "comps/controls/codeControl"; +import { StringControl, jsonControl } from "comps/controls/codeControl"; +import { BoolControl } from "comps/controls/boolControl"; import { styleControl } from "comps/controls/styleControl"; import { NavLayoutStyle, @@ -36,21 +37,20 @@ import history from "util/history"; import { DataOption, DataOptionType, - ModeOptions, jsonMenuItems, menuItemStyleOptions } from "./navLayoutConstants"; import { clickEvent, eventHandlerControl } from "@lowcoder-ee/comps/controls/eventHandlerControl"; -import { childrenToProps } from "@lowcoder-ee/comps/generators/multi"; +import { NavPosition, NavPositionOptions } from "./navLayoutConstants"; -const { Header } = Layout; +const { Header, Footer } = Layout; const DEFAULT_WIDTH = 240; type MenuItemStyleOptionValue = "normal" | "hover" | "active"; const EventOptions = [clickEvent] as const; -const StyledSide = styled(LayoutSider)` - max-height: calc(100vh - ${TopHeaderHeight}); +const StyledSide = styled(LayoutSider)<{ $isPreview: boolean }>` + max-height: ${(props) => (props.$isPreview ? `calc(100vh - ${TopHeaderHeight})` : "100vh")}; overflow: auto; .ant-menu-item:first-child { @@ -87,18 +87,42 @@ const StyledMenu = styled(AntdMenu)<{ border: ${(props) => `1px solid ${props.$navItemStyle?.border}`}; margin: ${(props) => props.$navItemStyle?.margin}; padding: ${(props) => props.$navItemStyle?.padding}; + } + .ant-menu-title-content { + font-size: ${(props) => props.$navItemStyle?.textSize}; + font-family: ${(props) => props.$navItemStyle?.fontFamily}; + font-style: ${(props) => props.$navItemStyle?.fontStyle}; + font-weight: ${(props) => props.$navItemStyle?.textWeight}; + text-decoration: ${(props) => props.$navItemStyle?.textDecoration}; } + .ant-menu-item-active { background-color: ${(props) => props.$navItemHoverStyle?.background} !important; color: ${(props) => props.$navItemHoverStyle?.text} !important; border: ${(props) => `1px solid ${props.$navItemHoverStyle?.border}`}; + + .ant-menu-title-content { + font-size: ${(props) => props.$navItemHoverStyle?.textSize || props.$navItemStyle?.textSize}; + font-family: ${(props) => props.$navItemHoverStyle?.fontFamily || props.$navItemStyle?.fontFamily}; + font-style: ${(props) => props.$navItemHoverStyle?.fontStyle || props.$navItemStyle?.fontStyle}; + font-weight: ${(props) => props.$navItemHoverStyle?.textWeight || props.$navItemStyle?.textWeight}; + text-decoration: ${(props) => props.$navItemHoverStyle?.textDecoration || props.$navItemStyle?.textDecoration}; + } } .ant-menu-item-selected { background-color: ${(props) => props.$navItemActiveStyle?.background} !important; color: ${(props) => props.$navItemActiveStyle?.text} !important; border: ${(props) => `1px solid ${props.$navItemActiveStyle?.border}`}; + + .ant-menu-title-content { + font-size: ${(props) => props.$navItemActiveStyle?.textSize || props.$navItemStyle?.textSize}; + font-family: ${(props) => props.$navItemActiveStyle?.fontFamily || props.$navItemStyle?.fontFamily}; + font-style: ${(props) => props.$navItemActiveStyle?.fontStyle || props.$navItemStyle?.fontStyle}; + font-weight: ${(props) => props.$navItemActiveStyle?.textWeight || props.$navItemStyle?.textWeight}; + text-decoration: ${(props) => props.$navItemActiveStyle?.textDecoration || props.$navItemStyle?.textDecoration}; + } } .ant-menu-submenu { @@ -112,11 +136,15 @@ const StyledMenu = styled(AntdMenu)<{ max-height: 100%; background-color: ${(props) => props.$navItemStyle?.background}; color: ${(props) => props.$navItemStyle?.text}; + font-size: ${(props) => props.$navItemStyle?.textSize}; + font-family: ${(props) => props.$navItemStyle?.fontFamily}; + font-style: ${(props) => props.$navItemStyle?.fontStyle}; + font-weight: ${(props) => props.$navItemStyle?.textWeight}; + text-decoration: ${(props) => props.$navItemStyle?.textDecoration}; border-radius: ${(props) => props.$navItemStyle?.radius} !important; border: ${(props) => `1px solid ${props.$navItemStyle?.border}`}; margin: 0; padding: ${(props) => props.$navItemStyle?.padding}; - } .ant-menu-item { @@ -129,6 +157,11 @@ const StyledMenu = styled(AntdMenu)<{ background-color: ${(props) => props.$navItemHoverStyle?.background} !important; color: ${(props) => props.$navItemHoverStyle?.text} !important; border: ${(props) => `1px solid ${props.$navItemHoverStyle?.border}`}; + font-size: ${(props) => props.$navItemHoverStyle?.textSize || props.$navItemStyle?.textSize}; + font-family: ${(props) => props.$navItemHoverStyle?.fontFamily || props.$navItemStyle?.fontFamily}; + font-style: ${(props) => props.$navItemHoverStyle?.fontStyle || props.$navItemStyle?.fontStyle}; + font-weight: ${(props) => props.$navItemHoverStyle?.textWeight || props.$navItemStyle?.textWeight}; + text-decoration: ${(props) => props.$navItemHoverStyle?.textDecoration || props.$navItemStyle?.textDecoration}; } } &.ant-menu-submenu-selected { @@ -137,10 +170,38 @@ const StyledMenu = styled(AntdMenu)<{ background-color: ${(props) => props.$navItemActiveStyle?.background} !important; color: ${(props) => props.$navItemActiveStyle?.text} !important; border: ${(props) => `1px solid ${props.$navItemActiveStyle?.border}`}; + font-size: ${(props) => props.$navItemActiveStyle?.textSize || props.$navItemStyle?.textSize}; + font-family: ${(props) => props.$navItemActiveStyle?.fontFamily || props.$navItemStyle?.fontFamily}; + font-style: ${(props) => props.$navItemActiveStyle?.fontStyle || props.$navItemStyle?.fontStyle}; + font-weight: ${(props) => props.$navItemActiveStyle?.textWeight || props.$navItemStyle?.textWeight}; + text-decoration: ${(props) => props.$navItemActiveStyle?.textDecoration || props.$navItemStyle?.textDecoration}; } } } + /* Collapse mode: hide label text and center icons */ + &.ant-menu-inline-collapsed { + .ant-menu-title-content { + display: none !important; + } + + > .ant-menu-item, + > .ant-menu-submenu > .ant-menu-submenu-title { + display: flex; + justify-content: center; + align-items: center; + } + + .anticon { + line-height: 1 !important; + } + } + +`; + + +const ViewerMainContent = styled(MainContent)<{ $isPreview: boolean }>` + height: ${(props) => (props.$isPreview ? `calc(100vh - ${TopHeaderHeight})` : "100vh")}; `; const StyledImage = styled.img` @@ -148,12 +209,6 @@ const StyledImage = styled.img` color: currentColor; `; -const defaultStyle = { - radius: '0px', - margin: '0px', - padding: '0px', -} - type UrlActionType = { url?: string; newTab?: boolean; @@ -163,7 +218,7 @@ export type MenuItemNode = { label: string; key: string; hidden?: boolean; - icon?: any; + icon?: string; action?: UrlActionType, children?: MenuItemNode[]; } @@ -192,13 +247,19 @@ let NavTmpLayout = (function () { { label: trans("menuItem") + " 1", itemKey: genRandomKey(), + items: [ + { + label: trans("subMenuItem") + " 1", + itemKey: genRandomKey(), + }, + ], }, ]), jsonItems: jsonControl(convertTreeData, jsonMenuItems), width: withDefault(StringControl, DEFAULT_WIDTH), backgroundImage: withDefault(StringControl, ""), - mode: dropdownControl(ModeOptions, "inline"), - collapse: BoolCodeControl, + position: dropdownControl(NavPositionOptions, NavPosition.Left), + collapse: BoolControl, navStyle: styleControl(NavLayoutStyle, 'navStyle'), navItemStyle: styleControl(NavLayoutItemStyle, 'navItemStyle'), navItemHoverStyle: styleControl(NavLayoutItemHoverStyle, 'navItemHoverStyle'), @@ -208,66 +269,94 @@ let NavTmpLayout = (function () { return null; }) .setPropertyViewFn((children) => { - const [styleSegment, setStyleSegment] = useState('normal') + const [styleSegment, setStyleSegment] = useState("normal"); + + const { + dataOptionType, + items, + jsonItems, + onEvent, + width, + position, + collapse, + backgroundImage, + navStyle, + navItemStyle, + navItemHoverStyle, + navItemActiveStyle, + } = children; + + const renderMenuSection = () => ( +
+ {dataOptionType.propertyView({ + radioButton: true, + type: "oneline", + })} + {dataOptionType.getView() === DataOption.Manual + ? menuPropertyView(items) + : jsonItems.propertyView({ + label: "Json Data", + })} +
+ ); + + const renderEventHandlerSection = () => ( +
+ {onEvent.getPropertyView()} +
+ ); + + const renderLayoutSection = () => ( +
+ {width.propertyView({ + label: trans("navLayout.width"), + tooltip: trans("navLayout.widthTooltip"), + placeholder: `${DEFAULT_WIDTH}`, + })} + {position.propertyView({ + label: trans("labelProp.position"), + radioButton: true, + })} + {collapse.propertyView({ + label: trans("labelProp.collapse"), + })} + {backgroundImage.propertyView({ + label: "Background Image", + placeholder: "https://temp.im/350x400", + })} +
+ ); + + const renderNavStyleSection = () => ( +
+ {navStyle.getPropertyView()} +
+ ); + + const renderNavItemStyleSection = () => ( +
+ {controlItem( + {}, + setStyleSegment(k as MenuItemStyleOptionValue)} + /> + )} + {styleSegment === "normal" && navItemStyle.getPropertyView()} + {styleSegment === "hover" && navItemHoverStyle.getPropertyView()} + {styleSegment === "active" && navItemActiveStyle.getPropertyView()} +
+ ); return ( -
-
- {children.dataOptionType.propertyView({ - radioButton: true, - type: "oneline", - })} - { - children.dataOptionType.getView() === DataOption.Manual - ? menuPropertyView(children.items) - : children.jsonItems.propertyView({ - label: "Json Data", - }) - } -
-
- { children.onEvent.getPropertyView() } -
-
- { children.width.propertyView({ - label: trans("navLayout.width"), - tooltip: trans("navLayout.widthTooltip"), - placeholder: DEFAULT_WIDTH + "", - })} - { children.mode.propertyView({ - label: trans("labelProp.position"), - radioButton: true - })} - { children.collapse.propertyView({ - label: trans("labelProp.collapse"), - })} - {children.backgroundImage.propertyView({ - label: `Background Image`, - placeholder: 'https://temp.im/350x400', - })} -
-
- { children.navStyle.getPropertyView() } -
-
- {controlItem({}, ( - setStyleSegment(k as MenuItemStyleOptionValue)} - /> - ))} - {styleSegment === 'normal' && ( - children.navItemStyle.getPropertyView() - )} - {styleSegment === 'hover' && ( - children.navItemHoverStyle.getPropertyView() - )} - {styleSegment === 'active' && ( - children.navItemActiveStyle.getPropertyView() - )} -
+
+ {renderMenuSection()} + {renderEventHandlerSection()} + {renderLayoutSection()} + {renderNavStyleSection()} + {renderNavItemStyleSection()}
); }) @@ -277,10 +366,11 @@ let NavTmpLayout = (function () { NavTmpLayout = withViewFn(NavTmpLayout, (comp) => { const pathParam = useAppPathParam(); const isViewMode = isUserViewMode(pathParam); + const isPreview = pathParam.viewMode === "preview"; const [selectedKey, setSelectedKey] = useState(""); const items = comp.children.items.getView(); const navWidth = comp.children.width.getView(); - const navMode = comp.children.mode.getView(); + const navPosition = comp.children.position.getView(); const navCollapse = comp.children.collapse.getView(); const navStyle = comp.children.navStyle.getView(); const navItemStyle = comp.children.navItemStyle.getView(); @@ -568,12 +658,14 @@ NavTmpLayout = withViewFn(NavTmpLayout, (comp) => { let navMenu = ( { defaultOpenKeys={defaultOpenKeys} selectedKeys={[selectedKey]} $navItemStyle={{ - width: navMode === 'horizontal' ? 'auto' : `calc(100% - ${getHorizontalMargin(navItemStyle.margin.split(' '))})`, + width: (navPosition === 'top' || navPosition === 'bottom') ? 'auto' : `calc(100% - ${getHorizontalMargin(navItemStyle.margin.split(' '))})`, ...navItemStyle, }} $navItemHoverStyle={navItemHoverStyle} @@ -594,17 +686,28 @@ NavTmpLayout = withViewFn(NavTmpLayout, (comp) => { ); let content = ( - - {navMode === 'horizontal' ? ( + + {(navPosition === 'top') && (
{ navMenu }
- ) : ( - + )} + {(navPosition === 'left') && ( + + {navMenu} + + )} + {pageView} + {(navPosition === 'bottom') && ( +
+ { navMenu } +
+ )} + {(navPosition === 'right') && ( + {navMenu} )} - {pageView}
); return isViewMode ? ( @@ -637,7 +740,7 @@ NavTmpLayout = withDispatchHook(NavTmpLayout, (dispatch) => (action) => { }); }); -export const NavLayout = class extends NavTmpLayout { +export class NavLayout extends NavTmpLayout { getAllCompItems() { return {}; } @@ -645,5 +748,5 @@ export const NavLayout = class extends NavTmpLayout { nameAndExposingInfo(): NameAndExposingInfo { return {}; } -}; +} registerLayoutMap({ compType: navLayoutCompType, comp: NavLayout }); diff --git a/client/packages/lowcoder/src/comps/comps/layout/navLayoutConstants.ts b/client/packages/lowcoder/src/comps/comps/layout/navLayoutConstants.ts index 66043303ac..f9a2dc456c 100644 --- a/client/packages/lowcoder/src/comps/comps/layout/navLayoutConstants.ts +++ b/client/packages/lowcoder/src/comps/comps/layout/navLayoutConstants.ts @@ -6,6 +6,60 @@ export const ModeOptions = [ { label: trans("navLayout.modeHorizontal"), value: "horizontal" }, ] as const; +// Desktop navigation position +export const NavPosition = { + Top: "top", + Left: "left", + Bottom: "bottom", + Right: "right", +} as const; + +export const NavPositionOptions = [ + { label: "Top", value: NavPosition.Top }, + { label: "Left", value: NavPosition.Left }, + { label: "Bottom", value: NavPosition.Bottom }, + { label: "Right", value: NavPosition.Right }, +] as const; + +// Mobile navigation specific modes and options +export const MobileMode = { + Vertical: "vertical", + Hamburger: "hamburger", +} as const; + +export const MobileModeOptions = [ + { label: "Normal", value: MobileMode.Vertical }, + { label: "Hamburger", value: MobileMode.Hamburger }, +]; + +export const HamburgerPosition = { + BottomRight: "bottom-right", + BottomLeft: "bottom-left", + TopRight: "top-right", + TopLeft: "top-left", +} as const; + +export const HamburgerPositionOptions = [ + { label: "Bottom Right", value: HamburgerPosition.BottomRight }, + { label: "Bottom Left", value: HamburgerPosition.BottomLeft }, + { label: "Top Right", value: HamburgerPosition.TopRight }, + { label: "Top Left", value: HamburgerPosition.TopLeft }, +] as const; + +export const DrawerPlacement = { + Bottom: "bottom", + Top: "top", + Left: "left", + Right: "right", +} as const; + +export const DrawerPlacementOptions = [ + { label: "Bottom", value: DrawerPlacement.Bottom }, + { label: "Top", value: DrawerPlacement.Top }, + { label: "Left", value: DrawerPlacement.Left }, + { label: "Right", value: DrawerPlacement.Right }, +]; + export const DataOption = { Manual: 'manual', Json: 'json', diff --git a/client/packages/lowcoder/src/comps/comps/listViewComp/listView.tsx b/client/packages/lowcoder/src/comps/comps/listViewComp/listView.tsx index 44ff6753d8..849bb4322f 100644 --- a/client/packages/lowcoder/src/comps/comps/listViewComp/listView.tsx +++ b/client/packages/lowcoder/src/comps/comps/listViewComp/listView.tsx @@ -22,7 +22,7 @@ import { useMergeCompStyles } from "@lowcoder-ee/util/hooks"; import { childrenToProps } from "@lowcoder-ee/comps/generators/multi"; import { AnimationStyleType } from "@lowcoder-ee/comps/controls/styleControlConstants"; import { getBackgroundStyle } from "@lowcoder-ee/util/styleUtils"; -import { DndContext } from "@dnd-kit/core"; +import { DndContext, DragEndEvent } from "@dnd-kit/core"; import { SortableContext, useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { JSONObject } from "@lowcoder-ee/util/jsonTypes"; @@ -354,7 +354,7 @@ export function ListView(props: Props) { useMergeCompStyles(childrenProps, comp.dispatch); - const handleDragEnd = (e: { active: { id: string }; over: { id: string } | null }) => { + const handleDragEnd = (e: DragEndEvent) => { if (!e.over) { return; } diff --git a/client/packages/lowcoder/src/comps/comps/mediaComp/colorPickerComp.tsx b/client/packages/lowcoder/src/comps/comps/mediaComp/colorPickerComp.tsx index b11e528a3f..ab7bf8b30e 100644 --- a/client/packages/lowcoder/src/comps/comps/mediaComp/colorPickerComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/mediaComp/colorPickerComp.tsx @@ -1,7 +1,7 @@ import { Section, sectionNames } from "lowcoder-design"; import { BoolControl } from "comps/controls/boolControl"; import { styleControl } from "comps/controls/styleControl"; -import { ColorPickerStyle, ColorPickerStyleType } from "comps/controls/styleControlConstants"; +import { AnimationStyle, ColorPickerStyle, ColorPickerStyleType, DisabledInputStyle, DisabledInputStyleType, InputFieldStyle, InputLikeStyle, InputLikeStyleType, LabelStyle } from "comps/controls/styleControlConstants"; import { NameConfig } from "comps/generators/withExposing"; import styled, { css } from "styled-components"; import { UICompBuilder, withDefault } from "../../generators"; @@ -16,18 +16,22 @@ import { changeEvent, eventHandlerControl } from "comps/controls/eventHandlerCon import { jsonObjectExposingStateControl, stringExposingStateControl } from "comps/controls/codeStateControl"; import { dropdownControl } from "comps/controls/dropdownControl"; import { ArrayOrJSONObjectControl } from "comps/controls/codeControl"; -import { JSONObject } from "@lowcoder-ee/util/jsonTypes"; -export function getStyle(style: ColorPickerStyleType) { +export function getStyle(style: InputLikeStyleType) { return css` border-radius: ${style.radius}; &:not(.ant-input-disabled, .ant-input-affix-wrapper-disabled), - input { + .ant-color-picker-trigger { + color: ${style.text}; + font-weight: ${style.textWeight}; + font-family: ${style.fontFamily}; + font-style:${style.fontStyle}; + text-transform:${style.textTransform}; + text-decoration:${style.textDecoration}; background-color: ${style.background}; - color:${style.text}; - font-weight:${style.textWeight}; - font-family:${style.fontFamily}; border-color: ${style.border}; + padding: ${style.padding}; + margin: ${style.margin}; &:focus, &.ant-input-affix-wrapper-focused { border-color: ${style.accent}; @@ -38,15 +42,31 @@ export function getStyle(style: ColorPickerStyleType) { .ant-input-clear-icon svg:hover { opacity: 0.65; } + .ant-color-picker-trigger-text { + font-size: ${style.textSize}; + } } `; } -const ColorPickerWrapper = styled(ColorPicker) <{ $style: ColorPickerStyleType }>` - width: 100%; +const ColorPickerWrapper = styled(ColorPicker) <{ + $style: InputLikeStyleType; + $disabledStyle?: DisabledInputStyleType; +}>` display: flex; justify-content: flex-start; + box-shadow: ${(props) => + `${props.$style?.boxShadow} ${props.$style?.boxShadowColor}`}; ${(props) => props.$style && getStyle(props.$style)} + + /* Disabled state styling */ + &:disabled, + &.ant-input-disabled { + color: ${(props) => props.$disabledStyle?.disabledText}; + background: ${(props) => props.$disabledStyle?.disabledBackground}; + border-color: ${(props) => props.$disabledStyle?.disabledBorder}; + cursor: not-allowed; + } `; const colorPickerTriggerOption = [ @@ -61,20 +81,25 @@ export const colorPickerEvent = eventHandlerControl([ const childrenMap = { ...textInputChildren, value: stringExposingStateControl('value', '#3377ff'), - style: styleControl(ColorPickerStyle , 'style'), color: jsonObjectExposingStateControl('color', {}), trigger: dropdownControl(colorPickerTriggerOption, 'click'), disabledAlpha: BoolControl, showPresets: BoolControl, onEvent: colorPickerEvent, presets: withDefault(ArrayOrJSONObjectControl, JSON.stringify(presets, null, 2)), + style: styleControl(InputFieldStyle, 'style'), + labelStyle:styleControl(LabelStyle, 'labelStyle'), + inputFieldStyle: styleControl(InputLikeStyle, 'inputFieldStyle'), + animationStyle: styleControl(AnimationStyle, 'animationStyle'), + disabledStyle: styleControl(DisabledInputStyle, 'disabledStyle'), }; export const ColorPickerComp = new UICompBuilder(childrenMap, (props) => { return props.label({ children: ( value.toHexString().toUpperCase()} @@ -94,6 +119,9 @@ export const ColorPickerComp = new UICompBuilder(childrenMap, (props) => { /> ), style: props.style, + labelStyle: props.labelStyle, + inputFieldStyle:props.inputFieldStyle, + animationStyle:props.animationStyle, }); }) .setPropertyViewFn((children) => { @@ -121,6 +149,10 @@ export const ColorPickerComp = new UICompBuilder(childrenMap, (props) => {
{hiddenPropertyView(children)}
{children.style.getPropertyView()}
+
{children.labelStyle.getPropertyView()}
+
{children.inputFieldStyle.getPropertyView()}
+
{children.disabledStyle.getPropertyView()}
+
{children.animationStyle.getPropertyView()}
); }) diff --git a/client/packages/lowcoder/src/comps/comps/meetingComp/controlButton.tsx b/client/packages/lowcoder/src/comps/comps/meetingComp/controlButton.tsx index 543644b552..c5e7709c97 100644 --- a/client/packages/lowcoder/src/comps/comps/meetingComp/controlButton.tsx +++ b/client/packages/lowcoder/src/comps/comps/meetingComp/controlButton.tsx @@ -1,4 +1,4 @@ -import { BoolCodeControl, StringControl } from "comps/controls/codeControl"; +import { BoolCodeControl, StringControl, NumberControl } from "comps/controls/codeControl"; import { dropdownControl } from "comps/controls/dropdownControl"; import { ButtonEventHandlerControl } from "comps/controls/eventHandlerControl"; import { IconControl } from "comps/controls/iconControl"; @@ -204,7 +204,8 @@ const childrenMap = { style: ButtonStyleControl, viewRef: RefControl, restrictPaddingOnRotation:withDefault(StringControl, 'controlButton'), - tooltip: StringControl + tooltip: StringControl, + tabIndex: NumberControl }; let ButtonTmpComp = (function () { @@ -294,6 +295,7 @@ let ButtonTmpComp = (function () { ? handleClickEvent() : submitForm(editorState, props.form) } + tabIndex={typeof props.tabIndex === 'number' ? props.tabIndex : undefined} > {props.sourceMode === 'standard' && props.prefixIcon && ( )} diff --git a/client/packages/lowcoder/src/comps/comps/meetingComp/videobuttonCompConstants.tsx b/client/packages/lowcoder/src/comps/comps/meetingComp/videobuttonCompConstants.tsx index 2ffa2e6cf3..8ba580f4ab 100644 --- a/client/packages/lowcoder/src/comps/comps/meetingComp/videobuttonCompConstants.tsx +++ b/client/packages/lowcoder/src/comps/comps/meetingComp/videobuttonCompConstants.tsx @@ -48,6 +48,7 @@ export function getButtonStyle(buttonStyle: any, disabledStyle: any) { &.ant-btn-disabled { color: ${disabledStyle.disabledText}; background: ${disabledStyle.disabledBackground}; + border-color: ${disabledStyle.disabledBorder}; cursor: not-allowed; } } @@ -70,15 +71,15 @@ export const Button100 = styled(Button)<{ $buttonStyle?: any; $disabledStyle?: a `; export const ButtonCompWrapper = styled.div<{ disabled: boolean }>` - // The button component is disabled but can respond to drag & select events - ${(props) => - props.disabled && - ` - cursor: not-allowed; - button:disabled { - pointer-events: none; - } - `}; + ${(props) => + props.disabled + ? css` + cursor: not-allowed; + button:disabled { + pointer-events: none; + } + ` + : ''}; `; /** diff --git a/client/packages/lowcoder/src/comps/comps/navComp/components/DraggableItem.tsx b/client/packages/lowcoder/src/comps/comps/navComp/components/DraggableItem.tsx deleted file mode 100644 index 7a4c6ba1b3..0000000000 --- a/client/packages/lowcoder/src/comps/comps/navComp/components/DraggableItem.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { DragIcon } from "lowcoder-design"; -import React, { Ref } from "react"; -import { HTMLAttributes, ReactNode } from "react"; -import styled from "styled-components"; - -const Wrapper = styled.div<{ $dragging: boolean; $isOver: boolean; $dropInAsSub: boolean }>` - position: relative; - width: 100%; - height: 30px; - border: 1px solid #d7d9e0; - border-radius: 4px; - margin-bottom: 4px; - display: flex; - padding: 0 8px; - background-color: #ffffff; - align-items: center; - opacity: ${(props) => (props.$dragging ? "0.5" : 1)}; - - &::after { - content: ""; - display: ${(props) => (props.$isOver ? "block" : "none")}; - height: 4px; - border-radius: 4px; - position: absolute; - left: ${(props) => (props.$dropInAsSub ? "15px" : "-1px")}; - right: 0; - background-color: #315efb; - bottom: -5px; - } - - .draggable-handle-icon { - &:hover, - &:focus { - cursor: grab; - } - - &, - & > svg { - width: 16px; - height: 16px; - } - } - - .draggable-text { - color: #333; - font-size: 13px; - margin-left: 4px; - height: 100%; - display: flex; - align-items: center; - flex: 1; - overflow: hidden; - cursor: pointer; - - & > div { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - display: inline-block; - height: 28px; - line-height: 28px; - } - } - - .draggable-extra-icon { - cursor: pointer; - - &, - & > svg { - width: 16px; - height: 16px; - } - } -`; - -interface IProps extends HTMLAttributes { - dragContent: ReactNode; - isOver?: boolean; - extra?: ReactNode; - dragging?: boolean; - dropInAsSub?: boolean; - dragListeners?: Record; -} - -function DraggableItem(props: IProps, ref: Ref) { - const { - dragContent: text, - extra, - dragging = false, - dropInAsSub = true, - isOver = false, - dragListeners, - ...divProps - } = props; - return ( - -
- -
-
{text}
-
{extra}
-
- ); -} - -export default React.forwardRef(DraggableItem); diff --git a/client/packages/lowcoder/src/comps/comps/navComp/components/DroppableMenuItem.tsx b/client/packages/lowcoder/src/comps/comps/navComp/components/DroppableMenuItem.tsx deleted file mode 100644 index c4f22191a4..0000000000 --- a/client/packages/lowcoder/src/comps/comps/navComp/components/DroppableMenuItem.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { useDraggable, useDroppable } from "@dnd-kit/core"; -import { trans } from "i18n"; -import { Fragment, useEffect } from "react"; -import styled from "styled-components"; -import DroppablePlaceholder from "./DroppablePlaceHolder"; -import MenuItem, { ICommonItemProps } from "./MenuItem"; -import { IDragData, IDropData } from "./types"; -import { LayoutMenuItemComp } from "comps/comps/layout/layoutMenuItemComp"; -import { genRandomKey } from "comps/utils/idGenerator"; - -const DraggableMenuItemWrapper = styled.div` - position: relative; -`; - -interface IDraggableMenuItemProps extends ICommonItemProps { - level: number; - active?: boolean; - disabled?: boolean; - disableDropIn?: boolean; - parentDragging?: boolean; -} - -export default function DraggableMenuItem(props: IDraggableMenuItemProps) { - const { - item, - path, - active, - disabled, - parentDragging, - disableDropIn, - dropInAsSub = true, - onAddSubMenu, - onDelete, - } = props; - - const id = path.join("_"); - const items = item.getView().items; - - const handleAddSubMenu = (path: number[]) => { - onAddSubMenu?.(path, { - label: trans("droppadbleMenuItem.subMenu", { number: items.length + 1 }), - }); - }; - - const dragData: IDragData = { - path, - item, - }; - const { - listeners: dragListeners, - setNodeRef: setDragNodeRef, - isDragging, - } = useDraggable({ - id, - data: dragData, - }); - - const dropData: IDropData = { - targetListSize: items.length, - targetPath: dropInAsSub ? [...path, 0] : [...path.slice(0, -1), path[path.length - 1] + 1], - dropInAsSub, - }; - const { setNodeRef: setDropNodeRef, isOver } = useDroppable({ - id, - disabled: isDragging || disabled || disableDropIn, - data: dropData, - }); - - // TODO: Remove this later. - // Set ItemKey for previously added sub-menus - useEffect(() => { - if(!items.length) return; - if(!(items[0] instanceof LayoutMenuItemComp)) return; - - return items.forEach(item => { - const subItem = item as LayoutMenuItemComp; - const itemKey = subItem.children.itemKey.getView(); - if(itemKey === '') { - subItem.children.itemKey.dispatchChangeValueAction(genRandomKey()) - } - }) - }, [items]) - - return ( - <> - - {active && ( - - )} - { - setDragNodeRef(node); - setDropNodeRef(node); - }} - isOver={isOver} - dropInAsSub={dropInAsSub} - dragging={isDragging || parentDragging} - dragListeners={{ ...dragListeners }} - onAddSubMenu={onAddSubMenu && handleAddSubMenu} - onDelete={onDelete} - /> - - {items.length > 0 && ( -
- {items.map((subItem, i) => ( - - - - ))} -
- )} - - ); -} diff --git a/client/packages/lowcoder/src/comps/comps/navComp/components/DroppablePlaceHolder.tsx b/client/packages/lowcoder/src/comps/comps/navComp/components/DroppablePlaceHolder.tsx deleted file mode 100644 index 72c15cf854..0000000000 --- a/client/packages/lowcoder/src/comps/comps/navComp/components/DroppablePlaceHolder.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { useDroppable } from "@dnd-kit/core"; -import styled from "styled-components"; -import { IDropData } from "./types"; - -interface IDroppablePlaceholderProps { - path: number[]; - disabled?: boolean; - targetListSize: number; -} - -const PlaceHolderWrapper = styled.div<{ $active: boolean }>` - position: absolute; - width: 100%; - top: -4px; - height: 25px; - z-index: 10; - /* background-color: rgba(0, 0, 0, 0.2); */ - .position-line { - height: 4px; - border-radius: 4px; - background-color: ${(props) => (props.$active ? "#315efb" : "transparent")}; - width: 100%; - } -`; - -export default function DroppablePlaceholder(props: IDroppablePlaceholderProps) { - const { path, disabled, targetListSize } = props; - const data: IDropData = { - targetPath: path, - targetListSize, - dropInAsSub: false, - }; - const { setNodeRef: setDropNodeRef, isOver } = useDroppable({ - id: `p_${path.join("_")}`, - disabled, - data, - }); - return ( - -
-
- ); -} diff --git a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItem.tsx b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItem.tsx index b328c30b01..37cdfc1681 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItem.tsx +++ b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItem.tsx @@ -1,24 +1,15 @@ import { ActiveTextColor, GreyTextColor } from "constants/style"; import { EditPopover, SimplePopover } from "lowcoder-design"; import { PointIcon } from "lowcoder-design"; -import React, { HTMLAttributes, useState } from "react"; +import React, { useState } from "react"; import styled from "styled-components"; -import DraggableItem from "./DraggableItem"; import { NavCompType } from "comps/comps/navComp/components/types"; import { trans } from "i18n"; -export interface ICommonItemProps { - path: number[]; +export interface IMenuItemProps { item: NavCompType; - dropInAsSub?: boolean; - onDelete?: (path: number[]) => void; - onAddSubMenu?: (path: number[], value?: any) => void; -} - -interface IMenuItemProps extends ICommonItemProps, Omit, "id"> { - isOver?: boolean; - dragging?: boolean; - dragListeners?: Record; + onDelete?: () => void; + onAddSubMenu?: () => void; } const MenuItemWrapper = styled.div` @@ -29,6 +20,13 @@ const MenuItemWrapper = styled.div` const MenuItemContent = styled.div` width: 100%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + cursor: pointer; + flex: 1; + color: #333; + font-size: 13px; `; const StyledPointIcon = styled(PointIcon)` @@ -39,61 +37,50 @@ const StyledPointIcon = styled(PointIcon)` } `; -const MenuItem = React.forwardRef((props: IMenuItemProps, ref: React.Ref) => { +const MenuItem: React.FC = (props) => { const { - path, item, - isOver, - dragging, - dragListeners, - dropInAsSub = true, onDelete, onAddSubMenu, - ...divProps } = props; const [isConfigPopShow, showConfigPop] = useState(false); const handleDel = () => { - onDelete?.(path); + onDelete?.(); }; const handleAddSubMenu = () => { - onAddSubMenu?.(path); + onAddSubMenu?.(); }; const content = {item.getPropertyView()}; return ( - - {item.children.label.getView()} - - } - extra={ - + + e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} > - - - } - /> + {item.children.label.getView() || trans("untitled")} + + + + + + ); -}); +}; export default MenuItem; diff --git a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx index 4c9d0de1ef..f5025ed6b2 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx +++ b/client/packages/lowcoder/src/comps/comps/navComp/components/MenuItemList.tsx @@ -1,135 +1,183 @@ -import { DndContext, DragEndEvent, DragOverlay, DragStartEvent } from "@dnd-kit/core"; +import { SortableTree, TreeItems, TreeItemComponentProps, SimpleTreeItemWrapper } from "dnd-kit-sortable-tree"; import LinkPlusButton from "components/LinkPlusButton"; -import { BluePlusIcon, controlItem } from "lowcoder-design"; +import { BluePlusIcon, controlItem, ScrollBar } from "lowcoder-design"; import { trans } from "i18n"; -import _ from "lodash"; -import { useState } from "react"; +import React, { useMemo, useCallback, createContext, useContext } from "react"; import styled from "styled-components"; -import DraggableMenuItem from "./DroppableMenuItem"; -import DroppablePlaceholder from "./DroppablePlaceHolder"; +import { NavCompType, NavListCompType, NavTreeItemData } from "./types"; import MenuItem from "./MenuItem"; -import { IDragData, IDropData, NavCompType, NavListCompType } from "./types"; - const Wrapper = styled.div` .menu-title { display: flex; flex-direction: row; justify-content: space-between; align-items: center; + margin-bottom: 8px; } .menu-list { - margin-top: 8px; position: relative; } +`; - .sub-menu-list { - padding-left: 16px; +const StyledTreeItem = styled.div` + .dnd-sortable-tree_simple_tree-item { + padding: 5px; + border-radius: 4px; + &:hover { + background-color: #f5f5f6; + } } `; +const TreeItemContent = styled.div` + display: flex; + align-items: center; + width: 100%; + box-sizing: border-box; +`; + +// Context for passing handlers to tree items +interface MenuItemHandlers { + onDeleteItem: (path: number[]) => void; + onAddSubItem: (path: number[], value?: any) => void; +} + +const MenuItemHandlersContext = createContext(null); + +// Tree item component +const NavTreeItemComponent = React.forwardRef< + HTMLDivElement, + TreeItemComponentProps +>((props, ref) => { + const { item, depth, collapsed, ...rest } = props; + const { comp, path } = item; + + const handlers = useContext(MenuItemHandlersContext); + + const handleDelete = () => { + handlers?.onDeleteItem(path); + }; + + const handleAddSubMenu = () => { + handlers?.onAddSubItem(path, { + label: `Sub Menu ${(item.children?.length || 0) + 1}`, + }); + }; + + return ( + + + e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > + + + + + ); +}); + +NavTreeItemComponent.displayName = "NavTreeItemComponent"; + interface IMenuItemListProps { items: NavCompType[]; onAddItem: (path: number[], value?: any) => number; onDeleteItem: (path: number[]) => void; onAddSubItem: (path: number[], value: any, unshift?: boolean) => number; - onMoveItem: (path: number[], from: number, to: number) => void; + onReorderItems: (newOrder: TreeItems) => void; } const menuItemLabel = trans("navigation.itemsDesc"); -function MenuItemList(props: IMenuItemListProps) { - const { items, onAddItem, onDeleteItem, onMoveItem, onAddSubItem } = props; +// Convert NavCompType[] to TreeItems format for dnd-kit-sortable-tree +function convertToTreeItems( + items: NavCompType[], + basePath: number[] = [] +): TreeItems { + return items.map((item, index) => { + const path = [...basePath, index]; + const subItems = item.getView().items || []; + // Read collapsed state from the item itself + const collapsed = item.getCollapsed?.() ?? false; + + return { + id: path.join("_"), + collapsed, + comp: item, + path: path, + children: subItems.length > 0 + ? convertToTreeItems(subItems, path) + : [], + }; + }); +} - const [active, setActive] = useState(null); - const isDraggingWithSub = active && active.item.children.items.getView().length > 0; +function MenuItemList(props: IMenuItemListProps) { + const { items, onAddItem, onDeleteItem, onAddSubItem, onReorderItems } = props; - function handleDragStart(event: DragStartEvent) { - setActive(event.active.data.current as IDragData); - } + // Convert items to tree format + const treeItems = useMemo(() => convertToTreeItems(items), [items]); - function handleDragEnd(e: DragEndEvent) { - const activeData = e.active.data.current as IDragData; - const overData = e.over?.data.current as IDropData; - - if (overData) { - const sourcePath = activeData.path; - const targetPath = overData.targetPath; - - if ( - sourcePath.length === targetPath.length && - _.isEqual(sourcePath.slice(0, -1), targetPath.slice(0, -1)) - ) { - // same level move - const from = sourcePath[sourcePath.length - 1]; - let to = targetPath[targetPath.length - 1]; - if (from < to) { - to -= 1; - } - onMoveItem(targetPath, from, to); - } else { - // cross level move - let targetIndex = targetPath[targetPath.length - 1]; - let targetListPath = targetPath; - let size = 0; - - onDeleteItem(sourcePath); - - if (overData.dropInAsSub) { - targetListPath = targetListPath.slice(0, -1); - size = onAddSubItem(targetListPath, activeData.item.toJsonValue()); - } else { - size = onAddItem(targetListPath, activeData.item.toJsonValue()); - } - - if (overData.targetListSize !== -1) { - onMoveItem(targetListPath, size, targetIndex); - } - } - } + // Handle all tree changes (drag/drop, collapse/expand) + const handleItemsChanged = useCallback( + (newItems: TreeItems) => { + onReorderItems(newItems); + }, + [onReorderItems] + ); - setActive(null); - } + // Handlers context value + const handlers = useMemo( + () => ({ + onDeleteItem, + onAddSubItem, + }), + [onDeleteItem, onAddSubItem] + ); return ( - -
-
{menuItemLabel}
- onAddItem([0])} icon={}> - {trans("newItem")} - -
-
- {items.map((i, idx) => { - return ( - - ); - })} -
- {active && } -
-
- - {active && } - -
+
+
{menuItemLabel}
+ onAddItem([0])} icon={}> + {trans("newItem")} + +
+
+ + + false }} + /> + + +
); } export function menuPropertyView(itemsComp: NavListCompType) { const items = itemsComp.getView(); + const getItemByPath = (path: number[], scope?: NavCompType[]): NavCompType => { if (!scope) { scope = items; @@ -150,6 +198,25 @@ export function menuPropertyView(itemsComp: NavListCompType) { return getItemListByPath(path.slice(1), root.getView()[path[0]].children.items); }; + // Convert tree structure back to nested comp structure + const handleReorderItems = (newItems: TreeItems) => { + const buildJsonFromTree = (treeItems: TreeItems): any[] => { + return treeItems.map((item) => { + const jsonValue = item.comp.toJsonValue() as Record; + return { + ...jsonValue, + collapsed: item.collapsed ?? false, // sync collapsed from tree item + items: item.children && item.children.length > 0 + ? buildJsonFromTree(item.children) + : [], + }; + }); + }; + + const newJson = buildJsonFromTree(newItems); + itemsComp.dispatch(itemsComp.setChildrensAction(newJson)); + }; + return controlItem( { filterText: menuItemLabel }, { - getItemListByPath(path).moveItem(from, to); - }} + onReorderItems={handleReorderItems} /> ); } diff --git a/client/packages/lowcoder/src/comps/comps/navComp/components/NavItemsControl.tsx b/client/packages/lowcoder/src/comps/comps/navComp/components/NavItemsControl.tsx new file mode 100644 index 0000000000..752685f78c --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/navComp/components/NavItemsControl.tsx @@ -0,0 +1,80 @@ +import { BoolCodeControl, StringControl } from "comps/controls/codeControl"; +import { clickEvent, eventHandlerControl } from "comps/controls/eventHandlerControl"; +import { MultiCompBuilder } from "comps/generators/multi"; +import { dropdownControl } from "comps/controls/dropdownControl"; +import { mapOptionsControl } from "comps/controls/optionsControl"; +import { trans } from "i18n"; +import { navListComp } from "../navItemComp"; +import { IconControl } from "comps/controls/iconControl"; +import { controlItem } from "lowcoder-design"; +import { menuPropertyView } from "./MenuItemList"; + +export function createNavItemsControl() { + const OptionTypes = [ + { label: trans("prop.manual"), value: "manual" }, + { label: trans("prop.map"), value: "map" }, + ] as const; + + const NavMapOption = new MultiCompBuilder( + { + label: StringControl, + icon: IconControl, + hidden: BoolCodeControl, + disabled: BoolCodeControl, + active: BoolCodeControl, + onEvent: eventHandlerControl([clickEvent]), + }, + (props) => props + ) + .setPropertyViewFn((children) => ( + <> + {children.label.propertyView({ label: trans("label"), placeholder: "{{item}}" })} + {children.icon.propertyView({ label: trans("icon") })} + {children.active.propertyView({ label: trans("navItemComp.active") })} + {children.hidden.propertyView({ label: trans("hidden") })} + {children.disabled.propertyView({ label: trans("disabled") })} + {children.onEvent.getPropertyView()} + + )) + .build(); + + const TmpNavItemsControl = new MultiCompBuilder( + { + optionType: dropdownControl(OptionTypes, "manual"), + manual: navListComp(), + mapData: mapOptionsControl(NavMapOption), + }, + (props) => { + return props.optionType === "manual" ? props.manual : props.mapData; + } + ) + .setPropertyViewFn(() => { + throw new Error("Method not implemented."); + }) + .build(); + + return class NavItemsControl extends TmpNavItemsControl { + exposingNode() { + return this.children.optionType.getView() === "manual" + ? (this.children.manual as any).exposingNode() + : (this.children.mapData as any).exposingNode(); + } + + propertyView() { + const isManual = this.children.optionType.getView() === "manual"; + const content = isManual + ? menuPropertyView(this.children.manual as any) + : this.children.mapData.getPropertyView(); + + return controlItem( + { searchChild: true }, + <> + {this.children.optionType.propertyView({ radioButton: true, type: "oneline" })} + {content} + + ); + } + }; +} + + diff --git a/client/packages/lowcoder/src/comps/comps/navComp/components/types.ts b/client/packages/lowcoder/src/comps/comps/navComp/components/types.ts index 09640aac33..86e45194c9 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/components/types.ts +++ b/client/packages/lowcoder/src/comps/comps/navComp/components/types.ts @@ -1,5 +1,6 @@ import { NavItemComp, navListComp } from "../navItemComp"; import { LayoutMenuItemComp, LayoutMenuItemListComp } from "comps/comps/layout/layoutMenuItemComp"; +import { TreeItem } from "dnd-kit-sortable-tree"; export type NavCompType = NavItemComp | LayoutMenuItemComp; @@ -15,13 +16,12 @@ export interface NavCompItemType { onEvent: (name: string) => void; } -export interface IDropData { - targetListSize: number; - targetPath: number[]; - dropInAsSub: boolean; -} - -export interface IDragData { - item: NavCompType; +// Tree item data for dnd-kit-sortable-tree +export interface NavTreeItemData { + comp: NavCompType; path: number[]; + collapsed?: boolean; } + +// Full tree item type for the sortable tree +export type NavTreeItem = TreeItem; diff --git a/client/packages/lowcoder/src/comps/comps/navComp/navComp.tsx b/client/packages/lowcoder/src/comps/comps/navComp/navComp.tsx index 1ee33fa616..846cc8c1e1 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/navComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/navComp/navComp.tsx @@ -1,27 +1,45 @@ import { NameConfig, NameConfigHidden, withExposingConfigs } from "comps/generators/withExposing"; +import { MultiCompBuilder } from "comps/generators/multi"; import { UICompBuilder, withDefault } from "comps/generators"; import { Section, sectionNames } from "lowcoder-design"; import styled from "styled-components"; import { clickEvent, eventHandlerControl } from "comps/controls/eventHandlerControl"; -import { StringControl } from "comps/controls/codeControl"; +import { BoolCodeControl, StringControl } from "comps/controls/codeControl"; +import { dropdownControl, PositionControl } from "comps/controls/dropdownControl"; import { alignWithJustifyControl } from "comps/controls/alignControl"; import { navListComp } from "./navItemComp"; import { menuPropertyView } from "./components/MenuItemList"; import { default as DownOutlined } from "@ant-design/icons/DownOutlined"; +import { default as MenuOutlined } from "@ant-design/icons/MenuOutlined"; import { default as Dropdown } from "antd/es/dropdown"; import { default as Menu, MenuProps } from "antd/es/menu"; +import Segmented from "antd/es/segmented"; +import { Drawer, ScrollBar } from "lowcoder-design"; import { migrateOldData } from "comps/generators/simpleGenerators"; import { styleControl } from "comps/controls/styleControl"; +import { IconControl } from "comps/controls/iconControl"; +import { controlItem } from "components/control"; +import { PreviewContainerID } from "constants/domLocators"; import { AnimationStyle, AnimationStyleType, NavigationStyle, + HamburgerButtonStyle, + DrawerContainerStyle, + NavLayoutItemStyle, + NavLayoutItemHoverStyle, + NavLayoutItemActiveStyle, } from "comps/controls/styleControlConstants"; import { hiddenPropertyView, showDataLoadingIndicatorsPropertyView } from "comps/utils/propertyUtils"; import { trans } from "i18n"; -import { useContext } from "react"; +import { useContext, useState, useCallback } from "react"; import { EditorContext } from "comps/editorState"; +import { createNavItemsControl } from "./components/NavItemsControl"; +import { Layers } from "constants/Layers"; +import { CanvasContainerID } from "constants/domLocators"; +import { isNumeric } from "util/stringUtils"; +import { hasIcon } from "comps/utils"; type IProps = { $justify: boolean; @@ -31,6 +49,7 @@ type IProps = { $borderRadius: string; $borderStyle: string; $animationStyle: AnimationStyleType; + $orientation: "horizontal" | "vertical"; }; const Wrapper = styled("div")< @@ -42,43 +61,107 @@ ${props=>props.$animationStyle} box-sizing: border-box; border: ${(props) => props.$borderWidth ? `${props.$borderWidth}` : '1px'} ${props=>props.$borderStyle} ${(props) => props.$borderColor}; background: ${(props) => props.$bgColor}; + position: relative; `; -const NavInner = styled("div") >` +const DEFAULT_SIZE = 378; + +function transToPxSize(size: string | number) { + return isNumeric(size) ? size + "px" : (size as string); +} + +type MenuItemStyleOptionValue = "normal" | "hover" | "active"; +const menuItemStyleOptions = [ + { label: "Normal", value: "normal" }, + { label: "Hover", value: "hover" }, + { label: "Active", value: "active" }, +] as const; + +const NavInner = styled("div") >` // margin: 0 -16px; height: 100%; display: flex; - justify-content: ${(props) => (props.$justify ? "space-between" : "left")}; + flex-direction: ${(props) => (props.$orientation === "vertical" ? "column" : "row")}; + justify-content: ${(props) => (props.$orientation === "vertical" ? "flex-start" : (props.$justify ? "space-between" : "left"))}; `; const Item = styled.div<{ $active: boolean; $activeColor: string; + $hoverColor: string; $color: string; $fontFamily: string; $fontStyle: string; $textWeight: string; $textSize: string; + $textDecoration: string; + $hoverFontFamily?: string; + $hoverFontStyle?: string; + $hoverTextWeight?: string; + $hoverTextSize?: string; + $hoverTextDecoration?: string; + $activeFontFamily?: string; + $activeFontStyle?: string; + $activeTextWeight?: string; + $activeTextSize?: string; + $activeTextDecoration?: string; $margin: string; $padding: string; - $textTransform:string; - $textDecoration:string; + $bg?: string; + $hoverBg?: string; + $activeBg?: string; + $border?: string; + $hoverBorder?: string; + $activeBorder?: string; + $borderWidth?: string; + $radius?: string; + $disabled?: boolean; }>` - height: 30px; line-height: 30px; - padding: ${(props) => props.$padding ? props.$padding : '0 16px'}; - color: ${(props) => (props.$active ? props.$activeColor : props.$color)}; - font-weight: ${(props) => (props.$textWeight ? props.$textWeight : 500)}; - font-family:${(props) => (props.$fontFamily ? props.$fontFamily : 'sans-serif')}; - font-style:${(props) => (props.$fontStyle ? props.$fontStyle : 'normal')}; - font-size:${(props) => (props.$textSize ? props.$textSize : '14px')}; - text-transform:${(props) => (props.$textTransform ? props.$textTransform : '')}; - text-decoration:${(props) => (props.$textDecoration ? props.$textDecoration : '')}; - margin:${(props) => props.$margin ? props.$margin : '0px'}; + padding: ${(props) => props.$padding || '0 16px'}; + color: ${(props) => props.$disabled ? `${props.$color}80` : (props.$active ? props.$activeColor : props.$color)}; + background-color: ${(props) => (props.$active ? (props.$activeBg || 'transparent') : (props.$bg || 'transparent'))}; + border: ${(props) => { + const width = props.$borderWidth || '1px'; + if (props.$active) { + return props.$activeBorder ? `${width} solid ${props.$activeBorder}` : (props.$border ? `${width} solid ${props.$border}` : `${width} solid transparent`); + } + return props.$border ? `${width} solid ${props.$border}` : `${width} solid transparent`; + }}; + border-radius: ${(props) => props.$radius || '0px'}; + font-weight: ${(props) => props.$active + ? (props.$activeTextWeight || props.$textWeight || 500) + : (props.$textWeight || 500)}; + font-family: ${(props) => props.$active + ? (props.$activeFontFamily || props.$fontFamily || 'sans-serif') + : (props.$fontFamily || 'sans-serif')}; + font-style: ${(props) => props.$active + ? (props.$activeFontStyle || props.$fontStyle || 'normal') + : (props.$fontStyle || 'normal')}; + font-size: ${(props) => props.$active + ? (props.$activeTextSize || props.$textSize || '14px') + : (props.$textSize || '14px')}; + text-decoration: ${(props) => props.$active + ? (props.$activeTextDecoration || props.$textDecoration || 'none') + : (props.$textDecoration || 'none')}; + margin: ${(props) => props.$margin || '0px'}; &:hover { - color: ${(props) => props.$activeColor}; - cursor: pointer; + color: ${(props) => props.$disabled ? (props.$active ? props.$activeColor : props.$color) : (props.$hoverColor || props.$activeColor)}; + background-color: ${(props) => props.$disabled ? (props.$active ? (props.$activeBg || 'transparent') : (props.$bg || 'transparent')) : (props.$hoverBg || props.$activeBg || props.$bg || 'transparent')}; + border: ${(props) => { + const width = props.$borderWidth || '1px'; + if (props.$hoverBorder) return `${width} solid ${props.$hoverBorder}`; + if (props.$activeBorder) return `${width} solid ${props.$activeBorder}`; + if (props.$border) return `${width} solid ${props.$border}`; + return `${width} solid transparent`; + }}; + cursor: ${(props) => props.$disabled ? 'not-allowed' : 'pointer'}; + font-weight: ${(props) => props.$disabled ? undefined : (props.$hoverTextWeight || props.$textWeight || 500)}; + font-family: ${(props) => props.$disabled ? undefined : (props.$hoverFontFamily || props.$fontFamily || 'sans-serif')}; + font-style: ${(props) => props.$disabled ? undefined : (props.$hoverFontStyle || props.$fontStyle || 'normal')}; + font-size: ${(props) => props.$disabled ? undefined : (props.$hoverTextSize || props.$textSize || '14px')}; + text-decoration: ${(props) => props.$disabled ? undefined : (props.$hoverTextDecoration || props.$textDecoration || 'none')}; } .anticon { @@ -97,17 +180,156 @@ const LogoWrapper = styled.div` } `; -const ItemList = styled.div<{ $align: string }>` +const ItemList = styled.div<{ $align: string, $orientation?: string }>` flex: 1; display: flex; - flex-direction: row; + flex-direction: ${(props) => (props.$orientation === "vertical" ? "column" : "row")}; justify-content: ${(props) => props.$align}; `; -const StyledMenu = styled(Menu) ` - &.ant-dropdown-menu { - min-width: 160px; +const StyledMenu = styled(Menu) < + MenuProps & { + $color: string; + $hoverColor: string; + $activeColor: string; + $bg?: string; + $hoverBg?: string; + $activeBg?: string; + $border?: string; + $hoverBorder?: string; + $activeBorder?: string; + $radius?: string; + $fontFamily?: string; + $fontStyle?: string; + $textWeight?: string; + $textSize?: string; + $textDecoration?: string; + $hoverFontFamily?: string; + $hoverFontStyle?: string; + $hoverTextWeight?: string; + $hoverTextSize?: string; + $hoverTextDecoration?: string; + $activeFontFamily?: string; + $activeFontStyle?: string; + $activeTextWeight?: string; + $activeTextSize?: string; + $activeTextDecoration?: string; + $padding?: string; + $margin?: string; + } +>` + /* Base submenu item styles */ + .ant-dropdown-menu-item { + color: ${(p) => p.$color}; + background-color: ${(p) => p.$bg || "transparent"}; + border-radius: ${(p) => p.$radius || "0px"}; + font-weight: ${(p) => p.$textWeight || 500}; + font-family: ${(p) => p.$fontFamily || "sans-serif"}; + font-style: ${(p) => p.$fontStyle || "normal"}; + font-size: ${(p) => p.$textSize || "14px"}; + text-decoration: ${(p) => p.$textDecoration || "none"}; + padding: ${(p) => p.$padding || "0 16px"}; + margin: ${(p) => p.$margin || "0px"}; + line-height: 30px; + } + /* Hover state */ + .ant-dropdown-menu-item:hover { + color: ${(p) => p.$hoverColor || p.$color}; + background-color: ${(p) => p.$hoverBg || p.$bg || "transparent"} !important; + font-weight: ${(p) => p.$hoverTextWeight || p.$textWeight || 500}; + font-family: ${(p) => p.$hoverFontFamily || p.$fontFamily || "sans-serif"}; + font-style: ${(p) => p.$hoverFontStyle || p.$fontStyle || "normal"}; + font-size: ${(p) => p.$hoverTextSize || p.$textSize || "14px"}; + text-decoration: ${(p) => p.$hoverTextDecoration || p.$textDecoration || "none"}; + cursor: pointer; + } + /* Selected/active state */ + .ant-dropdown-menu-item-selected, + .ant-menu-item-selected { + color: ${(p) => p.$activeColor}; + background-color: ${(p) => p.$activeBg || p.$bg || "transparent"}; + font-weight: ${(p) => p.$activeTextWeight || p.$textWeight || 500}; + font-family: ${(p) => p.$activeFontFamily || p.$fontFamily || "sans-serif"}; + font-style: ${(p) => p.$activeFontStyle || p.$fontStyle || "normal"}; + font-size: ${(p) => p.$activeTextSize || p.$textSize || "14px"}; + text-decoration: ${(p) => p.$activeTextDecoration || p.$textDecoration || "none"}; } + /* Disabled state */ + .ant-dropdown-menu-item-disabled, + .ant-menu-item-disabled { + opacity: 0.5; + cursor: not-allowed; + } +`; + +const FloatingHamburgerButton = styled.button<{ + $size: string; + $position: string; // left | right + $zIndex: number; + $background?: string; + $borderColor?: string; + $radius?: string; + $margin?: string; + $padding?: string; + $borderWidth?: string; + $iconColor?: string; +}>` + position: fixed; + top: 16px; + ${(props) => (props.$position === 'right' ? 'right: 16px;' : 'left: 16px;')} + width: ${(props) => props.$size}; + height: ${(props) => props.$size}; + border-radius: ${(props) => props.$radius || '50%'}; + border: ${(props) => props.$borderWidth || '1px'} solid ${(props) => props.$borderColor || 'rgba(0,0,0,0.1)'}; + background: ${(props) => props.$background || 'white'}; + margin: ${(props) => props.$margin || '0px'}; + padding: ${(props) => props.$padding || '0px'}; + display: flex; + align-items: center; + justify-content: center; + z-index: ${(props) => props.$zIndex}; + cursor: pointer; + box-shadow: 0 6px 16px rgba(0,0,0,0.15); + color: ${(props) => props.$iconColor || 'inherit'}; +`; + +const DrawerContent = styled.div<{ + $background: string; + $padding?: string; + $borderColor?: string; + $borderWidth?: string; + $margin?: string; +}>` + background: ${(p) => p.$background}; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + padding: ${(p) => p.$padding || '12px'}; + margin: ${(p) => p.$margin || '0px'}; + box-sizing: border-box; + border: ${(p) => p.$borderWidth || '1px'} solid ${(p) => p.$borderColor || 'transparent'}; +`; + +const DrawerHeader = styled.div` + display: flex; + justify-content: flex-end; + align-items: center; +`; + +const DrawerCloseButton = styled.button<{ + $color: string; +}>` + background: transparent; + border: none; + cursor: pointer; + color: ${(p) => p.$color}; + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 16px; `; const logoEventHandlers = [clickEvent]; @@ -131,74 +353,337 @@ function fixOldStyleData(oldData: any) { return oldData; } +function fixOldItemsData(oldData: any) { + if (Array.isArray(oldData)) { + return { + optionType: "manual", + manual: oldData, + }; + } + if (oldData && !oldData.optionType && Array.isArray(oldData.manual)) { + return { + optionType: "manual", + manual: oldData.manual, + }; + } + return oldData; +} + +// Property View Helpers +function renderBasicSection(children: any) { + return ( +
+ {children.items.propertyView()} +
+ ); +} + +function renderInteractionSection(children: any) { + return ( +
+ {hiddenPropertyView(children)} + {showDataLoadingIndicatorsPropertyView(children)} +
+ ); +} + +function renderLayoutSection(children: any) { + const isHamburger = children.displayMode.getView() === 'hamburger'; + const common = [ + children.displayMode.propertyView({ label: "Display Mode", radioButton: true }), + ]; + const hamburger = [ + ...common, + children.hamburgerPosition.propertyView({ label: "Hamburger Position", radioButton: true }), + children.hamburgerSize.propertyView({ label: "Hamburger Size" }), + children.placement.propertyView({ label: trans("drawer.placement"), radioButton: true }), + ...(["top", "bottom"].includes(children.placement.getView()) + ? [children.drawerHeight.propertyView({ + label: trans("drawer.height"), + tooltip: trans("drawer.heightTooltip"), + placeholder: DEFAULT_SIZE + "", + })] + : [children.drawerWidth.propertyView({ + label: trans("drawer.width"), + tooltip: trans("drawer.widthTooltip"), + placeholder: DEFAULT_SIZE + "", + })]), + children.hamburgerIcon.propertyView({ label: "Menu Icon" }), + children.drawerCloseIcon.propertyView({ label: "Close Icon" }), + children.shadowOverlay.propertyView({ label: "Shadow Overlay" }), + ]; + const bar = [ + ...common, + children.orientation.propertyView({ label: "Orientation", radioButton: true }), + children.horizontalAlignment.propertyView({ + label: trans("navigation.horizontalAlignment"), + radioButton: true, + }), + ]; + + return ( +
+ {isHamburger ? hamburger : bar} +
+ ); +} + +function renderAdvancedSection(children: any) { + return ( +
+ {children.logoUrl.propertyView({ label: trans("navigation.logoURL"), tooltip: trans("navigation.logoURLDesc") })} + {children.logoUrl.getView() && children.logoEvent.propertyView({ inline: true })} +
+ ); +} + +function renderStyleSections( + children: any, + styleSegment: MenuItemStyleOptionValue, + setStyleSegment: (k: MenuItemStyleOptionValue) => void +) { + const isHamburger = children.displayMode.getView() === 'hamburger'; + return ( + <> + {!isHamburger && ( +
+ {children.style.getPropertyView()} +
+ )} +
+ {controlItem({}, ( + setStyleSegment(k as MenuItemStyleOptionValue)} + /> + ))} + {styleSegment === "normal" && children.navItemStyle.getPropertyView()} + {styleSegment === "hover" && children.navItemHoverStyle.getPropertyView()} + {styleSegment === "active" && children.navItemActiveStyle.getPropertyView()} +
+ {isHamburger && ( + <> +
+ {children.hamburgerButtonStyle.getPropertyView()} +
+
+ {children.drawerContainerStyle.getPropertyView()} +
+ + )} +
+ {children.animationStyle.getPropertyView()} +
+ + ); +} + const childrenMap = { logoUrl: StringControl, logoEvent: withDefault(eventHandlerControl(logoEventHandlers), [{ name: "click" }]), + orientation: dropdownControl([ + { label: "Horizontal", value: "horizontal" }, + { label: "Vertical", value: "vertical" }, + ], "horizontal"), + displayMode: dropdownControl([ + { label: "Bar", value: "bar" }, + { label: "Hamburger", value: "hamburger" }, + ], "bar"), + hamburgerPosition: dropdownControl([ + { label: "Left", value: "left" }, + { label: "Right", value: "right" }, + ], "right"), + hamburgerSize: withDefault(StringControl, "56px"), + placement: PositionControl, + drawerWidth: StringControl, + drawerHeight: StringControl, + hamburgerIcon: withDefault(IconControl, ""), + drawerCloseIcon: withDefault(IconControl, ""), + shadowOverlay: withDefault(BoolCodeControl, true), horizontalAlignment: alignWithJustifyControl(), style: migrateOldData(styleControl(NavigationStyle, 'style'), fixOldStyleData), + navItemStyle: styleControl(NavLayoutItemStyle, 'navItemStyle'), + navItemHoverStyle: styleControl(NavLayoutItemHoverStyle, 'navItemHoverStyle'), + navItemActiveStyle: styleControl(NavLayoutItemActiveStyle, 'navItemActiveStyle'), + hamburgerButtonStyle: styleControl(HamburgerButtonStyle, 'hamburgerButtonStyle'), + drawerContainerStyle: styleControl(DrawerContainerStyle, 'drawerContainerStyle'), animationStyle: styleControl(AnimationStyle, 'animationStyle'), - items: withDefault(navListComp(), [ - { - label: trans("menuItem") + " 1", - }, - ]), + items: withDefault(migrateOldData(createNavItemsControl(), fixOldItemsData), { + optionType: "manual", + manual: [ + { + label: trans("menuItem") + " 1", + items: [ + { + label: trans("subMenuItem") + " 1", + items: [ + { + label: trans("subMenuItem") + " 1-1", + }, + { + label: trans("subMenuItem") + " 1-2", + }, + ], + }, + { + label: trans("subMenuItem") + " 2", + }, + { + label: trans("subMenuItem") + " 3", + }, + ], + }, + ], + }), }; const NavCompBase = new UICompBuilder(childrenMap, (props) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const getContainer = useCallback(() => + document.querySelector(`#${CanvasContainerID}`) || document.querySelector(`#${PreviewContainerID}`) || document.body, + [] + ); const data = props.items; const items = ( <> - {data.map((menuItem, idx) => { - const { hidden, label, items, active, onEvent } = menuItem.getView(); + {data.map((menuItem: any, idx: number) => { + const isCompItem = typeof menuItem?.getView === "function"; + const view = isCompItem ? menuItem.getView() : menuItem; + const hidden = !!view?.hidden; if (hidden) { return null; } - const visibleSubItems = items.filter((item) => !item.children.hidden.getView()); - const subMenuItems: Array<{ key: string; label: string }> = []; + + const label = view?.label || trans("untitled"); + const icon = hasIcon(view?.icon) ? view.icon : undefined; + const active = !!view?.active; + const onEvent = view?.onEvent; + const disabled = !!view?.disabled; + const subItems = isCompItem ? view?.items : []; + const subMenuSelectedKeys: Array = []; - visibleSubItems.forEach((subItem, index) => { - const key = index + ""; - subItem.children.active.getView() && subMenuSelectedKeys.push(key); - subMenuItems.push({ - key: key, - label: subItem.children.label.getView(), - }); - }); + const buildSubMenuItems = (list: any[], prefix = ""): Array => { + if (!Array.isArray(list)) return []; + return list + .map((subItem: any, originalIndex: number) => { + if (subItem.children.hidden.getView()) return null; + const key = prefix ? `${prefix}-${originalIndex}` : `${originalIndex}`; + subItem.children.active.getView() && subMenuSelectedKeys.push(key); + const subIcon = hasIcon(subItem.children.icon?.getView?.()) ? subItem.children.icon.getView() : undefined; + const children = buildSubMenuItems(subItem.getView()?.items, key); + return { + key, + label: subItem.children.label.getView() || trans("untitled"), + icon: subIcon, + disabled: !!subItem.children.disabled.getView(), + ...(children.length > 0 ? { children } : {}), + }; + }) + .filter(Boolean); + }; + const subMenuItems: Array = buildSubMenuItems(subItems); + const item = ( 0} - $color={props.style.text} - $activeColor={props.style.accent} - $fontFamily={props.style.fontFamily} - $fontStyle={props.style.fontStyle} - $textWeight={props.style.textWeight} - $textSize={props.style.textSize} - $padding={props.style.padding} - $textTransform={props.style.textTransform} - $textDecoration={props.style.textDecoration} - $margin={props.style.margin} - onClick={() => onEvent("click")} + $color={props.navItemStyle?.text || props.style.accent} + $hoverColor={props.navItemHoverStyle?.text || props.navItemStyle?.text || props.style.accent} + $activeColor={props.navItemActiveStyle?.text || props.navItemStyle?.text || props.style.accent} + $fontFamily={props.navItemStyle?.fontFamily || 'sans-serif'} + $fontStyle={props.navItemStyle?.fontStyle || 'normal'} + $textWeight={props.navItemStyle?.textWeight || '500'} + $textSize={props.navItemStyle?.textSize || '14px'} + $textDecoration={props.navItemStyle?.textDecoration || 'none'} + $hoverFontFamily={props.navItemHoverStyle?.fontFamily} + $hoverFontStyle={props.navItemHoverStyle?.fontStyle} + $hoverTextWeight={props.navItemHoverStyle?.textWeight} + $hoverTextSize={props.navItemHoverStyle?.textSize} + $hoverTextDecoration={props.navItemHoverStyle?.textDecoration} + $activeFontFamily={props.navItemActiveStyle?.fontFamily} + $activeFontStyle={props.navItemActiveStyle?.fontStyle} + $activeTextWeight={props.navItemActiveStyle?.textWeight} + $activeTextSize={props.navItemActiveStyle?.textSize} + $activeTextDecoration={props.navItemActiveStyle?.textDecoration} + $padding={props.navItemStyle?.padding || '0 16px'} + $margin={props.navItemStyle?.margin || '0px'} + $bg={props.navItemStyle?.background} + $hoverBg={props.navItemHoverStyle?.background} + $activeBg={props.navItemActiveStyle?.background} + $border={props.navItemStyle?.border} + $hoverBorder={props.navItemHoverStyle?.border} + $activeBorder={props.navItemActiveStyle?.border} + $radius={props.navItemStyle?.radius} + $borderWidth={props.navItemStyle?.borderWidth} + $disabled={disabled} + onClick={() => { if (!disabled && onEvent) onEvent("click"); }} > + {icon && {icon}} {label} - {items.length > 0 && } + {Array.isArray(subItems) && subItems.length > 0 && } ); - if (visibleSubItems.length > 0) { + if (subMenuItems.length > 0) { const subMenu = ( - { - const { onEvent: onSubEvent } = items[Number(e.key)]?.getView(); - onSubEvent("click"); - }} - selectedKeys={subMenuSelectedKeys} - items={subMenuItems} - /> + + { + if (disabled) return; + const parts = String(e.key).split("-").filter(Boolean); + let currentList: any[] = subItems; + let current: any = null; + for (const part of parts) { + current = currentList?.[Number(part)]; + if (!current) return; + currentList = current.getView()?.items || []; + } + const isSubDisabled = !!current?.children?.disabled?.getView?.(); + if (isSubDisabled) return; + const onSubEvent = current?.getView?.()?.onEvent; + onSubEvent && onSubEvent("click"); + }} + selectedKeys={subMenuSelectedKeys} + items={subMenuItems.map(item => ({ + ...item, + icon: item.icon || undefined, + }))} + $color={props.navItemStyle?.text || props.style.accent} + $hoverColor={props.navItemHoverStyle?.text || props.navItemStyle?.text || props.style.accent} + $activeColor={props.navItemActiveStyle?.text || props.navItemStyle?.text || props.style.accent} + $bg={props.navItemStyle?.background} + $hoverBg={props.navItemHoverStyle?.background} + $activeBg={props.navItemActiveStyle?.background} + $border={props.navItemStyle?.border} + $hoverBorder={props.navItemHoverStyle?.border} + $activeBorder={props.navItemActiveStyle?.border} + $radius={props.navItemStyle?.radius} + $fontFamily={props.navItemStyle?.fontFamily || 'sans-serif'} + $fontStyle={props.navItemStyle?.fontStyle || 'normal'} + $textWeight={props.navItemStyle?.textWeight || '500'} + $textSize={props.navItemStyle?.textSize || '14px'} + $textDecoration={props.navItemStyle?.textDecoration || 'none'} + $hoverFontFamily={props.navItemHoverStyle?.fontFamily} + $hoverFontStyle={props.navItemHoverStyle?.fontStyle} + $hoverTextWeight={props.navItemHoverStyle?.textWeight} + $hoverTextSize={props.navItemHoverStyle?.textSize} + $hoverTextDecoration={props.navItemHoverStyle?.textDecoration} + $activeFontFamily={props.navItemActiveStyle?.fontFamily} + $activeFontStyle={props.navItemActiveStyle?.fontStyle} + $activeTextWeight={props.navItemActiveStyle?.textWeight} + $activeTextSize={props.navItemActiveStyle?.textSize} + $activeTextDecoration={props.navItemActiveStyle?.textDecoration} + $padding={props.navItemStyle?.padding || '0 16px'} + $margin={props.navItemStyle?.margin || '0px'} + /> + ); return ( subMenu} + disabled={disabled} > {item} @@ -210,6 +695,8 @@ const NavCompBase = new UICompBuilder(childrenMap, (props) => { ); const justify = props.horizontalAlignment === "justify"; + const isVertical = props.orientation === "vertical"; + const isHamburger = props.displayMode === "hamburger"; return ( { $borderWidth={props.style.borderWidth} $borderRadius={props.style.radius} > - - {props.logoUrl && ( - props.logoEvent("click")}> - LOGO - - )} - {!justify ? {items} : items} - + {!isHamburger && ( + + {props.logoUrl && ( + props.logoEvent("click")}> + LOGO + + )} + {!justify ? {items} : items} + + )} + {isHamburger && ( + <> + setDrawerVisible(true)} + > + {hasIcon(props.hamburgerIcon) ? props.hamburgerIcon : } + + setDrawerVisible(false)} + open={drawerVisible} + mask={props.shadowOverlay} + maskClosable={true} + closable={false} + getContainer={getContainer} + width={["left", "right"].includes(props.placement as any) ? transToPxSize(props.drawerWidth || DEFAULT_SIZE) : undefined as any} + height={["top", "bottom"].includes(props.placement as any) ? transToPxSize(props.drawerHeight || DEFAULT_SIZE) : undefined as any} + styles={{ body: { padding: 0 } }} + destroyOnClose + > + + + setDrawerVisible(false)} + > + {hasIcon(props.drawerCloseIcon) + ? props.drawerCloseIcon + : ×} + + + {items} + + + + )} ); }) .setPropertyViewFn((children) => { + const mode = useContext(EditorContext).editorModeStatus; + const showLogic = mode === "logic" || mode === "both"; + const showLayout = mode === "layout" || mode === "both"; + const [styleSegment, setStyleSegment] = useState("normal"); + return ( <> -
- {menuPropertyView(children.items)} -
- - {(useContext(EditorContext).editorModeStatus === "logic" || useContext(EditorContext).editorModeStatus === "both") && ( -
- {hiddenPropertyView(children)} - {showDataLoadingIndicatorsPropertyView(children)} -
- )} - - {(useContext(EditorContext).editorModeStatus === "layout" || useContext(EditorContext).editorModeStatus === "both") && ( -
- {children.horizontalAlignment.propertyView({ - label: trans("navigation.horizontalAlignment"), - radioButton: true, - })} - {hiddenPropertyView(children)} -
- )} - - {(useContext(EditorContext).editorModeStatus === "logic" || useContext(EditorContext).editorModeStatus === "both") && ( -
- {children.logoUrl.propertyView({ label: trans("navigation.logoURL"), tooltip: trans("navigation.logoURLDesc") })} - {children.logoUrl.getView() && children.logoEvent.propertyView({ inline: true })} -
- )} - - {(useContext(EditorContext).editorModeStatus === "layout" || - useContext(EditorContext).editorModeStatus === "both") && ( - <> -
- {children.style.getPropertyView()} -
-
- {children.animationStyle.getPropertyView()} -
- - )} + {renderBasicSection(children)} + {showLogic && renderInteractionSection(children)} + {showLayout && renderLayoutSection(children)} + {showLogic && renderAdvancedSection(children)} + {showLayout && renderStyleSections(children, styleSegment, setStyleSegment)} ); }) diff --git a/client/packages/lowcoder/src/comps/comps/navComp/navItemComp.tsx b/client/packages/lowcoder/src/comps/comps/navComp/navItemComp.tsx index 565013ab4f..1bd02bed96 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/navItemComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/navComp/navItemComp.tsx @@ -1,20 +1,29 @@ import { BoolCodeControl, StringControl } from "comps/controls/codeControl"; +import { BoolControl } from "comps/controls/boolControl"; import { clickEvent, eventHandlerControl } from "comps/controls/eventHandlerControl"; +import { withPropertyViewFn } from "comps/generators"; import { list } from "comps/generators/list"; import { parseChildrenFromValueAndChildrenMap, ToViewReturn } from "comps/generators/multi"; -import { withDefault } from "comps/generators/simpleGenerators"; -import { hiddenPropertyView } from "comps/utils/propertyUtils"; +import { migrateOldData, withDefault } from "comps/generators/simpleGenerators"; +import { disabledPropertyView, hiddenPropertyView } from "comps/utils/propertyUtils"; import { trans } from "i18n"; import _ from "lodash"; import { fromRecord, MultiBaseComp, Node, RecordNode, RecordNodeToValue } from "lowcoder-core"; import { ReactNode } from "react"; +import { IconControl } from "comps/controls/iconControl"; const events = [clickEvent]; +// BoolControl without property view (internal state only) +const CollapsedControl = withPropertyViewFn(BoolControl, () => null); + const childrenMap = { label: StringControl, + icon: IconControl, hidden: BoolCodeControl, + disabled: BoolCodeControl, active: BoolCodeControl, + collapsed: CollapsedControl, // tree editor collapsed state onEvent: withDefault(eventHandlerControl(events), [ { // name: "click", @@ -28,8 +37,11 @@ const childrenMap = { type ChildrenType = { label: InstanceType; + icon: InstanceType; hidden: InstanceType; + disabled: InstanceType; active: InstanceType; + collapsed: InstanceType; onEvent: InstanceType>; items: InstanceType>; }; @@ -43,8 +55,10 @@ export class NavItemComp extends MultiBaseComp { return ( <> {this.children.label.propertyView({ label: trans("label") })} + {this.children.icon.propertyView({ label: trans("icon") })} {hiddenPropertyView(this.children)} {this.children.active.propertyView({ label: trans("navItemComp.active") })} + {disabledPropertyView(this.children)} {this.children.onEvent.propertyView({ inline: true })} ); @@ -65,10 +79,20 @@ export class NavItemComp extends MultiBaseComp { this.children.items.addItem(value); } + getCollapsed(): boolean { + return this.children.collapsed.getView(); + } + + setCollapsed(collapsed: boolean) { + this.children.collapsed.dispatchChangeValueAction(collapsed); + } + exposingNode(): RecordNode { return fromRecord({ label: this.children.label.exposingNode(), + icon: this.children.icon.exposingNode(), hidden: this.children.hidden.exposingNode(), + disabled: this.children.disabled.exposingNode(), active: this.children.active.exposingNode(), items: this.children.items.exposingNode(), }); @@ -77,22 +101,38 @@ export class NavItemComp extends MultiBaseComp { type NavItemExposing = { label: Node; + icon: Node; hidden: Node; + disabled: Node; active: Node; items: Node[]>; }; +// Migrate old nav items to strip out deprecated itemKey field +function migrateNavItemData(oldData: any): any { + if (!oldData) return oldData; + + const { itemKey, ...rest } = oldData; + + // Also migrate nested items recursively + if (Array.isArray(rest.items)) { + rest.items = rest.items.map((item: any) => migrateNavItemData(item)); + } + + return rest; +} + +const NavItemCompMigrated = migrateOldData(NavItemComp, migrateNavItemData); + export function navListComp() { - const NavItemListCompBase = list(NavItemComp); + const NavItemListCompBase = list(NavItemCompMigrated); return class NavItemListComp extends NavItemListCompBase { addItem(value?: any) { const data = this.getView(); this.dispatch( this.pushAction( - value || { - label: trans("menuItem") + " " + (data.length + 1), - } + value || { label: trans("menuItem") + " " + (data.length + 1) } ) ); } diff --git a/client/packages/lowcoder/src/comps/comps/numberInputComp/numberInputComp.tsx b/client/packages/lowcoder/src/comps/comps/numberInputComp/numberInputComp.tsx index 15db21305f..fad9d36d14 100644 --- a/client/packages/lowcoder/src/comps/comps/numberInputComp/numberInputComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/numberInputComp/numberInputComp.tsx @@ -293,7 +293,7 @@ const childrenMap = { viewRef: RefControl, style: styleControl(InputFieldStyle , 'style') , labelStyle: styleControl(LabelStyle , 'labelStyle'), - prefixText : stringExposingStateControl("defaultValue"), + prefixText: StringControl, animationStyle: styleControl(AnimationStyle , 'animationStyle'), prefixIcon: IconControl, inputFieldStyle: styleControl(InputLikeStyle , 'inputFieldStyle'), @@ -331,7 +331,7 @@ const CustomInputNumber = (props: RecordConstructorToView) = value = Number(defaultValue); } props.value.onChange(value); - }, [defaultValue]); + }, [defaultValue, props.allowNull]); const formatFn = (value: number) => format(value, props.allowNull, props.formatter, props.precision, props.thousandsSeparator); @@ -388,7 +388,6 @@ const CustomInputNumber = (props: RecordConstructorToView) = return; } if ( - cursor !== 0 && props.precision > 0 && (event.key === "." || event.key === "。") && !/[.]/.test(value) @@ -415,7 +414,7 @@ const CustomInputNumber = (props: RecordConstructorToView) = precision={props.precision} $style={props.inputFieldStyle} $disabledStyle={props.disabledStyle} - prefix={hasIcon(props.prefixIcon) ? props.prefixIcon : props.prefixText.value} + prefix={hasIcon(props.prefixIcon) ? props.prefixIcon : props.prefixText} tabIndex={typeof props.tabIndex === 'number' ? props.tabIndex : undefined} onPressEnter={() => { handleFinish(); diff --git a/client/packages/lowcoder/src/comps/comps/preLoadComp/actions/appConfiguration.ts b/client/packages/lowcoder/src/comps/comps/preLoadComp/actions/appConfiguration.ts index 4b386f9b27..5220a8891f 100644 --- a/client/packages/lowcoder/src/comps/comps/preLoadComp/actions/appConfiguration.ts +++ b/client/packages/lowcoder/src/comps/comps/preLoadComp/actions/appConfiguration.ts @@ -69,7 +69,12 @@ export const publishAppAction: ActionConfig = { return; } - const response = await ApplicationApi.publishApplication({ applicationId }); + const response = await ApplicationApi.publishApplication({ + applicationId, + request: { + tag: "1.0.0" + } + }); if (response.data.success) { message.success('Application published successfully'); diff --git a/client/packages/lowcoder/src/comps/comps/ratingComp.tsx b/client/packages/lowcoder/src/comps/comps/ratingComp.tsx index 42a80c8ee0..025453c256 100644 --- a/client/packages/lowcoder/src/comps/comps/ratingComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/ratingComp.tsx @@ -52,12 +52,31 @@ const RatingBasicComp = (function () { 'labelStyle', ), inputFieldStyle: migrateOldData(styleControl(RatingStyle, 'inputFieldStyle'), fixOldData), + tabIndex: NumberControl, ...formDataChildren, }; return new UICompBuilder(childrenMap, (props) => { const defaultValue = { ...props.defaultValue }.value; const value = { ...props.value }.value; const changeRef = useRef(false); + const mountedRef = useRef(true); + const rateRef = useRef(null); + + useEffect(() => { + if (!mountedRef.current) return; + if (rateRef.current && typeof props.tabIndex === 'number') { + const stars = rateRef.current.querySelectorAll('li'); + stars.forEach((star, index) => { + (star as HTMLElement).setAttribute('tabindex', (props.tabIndex + index).toString()); + }); + } + }, [props.tabIndex, props.max]); + + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); useEffect(() => { props.value.onChange(defaultValue); @@ -76,7 +95,8 @@ const RatingBasicComp = (function () { inputFieldStyle:props.inputFieldStyle, animationStyle:props.animationStyle, children: ( - + { @@ -86,7 +106,9 @@ const RatingBasicComp = (function () { allowHalf={props.allowHalf} disabled={props.disabled} $style={props.inputFieldStyle} + tabIndex={typeof props.tabIndex === 'number' ? props.tabIndex : undefined} /> +
), }); }) @@ -108,6 +130,7 @@ const RatingBasicComp = (function () { {disabledPropertyView(children)} {hiddenPropertyView(children)} {showDataLoadingIndicatorsPropertyView(children)} + {children.tabIndex.propertyView({ label: trans("prop.tabIndex") })}
{children.allowHalf.propertyView({ diff --git a/client/packages/lowcoder/src/comps/comps/remoteComp/remoteComp.tsx b/client/packages/lowcoder/src/comps/comps/remoteComp/remoteComp.tsx index 8399535d7e..173c8c8613 100644 --- a/client/packages/lowcoder/src/comps/comps/remoteComp/remoteComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/remoteComp/remoteComp.tsx @@ -45,7 +45,7 @@ function ViewLoading(props: { padding?: number }) { ); } -interface RemoteCompReadyAction { +export interface RemoteCompReadyAction { type: "RemoteCompReady"; comp: Comp; } diff --git a/client/packages/lowcoder/src/comps/comps/responsiveLayout/responsiveLayout.tsx b/client/packages/lowcoder/src/comps/comps/responsiveLayout/responsiveLayout.tsx index c9c8229ed4..e4a7fd0b57 100644 --- a/client/packages/lowcoder/src/comps/comps/responsiveLayout/responsiveLayout.tsx +++ b/client/packages/lowcoder/src/comps/comps/responsiveLayout/responsiveLayout.tsx @@ -51,7 +51,6 @@ const RowWrapper = styled(Row)<{ $style: ResponsiveLayoutRowStyleType; $animationStyle: AnimationStyleType; $showScrollbar: boolean; - $columnCount: number; }>` ${(props) => props.$animationStyle} height: 100%; @@ -62,14 +61,11 @@ const RowWrapper = styled(Row)<{ padding: ${(props) => props.$style.padding}; rotate: ${(props) => props.$style.rotation}; overflow: ${(props) => (props.$showScrollbar ? 'auto' : 'hidden')}; - display: flex; - flex-wrap: wrap; // Ensure columns wrap properly when rowBreak = true + ::-webkit-scrollbar { display: ${(props) => (props.$showScrollbar ? 'block' : 'none')}; } ${props => getBackgroundStyle(props.$style)} - - --columns: ${(props) => props.$columnCount || 3}; `; const ColWrapper = styled(Col)<{ @@ -80,17 +76,31 @@ const ColWrapper = styled(Col)<{ }>` display: flex; flex-direction: column; - flex-grow: 1; - - // When rowBreak is true, columns are stretched evenly based on configured number - // When rowBreak is false, they stay at minWidth but break only if necessary - flex-basis: ${(props) => - props.$rowBreak - ? `calc(100% / var(--columns))` // Force exact column distribution - : `clamp(${props.$minWidth || "0px"}, calc(100% / var(--columns)), 100%)`}; // MinWidth respected + + /* When rowBreak is true: columns stretch evenly to fill available space */ + /* When rowBreak is false: columns take available space but respect minWidth */ + flex-grow: ${(props) => props.$rowBreak ? '1' : '1'}; + flex-shrink: ${(props) => { + if (props.$rowBreak) { + return '1'; // Can shrink when rowBreak is true + } else { + // When rowBreak is false, only allow shrinking if no minWidth is set + return props.$minWidth ? '0' : '1'; + } + }}; + flex-basis: ${(props) => { + if (props.$rowBreak) { + // When rowBreak is true, distribute columns evenly + return '0%'; + } else { + // When rowBreak is false, use minWidth if specified, otherwise auto + return props.$minWidth || 'auto'; + } + }}; - min-width: ${(props) => props.$minWidth}; // Ensure minWidth is respected - max-width: 100%; // Prevent more columns than allowed + /* Ensure minWidth is respected */ + min-width: ${(props) => props.$minWidth || 'auto'}; + max-width: 100%; > div { height: ${(props) => (props.$matchColumnsHeight ? "100%" : "auto")}; @@ -204,68 +214,70 @@ const ResponsiveLayout = (props: ResponsiveLayoutProps) => { const effectiveWidth = useComponentWidth ? componentWidth ?? safeScreenInfo.width : safeScreenInfo.width; const effectiveDeviceType = useComponentWidth ? getDeviceType(effectiveWidth || 1000) : safeScreenInfo.deviceType; - // Get columns per row based on device type - let configuredColumnsPerRow = effectiveDeviceType === "mobile" + // Get current columns per row based on device type + const currentColumnsPerRow = effectiveDeviceType === "mobile" ? columnPerRowSM : effectiveDeviceType === "tablet" ? columnPerRowMD : columnPerRowLG; - // Calculate max columns that fit based on minWidth - let maxColumnsThatFit = componentWidth - ? Math.floor(componentWidth / Math.max(...columns.map((col) => parseFloat(col.minWidth || "0")))) - : configuredColumnsPerRow; - - // Determine actual number of columns - let numberOfColumns = rowBreak ? configuredColumnsPerRow : Math.min(maxColumnsThatFit, totalColumns); + // Group columns into rows based on currentColumnsPerRow only when rowBreak is true + const columnRows = rowBreak ? (() => { + const rows = []; + for (let i = 0; i < columns.length; i += currentColumnsPerRow) { + rows.push(columns.slice(i, i + currentColumnsPerRow)); + } + return rows; + })() : [columns]; // When rowBreak is false, put all columns in a single row return (
- - {columns.map((column) => { - const id = String(column.id); - const childDispatch = wrapDispatch(wrapDispatch(dispatch, "containers"), id); - if (!containers[id] || column.hidden) return null; - const containerProps = containers[id].children; - - // Use the actual minWidth from column configuration instead of calculated width - const columnMinWidth = column.minWidth || `${100 / numberOfColumns}px`; - - return ( - - - - ); - })} - + {columnRows.map((row, rowIndex) => ( + + {row.map((column) => { + const id = String(column.id); + const childDispatch = wrapDispatch(wrapDispatch(dispatch, "containers"), id); + if (!containers[id] || column.hidden) return null; + const containerProps = containers[id].children; + + const columnMinWidth = column.minWidth; + + return ( + + + + ); + })} + + ))}
diff --git a/client/packages/lowcoder/src/comps/comps/richTextEditorComp.tsx b/client/packages/lowcoder/src/comps/comps/richTextEditorComp.tsx index 64ec62a952..979b7fbfa3 100644 --- a/client/packages/lowcoder/src/comps/comps/richTextEditorComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/richTextEditorComp.tsx @@ -171,7 +171,8 @@ const toolbarOptions = [ ]; const childrenMap = { - value: stringExposingStateControl("value"), + value: stringExposingStateControl("value"), + delta: stringExposingStateControl("delta"), hideToolbar: BoolControl, readOnly: BoolControl, autoHeight: withDefault(AutoHeightControl, "fixed"), @@ -194,7 +195,7 @@ interface IProps { hideToolbar: boolean; readOnly: boolean; autoHeight: boolean; - onChange: (value: string) => void; + onChange: (html: string, deltaJSON: string, text: string) => void; $style: RichTextEditorStyleType; contentScrollBar: boolean; tabIndex?: number; @@ -207,6 +208,28 @@ function RichTextEditor(props: IProps) { const [content, setContent] = useState(""); const wrapperRef = useRef(null); const editorRef = useRef(null); + + // know exactly when the editor mounts + const [editorReady, setEditorReady] = useState(false); + const setEditorRef = (node: ReactQuill | null) => { + (editorRef as any).current = node as any; + setEditorReady(!!node); + }; + + const getQuill = () => (editorRef.current as any)?.getEditor?.(); + + const tryParseDelta = (v: unknown) => { + if (!v) return null; + if (typeof v === "string") { + try { + const d = JSON.parse(v); + return Array.isArray(d?.ops) ? d : null; + } catch { return null; } + } + if (typeof v === "object" && Array.isArray((v as any).ops)) return v as any; + return null; + }; + const isTypingRef = useRef(0); const debounce = INPUT_DEFAULT_ONCHANGE_DEBOUNCE; @@ -214,8 +237,8 @@ function RichTextEditor(props: IProps) { const originOnChangeRef = useRef(props.onChange); originOnChangeRef.current = props.onChange; - const onChangeRef = useRef( - (v: string) => originOnChangeRef.current?.(v) + const onChangeRef = useRef((html: string, deltaJSON: string, text: string) => + originOnChangeRef.current?.(html, deltaJSON, text) ); // react-quill will not take effect after the placeholder is updated @@ -235,7 +258,7 @@ function RichTextEditor(props: IProps) { (editor.scroll.domNode as HTMLElement).tabIndex = props.tabIndex; } } - }, [props.tabIndex, key]); // Also re-run when key changes due to placeholder update + }, [props.tabIndex, key]); const contains = (parent: HTMLElement, descendant: HTMLElement) => { try { @@ -248,19 +271,26 @@ function RichTextEditor(props: IProps) { return parent.contains(descendant); }; - const handleChange = (value: string) => { - setContent(value); - // props.onChange(value); - onChangeRef.current(value); - }; useEffect(() => { - let finalValue = props.value; - if (!/^<\w+>.+<\/\w+>$/.test(props.value)) { - finalValue = `

${props.value}

`; + const q = getQuill(); + if (!q) { + return; + } + + const asDelta = tryParseDelta(props.value); + if (asDelta) { + q.setContents(asDelta, "api"); + const html = q.root?.innerHTML ?? ""; + setContent(html); + return; } - setContent(finalValue); - }, [props.value]); + const v = props.value ?? ""; + const looksHtml = /<\/?[a-z][\s\S]*>/i.test(v); + const html = looksHtml ? v : `

${v}

`; + setContent(html); + }, [props.value, editorReady]); + const handleClickWrapper = (e: React.MouseEvent) => { // grid item prevents bubbling, quill can't listen to events on document.body, so it can't close the toolbar drop-down box @@ -288,7 +318,7 @@ function RichTextEditor(props: IProps) { }> { + setContent(html); + const quill = editorRef.current?.getEditor?.(); + const fullDelta = quill?.getContents?.() ?? { ops: [] }; + const text = quill?.getText?.() ?? ""; + onChangeRef.current(html, JSON.stringify(fullDelta), text); + }} /> @@ -305,15 +341,47 @@ function RichTextEditor(props: IProps) { } const RichTextEditorCompBase = new UICompBuilder(childrenMap, (props) => { + const propsRef = useRef(props); + propsRef.current = props; + + // Local state to manage editor content + const [localValue, setLocalValue] = useState(props.value.value); + const isUserTyping = useRef(false); + + // Sync local state with props when they change externally (not from user typing) + useEffect(() => { + if (!isUserTyping.current) { + setLocalValue(props.value.value); + } + }, [props.value.value]); + const debouncedOnChangeRef = useRef( - debounce((value: string) => { - props.value.onChange(value); - props.onEvent("change"); + debounce((html: string, deltaJSON: string, text: string) => { + // Update delta first as it's the primary source of truth + propsRef.current.delta.onChange(deltaJSON); + // Update value with the HTML representation + propsRef.current.value.onChange(html); + propsRef.current.onEvent("change"); }, 1000) ); - const handleChange = (value: string) => { - debouncedOnChangeRef.current?.(value); + useEffect(() => { + return () => { + debouncedOnChangeRef.current?.cancel(); + }; + }, []); + + const handleChange = (html: string, deltaJSON: string, text: string) => { + // Mark that user is typing + isUserTyping.current = true; + // Update local state immediately for responsive UI + setLocalValue(html); + // Debounce the prop updates + debouncedOnChangeRef.current?.(html, deltaJSON, text); + // Reset the flag after a brief delay + setTimeout(() => { + isUserTyping.current = false; + }, 100); }; return ( @@ -322,7 +390,7 @@ const RichTextEditorCompBase = new UICompBuilder(childrenMap, (props) => { hideToolbar={props.hideToolbar} toolbar={props.toolbar} readOnly={props.readOnly} - value={props.value.value} + value={localValue} placeholder={props.placeholder} onChange={handleChange} $style={props.style} @@ -379,6 +447,7 @@ class RichTextEditorCompAutoHeight extends RichTextEditorCompBase { export const RichTextEditorComp = withExposingConfigs(RichTextEditorCompAutoHeight, [ new NameConfig("value", trans("export.richTextEditorValueDesc")), + new NameConfig("delta", trans("export.richTextEditorDeltaDesc")), new NameConfig("readOnly", trans("export.richTextEditorReadOnlyDesc")), new NameConfig("hideToolbar", trans("export.richTextEditorHideToolBarDesc")), NameConfigHidden, diff --git a/client/packages/lowcoder/src/comps/comps/rootComp.tsx b/client/packages/lowcoder/src/comps/comps/rootComp.tsx index 50fe1229ed..7f0f69f761 100644 --- a/client/packages/lowcoder/src/comps/comps/rootComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/rootComp.tsx @@ -100,7 +100,7 @@ const RootView = React.memo((props: RootViewProps) => { return (oldState ? changeEditorStateFn(oldState) : undefined) }); } - }); + }, undefined, isModuleRoot); editorStateRef.current = newEditorState; setEditorState(newEditorState); @@ -109,7 +109,7 @@ const RootView = React.memo((props: RootViewProps) => { editorStateRef.current = undefined; } }; - }, []); + }, [isModuleRoot]); useEffect(() => { if (!mountedRef.current || !editorState) return; diff --git a/client/packages/lowcoder/src/comps/comps/selectInputComp/cascaderComp.tsx b/client/packages/lowcoder/src/comps/comps/selectInputComp/cascaderComp.tsx index c71b85e2dd..662cd9d98f 100644 --- a/client/packages/lowcoder/src/comps/comps/selectInputComp/cascaderComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/selectInputComp/cascaderComp.tsx @@ -32,6 +32,9 @@ const DropdownRenderStyle = styled.div<{ $childrenInputFieldStyle: ChildrenMulti text-decoration: ${props => props.$childrenInputFieldStyle?.textDecoration}; color: ${props => props.$childrenInputFieldStyle?.text}; } + .ant-cascader-menu-item-active:not(.ant-cascader-menu-item-disabled) { + background-color: ${props => props.$childrenInputFieldStyle?.activeBackground ? `${props.$childrenInputFieldStyle.activeBackground}22` : 'rgb(242, 247, 252)'}; + } ` let CascaderBasicComp = (function () { @@ -56,6 +59,7 @@ let CascaderBasicComp = (function () { showSearch={props.showSearch} $style={props.inputFieldStyle} $childrenInputFieldStyle={props.childrenInputFieldStyle} + tabIndex={typeof props.tabIndex === 'number' ? props.tabIndex : undefined} onFocus={() => props.onEvent("focus")} onBlur={() => props.onEvent("blur")} popupRender={(menus: React.ReactNode) => ( diff --git a/client/packages/lowcoder/src/comps/comps/selectInputComp/cascaderContants.tsx b/client/packages/lowcoder/src/comps/comps/selectInputComp/cascaderContants.tsx index 330c94120d..d88289c880 100644 --- a/client/packages/lowcoder/src/comps/comps/selectInputComp/cascaderContants.tsx +++ b/client/packages/lowcoder/src/comps/comps/selectInputComp/cascaderContants.tsx @@ -1,7 +1,7 @@ import { SelectEventHandlerControl } from "../../controls/eventHandlerControl"; import { Section, sectionNames } from "lowcoder-design"; import { RecordConstructorToComp } from "lowcoder-core"; -import { BoolCodeControl, JSONObjectArrayControl, StringControl } from "comps/controls/codeControl"; +import { BoolCodeControl, JSONObjectArrayControl, StringControl, NumberControl } from "comps/controls/codeControl"; import { arrayStringExposingStateControl } from "comps/controls/codeStateControl"; import { BoolControl } from "comps/controls/boolControl"; import { LabelControl } from "comps/controls/labelControl"; @@ -43,7 +43,8 @@ export const CascaderChildren = { padding: PaddingControl, inputFieldStyle:styleControl(CascaderStyle , 'inputFieldStyle'), childrenInputFieldStyle:styleControl(ChildrenMultiSelectStyle), - animationStyle:styleControl(AnimationStyle ,'animationStyle') + animationStyle:styleControl(AnimationStyle ,'animationStyle'), + tabIndex: NumberControl }; export const CascaderPropertyView = ( @@ -62,6 +63,7 @@ export const CascaderPropertyView = ( {disabledPropertyView(children)} {hiddenPropertyView(children)} {showDataLoadingIndicatorsPropertyView(children as any)} + {children.tabIndex.propertyView({ label: trans("prop.tabIndex") })}
)} diff --git a/client/packages/lowcoder/src/comps/comps/selectInputComp/checkboxComp.tsx b/client/packages/lowcoder/src/comps/comps/selectInputComp/checkboxComp.tsx index 9a7abb8bd1..6f3cfec54b 100644 --- a/client/packages/lowcoder/src/comps/comps/selectInputComp/checkboxComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/selectInputComp/checkboxComp.tsx @@ -214,8 +214,20 @@ let CheckboxBasicComp = (function () { return new UICompBuilder(childrenMap, (props) => { const mountedRef = useRef(true); + const checkboxRef = useRef(null); const [validateState, handleChange] = useSelectInputValidate(props); + useEffect(() => { + if (!mountedRef.current) return; + if (checkboxRef.current && typeof props.tabIndex === 'number') { + const checkboxInputs = checkboxRef.current.querySelectorAll('input[type="checkbox"]'); + checkboxInputs.forEach((input, index) => { + // Set sequential tabindex for each checkbox + input.setAttribute('tabindex', (props.tabIndex + index).toString()); + }); + } + }, [props.tabIndex, props.options]); + useEffect(() => { return () => { mountedRef.current = false; @@ -251,7 +263,13 @@ let CheckboxBasicComp = (function () { layout={props.layout} options={filteredOptions()} onChange={handleValidateChange} - viewRef={props.viewRef} + viewRef={(el) => { + if (!mountedRef.current) return; + if (el) { + props.viewRef(el); + checkboxRef.current = el; + } + }} tabIndex={typeof props.tabIndex === 'number' ? props.tabIndex : undefined} /> ), diff --git a/client/packages/lowcoder/src/comps/comps/selectInputComp/multiSelectComp.tsx b/client/packages/lowcoder/src/comps/comps/selectInputComp/multiSelectComp.tsx index 8380c56722..2527d57bd4 100644 --- a/client/packages/lowcoder/src/comps/comps/selectInputComp/multiSelectComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/selectInputComp/multiSelectComp.tsx @@ -30,7 +30,7 @@ let MultiSelectBasicComp = (function () { padding: PaddingControl, }; return new UICompBuilder(childrenMap, (props, dispatch) => { - const valueSet = new Set(props.options.map((o) => o.value)); // Filter illegal default values entered by the user + const valueSet = new Set((props.options as any[]).map((o: any) => o.value)); // Filter illegal default values entered by the user const [ validateState, handleChange, diff --git a/client/packages/lowcoder/src/comps/comps/selectInputComp/selectComp.tsx b/client/packages/lowcoder/src/comps/comps/selectInputComp/selectComp.tsx index eef8cad608..a2415b4a58 100644 --- a/client/packages/lowcoder/src/comps/comps/selectInputComp/selectComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/selectInputComp/selectComp.tsx @@ -39,7 +39,7 @@ let SelectBasicComp = (function () { const propsRef = useRef>(props); propsRef.current = props; - const valueSet = new Set(props.options.map((o) => o.value)); // Filter illegal default values entered by the user + const valueSet = new Set((props.options as any[]).map((o: any) => o.value)); // Filter illegal default values entered by the user return props.label({ required: props.required, diff --git a/client/packages/lowcoder/src/comps/comps/selectInputComp/selectCompConstants.tsx b/client/packages/lowcoder/src/comps/comps/selectInputComp/selectCompConstants.tsx index a5d55ca935..08d7690fd9 100644 --- a/client/packages/lowcoder/src/comps/comps/selectInputComp/selectCompConstants.tsx +++ b/client/packages/lowcoder/src/comps/comps/selectInputComp/selectCompConstants.tsx @@ -6,7 +6,7 @@ import { } from "lowcoder-core"; import { BoolControl } from "../../controls/boolControl"; import { LabelControl } from "../../controls/labelControl"; -import { BoolCodeControl, StringControl } from "../../controls/codeControl"; +import { BoolCodeControl, StringControl, NumberControl } from "../../controls/codeControl"; import { PaddingControl } from "../../controls/paddingControl"; import { MarginControl } from "../../controls/marginControl"; import { @@ -217,6 +217,12 @@ export const DropdownStyled = styled.div<{ $style: ChildrenMultiSelectStyleType min-width: 14px; margin-right: 0; } + .ant-select-item-option-selected:not(.ant-select-item-option-disabled) { + background-color: ${props => props.$style?.selectBackground ? `${props.$style.selectBackground}` : 'transparent'}; + } + .ant-select-item-option-active:not(.ant-select-item-option-disabled) { + background-color: ${props => props.$style?.activeBackground ? `${props.$style.activeBackground}` : 'transparent'}; + } `; const Wrapper = styled.span` @@ -242,6 +248,7 @@ export const SelectChildrenMap = { margin: MarginControl, padding: PaddingControl, inputFieldStyle:styleControl(SelectStyle), + tabIndex: NumberControl, ...SelectInputValidationChildren, ...formDataChildren, }; @@ -269,6 +276,7 @@ export const SelectUIView = ( placeholder={props.placeholder} value={props.value} showSearch={props.showSearch} + tabIndex={typeof props.tabIndex === 'number' ? props.tabIndex : undefined} filterOption={(input, option) => { if (!option) return false; return String(option.label ?? option.value ?? "").toLowerCase().includes(input.toLowerCase()); @@ -348,6 +356,7 @@ export const SelectPropertyView = ( {disabledPropertyView(children)} {hiddenPropertyView(children)} {showDataLoadingIndicatorsPropertyView(children as any)} + {children.tabIndex.propertyView({ label: trans("prop.tabIndex") })}
)} diff --git a/client/packages/lowcoder/src/comps/comps/selectInputComp/selectInputConstants.tsx b/client/packages/lowcoder/src/comps/comps/selectInputComp/selectInputConstants.tsx index 290f3628d5..d7b5648e17 100644 --- a/client/packages/lowcoder/src/comps/comps/selectInputComp/selectInputConstants.tsx +++ b/client/packages/lowcoder/src/comps/comps/selectInputComp/selectInputConstants.tsx @@ -128,7 +128,8 @@ export const SelectInputValidationSection = (children: ValidationComp) => ( label: trans("prop.showEmptyValidation"), })} {children.allowCustomTags.propertyView({ - label: trans("prop.customTags") + label: trans("prop.customTags"), + tooltip: trans("prop.customTagsTooltip") })} {children.customRule.propertyView({})} diff --git a/client/packages/lowcoder/src/comps/comps/tableComp/ResizeableTable.tsx b/client/packages/lowcoder/src/comps/comps/tableComp/ResizeableTable.tsx new file mode 100644 index 0000000000..b85c16afbb --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableComp/ResizeableTable.tsx @@ -0,0 +1,229 @@ +import { default as Table, TableProps, ColumnType } from "antd/es/table"; +import React, { useCallback, useMemo, useRef, useState } from "react"; +import { Resizable } from "react-resizable"; +import styled from "styled-components"; +import _ from "lodash"; +import { useUserViewMode } from "util/hooks"; +import { ReactRef, ResizeHandleAxis } from "layout/gridLayoutPropTypes"; +import { COL_MIN_WIDTH, RecordType, CustomColumnType } from "./tableUtils"; +import { RowColorViewType, RowHeightViewType } from "./tableTypes"; +import { TableColumnStyleType, TableColumnLinkStyleType, TableRowStyleType } from "comps/controls/styleControlConstants"; +import { CellColorViewType } from "./column/tableColumnComp"; +import { TableCellView } from "./TableCell"; +import { TableRowView } from "./TableRow"; + +const TitleResizeHandle = styled.span` + position: absolute; + top: 0; + right: -5px; + width: 10px; + height: 100%; + cursor: col-resize; + z-index: 1; +`; + +const TableTh = styled.th<{ width?: number }>` + overflow: hidden; + + > div { + overflow: hidden; + white-space: pre; + text-overflow: ellipsis; + } + + ${(props) => props.width && `width: ${props.width}px`}; +`; + +const ResizeableTitle = React.forwardRef((props, ref) => { + const { onResize, onResizeStop, width, viewModeResizable, ...restProps } = props; + const [childWidth, setChildWidth] = useState(0); + const resizeRef = useRef(null); + const isUserViewMode = useUserViewMode(); + + const updateChildWidth = useCallback(() => { + if (resizeRef.current) { + const width = resizeRef.current.getBoundingClientRect().width; + setChildWidth(width); + } + }, []); + + React.useEffect(() => { + updateChildWidth(); + const resizeObserver = new ResizeObserver(() => { + updateChildWidth(); + }); + + if (resizeRef.current) { + resizeObserver.observe(resizeRef.current); + } + + return () => { + resizeObserver.disconnect(); + }; + }, [updateChildWidth]); + + React.useImperativeHandle(ref, () => resizeRef.current!, []); + + const isNotDataColumn = _.isNil(restProps.title); + if ((isUserViewMode && !viewModeResizable) || isNotDataColumn) { + return ; + } + + return ( + 0 ? width : childWidth} + height={0} + onResize={(e: React.SyntheticEvent, { size }: { size: { width: number } }) => { + e.stopPropagation(); + onResize(size.width); + }} + onResizeStart={(e: React.SyntheticEvent) => { + updateChildWidth(); + e.stopPropagation(); + e.preventDefault(); + }} + onResizeStop={onResizeStop} + draggableOpts={{ enableUserSelectHack: false }} + handle={(axis: ResizeHandleAxis, ref: ReactRef) => ( + { + e.preventDefault(); + e.stopPropagation(); + }} + /> + )} + > + + + ); +}); + +type CustomTableProps = Omit, "components" | "columns"> & { + columns: CustomColumnType[]; + viewModeResizable: boolean; + visibleResizables: boolean; + rowColorFn: RowColorViewType; + rowHeightFn: RowHeightViewType; + columnsStyle: TableColumnStyleType; + rowStyle: TableRowStyleType; + size?: string; + rowAutoHeight?: boolean; + customLoading?: boolean; + onCellClick: (columnName: string, dataIndex: string) => void; + virtual?: boolean; + scroll?: { + x?: number | string; + y?: number | string; + }; +}; + +function ResizeableTableComp(props: CustomTableProps) { + const { + columns, + viewModeResizable, + visibleResizables, + rowColorFn, + rowHeightFn, + columnsStyle, + rowStyle, + size, + rowAutoHeight, + customLoading, + onCellClick, + ...restProps + } = props; + const [resizeData, setResizeData] = useState({ index: -1, width: -1 }); + + // Memoize resize handlers + const handleResize = useCallback((width: number, index: number) => { + setResizeData({ index, width }); + }, []); + + const handleResizeStop = useCallback((width: number, index: number, onWidthResize?: (width: number) => void) => { + setResizeData({ index: -1, width: -1 }); + if (onWidthResize) { + onWidthResize(width); + } + }, []); + + // Memoize cell handlers + const createCellHandler = useCallback((col: CustomColumnType) => { + return (record: RecordType, index: number) => ({ + record, + title: String(col.dataIndex), + rowColorFn, + rowHeightFn, + cellColorFn: col.cellColorFn, + rowIndex: index, + columnsStyle, + columnStyle: col.style, + rowStyle: rowStyle, + linkStyle: col.linkStyle, + tableSize: size, + autoHeight: rowAutoHeight, + onClick: () => onCellClick(col.titleText, String(col.dataIndex)), + loading: customLoading, + customAlign: col.align, + className: col.columnClassName, + 'data-testid': col.columnDataTestId, + }); + }, [rowColorFn, rowHeightFn, columnsStyle, size, rowAutoHeight, onCellClick, customLoading]); + + // Memoize header cell handlers + const createHeaderCellHandler = useCallback((col: CustomColumnType, index: number, resizeWidth: number) => { + return () => ({ + width: resizeWidth, + title: col.titleText, + viewModeResizable, + onResize: (width: React.SyntheticEvent) => { + if (width) { + handleResize(Number(width), index); + } + }, + onResizeStop: (e: React.SyntheticEvent, { size }: { size: { width: number } }) => { + handleResizeStop(size.width, index, col.onWidthResize); + }, + className: col.columnClassName, + 'data-testid': col.columnDataTestId, + }); + }, [viewModeResizable, handleResize, handleResizeStop]); + + // Memoize columns to prevent unnecessary re-renders + const memoizedColumns = useMemo(() => { + return columns.map((col, index) => { + const { width, style, linkStyle, cellColorFn, onWidthResize, ...restCol } = col; + const resizeWidth = (resizeData.index === index ? resizeData.width : col.width) ?? 0; + + const column: ColumnType = { + ...restCol, + width: typeof resizeWidth === "number" && resizeWidth > 0 ? resizeWidth : undefined, + minWidth: typeof resizeWidth === "number" && resizeWidth > 0 ? undefined : COL_MIN_WIDTH, + onCell: (record: RecordType, index?: number) => createCellHandler(col)(record, index ?? 0), + onHeaderCell: () => createHeaderCellHandler(col, index, Number(resizeWidth))(), + }; + return column; + }); + }, [columns, resizeData, createCellHandler, createHeaderCellHandler]); + + return ( + + components={{ + header: { + cell: ResizeableTitle, + }, + body: { + cell: TableCellView, + row: TableRowView, + }, + }} + {...restProps} + pagination={false} + columns={memoizedColumns} + /> + ); +} +ResizeableTableComp.whyDidYouRender = true; + +export const ResizeableTable = React.memo(ResizeableTableComp) as typeof ResizeableTableComp; +export type { CustomTableProps }; diff --git a/client/packages/lowcoder/src/comps/comps/tableComp/TableCell.tsx b/client/packages/lowcoder/src/comps/comps/tableComp/TableCell.tsx new file mode 100644 index 0000000000..cc46bfe056 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableComp/TableCell.tsx @@ -0,0 +1,240 @@ +import React, { useContext, useMemo, useState } from "react"; +import styled, { css } from "styled-components"; +import { TableCellContext, TableRowContext } from "./tableContext"; +import { TableColumnStyleType, TableColumnLinkStyleType, ThemeDetail, TableRowStyleType } from "comps/controls/styleControlConstants"; +import { RowColorViewType, RowHeightViewType } from "./tableTypes"; +import { CellColorViewType } from "./column/tableColumnComp"; +import { RecordType, OB_ROW_ORI_INDEX } from "./tableUtils"; +import { defaultTheme } from "@lowcoder-ee/constants/themeConstants"; +import Skeleton from "antd/es/skeleton"; +import { SkeletonButtonProps } from "antd/es/skeleton/Button"; +import { isTransparentColor } from "lowcoder-design"; + +interface TableTdProps { + $background: string; + $style: TableColumnStyleType & { rowHeight?: string }; + $defaultThemeDetail: ThemeDetail; + $linkStyle?: TableColumnLinkStyleType; + $isEditing: boolean; + $tableSize?: string; + $autoHeight?: boolean; + $customAlign?: 'left' | 'center' | 'right'; +} + +const TableTd = styled.td` + .ant-table-row-expand-icon, + .ant-table-row-indent { + display: ${(props) => (props.$isEditing ? "none" : "initial")}; + } + &.ant-table-row-expand-icon-cell { + background: ${(props) => props.$background}; + border-color: ${(props) => props.$style.border}; + } + background: ${(props) => props.$background} !important; + border-color: ${(props) => props.$style.border} !important; + border-radius: ${(props) => props.$style.radius}; + padding: 0 !important; + text-align: ${(props) => props.$customAlign || 'left'} !important; + + > div:not(.editing-border, .editing-wrapper), + .editing-wrapper .ant-input, + .editing-wrapper .ant-input-number, + .editing-wrapper .ant-picker { + margin: ${(props) => props.$isEditing ? '0px' : props.$style.margin}; + color: ${(props) => props.$style.text}; + font-weight: ${(props) => props.$style.textWeight}; + font-family: ${(props) => props.$style.fontFamily}; + overflow: hidden; + display: flex; + justify-content: ${(props) => props.$customAlign === 'center' ? 'center' : props.$customAlign === 'right' ? 'flex-end' : 'flex-start'}; + align-items: center; + text-align: ${(props) => props.$customAlign || 'left'}; + box-sizing: border-box; + ${(props) => props.$tableSize === 'small' && ` + padding: 1px 8px; + font-size: ${props.$defaultThemeDetail.textSize == props.$style.textSize ? '14px !important' : props.$style.textSize + ' !important'}; + font-style:${props.$style.fontStyle} !important; + min-height: ${props.$style.rowHeight || '14px'}; + line-height: 20px; + ${!props.$autoHeight && ` + overflow-y: auto; + max-height: ${props.$style.rowHeight || '28px'}; + `}; + `}; + ${(props) => props.$tableSize === 'middle' && ` + padding: 8px 8px; + font-size: ${props.$defaultThemeDetail.textSize == props.$style.textSize ? '16px !important' : props.$style.textSize + ' !important'}; + font-style:${props.$style.fontStyle} !important; + min-height: ${props.$style.rowHeight || '24px'}; + line-height: 24px; + ${!props.$autoHeight && ` + overflow-y: auto; + max-height: ${props.$style.rowHeight || '48px'}; + `}; + `}; + ${(props) => props.$tableSize === 'large' && ` + padding: 16px 16px; + font-size: ${props.$defaultThemeDetail.textSize == props.$style.textSize ? '18px !important' : props.$style.textSize + ' !important'}; + font-style:${props.$style.fontStyle} !important; + min-height: ${props.$style.rowHeight || '48px'}; + ${!props.$autoHeight && ` + overflow-y: auto; + max-height: ${props.$style.rowHeight || '96px'}; + `}; + `}; + + > .ant-badge > .ant-badge-status-text, + > div > .markdown-body { + color: ${(props) => props.$style.text}; + } + + > div > svg g { + stroke: ${(props) => props.$style.text}; + } + + // dark link|links color + > a, + > div a { + color: ${(props) => props.$linkStyle?.text}; + + &:hover { + color: ${(props) => props.$linkStyle?.hoverText}; + } + + &:active { + color: ${(props) => props.$linkStyle?.activeText}; + } + } + } +`; + +const TableTdLoading = styled(Skeleton.Button)` + width: 90% !important; + display: table !important; + + .ant-skeleton-button { + min-width: auto !important; + display: block !important; + ${(props) => props.$tableSize === 'small' && ` + height: 20px !important; + `} + ${(props) => props.$tableSize === 'middle' && ` + height: 24px !important; + `} + ${(props) => props.$tableSize === 'large' && ` + height: 28px !important; + `} + } +`; + +export const TableCellView = React.forwardRef((props, ref) => { + const { + record, + title, + rowIndex, + rowColorFn, + rowHeightFn, + cellColorFn, + children, + columnsStyle, + columnStyle, + rowStyle, + linkStyle, + tableSize, + autoHeight, + loading, + customAlign, + ...restProps + } = props; + + const [editing, setEditing] = useState(false); + const rowContext = useContext(TableRowContext); + + // Memoize style calculations + const style = useMemo(() => { + if (!record) return null; + const rowColor = rowColorFn({ + currentRow: record, + currentIndex: rowIndex, + currentOriginalIndex: record[OB_ROW_ORI_INDEX], + columnTitle: title, + }); + const rowHeight = rowHeightFn({ + currentRow: record, + currentIndex: rowIndex, + currentOriginalIndex: record[OB_ROW_ORI_INDEX], + columnTitle: title, + }); + const cellColor = cellColorFn({ + currentCell: record[title], + currentRow: record, + }); + + return { + background: cellColor || rowColor || columnStyle.background || columnsStyle.background, + margin: columnStyle.margin || columnsStyle.margin, + text: columnStyle.text || columnsStyle.text, + border: columnStyle.border || columnsStyle.border, + radius: columnStyle.radius || columnsStyle.radius, + // borderWidth: columnStyle.borderWidth || columnsStyle.borderWidth, + textSize: columnStyle.textSize || columnsStyle.textSize, + textWeight: columnsStyle.textWeight || columnStyle.textWeight, + fontFamily: columnsStyle.fontFamily || columnStyle.fontFamily, + fontStyle: columnsStyle.fontStyle || columnStyle.fontStyle, + rowHeight: rowHeight, + }; + }, [record, rowIndex, title, rowColorFn, rowHeightFn, cellColorFn, columnStyle, columnsStyle]); + + if (!record) { + return ( + + {children} + + ); + } + + let { background } = style!; + if (rowContext.hover && !isTransparentColor(rowStyle.hoverRowBackground)) { + background = 'transparent'; + } + + return ( + + + {loading + ? + : children + } + + + ); +}); diff --git a/client/packages/lowcoder/src/comps/comps/tableComp/TableRow.tsx b/client/packages/lowcoder/src/comps/comps/tableComp/TableRow.tsx new file mode 100644 index 0000000000..84dbbc3808 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableComp/TableRow.tsx @@ -0,0 +1,27 @@ +import React, { useCallback, useState } from "react"; +import { TableRowContext } from "./tableContext"; + +export const TableRowView = React.forwardRef((props, ref) => { + const [hover, setHover] = useState(false); + const [selected, setSelected] = useState(false); + + // Memoize event handlers + const handleMouseEnter = useCallback(() => setHover(true), []); + const handleMouseLeave = useCallback(() => setHover(false), []); + const handleFocus = useCallback(() => setSelected(true), []); + const handleBlur = useCallback(() => setSelected(false), []); + + return ( + + + + ); +}); diff --git a/client/packages/lowcoder/src/comps/comps/tableComp/column/columnTypeComp.tsx b/client/packages/lowcoder/src/comps/comps/tableComp/column/columnTypeComp.tsx index ff3df44c4e..07c31de474 100644 --- a/client/packages/lowcoder/src/comps/comps/tableComp/column/columnTypeComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/tableComp/column/columnTypeComp.tsx @@ -22,6 +22,8 @@ import { ColumnNumberComp } from "./columnTypeComps/ColumnNumberComp"; import { ColumnAvatarsComp } from "./columnTypeComps/columnAvatarsComp"; import { ColumnDropdownComp } from "./columnTypeComps/columnDropdownComp"; +import { ColumnPasswordComp } from "./columnTypeComps/columnPasswordComp"; +import { ColumnMultilineTextComp } from "./columnTypeComps/columnMultilineTextComp"; const actionOptions = [ { @@ -101,6 +103,14 @@ const actionOptions = [ label: trans("table.progress"), value: "progress", }, + { + label: "Password", + value: "password", + }, + { + label: trans("table.multilineText"), + value: "multilineText", + }, ] as const; export const ColumnTypeCompMap = { @@ -123,6 +133,8 @@ export const ColumnTypeCompMap = { progress: ProgressComp, date: DateComp, time: TimeComp, + password: ColumnPasswordComp, + multilineText: ColumnMultilineTextComp, }; type ColumnTypeMapType = typeof ColumnTypeCompMap; diff --git a/client/packages/lowcoder/src/comps/comps/tableComp/column/columnTypeComps/columnBooleanComp.tsx b/client/packages/lowcoder/src/comps/comps/tableComp/column/columnTypeComps/columnBooleanComp.tsx index d1d530eb6c..1cc26a5eae 100644 --- a/client/packages/lowcoder/src/comps/comps/tableComp/column/columnTypeComps/columnBooleanComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/tableComp/column/columnTypeComps/columnBooleanComp.tsx @@ -64,6 +64,7 @@ type CheckBoxEditPropsType = { value: boolean; onChange: (value: boolean) => void; onChangeEnd: () => void; + onImmediateSave?: (value: boolean) => void; }; // Memoized checkbox edit component @@ -92,8 +93,13 @@ const CheckBoxEdit = React.memo((props: CheckBoxEditPropsType) => { const handleChange = useCallback((e: CheckboxChangeEvent) => { if (!mountedRef.current) return; - props.onChange(e.target.checked); - }, [props.onChange]); + const newValue = e.target.checked; + props.onChange(newValue); + // Use immediate save to show Save Changes button without exiting edit mode + if (props.onImmediateSave) { + props.onImmediateSave(newValue); + } + }, [props.onChange, props.onImmediateSave]); return ( ); }) diff --git a/client/packages/lowcoder/src/comps/comps/tableComp/column/columnTypeComps/columnMultilineTextComp.tsx b/client/packages/lowcoder/src/comps/comps/tableComp/column/columnTypeComps/columnMultilineTextComp.tsx new file mode 100644 index 0000000000..5006e86f3f --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableComp/column/columnTypeComps/columnMultilineTextComp.tsx @@ -0,0 +1,105 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { default as AntdModal } from "antd/es/modal"; +import { default as Input } from "antd/es/input"; +import { StringOrNumberControl } from "comps/controls/codeControl"; +import { trans } from "i18n"; +import { ColumnTypeCompBuilder, ColumnTypeViewFn } from "../columnTypeCompBuilder"; +import { ColumnValueTooltip } from "../simpleColumnTypeComps"; +import { RecordConstructorToComp } from "lowcoder-core"; +import styled from "styled-components"; + +const { TextArea } = Input; + +const TextView = styled.div` + white-space: pre-wrap; + word-break: break-word; + cursor: pointer; +`; + +const childrenMap = { + text: StringOrNumberControl, +}; + +const getBaseValue: ColumnTypeViewFn = (props) => + typeof props.text === "string" ? props.text : String(props.text); + +const MultilineContent = React.memo(({ value }: { value: string }) => {value}); + +const MultilineEditModal = React.memo((props: { + value: string; + onCommit: (value: string) => void; + onCancel: () => void; +}) => { + const { value, onCommit, onCancel } = props; + const [localValue, setLocalValue] = useState(value); + const textAreaRef = useRef(null); + + useEffect(() => { + setLocalValue(value); + }, [value]); + + useEffect(() => { + const timeout = setTimeout(() => textAreaRef.current?.focus({ cursor: "end" }), 0); + return () => clearTimeout(timeout); + }, []); + + const handleSave = useCallback(() => { + onCommit(localValue); + }, [localValue, onCommit]); + + const handleCancel = useCallback(() => { + onCancel(); + }, [onCancel]); + + return ( + +