diff --git a/.github/workflows/deploy-lowcoder-sdk-webpack-netlify.yml b/.github/workflows/deploy-lowcoder-sdk-webpack-netlify.yml new file mode 100644 index 000000000..13f64a674 --- /dev/null +++ b/.github/workflows/deploy-lowcoder-sdk-webpack-netlify.yml @@ -0,0 +1,55 @@ +# Builds client/packages/lowcoder-sdk-webpack-bundle and deploys its dist/ folder to Netlify. +# +# Deploy uses --no-build so Netlify CLI does not run the site UI "build command" (e.g. expo). +# The webpack bundle is built in the prior CI step. +# +# Repository secrets (Netlify: Site settings → General → Site details → Site ID; +# User settings → Applications → Personal access tokens): +# NETLIFY_AUTH_TOKEN — Netlify personal access token +# NETLIFY_SITE_ID — Site API ID for the Netlify site + +name: Deploy SDK Webpack Bundle to Netlify + +on: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: deploy-sdk-webpack-netlify-${{ github.ref }} + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Use Node.js + uses: actions/setup-node@v3 + with: + node-version: 20.x + cache: yarn + cache-dependency-path: client/yarn.lock + + - name: Install dependencies + uses: borales/actions-yarn@v4.2.0 + with: + cmd: install + dir: client + + - name: Build lowcoder-sdk-webpack-bundle + uses: borales/actions-yarn@v4.2.0 + with: + cmd: workspace lowcoder-sdk-webpack-bundle build + dir: client + + - name: Deploy dist to Netlify + working-directory: client/packages/lowcoder-sdk-webpack-bundle + env: + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SDK_SITE_ID }} + run: npx --yes netlify-cli deploy --prod --dir=dist --no-build diff --git a/client/packages/lowcoder-comps/src/comps/calendarComp/calendarComp.tsx b/client/packages/lowcoder-comps/src/comps/calendarComp/calendarComp.tsx index 9bf7a4a6c..bc726a327 100644 --- a/client/packages/lowcoder-comps/src/comps/calendarComp/calendarComp.tsx +++ b/client/packages/lowcoder-comps/src/comps/calendarComp/calendarComp.tsx @@ -215,6 +215,7 @@ let childrenMap: any = { updatedEvents: stateComp({}), insertedEvents: stateComp({}), deletedEvents: stateComp({}), + selectedEvent: stateComp({}), inputFormat: withDefault(StringControl, DATE_TIME_FORMAT), }; @@ -999,6 +1000,14 @@ let CalendarBasicComp = (function () { const event = events.find( (item: EventInput) => item.id === info.event.id ); + // Find original event from props.events to include all custom fields (e.g., join_url) + const originalEvent = props.events.find( + (item: EventType) => String(item.id) === String(info.event.id) + ); + // Update selectedEvent state with all original data + comp?.children?.comp?.children?.selectedEvent?.dispatchChangeValueAction?.( + originalEvent || event || {} + ); editEvent.current = event; setTimeout(() => { editEvent.current = undefined; @@ -1228,6 +1237,14 @@ const TmpCalendarComp = withExposingConfigs(CalendarBasicComp, [ return input.deletedEvents; }, }), + depsConfig({ + name: "selectedEvent", + desc: trans("calendar.selectedEvent"), + depKeys: ["selectedEvent"], + func: (input: { selectedEvent: any; }) => { + return input.selectedEvent; + }, + }), ]); let CalendarComp = withMethodExposing(TmpCalendarComp, [ diff --git a/client/packages/lowcoder-comps/src/comps/pieChartComp/pieChartUtils.ts b/client/packages/lowcoder-comps/src/comps/pieChartComp/pieChartUtils.ts index 545393339..c72fe4a32 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/i18n/comps/locales/en.ts b/client/packages/lowcoder-comps/src/i18n/comps/locales/en.ts index 66b62f7a1..e663e986a 100644 --- a/client/packages/lowcoder-comps/src/i18n/comps/locales/en.ts +++ b/client/packages/lowcoder-comps/src/i18n/comps/locales/en.ts @@ -767,6 +767,7 @@ export const en = { deletedEvents : "List of deleted events", updatedEvents : "List of updated events", insertedEvents : "List of inserted events", + selectedEvent : "The currently selected/clicked event", editable: "Editable", license: "Licence Key", licenseTooltip: "Get your licence key from https://fullcalendar.io/purchase to enable premium views like Resource Timeline and Resource Grid.", diff --git a/client/packages/lowcoder/src/components/PermissionDialog/DatasourcePermissionDialog.tsx b/client/packages/lowcoder/src/components/PermissionDialog/DatasourcePermissionDialog.tsx index 41273467a..4f11a03d7 100644 --- a/client/packages/lowcoder/src/components/PermissionDialog/DatasourcePermissionDialog.tsx +++ b/client/packages/lowcoder/src/components/PermissionDialog/DatasourcePermissionDialog.tsx @@ -14,6 +14,44 @@ import { getDataSourcePermissionInfo } from "../../redux/selectors/datasourceSel 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; @@ -85,6 +123,19 @@ export const DatasourcePermissionDialog = (props: { } 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/ThemeSettingsSelector.tsx b/client/packages/lowcoder/src/components/ThemeSettingsSelector.tsx index f2004d43a..67cbb9c20 100644 --- a/client/packages/lowcoder/src/components/ThemeSettingsSelector.tsx +++ b/client/packages/lowcoder/src/components/ThemeSettingsSelector.tsx @@ -254,7 +254,7 @@ export default function ThemeSettingsSelector(props: ColorConfigProps) { }; const gridPaddingInputBlur = (padding: string) => { - let result = 20; + let result = 0; if (padding !== '') { result = Number(padding); } diff --git a/client/packages/lowcoder/src/components/table/EditableCell.tsx b/client/packages/lowcoder/src/components/table/EditableCell.tsx index b88616744..4bc15fedc 100644 --- a/client/packages/lowcoder/src/components/table/EditableCell.tsx +++ b/client/packages/lowcoder/src/components/table/EditableCell.tsx @@ -54,6 +54,8 @@ 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; @@ -152,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", @@ -164,10 +166,26 @@ function EditableCellComp(props: EditableCellProps) { false ) ); - if(!_.isEqual(tmpValue, value)) { + if(!_.isEqual(finalValue, value)) { onTableEvent?.('columnEdited'); } - }, [dispatch, tmpValue, baseValue, value, onTableEvent, setIsEditing]); + }, [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; @@ -187,8 +205,8 @@ function EditableCellComp(props: EditableCellProps) { }, [dispatch, baseValue, value, onTableEvent]); const editView = useMemo( - () => editViewFn?.({ value, onChange, onChangeEnd, onImmediateSave, otherProps }) ?? <>, - [editViewFn, value, onChange, onChangeEnd, onImmediateSave, otherProps] + () => editViewFn?.({ value, onChange, onChangeEnd, onCommit, onCancel, onImmediateSave, otherProps }) ?? <>, + [editViewFn, value, onChange, onChangeEnd, onCommit, onCancel, onImmediateSave, otherProps] ); const enterEditFn = useCallback(() => { @@ -243,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/appSettingsComp.tsx b/client/packages/lowcoder/src/comps/comps/appSettingsComp.tsx index 64122daba..b38260eed 100644 --- a/client/packages/lowcoder/src/comps/comps/appSettingsComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/appSettingsComp.tsx @@ -219,8 +219,8 @@ const childrenMap = { gridColumns: RangeControl.closed(1, 48, 24), gridRowHeight: RangeControl.closed(4, 100, 8), gridRowCount: withDefault(NumberControl, DEFAULT_ROW_COUNT), - gridPaddingX: withDefault(NumberControl, 20), - gridPaddingY: withDefault(NumberControl, 20), + gridPaddingX: withDefault(NumberControl, 0), + gridPaddingY: withDefault(NumberControl, 0), gridBg: ColorControl, gridBgImage: StringControl, gridBgImageRepeat: StringControl, @@ -342,6 +342,10 @@ function AppGeneralSettingsModal(props: ChildrenInstance) { function AppCanvasSettingsModal(props: ChildrenInstance) { const isPublicApp = useSelector(isPublicApplication); + const application = useSelector(currentApplication); + const isAggregation = !!application && isAggregationApp( + AppUILayoutType[application.applicationType] + ); const { themeList, defaultTheme, @@ -397,7 +401,7 @@ function AppCanvasSettingsModal(props: ChildrenInstance) { return ( <> - {maxWidth.propertyView({ + {!isAggregation && maxWidth.propertyView({ dropdownLabel: trans("appSetting.canvasMaxWidth"), inputLabel: trans("appSetting.userDefinedMaxWidth"), inputPlaceholder: trans("appSetting.inputUserDefinedPxValue"), @@ -462,25 +466,25 @@ function AppCanvasSettingsModal(props: ChildrenInstance) { min: 350, lastNode: {trans("appSetting.maxWidthTip")}, })} - {gridColumns.propertyView({ + {!isAggregation && gridColumns.propertyView({ label: trans("appSetting.gridColumns"), placeholder: '24', })} - {gridRowHeight.propertyView({ + {!isAggregation && gridRowHeight.propertyView({ label: trans("appSetting.gridRowHeight"), placeholder: '8', })} - {gridRowCount.propertyView({ + {!isAggregation && gridRowCount.propertyView({ label: trans("appSetting.gridRowCount"), placeholder: 'Infinity', })} {gridPaddingX.propertyView({ label: trans("appSetting.gridPaddingX"), - placeholder: '20', + placeholder: '0', })} {gridPaddingY.propertyView({ label: trans("appSetting.gridPaddingY"), - placeholder: '20', + placeholder: '0', })} {gridBg.propertyView({ label: trans("style.background"), diff --git a/client/packages/lowcoder/src/comps/comps/columnLayout/columnLayout.tsx b/client/packages/lowcoder/src/comps/comps/columnLayout/columnLayout.tsx index c457ba4c0..2fc3fbdd6 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/dateComp/dateComp.tsx b/client/packages/lowcoder/src/comps/comps/dateComp/dateComp.tsx index ea6f37f5a..d5d0359de 100644 --- a/client/packages/lowcoder/src/comps/comps/dateComp/dateComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/dateComp/dateComp.tsx @@ -282,9 +282,6 @@ const DatePickerTmpCmp = new UICompBuilder(childrenMap, (props) => { props.onEvent ); }} - onPanelChange={() => { - handleDateChange("", props.value.onChange, noop); - }} onFocus={() => props.onEvent("focus")} onBlur={() => props.onEvent("blur")} suffixIcon={hasIcon(props.suffixIcon) && props.suffixIcon} diff --git a/client/packages/lowcoder/src/comps/comps/dateComp/dateCompUtil.ts b/client/packages/lowcoder/src/comps/comps/dateComp/dateCompUtil.ts index 16bc634ed..ed0b794ec 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 65677b63b..7e0c7bd5d 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( , ['value']; onChange: DatePickerProps['onChange']; - onPanelChange: () => void; + onPanelChange?: () => void; onClickDateTimeZone:(value:any)=>void; tabIndex?: number; $disabledStyle?: DisabledInputStyleType; @@ -67,15 +67,15 @@ const DateMobileUIView = React.lazy(() => export const DateUIView = (props: DataUIViewProps) => { const editorState = useContext(EditorContext); - const placeholder = Array.isArray(props.placeholder) ? props.placeholder[0] : props.placeholder; + return useUIView( , import("react-webcam")); export const ImageCaptureModal = (props: { showModal: boolean; + captureResolution?: CaptureResolution; onModalClose: () => void; onImageCapture: (image: string) => void; }) => { const [errMessage, setErrMessage] = useState(""); - const [videoConstraints, setVideoConstraints] = useState({ - facingMode: "environment", - }); + 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(""); - setVideoConstraints({ facingMode: "environment" }); + setSelectedDeviceId(null); setDropdownShow(false); } }, [props.showModal]); @@ -125,6 +135,8 @@ export const ImageCaptureModal = (props: { ref={webcamRef} onUserMediaError={handleMediaErr} screenshotFormat="image/jpeg" + screenshotQuality={1} + forceScreenshotSourceSize videoConstraints={videoConstraints} /> @@ -172,7 +184,7 @@ export const ImageCaptureModal = (props: { { - setVideoConstraints({ deviceId: { exact: value.key } }); + setSelectedDeviceId(value.key); setDropdownShow(false); }} /> diff --git a/client/packages/lowcoder/src/comps/comps/fileComp/draggerUpload.tsx b/client/packages/lowcoder/src/comps/comps/fileComp/draggerUpload.tsx index 2f230ad38..19aef9aec 100644 --- a/client/packages/lowcoder/src/comps/comps/fileComp/draggerUpload.tsx +++ b/client/packages/lowcoder/src/comps/comps/fileComp/draggerUpload.tsx @@ -1,7 +1,7 @@ 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, useEffect } from "react"; +import { useState, useMemo } from "react"; import styled, { css } from "styled-components"; import { trans } from "i18n"; import _ from "lodash"; @@ -11,8 +11,7 @@ import { multiChangeAction, } from "lowcoder-core"; import { hasIcon } from "comps/utils"; -import { messageInstance } from "lowcoder-design/src/components/GlobalInstances"; -import { resolveValue, resolveParsedValue, commonProps } from "./fileComp"; +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"; @@ -149,9 +148,11 @@ interface DraggerUploadProps { prefixIcon: any; suffixIcon: any; forceCapture: boolean; + captureResolution: CaptureResolution; minSize: number; maxSize: number; maxFiles: number; + fileNamePattern: string; uploadType: "single" | "multiple" | "directory"; text: string; dragHintText?: string; @@ -162,25 +163,27 @@ interface DraggerUploadProps { export const DraggerUpload = (props: DraggerUploadProps) => { const { dispatch, files, style, autoHeight, animationStyle } = 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]); const handleOnChange = (param: UploadChangeParam) => { - const uploadingFiles = param.fileList.filter((f) => f.status === "uploading"); - if (uploadingFiles.length !== 0) { - setFileList(param.fileList); + 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; @@ -240,8 +243,6 @@ export const DraggerUpload = (props: DraggerUploadProps) => { props.onEvent("parse"); }); } - - setFileList(uploadedFiles.slice(-maxFiles)); }; return ( @@ -254,21 +255,11 @@ export const DraggerUpload = (props: DraggerUploadProps) => { $auto={autoHeight} capture={props.forceCapture} openFileDialogOnClick={!(props.forceCapture && !isMobile)} - 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} >

@@ -301,6 +292,7 @@ export const DraggerUpload = (props: DraggerUploadProps) => { setShowModal(false)} onImageCapture={async (image) => { setShowModal(false); diff --git a/client/packages/lowcoder/src/comps/comps/fileComp/fileComp.tsx b/client/packages/lowcoder/src/comps/comps/fileComp/fileComp.tsx index 360a81556..b804c5341 100644 --- a/client/packages/lowcoder/src/comps/comps/fileComp/fileComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/fileComp/fileComp.tsx @@ -24,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"; @@ -97,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 = { @@ -113,6 +130,7 @@ const commonChildren = { prefixIcon: withDefault(IconControl, "/icon:solid/arrow-up-from-bracket"), suffixIcon: IconControl, forceCapture: BoolControl, + captureResolution: dropdownControl(CaptureResolutionOptions, "auto"), ...validationChildren, }; @@ -127,6 +145,11 @@ const commonValidationFields = (children: RecordConstructorToComp 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 { @@ -265,29 +331,32 @@ const Upload = ( }, ) => { 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; @@ -348,8 +417,6 @@ const Upload = ( props.onEvent("parse"); }); } - - setFileList(uploadedFiles.slice(-maxFiles)); }; return ( @@ -360,21 +427,11 @@ 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} > @@ -401,6 +458,7 @@ const Upload = ( setShowModal(false)} onImageCapture={async (image) => { setShowModal(false); @@ -508,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"), @@ -552,6 +614,40 @@ const FileWithMethods = withMethodExposing(FileImplComp, [ }) ), }, + { + 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(FileWithMethods, [ diff --git a/client/packages/lowcoder/src/comps/comps/layout/mobileTabLayout.tsx b/client/packages/lowcoder/src/comps/comps/layout/mobileTabLayout.tsx index 8ae653ffa..bd4016c16 100644 --- a/client/packages/lowcoder/src/comps/comps/layout/mobileTabLayout.tsx +++ b/client/packages/lowcoder/src/comps/comps/layout/mobileTabLayout.tsx @@ -11,11 +11,11 @@ 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 { CanvasContainerID, PreviewContainerID } from "constants/domLocators"; import { EditorContainer, EmptyContent } from "pages/common/styledComponent"; import { Layers } from "constants/Layers"; import { ExternalEditorContext } from "util/context/ExternalEditorContext"; +import { EditorContext } from "comps/editorState"; import { default as Skeleton } from "antd/es/skeleton"; import { hiddenPropertyView } from "comps/utils/propertyUtils"; import { dropdownControl } from "@lowcoder-ee/comps/controls/dropdownControl"; @@ -47,6 +47,21 @@ const TabBarItem = React.lazy(() => ); const EventOptions = [clickEvent] as const; +/** Mobile nav editor: tab bar uses position:absolute bottom; this root is the containing block */ +const MobileNavCanvasRoot = styled(CanvasContainer)` + position: relative; +`; + +/** Strip shared EditorContainer defaults (16px padding + scrollbar-gutter: stable) for mobile nav */ +const MobileNavEditorContainer = styled(EditorContainer)` + padding: 0; + padding-right: 0; + scrollbar-gutter: auto; + overflow-x: auto; + overflow-y: auto; + background: transparent; +`; + const AppViewContainer = styled.div` position: absolute; width: 100%; @@ -221,17 +236,17 @@ const TabBarWrapper = styled.div<{ $readOnly: boolean, $canvasBg: string, $tabBarHeight: string, - $maxWidth: number, $verticalAlignment: string; }>` + box-sizing: border-box; max-width: inherit; background: ${(props) => (props.$canvasBg)}; margin: 0 auto; - position: fixed; + position: ${(props) => (props.$readOnly ? "fixed" : "absolute")}; bottom: 0; left: 0; right: 0; - width: ${(props) => props.$readOnly ? "100%" : `${props.$maxWidth - 30}px`}; + width: 100%; z-index: ${Layers.tabBar}; padding-bottom: env(safe-area-inset-bottom, 0); @@ -389,7 +404,6 @@ function convertTreeData(data: any) { function TabBarView(props: TabBarProps & { tabBarHeight: string; - maxWidth: number; verticalAlignment: string; showSeparator: boolean; navIconSize: string; @@ -404,7 +418,6 @@ function TabBarView(props: TabBarProps & { $readOnly={props.readOnly} $canvasBg={canvasBg} $tabBarHeight={props.tabBarHeight} - $maxWidth={props.maxWidth} $verticalAlignment={props.verticalAlignment} > { const bgColor = (useContext(ThemeContext)?.theme || defaultTheme).canvas; const onEvent = comp.children.onEvent.getView(); + // Pull app-level Theme / Canvas Settings (managed via the left-sidebar + // "Canvas" pane and shared with normal apps + modules). Mobile nav already + // owns its own maxWidth + grid behaviour, so we only consume the + // background + padding subset here. + const editorState = useContext(EditorContext); + const appSettings = editorState?.getAppSettings(); + const canvasBg = appSettings?.gridBg; + const canvasBgImage = appSettings?.gridBgImage; + const canvasBgImageRepeat = appSettings?.gridBgImageRepeat || "no-repeat"; + const canvasBgImageSize = appSettings?.gridBgImageSize || "cover"; + const canvasBgImagePosition = appSettings?.gridBgImagePosition || "center"; + const canvasBgImageOrigin = appSettings?.gridBgImageOrigin || "padding-box"; + const canvasPaddingX = appSettings?.gridPaddingX ?? 0; + const canvasPaddingY = appSettings?.gridPaddingY ?? 0; + + const canvasBackgroundStyle: React.CSSProperties = { + background: "#FFFFFF", + }; + if (canvasBg) { + canvasBackgroundStyle.background = canvasBg; + } + if (canvasBgImage) { + canvasBackgroundStyle.backgroundImage = `url('${canvasBgImage}')`; + canvasBackgroundStyle.backgroundRepeat = canvasBgImageRepeat; + canvasBackgroundStyle.backgroundSize = canvasBgImageSize; + canvasBackgroundStyle.backgroundPosition = canvasBgImagePosition; + canvasBackgroundStyle.backgroundOrigin = canvasBgImageOrigin; + } + const canvasContentPadding = `${canvasPaddingY}px ${canvasPaddingX}px`; + const getContainer = useCallback(() => document.querySelector(`#${PreviewContainerID}`) || document.querySelector(`#${CanvasContainerID}`) || @@ -702,7 +745,7 @@ MobileTabLayoutTmp = withViewFn(MobileTabLayoutTmp, (comp) => { currentTab.children.app.getView()) || ( ); } @@ -712,7 +755,7 @@ MobileTabLayoutTmp = withViewFn(MobileTabLayoutTmp, (comp) => { currentTab.children.action.getView()) || ( ) }, [tabIndex, tabViews, dataOptionType]); @@ -769,7 +812,6 @@ MobileTabLayoutTmp = withViewFn(MobileTabLayoutTmp, (comp) => { tabItemActiveStyle={navItemActiveStyle} tabBarHeight={tabBarHeight} navIconSize={navIconSize} - maxWidth={maxWidth} verticalAlignment={verticalAlignment} showSeparator={showSeparator} /> @@ -870,8 +912,12 @@ MobileTabLayoutTmp = withViewFn(MobileTabLayoutTmp, (comp) => { if (readOnly) { return ( - - {appView} + + {appView} {menuMode === MobileMode.Hamburger ? ( <> {hamburgerButton} @@ -885,8 +931,12 @@ MobileTabLayoutTmp = withViewFn(MobileTabLayoutTmp, (comp) => { } return ( - - {appView} + + {appView} {menuMode === MobileMode.Hamburger ? ( <> {hamburgerButton} @@ -895,7 +945,7 @@ MobileTabLayoutTmp = withViewFn(MobileTabLayoutTmp, (comp) => { ) : ( tabBarView )} - + ); }); diff --git a/client/packages/lowcoder/src/comps/comps/layout/navLayout.tsx b/client/packages/lowcoder/src/comps/comps/layout/navLayout.tsx index 4a7e2b355..66f23635c 100644 --- a/client/packages/lowcoder/src/comps/comps/layout/navLayout.tsx +++ b/client/packages/lowcoder/src/comps/comps/layout/navLayout.tsx @@ -6,6 +6,7 @@ import MainContent from "components/layout/MainContent"; import { LayoutMenuItemComp, LayoutMenuItemListComp } from "comps/comps/layout/layoutMenuItemComp"; import { menuPropertyView } from "comps/comps/navComp/components/MenuItemList"; import { registerLayoutMap } from "comps/comps/uiComp"; +import { EditorContext } from "comps/editorState"; import { MultiCompBuilder, withDefault, withViewFn } from "comps/generators"; import { withDispatchHook } from "comps/generators/withDispatchHook"; import { NameAndExposingInfo } from "comps/utils/exposingTypes"; @@ -14,7 +15,7 @@ import { TopHeaderHeight } from "constants/style"; import { Section, controlItem, sectionNames } from "lowcoder-design"; import { trans } from "i18n"; import { EditorContainer, EmptyContent } from "pages/common/styledComponent"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import styled from "styled-components"; import { isUserViewMode, useAppPathParam } from "util/hooks"; import { StringControl, jsonControl } from "comps/controls/codeControl"; @@ -381,6 +382,21 @@ NavTmpLayout = withViewFn(NavTmpLayout, (comp) => { const dataOptionType = comp.children.dataOptionType.getView(); const onEvent = comp.children.onEvent.getView(); + // Pull app-level Theme / Canvas Settings (managed via the left-sidebar + // "Canvas" pane and shared with normal apps + modules). For aggregation + // apps the grid sizing fields are intentionally hidden in the settings UI; + // we only consume the background + padding subset here. + const editorState = useContext(EditorContext); + const appSettings = editorState?.getAppSettings(); + const canvasBg = appSettings?.gridBg; + const canvasBgImage = appSettings?.gridBgImage; + const canvasBgImageRepeat = appSettings?.gridBgImageRepeat || "no-repeat"; + const canvasBgImageSize = appSettings?.gridBgImageSize || "cover"; + const canvasBgImagePosition = appSettings?.gridBgImagePosition || "center"; + const canvasBgImageOrigin = appSettings?.gridBgImageOrigin || "padding-box"; + const canvasPaddingX = appSettings?.gridPaddingX ?? 0; + const canvasPaddingY = appSettings?.gridPaddingY ?? 0; + // filter out hidden. unauthorised items filtered by server const filterItem = useCallback((item: LayoutMenuItemComp): boolean => { return !item.children.hidden.getView(); @@ -685,8 +701,25 @@ NavTmpLayout = withViewFn(NavTmpLayout, (comp) => { /> ); + // Build canvas background style (color + optional image), driven by the + // shared app-level Canvas Settings. + const canvasBackgroundStyle: React.CSSProperties = {}; + if (canvasBg) { + canvasBackgroundStyle.background = canvasBg; + } + if (canvasBgImage) { + canvasBackgroundStyle.backgroundImage = `url('${canvasBgImage}')`; + canvasBackgroundStyle.backgroundRepeat = canvasBgImageRepeat; + canvasBackgroundStyle.backgroundSize = canvasBgImageSize; + canvasBackgroundStyle.backgroundPosition = canvasBgImagePosition; + canvasBackgroundStyle.backgroundOrigin = canvasBgImageOrigin; + } + let content = ( - + {(navPosition === 'top') && (

{ navMenu } @@ -697,7 +730,15 @@ NavTmpLayout = withViewFn(NavTmpLayout, (comp) => { {navMenu} )} - {pageView} + + {pageView} + {(navPosition === 'bottom') && (