Commit fb9d7988 authored by drigle's avatar drigle

fix: inherit view restrictions and honor short titles

parent 8789a8c6
...@@ -20,7 +20,9 @@ import { ...@@ -20,7 +20,9 @@ import {
getSelectOptions, getSelectOptions,
isDateFieldType, isDateFieldType,
isReferenceFieldType, isReferenceFieldType,
getViewRestrictionDefaults,
normalizeValueForSubmit, normalizeValueForSubmit,
shouldHideViewTitleColumn,
toArray, toArray,
} from '@/utils/subjectView'; } from '@/utils/subjectView';
import { Delete, PlusIcon } from '@/components/AppIcons'; import { Delete, PlusIcon } from '@/components/AppIcons';
...@@ -45,8 +47,13 @@ function isConfiguredRequired(column) { ...@@ -45,8 +47,13 @@ function isConfiguredRequired(column) {
} }
function getDialogColumns(subject, view) { function getDialogColumns(subject, view) {
const hideTitleColumn = shouldHideViewTitleColumn(view);
const columns = buildViewColumns(view, subject, { includeHidden: true }) const columns = buildViewColumns(view, subject, { includeHidden: true })
.filter((column) => column?.field && !column.hide) .filter((column) => (
column?.field &&
!column.hide &&
(!hideTitleColumn || column.field !== 'title')
))
.map((column) => column.field === 'title' ? { .map((column) => column.field === 'title' ? {
...column, ...column,
fieldDef: { fieldDef: {
...@@ -477,33 +484,50 @@ export default function DynamicRecordDialog({ ...@@ -477,33 +484,50 @@ export default function DynamicRecordDialog({
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const editing = Boolean(record); const editing = Boolean(record);
const hideTitle = shouldHideViewTitleColumn(view);
const dialogColumns = useMemo(() => getDialogColumns(subject, view), [subject, view]); const dialogColumns = useMemo(() => getDialogColumns(subject, view), [subject, view]);
const titleColumn = dialogColumns.find((column) => column.field === 'title'); const titleColumn = dialogColumns.find((column) => column.field === 'title');
const dialogFields = dialogColumns.filter((column) => column.field !== 'title') const dialogFields = dialogColumns.filter((column) => column.field !== 'title')
.map((column) => column.fieldDef).filter((field) => field?.name); .map((column) => column.fieldDef).filter((field) => field?.name);
const restrictionDefaults = useMemo(
() => getViewRestrictionDefaults(view, subject),
[subject, view],
);
const initialValues = useMemo(() => { const initialValues = useMemo(() => {
return (subject?.fields || []).reduce((result, field) => { const defaults = (subject?.fields || []).reduce((result, field) => {
result[field.name] = fieldInitialValue(field, record?.metadata?.[field.name]); result[field.name] = fieldInitialValue(field, record?.metadata?.[field.name]);
return result; return result;
}, {}); }, {});
}, [subject, record]); if (!record) {
Object.entries(restrictionDefaults.metadata).forEach(([fieldName, value]) => {
defaults[fieldName] = value;
});
}
return defaults;
}, [subject, record, restrictionDefaults]);
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
return; return;
} }
setTitle(record?.title || record?.metadata?.project_name || ''); setTitle(record?.title || record?.metadata?.project_name || (
!editing ? restrictionDefaults.title ?? '' : ''
));
setValues(initialValues); setValues(initialValues);
setError(''); setError('');
}, [open, record, initialValues]); }, [open, record, initialValues, editing, restrictionDefaults]);
const handleSubmit = async () => { const handleSubmit = async () => {
setSaving(true); setSaving(true);
setError(''); setError('');
try { try {
const prepared = await prepareValuesForSubmit(subject, values); const prepared = await prepareValuesForSubmit(subject, values);
const validationErrors = prepared.errors; const validationErrors = [...prepared.errors];
if (!editing && restrictionDefaults.errors.length > 0) {
validationErrors.unshift(...restrictionDefaults.errors);
}
if (titleColumn?.fieldDef?.required && !title.trim()) { if (titleColumn?.fieldDef?.required && !title.trim()) {
validationErrors.unshift('记录标题为必填项'); validationErrors.unshift('记录标题为必填项');
} }
...@@ -518,9 +542,13 @@ export default function DynamicRecordDialog({ ...@@ -518,9 +542,13 @@ export default function DynamicRecordDialog({
} else { } else {
const payload = { const payload = {
subject: subjectName, subject: subjectName,
metadata: prepared.metadata, metadata: {
...prepared.metadata,
...restrictionDefaults.metadata,
},
}; };
if (titleColumn && title.trim()) payload.title = title.trim(); if (titleColumn && title.trim()) payload.title = title.trim();
else if (!hideTitle && restrictionDefaults.title !== undefined) payload.title = restrictionDefaults.title;
await createRecord(payload); await createRecord(payload);
} }
await onSaved?.(); await onSaved?.();
...@@ -542,7 +570,7 @@ export default function DynamicRecordDialog({ ...@@ -542,7 +570,7 @@ export default function DynamicRecordDialog({
title={editing ? '编辑记录' : '新建记录'} title={editing ? '编辑记录' : '新建记录'}
description={editing description={editing
? '修改当前记录的字段内容后保存,字段校验将在保存时执行。' ? '修改当前记录的字段内容后保存,字段校验将在保存时执行。'
: '填写记录标题和字段内容后保存新记录。'} : `${hideTitle ? '填写字段内容' : '填写记录标题和字段内容'}后保存新记录。`}
footer={( footer={(
<> <>
<Button isDisabled={saving} variant="secondary" onPress={onClose}>取消</Button> <Button isDisabled={saving} variant="secondary" onPress={onClose}>取消</Button>
...@@ -559,6 +587,7 @@ export default function DynamicRecordDialog({ ...@@ -559,6 +587,7 @@ export default function DynamicRecordDialog({
placeholder={`请输入${titleColumn.label || '记录标题'}`} placeholder={`请输入${titleColumn.label || '记录标题'}`}
value={title} value={title}
onChange={(event) => setTitle(event.target.value)} onChange={(event) => setTitle(event.target.value)}
disabled={saving || (!editing && restrictionDefaults.title !== undefined)}
variant="secondary" variant="secondary"
/> />
</TextField> : null} </TextField> : null}
...@@ -571,7 +600,9 @@ export default function DynamicRecordDialog({ ...@@ -571,7 +600,9 @@ export default function DynamicRecordDialog({
<DynamicField <DynamicField
fieldDef={field} fieldDef={field}
value={values[field.name]} value={values[field.name]}
disabled={saving || (editing && isPrimaryField(field))} disabled={saving || (editing && isPrimaryField(field)) || (
!editing && Object.prototype.hasOwnProperty.call(restrictionDefaults.metadata, field.name)
)}
onChange={(nextValue) => setValues((current) => ({ onChange={(nextValue) => setValues((current) => ({
...current, ...current,
[field.name]: nextValue, [field.name]: nextValue,
......
...@@ -30,9 +30,10 @@ ...@@ -30,9 +30,10 @@
5. 归档页为只读,不提供新建、编辑、复制或删除。 5. 归档页为只读,不提供新建、编辑、复制或删除。
6. 页面请求必须展示加载、成功和错误状态;删除与替换导入必须提供确认。删除确认弹窗 6. 页面请求必须展示加载、成功和错误状态;删除与替换导入必须提供确认。删除确认弹窗
仅展示选中记录数量和软删除说明,不展示记录标题列表。 仅展示选中记录数量和软删除说明,不展示记录标题列表。
7. Grid View 的 `settings.shortTitle === true` 时仅隐藏列表 `title` 展示列;Detail View 7. Grid View 的 `settings.shortTitle === true` 时隐藏列表 `title` 展示列及新建/编辑弹窗中的根级
的同名配置同时隐藏详情 Header 的记录标题和字段区 `title`。`false` 或未配置时正常 “记录标题”输入框;此时不校验、不提交根级 `title`。Detail View 的同名配置同时隐藏详情
显示。该配置不删除标题数据,也不改变标题搜索、排序、筛选、详情身份或保存协议。 Header 的记录标题和字段区 `title`。`false` 或未配置时正常显示并保留标题必填逻辑。该配置
不删除已有标题数据,也不改变标题搜索、排序、筛选或详情身份。
8. Subject 配置为多选的选择字段,在新建弹窗和详情编辑中使用可搜索的下拉勾选器; 8. Subject 配置为多选的选择字段,在新建弹窗和详情编辑中使用可搜索的下拉勾选器;
已选项以标签展示并支持逐项移除或清空,保存值仍为 `settings.options` 中的 value 数组。 已选项以标签展示并支持逐项移除或清空,保存值仍为 `settings.options` 中的 value 数组。
9. 列表工具栏默认只显示新建、导入和主要导出等常用入口;展示表的次要导出与归档动作 9. 列表工具栏默认只显示新建、导入和主要导出等常用入口;展示表的次要导出与归档动作
...@@ -72,7 +73,8 @@ ...@@ -72,7 +73,8 @@
- 列表字段、字段标题和筛选项均从当前 Grid View 配置生成;切换视图会清空当前筛选和勾选状态。 - 列表字段、字段标题和筛选项均从当前 Grid View 配置生成;切换视图会清空当前筛选和勾选状态。
- 筛选控件按视图字段类型生成,引用和用户字段通过远程搜索选择,查询提交后按字段值向 `/record/query` 发送远程 `filter`;列表不再提供关键字搜索。 - 筛选控件按视图字段类型生成,引用和用户字段通过远程搜索选择,查询提交后按字段值向 `/record/query` 发送远程 `filter`;列表不再提供关键字搜索。
- 新建记录弹窗只渲染当前视图配置的字段,包含视图显式配置的 `title` 字段;标题是否显示及是否必填遵循视图配置,业务字段仍按 Subject 定义写入 `metadata`。 - 新建记录弹窗只渲染当前视图配置的字段;`settings.shortTitle === true` 时隐藏列表和弹窗中的根级“记录标题”,不校验、不提交根级 `title`,未配置或为 `false` 时保留标题显示和必填逻辑。
- 从当前 Grid View 新建记录时读取 `settings.restrictions`:`$eq` 或单值 `$in` 约束自动写入新记录 `metadata`,对应字段(包括未列入弹窗的字段)不可修改;多值集合、否定比较或同字段冲突无法确定唯一值时禁止创建并提示调整视图。编辑已有记录保留原值,不用视图限制覆盖。
- 列表加载期间 loading 指示器在表格区域居中显示;查询失败保留错误状态,不提交成功反馈。 - 列表加载期间 loading 指示器在表格区域居中显示;查询失败保留错误状态,不提交成功反馈。
- Grid View 的 `settings.viewAuth` 只控制视图可见性。普通用户只读取 - Grid View 的 `settings.viewAuth` 只控制视图可见性。普通用户只读取
......
...@@ -534,6 +534,78 @@ export function mergeRestrictionsIntoFilter(baseFilter = {}, restrictions = []) ...@@ -534,6 +534,78 @@ export function mergeRestrictionsIntoFilter(baseFilter = {}, restrictions = [])
}, { ...baseFilter }); }, { ...baseFilter });
} }
function normalizeRestrictionFieldPath(fieldPath) {
const normalized = normalizeText(fieldPath);
if (!normalized || normalized === 'title' || normalized.startsWith('metadata.')) {
return normalized;
}
return `metadata.${normalized}`;
}
function hasRestrictionValue(value) {
return value !== undefined && value !== null && value !== '';
}
function restrictionValuesEqual(left, right) {
if (Object.is(left, right)) return true;
try {
return JSON.stringify(left) === JSON.stringify(right);
} catch (error) {
return false;
}
}
/** Resolve only restrictions that identify one unambiguous value for creation. */
export function getViewRestrictionDefaults(view, subject) {
const assignments = new Map();
const unresolvedPaths = new Set();
const errors = new Map();
const addError = (fieldPath, fieldDef) => {
if (errors.has(fieldPath)) return;
const label = fieldDef?.label || getFieldNameFromPath(fieldPath) || fieldPath;
errors.set(fieldPath, `${label}的视图约束无法自动确定唯一值,请先调整视图配置。`);
};
toArray(view?.settings?.restrictions).forEach((restriction) => {
if (restriction?.type !== 'field' || !restriction.field || restriction.value === undefined) return;
const fieldPath = normalizeRestrictionFieldPath(restriction.field);
const fieldDef = getFieldDefinition(fieldPath, subject);
const comparator = normalizeText(restriction.comparator || '$in');
const values = toArray(restriction.value).filter(hasRestrictionValue);
if (!fieldPath || !['$eq', '$in'].includes(comparator) || values.length !== 1) {
if (fieldPath) {
unresolvedPaths.add(fieldPath);
addError(fieldPath, fieldDef);
}
return;
}
if (unresolvedPaths.has(fieldPath)) return;
let value = normalizeValueForSubmit(values[0], fieldDef);
if (fieldDef?.multiple) value = toArray(value);
const existing = assignments.get(fieldPath);
if (assignments.has(fieldPath) && !restrictionValuesEqual(existing, value)) {
assignments.delete(fieldPath);
unresolvedPaths.add(fieldPath);
addError(fieldPath, fieldDef);
return;
}
assignments.set(fieldPath, value);
});
const metadata = {};
let title;
assignments.forEach((value, fieldPath) => {
if (unresolvedPaths.has(fieldPath)) return;
if (fieldPath === 'title') {
title = value;
return;
}
const fieldName = getFieldNameFromPath(fieldPath);
if (fieldName) metadata[fieldName] = value;
});
return { metadata, title, errors: [...errors.values()] };
}
export function sortRecordsByView(records, view) { export function sortRecordsByView(records, view) {
const sortColumns = toArray(view?.settings?.columns) const sortColumns = toArray(view?.settings?.columns)
.filter((column) => column.sort) .filter((column) => column.sort)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment