fix(client-v2): hydrate record picker relation labels (#10386)

This commit is contained in:
Drol
2026-08-20 18:36:31 +08:00
committed by GitHub
parent 3684e3836d
commit 4759c59b03
4 changed files with 310 additions and 140 deletions
@@ -9,6 +9,7 @@
import {
CollectionField,
MultiRecordResource,
EditableItemModel,
tExpr,
FlowModel,
@@ -31,7 +32,12 @@ import {
createRootItemChain,
type ItemChain,
} from './itemChain';
import { buildOpenerUids, LabelByField, type AssociationFieldNames } from './recordSelectShared';
import {
buildOpenerUids,
LabelByField,
type AssociationFieldNames,
useAssociationValueHydration,
} from './recordSelectShared';
const MULTIPLE_ASSOCIATION_TYPES = ['belongsToMany', 'hasMany', 'belongsToArray'];
@@ -328,6 +334,13 @@ function RecordPickerField(props) {
useEffect(() => {
ctx.model.selectedRows.value = props.value;
}, [ctx.model.selectedRows, props.value]);
useAssociationValueHydration({
model: ctx.model,
value: props.value,
isMultiple: allowMultiple,
fieldNames,
onChange: props.onChange,
});
return (
<Select
@@ -400,6 +413,7 @@ function RecordPickerField(props) {
}
export class RecordPickerFieldModel extends FieldModel {
declare resource: MultiRecordResource;
selectedRows = observable.ref([]);
_closeView;
selectBlockModel;
@@ -564,6 +578,15 @@ RecordPickerFieldModel.registerFlow({
title: tExpr('RecordPicker settings'),
sort: 200,
steps: {
init: {
handler(ctx) {
const { target, dataSourceKey } = ctx.model.collectionField;
const resource = ctx.createResource(MultiRecordResource);
resource.setDataSourceKey(dataSourceKey);
resource.setResourceName(target);
ctx.model.resource = resource;
},
},
fieldNames: {
use: 'titleField',
},
@@ -22,7 +22,7 @@ import { css } from '@emotion/css';
import { debounce } from 'lodash';
import { useRequest } from 'ahooks';
import { PlusOutlined } from '@ant-design/icons';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { SkeletonFallback } from '../../../components/SkeletonFallback';
import { AssociationFieldModel } from './AssociationFieldModel';
@@ -32,6 +32,7 @@ import {
normalizeAssociationFieldNames,
resolveOptions,
toSelectValue,
useAssociationValueHydration,
type AssociationOption,
type LazySelectProps,
} from './recordSelectShared';
@@ -41,81 +42,6 @@ import { ActionWithoutPermission } from '../../base/ActionModel';
import { EditFormModel } from '../../blocks';
import { hasAncestorModel } from './recordSelectSettingsUtils';
function isPlainObject(val: unknown): val is Record<string, any> {
return !!val && typeof val === 'object' && !Array.isArray(val);
}
type HydrateStatus = 'pending' | 'done';
type HydrationCandidate = {
item: AssociationOption;
tk: any;
tkKey: string;
};
export function collectAssociationHydrationCandidates(options: {
value: LazySelectProps['value'];
isMultiple: boolean;
valueKey: string;
labelKey: string;
statusMap: Map<string, HydrateStatus>;
}): HydrationCandidate[] {
const { value, isMultiple, valueKey, labelKey, statusMap } = options;
const list = isMultiple ? (Array.isArray(value) ? value : []) : value != null ? [value] : [];
const activeTkKeys = new Set<string>();
for (const item of list) {
if (!isPlainObject(item)) continue;
const tk = item?.[valueKey];
if (tk == null) continue;
const tkKey = typeof tk === 'object' ? JSON.stringify(tk) : String(tk);
if (!tkKey) continue;
activeTkKeys.add(tkKey);
}
for (const key of Array.from(statusMap.keys())) {
if (!activeTkKeys.has(key)) {
statusMap.delete(key);
}
}
const candidates: HydrationCandidate[] = [];
for (const item of list) {
if (!isPlainObject(item)) continue;
const tk = item?.[valueKey];
if (tk == null) continue;
if (item?.[labelKey] != null) continue;
const tkKey = typeof tk === 'object' ? JSON.stringify(tk) : String(tk);
if (!tkKey) continue;
const status = statusMap.get(tkKey);
if (status === 'pending' || status === 'done') continue;
statusMap.set(tkKey, 'pending');
candidates.push({ item, tk, tkKey });
}
return candidates;
}
export function getAssociationHydrationNamePath(model: any) {
return model?.context?.fieldPathArray ?? model?.context?.fieldPath ?? model?.props?.name;
}
export function getAssociationHydrationSetterContext(model: any) {
if (typeof model?.context?.setFormValue === 'function') {
return model.context;
}
return model?.context?.blockModel?.context;
}
function markAssociationHydrationDone(statusMap: Map<string, HydrateStatus>, tkKey: string | null | undefined) {
if (!tkKey) return;
statusMap.set(tkKey, 'done');
}
function RemoteModelRenderer({ options }) {
const ctx = useFlowViewContext();
const { data, loading } = useRequest(
@@ -281,66 +207,13 @@ const LazySelect = (props: Readonly<LazySelectProps>) => {
};
const isConfigMode = !!model.context.flowSettingsEnabled;
const { t } = useTranslation();
const hydrateStatusRef = useRef<Map<string, HydrateStatus>>(new Map());
useEffect(() => {
const resource: any = model?.resource;
if (!resource || typeof resource.get !== 'function') return;
const valueKey = normalizedFieldNames.value;
const labelKey = normalizedFieldNames.label;
if (!valueKey || !labelKey) return;
const current = value;
const candidates = collectAssociationHydrationCandidates({
value: current,
isMultiple,
valueKey,
labelKey,
statusMap: hydrateStatusRef.current,
});
if (!candidates.length) return;
const namePath = getAssociationHydrationNamePath(model);
const setterCtx: any = getAssociationHydrationSetterContext(model);
candidates.forEach(({ item, tk, tkKey }) => {
void (async () => {
try {
const record = await resource.get(tk);
if (!record || typeof record !== 'object') {
return;
}
const merge = { ...(item as any), ...(record as any) };
if (merge?.[labelKey] == null) {
return;
}
const nextValue = isMultiple
? (Array.isArray(current) ? current : []).map((v: any) => {
if (!isPlainObject(v)) return v;
return v?.[valueKey] === tk ? merge : v;
})
: merge;
if (setterCtx && typeof setterCtx.setFormValue === 'function' && namePath != null) {
await setterCtx.setFormValue(namePath, nextValue, {
source: 'default',
markExplicit: false,
triggerEvent: false,
});
return;
}
onChange(nextValue as any);
} catch (error) {
// ignore
} finally {
markAssociationHydrationDone(hydrateStatusRef.current, tkKey);
}
})();
});
}, [isMultiple, model, normalizedFieldNames.label, normalizedFieldNames.value, onChange, value]);
useAssociationValueHydration({
model,
value,
isMultiple,
fieldNames: normalizedFieldNames,
onChange,
});
const QuickAddContent = ({ searchText }) => {
return (
@@ -7,7 +7,8 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { FlowContext } from '@nocobase/flow-engine';
import { FlowContext, FlowEngine, FlowModel } from '@nocobase/flow-engine';
import { renderHook, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import {
buildCurrentItemTitle,
@@ -21,6 +22,7 @@ import {
} from '../itemChain';
import { injectRecordPickerPopupContext } from '@nocobase/client-v2';
import {
RecordPickerFieldModel,
canRecordPickerSelectMultiple,
getRecordPickerClearedValue,
getRecordPickerEmptyValue,
@@ -28,8 +30,12 @@ import {
normalizeRecordPickerValue,
shouldClearRecordPickerValueOnMultipleChange,
} from '../RecordPickerFieldModel';
import { getAssociationHydrationNamePath, getAssociationHydrationSetterContext } from '../RecordSelectFieldModel';
import {
collectAssociationHydrationCandidates,
useAssociationValueHydration,
getAssociationHydrationNamePath,
getAssociationHydrationSetterContext,
} from '../recordSelectShared';
function createMockCollection() {
return {
name: 'users',
@@ -78,6 +84,118 @@ describe('RecordPickerFieldModel item context', () => {
expect(normalizeRecordPickerValue(undefined, fieldNames, false)).toBeUndefined();
});
it('queues ID-only picker values for association label hydration', () => {
const statusMap = new Map<string, 'pending' | 'done'>();
const fieldNames = { label: 'name', value: 'id' };
expect(
collectAssociationHydrationCandidates({
value: [{ id: 1 }, { id: 2, name: 'Already hydrated' }],
isMultiple: true,
valueKey: fieldNames.value,
labelKey: fieldNames.label,
statusMap,
}),
).toEqual([{ item: { id: 1 }, tk: 1, tkKey: '1' }]);
expect(statusMap).toEqual(new Map([['1', 'pending']]));
expect(
collectAssociationHydrationCandidates({
value: { id: 1 },
isMultiple: false,
valueKey: fieldNames.value,
labelKey: fieldNames.label,
statusMap,
}),
).toEqual([]);
});
it('hydrates an ID-only picker value back into the form', async () => {
const resource = {
get: vi.fn().mockResolvedValue({ id: 1, name: '北京启明教育' }),
};
const setFormValue = vi.fn();
const onChange = vi.fn();
const model = {
resource,
props: { name: 'company' },
context: {
fieldPathArray: ['company'],
setFormValue,
},
};
const { rerender } = renderHook(() =>
useAssociationValueHydration({
model,
value: { id: 1 },
isMultiple: false,
fieldNames: { label: 'name', value: 'id' },
onChange,
}),
);
await waitFor(() => {
expect(setFormValue).toHaveBeenCalledWith(
['company'],
{ id: 1, name: '北京启明教育' },
{ source: 'default', markExplicit: false, triggerEvent: false },
);
});
rerender();
expect(resource.get).toHaveBeenCalledTimes(1);
expect(onChange).not.toHaveBeenCalled();
});
it('initializes a target resource before hydrating an ID-only picker value', async () => {
const engine = new FlowEngine();
engine.registerModels({ RecordPickerFieldModel });
const formItem = engine.createModel<FlowModel>({
use: 'FlowModel',
uid: 'form-item',
});
const field = engine.createModel<RecordPickerFieldModel>({
use: 'RecordPickerFieldModel',
uid: 'company-field',
parentId: formItem.uid,
});
const setFormValue = vi.fn();
field.context.defineProperty('collectionField', {
value: {
dataSourceKey: 'main',
target: 'rp_na_companies',
},
});
field.context.defineProperty('fieldPathArray', { value: ['company'] });
field.context.defineProperty('setFormValue', { value: setFormValue });
await field.applyFlow('recordPickerSettings');
const get = vi.spyOn(field.resource, 'get').mockResolvedValue({ id: 1, name: '北京启明教育' });
const onChange = vi.fn();
renderHook(() =>
useAssociationValueHydration({
model: field,
value: { id: 1 },
isMultiple: false,
fieldNames: { label: 'name', value: 'id' },
onChange,
}),
);
await waitFor(() => {
expect(get).toHaveBeenCalledWith(1);
expect(setFormValue).toHaveBeenCalledWith(
['company'],
{ id: 1, name: '北京启明教育' },
{ source: 'default', markExplicit: false, triggerEvent: false },
);
});
expect(onChange).not.toHaveBeenCalled();
});
it('returns the empty popup select value for the current multiple mode', () => {
expect(getRecordPickerEmptyValue(true)).toEqual([]);
expect(getRecordPickerEmptyValue(false)).toBeUndefined();
@@ -119,6 +119,162 @@ export interface LazySelectProps extends Omit<SelectProps<any>, 'mode' | 'option
allowEdit?: boolean;
}
type AssociationHydrationSetterOptions = {
source?: string;
markExplicit?: boolean;
triggerEvent?: boolean;
};
type AssociationHydrationSetterContext = {
setFormValue?: (
namePath: unknown,
value: unknown,
options?: AssociationHydrationSetterOptions,
) => Promise<unknown> | void;
};
export type AssociationHydrationModel = {
resource?: {
get?: (tk: unknown) => Promise<unknown> | unknown;
};
context?: unknown;
props?: {
name?: unknown;
};
};
type AssociationHydrationStatus = 'pending' | 'done';
type AssociationHydrationCandidate = {
item: AssociationOption;
tk: unknown;
tkKey: string;
};
export function isAssociationRecord(value: unknown): value is AssociationOption {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
export function collectAssociationHydrationCandidates(options: {
value: LazySelectProps['value'];
isMultiple: boolean;
valueKey: string;
labelKey: string;
statusMap: Map<string, AssociationHydrationStatus>;
}): AssociationHydrationCandidate[] {
const { value, isMultiple, valueKey, labelKey, statusMap } = options;
const list = isMultiple ? (Array.isArray(value) ? value : []) : value != null ? [value] : [];
const activeTkKeys = new Set<string>();
for (const item of list) {
if (!isAssociationRecord(item)) continue;
const tk = item[valueKey];
if (tk == null) continue;
const tkKey = typeof tk === 'object' ? JSON.stringify(tk) : String(tk);
if (tkKey) activeTkKeys.add(tkKey);
}
for (const key of Array.from(statusMap.keys())) {
if (!activeTkKeys.has(key)) statusMap.delete(key);
}
const candidates: AssociationHydrationCandidate[] = [];
for (const item of list) {
if (!isAssociationRecord(item)) continue;
const tk = item[valueKey];
if (tk == null || item[labelKey] != null) continue;
const tkKey = typeof tk === 'object' ? JSON.stringify(tk) : String(tk);
if (!tkKey || statusMap.has(tkKey)) continue;
statusMap.set(tkKey, 'pending');
candidates.push({ item, tk, tkKey });
}
return candidates;
}
function hasAssociationHydrationSetter(value: unknown): value is AssociationHydrationSetterContext {
return isAssociationRecord(value) && typeof value.setFormValue === 'function';
}
export function getAssociationHydrationNamePath(model: AssociationHydrationModel) {
const context = isAssociationRecord(model.context) ? model.context : undefined;
return context?.fieldPathArray ?? context?.fieldPath ?? model.props?.name;
}
export function getAssociationHydrationSetterContext(model: AssociationHydrationModel) {
if (hasAssociationHydrationSetter(model.context)) return model.context;
const context = isAssociationRecord(model.context) ? model.context : undefined;
const blockModel = isAssociationRecord(context?.blockModel) ? context.blockModel : undefined;
return hasAssociationHydrationSetter(blockModel?.context) ? blockModel.context : undefined;
}
export function useAssociationValueHydration(options: {
model: AssociationHydrationModel;
value: LazySelectProps['value'];
isMultiple: boolean;
fieldNames: AssociationFieldNames;
onChange: LazySelectProps['onChange'];
}) {
const { model, value, isMultiple, fieldNames, onChange } = options;
const hydrateStatusRef = React.useRef<Map<string, AssociationHydrationStatus>>(new Map());
React.useEffect(() => {
const resource = model.resource;
if (typeof resource?.get !== 'function' || !fieldNames.value || !fieldNames.label) return;
const current = value;
const candidates = collectAssociationHydrationCandidates({
value: current,
isMultiple,
valueKey: fieldNames.value,
labelKey: fieldNames.label,
statusMap: hydrateStatusRef.current,
});
if (!candidates.length) return;
const namePath = getAssociationHydrationNamePath(model);
const setterContext = getAssociationHydrationSetterContext(model);
const hydrateCandidate = async ({ item, tk, tkKey }: AssociationHydrationCandidate) => {
try {
const record = await resource.get?.(tk);
if (!isAssociationRecord(record)) return;
const merged = { ...item, ...record };
if (merged[fieldNames.label] == null) return;
const nextValue = isMultiple
? (Array.isArray(current) ? current : []).map((entry) =>
isAssociationRecord(entry) && entry[fieldNames.value] === tk ? merged : entry,
)
: merged;
if (typeof setterContext?.setFormValue === 'function' && namePath != null) {
await setterContext.setFormValue(namePath, nextValue, {
source: 'default',
markExplicit: false,
triggerEvent: false,
});
return;
}
onChange(nextValue);
} catch {
// Keep the original ID-only value when the related record cannot be loaded.
} finally {
hydrateStatusRef.current.set(tkKey, 'done');
}
};
candidates.forEach((candidate) => {
hydrateCandidate(candidate);
});
}, [fieldNames.label, fieldNames.value, isMultiple, model, onChange, value]);
}
export interface LabelByFieldProps {
option: AssociationOption;
fieldNames: AssociationFieldNames;