import { EventEmitter, MoveStyleUtils, type Rock, type RockInstanceContext } from "@ruiapp/move-style"; import SonicEntityTableSelectMeta from "./SonicEntityTableSelectMeta"; import type { SonicEntityTableSelectRockConfig } from "./sonic-entity-table-select-types"; import { convertToEventHandlers, renderRock } from "@ruiapp/react-renderer"; import { Table, Select, Input, TableProps, Empty, Spin } from "antd"; import { debounce, filter, forEach, get, isArray, isFunction, isObject, isPlainObject, isString, isUndefined, last, map, omit, pick, set, split } from "lodash"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useMergeState } from "../../hooks/use-merge-state"; import { getRapidApi } from "../../rapidApi"; import { FindEntityOptions, RapidEntity } from "../../rapid-types"; import { parseConfigToFilters } from "../../functions/searchParamsToFilters"; import rapidAppDefinition from "../../rapidAppDefinition"; import { EntityStore, EntityStoreConfig } from "../../stores/entity-store"; import dayjs from "dayjs"; import "../rapid-table-select/rapid-table-select-style.css"; import { getEntityPropertyByCode } from "../../helpers/metaHelper"; import { getExtensionLocaleStringResource, getMetaPropertyLocaleName } from "../../helpers/i18nHelper"; import { calculateColumnsTotalWidth, convertRapidTableColumnToAntdTableColumn } from "../rapid-table/RapidTable"; const bus = new EventEmitter(); interface ICurrentState { offset: number; keyword?: string; selectedRecordMap: Record; visible?: boolean; reloadKey?: string | number; } function getListDataSourceCode(props) { return props.listDataSourceCode || `${props.$id}-list`; } export default { onReceiveMessage(message, state, props) { if (message.name === "refreshData") { bus.emit(`${props.$id}-refresh`, message.payload); } else if (message.name === "reload") { bus.emit(`${props.$id}-reload`); } }, Renderer(context, props: SonicEntityTableSelectRockConfig) { const { framework, logger, page } = context; const entity = rapidAppDefinition.getEntityByCode(props.entityCode); const displayPropertyCode = entity.displayPropertyCode || "name"; const displayProperty = getEntityPropertyByCode(rapidAppDefinition.getAppDefinition(), entity, displayPropertyCode); let defaultDisplayField = "name"; let defaultDisplayTitle = getExtensionLocaleStringResource(framework, "name"); if (displayProperty) { defaultDisplayField = displayProperty.code; defaultDisplayTitle = getMetaPropertyLocaleName(framework, entity, displayProperty) || displayProperty.name; } const { listValueFieldName = "id", listTextFieldName = defaultDisplayField, listTextFormat, pageSize = 20, columns = [{ title: defaultDisplayTitle, code: defaultDisplayField, width: 120 }], listFilterFields = [defaultDisplayField], filterDisabled, allowClear, disabled, placeholder, tableHeight = 400, } = props; const listDataSourceCode = getListDataSourceCode(props); let { dropdownMatchSelectWidth } = props; if (isUndefined(dropdownMatchSelectWidth)) { dropdownMatchSelectWidth = 360; } const isMultiple = props.mode === "multiple"; const refreshDataRef = useRef(null); const [currentState, setCurrentState] = useMergeState({ offset: 0, selectedRecordMap: {} }); const [debouncedKeyword, setDebouncedKeyword] = useState(""); const debouncedCallBack = useCallback( debounce((m) => setDebouncedKeyword(m), 600), [], ); const apiIns = useRequest(context, props, entity); const { loadSelectedRecords, loading } = useSelectedRecords(props, (records) => { forEach(records, (record) => { const recordValue = get(record, listValueFieldName); setCurrentState((draft) => { return { ...draft, selectedRecordMap: { ...draft.selectedRecordMap, [recordValue]: record } }; }); }); }); const loadData = () => { const params: any = { filters: [], pagination: { limit: pageSize, offset: currentState.offset, }, }; if (currentState.keyword && listFilterFields?.length) { params.filters = [ { operator: "or", filters: listFilterFields.map((field) => { if (isString(field)) { const filterCodes = split(field, "."); return parseSelectedRecordFilters(filterCodes, "contains", currentState.keyword)[0]; } else if (isObject(field)) { return parseConfigToFilters([field], currentState.keyword)[0]; } }), }, ]; } apiIns.request(params); }; refreshDataRef.current = loadData; useEffect(() => { const refreshHandler = () => { refreshDataRef.current?.(); }; bus.on(`${props.$id}-refresh`, refreshHandler); return () => { bus.off(`${props.$id}-refresh`, refreshHandler); }; }, [props.$id]); useEffect(() => { const reloadHandler = () => { setCurrentState({ offset: 0, reloadKey: dayjs().unix() }); }; bus.on(`${props.$id}-reload`, reloadHandler); return () => { bus.off(`${props.$id}-reload`, reloadHandler); }; }, [props.$id, setCurrentState]); useEffect(() => { loadData(); }, [props.entityCode, currentState.offset, currentState.reloadKey, debouncedKeyword]); const getLabel = (record: Record) => { if (props.labelRendererType) { return renderRock({ context, rockConfig: { $type: props.labelRendererType, ...props.labelRendererProps, value: record, }, }); } if (!listTextFormat) { return get(record, listTextFieldName); } return MoveStyleUtils.fulfillVariablesInString(listTextFormat, record); }; const selectedKeys = useMemo(() => { let val: any | any[] = props.value != null ? props.value : []; if (!isArray(val)) { val = val !== "" ? [val] : []; } return val.map((item) => { if (isPlainObject(item)) { const lastCode = last(split(listValueFieldName, ".")); return get(item, listValueFieldName) || get(item, lastCode); } return item; }); }, [props.value]); useEffect(() => { const keys = (selectedKeys || []).filter((key) => !currentState.selectedRecordMap[key]); if (keys.length) { loadSelectedRecords(keys); } }, [selectedKeys, currentState.selectedRecordMap]); const selectOptions = useMemo(() => { return Object.keys(currentState.selectedRecordMap) .map((k) => { const record = currentState.selectedRecordMap[k]; return record ? { label: getLabel(record), value: get(record, listValueFieldName) } : null; }) .filter((record) => record != null); }, [currentState.selectedRecordMap]); const eventHandlers = convertToEventHandlers({ context, rockConfig: props }) as any; const tableColumns = map(columns, (column) => { if (!column.title) { const rpdField = rapidAppDefinition.getEntityFieldByCode(entity, column.code); if (rpdField) { column.title = getMetaPropertyLocaleName(framework, entity, rpdField) || rpdField.name; } } return convertRapidTableColumnToAntdTableColumn(logger, framework, context, { $type: "rapidTableColumn", ...column, }); }); const columnsTotalWidth = calculateColumnsTotalWidth(columns); const current = isMultiple ? selectedKeys : last(selectedKeys); const onSelectRows = (records: any[]) => { let keys = selectedKeys || []; let s = { ...currentState, selectedRecordMap: { ...currentState.selectedRecordMap } }; forEach(records, (record) => { const recordValue = get(record, listValueFieldName); const isExisted = keys.some((k) => k === recordValue); if (isExisted) { keys = keys.filter((k) => k !== recordValue); } else { keys = [...keys, recordValue]; } s.selectedRecordMap[recordValue] = record; if (isExisted) { s.selectedRecordMap = omit(s.selectedRecordMap, recordValue); } }); if (!isMultiple) { s.visible = false; } setCurrentState(s); eventHandlers.onChange?.(isMultiple ? keys : last(keys)); const selectedRecords = keys.map((k) => s.selectedRecordMap[k]); const validRecords = filter(records, (record) => s.selectedRecordMap[get(record, listValueFieldName)] != null); eventHandlers.onSelectedRecord?.(isMultiple ? validRecords : last(validRecords), selectedRecords, s); }; const data = context.scope.getStore(listDataSourceCode)?.data; let removeIcon = undefined; if (props.readOnly) { removeIcon = ; } return ( { const v = e.target.value; setCurrentState({ keyword: v, offset: 0 }); debouncedCallBack(v); }} /> ) : null} {!data?.list?.length ? ( ) : ( get(record, listValueFieldName)} scroll={{ x: columnsTotalWidth, y: tableHeight }} columns={tableColumns} dataSource={data.list || []} rowClassName="pm-table-row" rowSelection={{ fixed: true, type: isMultiple ? "checkbox" : "radio", selectedRowKeys: selectedKeys, onSelectAll(selected, selectedRows, changeRows) { onSelectRows(changeRows || []); }, onSelect(record) { onSelectRows([record]); }, }} onRow={(record) => { return { onClick: () => { onSelectRows([record]); }, }; }} pagination={{ size: "small", current: currentState.offset / pageSize + 1, pageSize, total: data?.total || 0, hideOnSinglePage: true, showSizeChanger: false, onChange(page) { setCurrentState({ offset: (page - 1) * pageSize }); }, }} /> )} ); }} /> ); }, ...SonicEntityTableSelectMeta, } as Rock; function EmptyRemoveIcon() { return null; } interface IRequestState { loading?: boolean; } function useRequest(context: RockInstanceContext, props: SonicEntityTableSelectRockConfig, entity: RapidEntity) { const [state, setState] = useMergeState({}); const { scope } = context; useEffect(() => { const listDataSourceCode = getListDataSourceCode(props); const store = scope.getStore(listDataSourceCode); if (store) { return; } const entity = rapidAppDefinition.getEntityByCode(props.entityCode); let { requestParams = {} } = props; const listDataStoreConfig: EntityStoreConfig = { type: "entityStore", name: listDataSourceCode, entityModel: entity, fixedFilters: requestParams.fixedFilters, filters: requestParams.filters, properties: requestParams.properties || [], orderBy: requestParams.orderBy || entity.defaultOrderBy || [ { field: "id", }, ], pagination: requestParams.pagination || { limit: props.pageSize || 20, offset: 0 }, keepNonPropertyFields: requestParams.keepNonPropertyFields, $exps: requestParams.$exps, }; scope.addStore(listDataStoreConfig); }, [scope]); const request = async (params: any) => { if (state.loading) { return; } let configParams = props.requestParams || {}; const expressions = configParams.$exps; if (expressions) { for (const propName in expressions) { const interpretedValue = context.page.interpreteExpression(expressions[propName], { $scope: context.scope, $page: context.page, }); set(configParams, propName, interpretedValue); } } setState({ loading: true }); try { const listDataSourceCode = getListDataSourceCode(props); const store: EntityStore = context.scope.getStore(listDataSourceCode); store.updateConfig({ fixedFilters: configParams.fixedFilters, }); await context.scope.loadStoreData(listDataSourceCode, { ...omit(configParams, "fixedFilters"), ...params, }); } finally { setState({ loading: false }); } }; return { request, ...state }; } function useSelectedRecords(props: SonicEntityTableSelectRockConfig, onSuccess: (records: any[]) => void) { const { requestParams, listValueFieldName = "id" } = props; const rapidApi = getRapidApi(); const [loading, setLoading] = useState(false); const loadSelectedRecords = async (keys: string[]) => { if (!keys?.length) { return; } const entity = rapidAppDefinition.getEntityByCode(props.entityCode); setLoading(true); const filterCodes = split(listValueFieldName, "."); rapidApi .post(`/${entity.namespace}/${entity.pluralCode}/operations/find`, { ...pick(requestParams || {}, "properties"), pagination: { limit: 100, offset: 0, }, filters: parseSelectedRecordFilters(filterCodes, "in", keys), }) .then((res) => { let records = res.data?.list || []; if (res.status >= 200 && res.status < 300) { onSuccess(records); setLoading(false); } }) .finally(() => { setLoading(false); }); }; return { loading, loadSelectedRecords }; } function parseSelectedRecordFilters(codes: string[], operator: FindEntityOptions["filters"][0]["operator"], value: any): FindEntityOptions["filters"] { const [currentCode, ...restCodes] = codes || []; if (restCodes.length) { const nextFilters = parseSelectedRecordFilters(restCodes, operator, value); return [ { field: currentCode, operator: "exists", filters: nextFilters || [], }, ]; } else if (currentCode) { return [ { field: currentCode, operator: operator as any, value: value, }, ]; } else { return []; } }