Commit 5c566366 authored by drigle's avatar drigle

feat: inherit grid restrictions on record creation

parent d5192776
...@@ -59,11 +59,12 @@ export default function SubjectViewContent({ ...@@ -59,11 +59,12 @@ export default function SubjectViewContent({
isFieldDisabled, isFieldDisabled,
minimal = false, minimal = false,
useAuditMaterialCenter = false, useAuditMaterialCenter = false,
hideTitle = false,
}) { }) {
const displayFields = buildViewColumns(view, subject); const displayFields = buildViewColumns(view, subject);
const contentFields = minimal const contentFields = minimal
? (displayFields || []).filter((field) => field.field !== 'title') ? (displayFields || []).filter((field) => field.field !== 'title')
: displayFields; : (displayFields || []).filter((field) => !hideTitle || field.field !== 'title');
const fieldVariant = 'secondary'; const fieldVariant = 'secondary';
if (!contentFields || contentFields.length === 0) { if (!contentFields || contentFields.length === 0) {
......
...@@ -63,6 +63,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -63,6 +63,7 @@ const RecordDetailDrawer = (props, ref) => {
deriveFieldValues, deriveFieldValues,
useAuditMaterialCenter = false, useAuditMaterialCenter = false,
initialMode = 'view', initialMode = 'view',
shortTitle = false,
} = props; } = props;
const currentUser = useContext(UserContext); const currentUser = useContext(UserContext);
...@@ -187,7 +188,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -187,7 +188,7 @@ const RecordDetailDrawer = (props, ref) => {
); );
const activeView = detailViews.find((view) => view.name === activeTabKey) || detailViews[0]; const activeView = detailViews.find((view) => view.name === activeTabKey) || detailViews[0];
const hideRecordTitle = shouldHideViewTitleColumn(activeView); const hideRecordTitle = shortTitle || shouldHideViewTitleColumn(activeView);
const isRecordLocked = remoteRecord?.metadata?.is_locked === true; const isRecordLocked = remoteRecord?.metadata?.is_locked === true;
const effectiveReadonly = readonly || isRecordLocked; const effectiveReadonly = readonly || isRecordLocked;
const disabledFieldSet = useMemo( const disabledFieldSet = useMemo(
...@@ -488,6 +489,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -488,6 +489,7 @@ const RecordDetailDrawer = (props, ref) => {
readonly={effectiveReadonly} readonly={effectiveReadonly}
isFieldDisabled={isDrawerFieldDisabled} isFieldDisabled={isDrawerFieldDisabled}
minimal={minimalContent} minimal={minimalContent}
hideTitle={shortTitle}
useAuditMaterialCenter={useAuditMaterialCenter} useAuditMaterialCenter={useAuditMaterialCenter}
/> />
</div> </div>
......
...@@ -2,10 +2,11 @@ import RecordWorkbench from '@/components/record-workbench'; ...@@ -2,10 +2,11 @@ import RecordWorkbench from '@/components/record-workbench';
import RecordDetailDrawer from '@/pages/RecordDetailDrawer'; import RecordDetailDrawer from '@/pages/RecordDetailDrawer';
import { Archive, Delete, Download, Edit, Plus, Reload, Send } from '@/components/AppIcons'; import { Archive, Delete, Download, Edit, Plus, Reload, Send } from '@/components/AppIcons';
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
import { AlertDialog, Button, Toast, toast } from '@heroui/react'; import { AlertDialog, Button, Input, Modal, TextArea, Toast, toast } from '@heroui/react';
import { useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'umi'; import { useLocation } from 'umi';
import UserAssignmentDialog from './UserAssignmentDialog'; import UserAssignmentDialog from './UserAssignmentDialog';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { import {
getSubjectNameFromPath, getSubjectNameFromPath,
INTEGRITY_PRACTICE_SUBJECT_CONFIG, INTEGRITY_PRACTICE_SUBJECT_CONFIG,
...@@ -19,7 +20,11 @@ import { ...@@ -19,7 +20,11 @@ import {
isConfiguredActionVisible, isConfiguredActionVisible,
loadSubjectRecords, loadSubjectRecords,
loadSubjectResources, loadSubjectResources,
buildIntegrityCreateFormFields,
buildIntegrityCreatePayload,
} from './integrityPracticeWorkbenchService'; } from './integrityPracticeWorkbenchService';
import { createRecord } from '@/services/DataService';
import { getViewRestrictionDefaults, getSelectOptions, emptyValueForField } from '@/utils/subjectView';
const EMPTY_ARRAY = []; const EMPTY_ARRAY = [];
const MUTATING_ACTION_PATTERN = /delete|archive|create|push/i; const MUTATING_ACTION_PATTERN = /delete|archive|create|push/i;
...@@ -89,6 +94,9 @@ export default function SubjectWorkbench() { ...@@ -89,6 +94,9 @@ export default function SubjectWorkbench() {
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
const [assignmentState, setAssignmentState] = useState(null); const [assignmentState, setAssignmentState] = useState(null);
const [confirmationState, setConfirmationState] = useState(null); const [confirmationState, setConfirmationState] = useState(null);
const [showCreate, setShowCreate] = useState(false);
const [createValues, setCreateValues] = useState({});
const [createError, setCreateError] = useState('');
const activeView = useMemo( const activeView = useMemo(
() => views.find((view) => view.name === activeViewName) || views[0] || null, () => views.find((view) => view.name === activeViewName) || views[0] || null,
...@@ -101,6 +109,9 @@ export default function SubjectWorkbench() { ...@@ -101,6 +109,9 @@ export default function SubjectWorkbench() {
); );
const filterSchema = useMemo(() => buildSubjectFilterSchema(activeView, subject), [activeView, subject]); const filterSchema = useMemo(() => buildSubjectFilterSchema(activeView, subject), [activeView, subject]);
const createFields = useMemo(() => buildIntegrityCreateFormFields(activeView, subject), [activeView, subject]);
const restrictionDefaults = useMemo(() => getViewRestrictionDefaults(activeView, subject), [activeView, subject]);
const hideCreateTitle = activeView?.settings?.shortTitle === true;
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
...@@ -164,6 +175,21 @@ export default function SubjectWorkbench() { ...@@ -164,6 +175,21 @@ export default function SubjectWorkbench() {
}; };
}, [subjectName, activeView, page, pageSize, filters, columns, paginationEnabled, refreshKey]); }, [subjectName, activeView, page, pageSize, filters, columns, paginationEnabled, refreshKey]);
useEffect(() => {
if (!showCreate) setCreateValues(createFields.reduce((values, field) => ({ ...values, [field.fieldDef.name]: restrictionDefaults.metadata?.[field.fieldDef.name] ?? emptyValueForField(field.fieldDef) }), {}));
}, [createFields, restrictionDefaults, showCreate]);
const handleCreate = async () => {
if (restrictionDefaults.errors.length) { setCreateError(restrictionDefaults.errors.join('\n')); return; }
if (!hideCreateTitle && !String(createValues.title || '').trim()) { setCreateError('记录标题为必填项。'); return; }
setActionLoading(true); setCreateError('');
try {
await createRecord(buildIntegrityCreatePayload(subjectName, createValues, createFields, restrictionDefaults, { hideTitle: hideCreateTitle }));
setShowCreate(false); setRefreshKey((value) => value + 1);
} catch (error) { setCreateError(error?.message || '新增记录失败,请稍后重试。'); }
finally { setActionLoading(false); }
};
const runAction = useCallback( const runAction = useCallback(
async (actionName, context, assignment = null) => { async (actionName, context, assignment = null) => {
setActionLoading(true); setActionLoading(true);
...@@ -243,14 +269,17 @@ export default function SubjectWorkbench() { ...@@ -243,14 +269,17 @@ export default function SubjectWorkbench() {
const toolbarActions = useMemo( const toolbarActions = useMemo(
() => () =>
(gridConfig.tabBarRightSlot || []).map((action, index) => ({ [
...(gridConfig.readonly?.disableAddRow ? [] : [{ key: 'create', label: '新增记录', tone: 'primary', disabled: actionLoading, onClick: () => { setCreateError(''); setShowCreate(true); } }]),
...(gridConfig.tabBarRightSlot || []).map((action, index) => ({
key: action.execute || `toolbar-${index}`, key: action.execute || `toolbar-${index}`,
label: action.label, label: action.label,
tone: 'primary', tone: 'primary',
disabled: actionLoading, disabled: actionLoading,
onClick: (context) => requestAction(action, context), onClick: (context) => requestAction(action, context),
})), })),
[actionLoading, gridConfig.tabBarRightSlot, requestAction], ],
[actionLoading, gridConfig.readonly, gridConfig.tabBarRightSlot, requestAction],
); );
const selectionActions = useMemo( const selectionActions = useMemo(
...@@ -325,6 +354,7 @@ export default function SubjectWorkbench() { ...@@ -325,6 +354,7 @@ export default function SubjectWorkbench() {
onDataChange={onDataChange} onDataChange={onDataChange}
filterView={subjectConfig?.drawerConfig?.filterView || EMPTY_ARRAY} filterView={subjectConfig?.drawerConfig?.filterView || EMPTY_ARRAY}
authorizationView={activeView} authorizationView={activeView}
shortTitle={hideCreateTitle}
useAuditMaterialCenter useAuditMaterialCenter
/> />
), ),
...@@ -351,6 +381,29 @@ export default function SubjectWorkbench() { ...@@ -351,6 +381,29 @@ export default function SubjectWorkbench() {
}} }}
/> />
<Modal.Root>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop isOpen={showCreate} onOpenChange={(open) => { if (!open && !actionLoading) setShowCreate(false); }}>
<Modal.Container size="lg"><Modal.Dialog>
<Modal.Header><Modal.Heading>新增记录</Modal.Heading></Modal.Header>
<Modal.Body>
{createError ? <AlertDialog.Body><div className="whitespace-pre-line text-danger">{createError}</div></AlertDialog.Body> : null}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{createFields.map((field) => {
const name = field.fieldDef.name; const value = createValues[name] ?? '';
const options = getSelectOptions(field.fieldDef);
return <div key={field.field} className="min-w-0">
<label className="mb-1.5 block text-sm font-medium">{field.label || field.fieldDef.label || name}{field.fieldDef.required ? ' *' : ''}</label>
{options.length ? <XinyuanAutocomplete aria-label={field.label || name} options={options.map((option) => ({ ...option, id: String(option.value) }))} selectedKey={value || null} valueText={options.find((option) => option.value === value)?.label || '请选择'} isDisabled={Object.prototype.hasOwnProperty.call(restrictionDefaults.metadata, name)} onSelectionChange={(key) => setCreateValues((current) => ({ ...current, [name]: key ? String(key) : '' }))} /> : field.fieldDef.type === 'long_text' ? <TextArea value={value || ''} onChange={(event) => setCreateValues((current) => ({ ...current, [name]: event.target.value }))} /> : <Input type={field.fieldDef.type === 'number' ? 'number' : field.fieldDef.type === 'date' ? 'date' : 'text'} value={value || ''} isDisabled={Object.prototype.hasOwnProperty.call(restrictionDefaults.metadata, name)} onChange={(event) => setCreateValues((current) => ({ ...current, [name]: event.target.value }))} />}
</div>;
})}
</div>
</Modal.Body>
<Modal.Footer><Button variant="secondary" onPress={() => setShowCreate(false)}>取消</Button><Button isPending={actionLoading} onPress={handleCreate}>确认新增</Button></Modal.Footer>
</Modal.Dialog></Modal.Container>
</Modal.Backdrop>
</Modal.Root>
<ActionConfirmationDialog <ActionConfirmationDialog
state={confirmationState} state={confirmationState}
submitting={actionLoading} submitting={actionLoading}
......
...@@ -27,7 +27,7 @@ ...@@ -27,7 +27,7 @@
4. `gridRecordConfig.pagination = false` 时单次最多读取 1000 条;其余页面使用服务端分页。 4. `gridRecordConfig.pagination = false` 时单次最多读取 1000 条;其余页面使用服务端分页。
5. 列表不再提供独立关键词搜索;用户通过当前视图字段筛选,筛选条件直接写入 `/record/query` 的 `filter`,清空筛选后回到第 1 页。 5. 列表不再提供独立关键词搜索;用户通过当前视图字段筛选,筛选条件直接写入 `/record/query` 的 `filter`,清空筛选后回到第 1 页。
6. 列定义、列顺序、初始排序、固定列和字段显示格式由 Subject View 与 Subject 字段定义共同决定;HeroUI Pro DataGrid 仅在真实横向溢出时固定首列和操作列,并允许当前页原生列排序和列宽调整,不改变服务端分页口径。 6. 列定义、列顺序、初始排序、固定列和字段显示格式由 Subject View 与 Subject 字段定义共同决定;HeroUI Pro DataGrid 仅在真实横向溢出时固定首列和操作列,并允许当前页原生列排序和列宽调整,不改变服务端分页口径。
`settings.shortTitle === true` 时只隐藏 title 展示列,标题仍作为记录身份和保存字段;false 或未配置时正常展示。若 View 配置包含标题列,标题也会作为可筛选字段显示。 `settings.shortTitle === true` 时隐藏 title 展示列,且新建/编辑弹窗不显示、不校验、不提交根级 `title`;false 或未配置时保留标题列及标题必填逻辑。
7. DataGrid 使用原生 Checkbox 选择;选中后显示 HeroUI Pro ActionBar。行操作 1-3 项直接展示,4 项以上展示前两项与“更多”菜单,删除等危险动作始终排在最后。 7. DataGrid 使用原生 Checkbox 选择;选中后显示 HeroUI Pro ActionBar。行操作 1-3 项直接展示,4 项以上展示前两项与“更多”菜单,删除等危险动作始终排在最后。
8. 点击列表操作列的预览图标打开 HeroUI Modal 并进入只读查看态;首个业务列只展示记录文本,不再承担预览交互。点击弹窗统一的“编辑”按钮或列表操作列铅笔图标进入整体编辑态,保存后返回查看态。字段权限、锁定规则和 Subject 字段类型保持不变。 8. 点击列表操作列的预览图标打开 HeroUI Modal 并进入只读查看态;首个业务列只展示记录文本,不再承担预览交互。点击弹窗统一的“编辑”按钮或列表操作列铅笔图标进入整体编辑态,保存后返回查看态。字段权限、锁定规则和 Subject 字段类型保持不变。
9. 当前 Detail 有有效 `settings.auth` 或 `settings.disableColumns` 时,字段权限整套使用 Detail;两项都未配置或没有实际 Detail 时,整体回退打开详情的当前 Grid。两套权限不合并、不逐字段回退;`disableColumns` 对管理员也始终只读,普通用户在选定权限来源中缺少字段 `auth` 时只读。切换 Detail 后立即重算字段状态和编辑入口,编辑入口只检查当前 Detail 展示字段。Grid `viewAuth` 只控制 Tab 可见性,不隐藏详情字段。 9. 当前 Detail 有有效 `settings.auth` 或 `settings.disableColumns` 时,字段权限整套使用 Detail;两项都未配置或没有实际 Detail 时,整体回退打开详情的当前 Grid。两套权限不合并、不逐字段回退;`disableColumns` 对管理员也始终只读,普通用户在选定权限来源中缺少字段 `auth` 时只读。切换 Detail 后立即重算字段状态和编辑入口,编辑入口只检查当前 Detail 展示字段。Grid `viewAuth` 只控制 Tab 可见性,不隐藏详情字段。
...@@ -40,6 +40,12 @@ ...@@ -40,6 +40,12 @@
- 没有可访问 `grid` 视图时不创建兜底 View、不查询记录,并隐藏新增、批量和行操作;只有一个可访问 View 时隐藏 Tabs 轨道。 - 没有可访问 `grid` 视图时不创建兜底 View、不查询记录,并隐藏新增、批量和行操作;只有一个可访问 View 时隐藏 Tabs 轨道。
- 配置动作执行期间禁用重复点击;成功后使用顶部 Toast,失败时保留当前列表并显示服务端错误。 - 配置动作执行期间禁用重复点击;成功后使用顶部 Toast,失败时保留当前列表并显示服务端错误。
### 2.1 新建记录与 View 限制
- 新建记录默认继承当前 Grid View 的 `settings.restrictions`。`$eq` 或单值 `$in` 条件会将唯一值写入 `metadata`,对应控件禁用;即使字段未列入弹窗,也会随请求写入。
- 多值集合、否定条件或同一字段互相冲突时无法确定唯一值,禁止创建并提示“请先调整视图配置”。
- 新建请求使用 `POST /record`,所有业务字段放在 `metadata`;编辑已有记录只提交用户修改字段,不使用 Grid View 限制覆盖原值。
## 3. 记录选择与动作规则 ## 3. 记录选择与动作规则
- 行动作来自 `gridRecordConfig.menuConfig`,顶部动作来自 `gridRecordConfig.tabBarRightSlot`。 - 行动作来自 `gridRecordConfig.menuConfig`,顶部动作来自 `gridRecordConfig.tabBarRightSlot`。
...@@ -71,6 +77,8 @@ ...@@ -71,6 +77,8 @@
4. 为每个填报人调用 `oa_notice:createProgress` 创建 OA 待办,并将任务 ID 写入 `metadata.progress_id_editor`。 4. 为每个填报人调用 `oa_notice:createProgress` 创建 OA 待办,并将任务 ID 写入 `metadata.progress_id_editor`。
5. 创建并发送站内通知,流程链接继续使用历史兼容入口 `/self_inspection_form?id=:flowName`。 5. 创建并发送站内通知,流程链接继续使用历史兼容入口 `/self_inspection_form?id=:flowName`。
填写人提交后,在审核人尚未处理前,填写人可在流程页点击“撤回提交”。撤回会将流程主单的 `editor_status`、`auditor_status` 重置为“未完成”,并调用 `oa_notice:updateProgressStatus` 将审核人待办标记为已完成;撤回不发送站内通知。审核人已完成审批或驳回后不可撤回。
限制与异常: 限制与异常:
- 未选择记录、填报人或审核人时不创建流程。 - 未选择记录、填报人或审核人时不创建流程。
......
...@@ -24,6 +24,10 @@ import { ...@@ -24,6 +24,10 @@ import {
checkViewCondition, checkViewCondition,
isViewAccessible, isViewAccessible,
mergeRestrictionsIntoFilter, mergeRestrictionsIntoFilter,
getViewRestrictionDefaults,
emptyValueForField,
normalizeValueForSubmit,
getFieldNameFromPath,
normalizeText, normalizeText,
sortRecordsByView, sortRecordsByView,
toArray, toArray,
...@@ -35,6 +39,37 @@ import * as XLSX from 'xlsx'; ...@@ -35,6 +39,37 @@ import * as XLSX from 'xlsx';
export const DEFAULT_PAGE_SIZE = 10; export const DEFAULT_PAGE_SIZE = 10;
export function buildIntegrityCreateFormFields(gridView, subject) {
const allColumns = buildViewColumns(gridView, subject, { includeHidden: true });
const includeTitle = gridView?.settings?.shortTitle !== true;
const fields = allColumns
.filter((column) => !column.hide && column.field?.startsWith('metadata.') && column.fieldDef?.name)
.reduce((result, column) => {
if (!result.some((field) => field.fieldDef.name === column.fieldDef.name)) result.push(column);
return result;
}, []);
const fallback = fields.length || toArray(gridView?.settings?.columns).length
? fields
: toArray(subject?.fields).map((field) => ({ field: `metadata.${field.name}`, label: field.label || field.name, fieldDef: field }));
return includeTitle
? [{ field: 'title', label: '记录标题', fieldDef: { name: 'title', type: 'text', label: '记录标题', required: true } }, ...fallback]
: fallback;
}
export function buildIntegrityCreatePayload(subjectName, formValues, fields, restrictionDefaults = {}, { hideTitle = false } = {}) {
const metadata = fields.reduce((result, field) => {
const name = field.fieldDef?.name;
if (!name || name === 'title') return result;
return { ...result, [name]: normalizeValueForSubmit(formValues[name], field.fieldDef) };
}, {});
const resolvedTitle = hideTitle ? undefined : normalizeText(formValues.title) || restrictionDefaults.title || '新建记录';
return {
subject: subjectName,
...(resolvedTitle ? { title: resolvedTitle } : {}),
metadata: { ...metadata, ...(restrictionDefaults.metadata || {}) },
};
}
function buildViewFilter(view, filters = {}) { function buildViewFilter(view, filters = {}) {
const restrictions = view?.settings?.restrictions || []; const restrictions = view?.settings?.restrictions || [];
const filter = mergeRestrictionsIntoFilter({}, restrictions); const filter = mergeRestrictionsIntoFilter({}, restrictions);
...@@ -522,6 +557,9 @@ async function pushSelfInspectionForm(records, userInfo, assignment) { ...@@ -522,6 +557,9 @@ async function pushSelfInspectionForm(records, userInfo, assignment) {
sender: userInfo?._id, sender: userInfo?._id,
editor: fillers, editor: fillers,
auditor: auditors, auditor: auditors,
editor_status: '未完成',
auditor_status: '未完成',
sender_status: '未完成',
restriction_key: '_id', restriction_key: '_id',
restriction_value: outcomeIds, restriction_value: outcomeIds,
view_name: toArray(views)[0]?.name || '', view_name: toArray(views)[0]?.name || '',
......
...@@ -450,6 +450,63 @@ export function mergeRestrictionsIntoFilter(baseFilter = {}, restrictions = []) ...@@ -450,6 +450,63 @@ export function mergeRestrictionsIntoFilter(baseFilter = {}, restrictions = [])
}, { ...baseFilter }); }, { ...baseFilter });
} }
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 safe value for a newly created record. */
export function getViewRestrictionDefaults(view, subject) {
const assignments = new Map();
const unresolved = 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 = restriction.field === 'title' || restriction.field.startsWith('metadata.')
? restriction.field : `metadata.${restriction.field}`;
const fieldDef = getFieldDefinition(fieldPath, subject);
const comparator = normalizeText(restriction.comparator || '$in');
const values = toArray(restriction.value).filter(hasRestrictionValue);
if (!['$eq', '$in'].includes(comparator) || values.length !== 1) {
unresolved.add(fieldPath);
addError(fieldPath, fieldDef);
return;
}
if (unresolved.has(fieldPath)) return;
let value = normalizeValueForSubmit(values[0], fieldDef);
if (fieldDef?.multiple) value = toArray(value);
if (assignments.has(fieldPath) && !restrictionValuesEqual(assignments.get(fieldPath), value)) {
assignments.delete(fieldPath);
unresolved.add(fieldPath);
addError(fieldPath, fieldDef);
return;
}
assignments.set(fieldPath, value);
});
const metadata = {};
let title;
assignments.forEach((value, fieldPath) => {
if (unresolved.has(fieldPath)) return;
if (fieldPath === 'title') title = value;
else {
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