mirror of
https://github.com/nocobase/nocobase.git
synced 2026-08-28 17:43:07 +08:00
fix(client-v2): hydrate record picker relation labels (#10386)
This commit is contained in:
+24
-1
@@ -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',
|
||||
},
|
||||
|
||||
+9
-136
@@ -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 (
|
||||
|
||||
+121
-3
@@ -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();
|
||||
|
||||
+156
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user