diff --git a/.changeset/7613-recipient-picker-field-kind.md b/.changeset/7613-recipient-picker-field-kind.md new file mode 100644 index 0000000000..4030a54ef4 --- /dev/null +++ b/.changeset/7613-recipient-picker-field-kind.md @@ -0,0 +1,53 @@ +--- +'@object-ui/fields': minor +'@object-ui/i18n': minor +--- + +`RecipientPickerField` gains a picker mode for the `field` sharing recipient +(objectui#7613; maintainer ruling objectstack#14103, executor objectstack#15072). + +`sys_sharing_rule.recipient_type` carries a sixth value, `field` — the +record-relative recipient. The recipients are whoever a user-valued column of the +SHARED object names on each matched record, so `recipient_id` stores a field +NAME (`assignees`, `owner_manager`) and not a record id. The widget's +`TYPE_TO_OBJECT` table had no entry for that kind, so it fell through to the +plain text input documented for unknown types: functional (the stored value was +already the right one), but the admin had to know and type the machine name of +the column by hand, with no list to choose from and nothing saying whether the +name existed. + +When `recipient_type` is `field`, the widget now reads the shared object from +the sibling `object_name` field — the same dependency the `filter-condition` +widget already reads — loads that object's schema through +`dataSource.getObjectSchema`, and offers its **user-valued** columns, storing the +column's name. + +**The offered set is exactly the set the evaluator honours**, and that agreement +is the point of the change rather than a detail of it. The sharing evaluator +reads a column as users when it is the `user` type, or a `lookup` / +`master_detail` whose `reference` is `sys_user`; anything else it treats as +"grants nobody" and warns about once per rule. A picker offering a wider set +would let an admin save a rule that looks configured and authorises nobody — +worse than a hand-typed name, because it has a credible appearance. `hidden` is +deliberately not filtered (the evaluator honours a hidden user column), and +`reference_to` is deliberately not read (the protocol refuses that spelling by +name, and so does the evaluator). + +Three things the mode says out loud rather than leaving to be inferred: it asks +for the shared object before offering anything when `object_name` is still +unset; an object with no user columns says so instead of rendering a +search-flavoured "No matches"; and a stored name that is not on the offered list +— a column deleted or retyped since the rule was saved — stays visible and is +marked, because the evaluator grants nobody for it and a control that merely +looked empty would hide a rule that still names it. + +Unknown recipient types keep degrading to the plain text input, and so does +`field` itself when the data source cannot enumerate an object's columns: a +hand-typed name is worse than a list, and better than a list that can never +fill. + +Three copy keys are added across all ten locale packs +(`fields.recipient.selectField`, `noUserFields`, `fieldNotUserTyped`). The +"select an object first" gate deliberately reuses the criteria builder's +existing sentence rather than adding a twin: it is the same sentence in the same +role on the same form, gating on the same sibling field. diff --git a/packages/fields/src/widgets/RecipientPickerField.tsx b/packages/fields/src/widgets/RecipientPickerField.tsx index 062ac5b308..a90a0e95f3 100644 --- a/packages/fields/src/widgets/RecipientPickerField.tsx +++ b/packages/fields/src/widgets/RecipientPickerField.tsx @@ -23,6 +23,11 @@ import { useFieldTranslation } from './useFieldTranslation.js'; * position → sys_position, store `name` (matched against * sys_user_position.position at evaluation time) * + * One kind is NOT a record picker and therefore has no row above: + * + * field → a user-valued COLUMN of the shared object, store + * the column's `name` (see FIELD_RECIPIENT_TYPE) + * * When `recipient_type` changes after mount the stored id is reset (an id valid * for one type is meaningless for another). Unknown types degrade to a plain * text input so nothing breaks. @@ -49,6 +54,84 @@ const TYPE_TO_OBJECT: Record = { position: { object: 'sys_position', storeField: 'name', labelFields: ['label', 'name'], placeholderKey: 'fields.recipient.selectPosition' }, }; +/** + * The RECORD-RELATIVE recipient kind (maintainer ruling objectstack#14103, + * executor objectstack#15072). It is deliberately absent from + * `TYPE_TO_OBJECT`: there is no target object to query and no record id to + * store. `recipient_id` holds the NAME of a user-valued column on the SHARED + * object, and the recipients are whoever that column names on each matched + * record — so this mode reads the object chosen in the sibling `object_name` + * field (the same dependency the `filter-condition` widget already reads) and + * offers that object's user-valued fields. + */ +const FIELD_RECIPIENT_TYPE = 'field'; + +/** A user-valued column offered by the `field` recipient mode. */ +interface UserFieldDef { + /** The machine name — this is what gets stored in `recipient_id`. */ + name: string; + label: string; +} + +/** + * Does this declared field hold USERS? + * + * ⭐ This predicate is the picker's half of a two-sided agreement with the + * sharing evaluator, which asks the same question of the same two spellings: + * the `user` type (whose target is fixed to `sys_user` by the type itself — + * `Field.user()` takes no target), and a `lookup` / `master_detail` whose + * `reference` is `sys_user`. + * + * ⛔ The two must not drift apart. The evaluator treats a column that is not + * user-typed as "grants nobody" and warns once per rule, so a picker that + * OFFERED a wider set would let an admin save a rule that looks configured and + * authorises nobody — worse than the hand-typed name this mode replaces, + * because it has a credible appearance. + * + * `reference` is the only spelling the protocol declares (objectui#6837): + * `FieldSchema` refuses `reference_to` by name, and the evaluator reads + * `reference` as well. + * + * @internal exported for tests + */ +export function fieldHoldsUsers(def: unknown): boolean { + if (!def || typeof def !== 'object') return false; + const d = def as { type?: unknown; reference?: unknown }; + return ( + d.type === 'user' || + ((d.type === 'lookup' || d.type === 'master_detail') && d.reference === 'sys_user') + ); +} + +/** + * The user-valued columns of an object schema, in declaration order. + * + * Accepts both shapes an object schema spells `fields` in — a name-keyed map + * and an array of definitions carrying their own `name`. + * + * ⛔ `hidden` is deliberately NOT filtered here, unlike the filter builder's + * own field derivation: the evaluator honours a hidden user column exactly + * like a visible one, and withholding it would break the agreement above in + * the other direction — an authorable, working configuration the picker + * refuses to offer. + * + * @internal exported for tests + */ +export function deriveUserFields(schema: any): UserFieldDef[] { + const raw = schema?.fields; + const entries: Array<[string, any]> = Array.isArray(raw) + ? raw.map((f: any) => [f?.name, f]) + : raw && typeof raw === 'object' + ? Object.entries(raw) + : []; + const out: UserFieldDef[] = []; + for (const [name, f] of entries) { + if (!name || !fieldHoldsUsers(f)) continue; + out.push({ name: String(name), label: f.label ? String(f.label) : String(name) }); + } + return out; +} + export function RecipientPickerField({ value, onChange, @@ -66,8 +149,17 @@ export function RecipientPickerField({ const dependentValues: Record = (props as any).dependentValues ?? {}; const recipientType = String(dependentValues.recipient_type ?? ''); const mapping = TYPE_TO_OBJECT[recipientType]; + const objectName = String(dependentValues.object_name ?? ''); + const isFieldRecipient = recipientType === FIELD_RECIPIENT_TYPE; + // Only this data source can answer "which columns does that object declare?". + // Without it the mode falls through to the degraded text input below rather + // than rendering a list that can never fill — the same "nothing breaks" + // promise the header makes for an unknown type. + const canListObjectFields = + isFieldRecipient && !!dataSource && typeof dataSource.getObjectSchema === 'function'; const [records, setRecords] = React.useState(null); + const [userFields, setUserFields] = React.useState(null); // Reset the stored recipient when the admin PICKS a different type (an id for // a user is not a valid team/business-unit id). @@ -109,6 +201,26 @@ export function RecipientPickerField({ }; }, [dataSource, mapping?.object]); + // The `field` mode's own load: the SHARED object's schema, keyed on the + // sibling `object_name`. Re-run when the admin switches object, so the + // offered columns always belong to the object the rule actually names. + React.useEffect(() => { + setUserFields(null); + if (!canListObjectFields || !objectName) return; + let cancelled = false; + (async () => { + try { + const schema = await dataSource.getObjectSchema(objectName); + if (!cancelled) setUserFields(deriveUserFields(schema)); + } catch { + if (!cancelled) setUserFields([]); + } + })(); + return () => { + cancelled = true; + }; + }, [dataSource, objectName, canListObjectFields]); + const labelOf = (r: any): string => { for (const f of mapping?.labelFields ?? ['name']) if (r?.[f]) return String(r[f]); return String(r?.id ?? ''); @@ -124,6 +236,21 @@ export function RecipientPickerField({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [records, value, mapping]); + const userFieldOptions = React.useMemo( + () => (userFields ?? []).map((f) => ({ value: f.name, label: f.label })), + [userFields], + ); + + // DOM pass-through onto the combobox trigger — the widget's real focusable + // control (objectui#3318). `name` is withheld: the trigger is a button, not + // a submission control (same reasoning as #3306's SelectTrigger). + // + // NOTE this widget stays on the #3318 ledger regardless: its dependency- + // gated state (no `recipient_type` chosen yet — the state a fresh form and + // the registry sweep render) is a plain hint paragraph with no focusable + // control, so there is nothing there to carry the attribute. + const { name: _domName, ...triggerDomProps } = toDomProps(props); + if (!recipientType) { return (

@@ -132,9 +259,63 @@ export function RecipientPickerField({ ); } + if (canListObjectFields) { + if (!objectName) { + // Deliberately the SAME key the criteria builder's gate sentence uses, + // not a `fields.recipient.*` twin: it is the same sentence, in the same + // role, on the same form, gating on the same sibling field — and a + // second spelling is precisely how two copies of one deliberately + // shared sentence come to read differently in a locale. + return ( +

+ {t('fields.filterCondition.selectObjectFirst')} +

+ ); + } + + // The stored name stays VISIBLE even when it is not on the offered list — + // a column deleted or retyped since the rule was saved. Dropping it would + // leave the control looking empty while the rule still names that column; + // the evaluator grants NOBODY for it, so the option says so instead of + // reading like any other choice. + const fieldOpts = + value && !userFieldOptions.some((o) => o.value === value) + ? [{ value, label: t('fields.recipient.fieldNotUserTyped', { name: value }) }, ...userFieldOptions] + : userFieldOptions; + + if (readonly) { + if (!value) return ; + return ( + {fieldOpts.find((o) => o.value === value)?.label ?? value} + ); + } + + return ( + onChange(v as any)} + placeholder={ + userFields === null ? t('fields.recipient.loading') : t('fields.recipient.selectField') + } + searchPlaceholder={t('fields.recipient.search')} + emptyText={ + userFields === null ? t('fields.recipient.loading') : t('fields.recipient.noUserFields') + } + disabled={disabled} + className={cn('w-full', className)} + // AFTER the spread so this widget's own computation wins (#3222). + aria-invalid={!!error} + /> + ); + } + if (!mapping) { // Unknown / unsupported recipient type — keep a plain text input so the - // field is never un-editable. + // field is never un-editable. `field` reaches here too when the data + // source cannot enumerate an object's columns: a hand-typed name is worse + // than a list, and better than a list that can never fill. return ( {options.find((o) => o.value === value)?.label ?? value}; } - // DOM pass-through onto the combobox trigger — the widget's real focusable - // control (objectui#3318). `name` is withheld: the trigger is a button, not - // a submission control (same reasoning as #3306's SelectTrigger). - // - // NOTE this widget stays on the #3318 ledger regardless: its dependency- - // gated state (no `recipient_type` chosen yet — the state a fresh form and - // the registry sweep render) is a plain hint paragraph with no focusable - // control, so there is nothing there to carry the attribute. - const { name: _domName, ...triggerDomProps } = toDomProps(props); - return ( , + ); + return { ...view, onChange }; +} + +describe('RecipientPickerField — the `field` kind offers the object\'s user columns', () => { + it('reads the object named by the sibling object_name, not a target object', async () => { + const ds = schemaDataSource(); + renderFieldPicker(ds); + await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalled()); + expect(ds.getObjectSchema).toHaveBeenCalledWith('account'); + // The five record-picking kinds query a target object; this one has none. + expect(ds.find).not.toHaveBeenCalled(); + }); + + it('offers exactly the user-valued columns and withholds the rest', async () => { + const ds = schemaDataSource(); + renderFieldPicker(ds); + await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalled()); + + fireEvent.click(screen.getByRole('combobox')); + // Same-subject control for the four zeros below: the three offered labels + // are rendered by the very same open list. + for (const label of OFFERED) { + expect(await screen.findByText(label)).toBeInTheDocument(); + } + for (const label of WITHHELD) { + expect(screen.queryByText(label)).not.toBeInTheDocument(); + } + }); + + it('stores the field NAME, never a record id', async () => { + const ds = schemaDataSource(); + const { onChange } = renderFieldPicker(ds); + await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalled()); + + fireEvent.click(screen.getByRole('combobox')); + fireEvent.click(await screen.findByText('Owner Manager')); + await waitFor(() => expect(onChange).toHaveBeenCalledWith('owner_manager')); + }); + + it('accepts an array-shaped `fields` declaration as well as a name-keyed map', () => { + const asArray = deriveUserFields({ + fields: [ + { name: 'assignees', type: 'user', label: 'Assignees' }, + { name: 'name', type: 'text', label: 'Name' }, + ], + }); + expect(asArray.map((f) => f.name)).toEqual(['assignees']); + // Control: the map shape of the SAME two columns answers identically. + expect( + deriveUserFields({ + fields: { assignees: { type: 'user', label: 'Assignees' }, name: { type: 'text' } }, + }).map((f) => f.name), + ).toEqual(['assignees']); + }); + + it('offers a hidden user column, because the evaluator honours one', () => { + const derived = deriveUserFields({ + fields: { + assignees: { type: 'user', label: 'Assignees', hidden: true }, + name: { type: 'text', label: 'Name', hidden: true }, + }, + }); + // Withholding it would break the agreement in the other direction: an + // authorable, working configuration the picker refuses to offer. + expect(derived.map((f) => f.name)).toEqual(['assignees']); + }); +}); + +describe('RecipientPickerField — the picker filter agrees with the evaluator', () => { + it('honours exactly the two spellings of a user-valued column', () => { + expect(fieldHoldsUsers({ type: 'user' })).toBe(true); + expect(fieldHoldsUsers({ type: 'lookup', reference: 'sys_user' })).toBe(true); + expect(fieldHoldsUsers({ type: 'master_detail', reference: 'sys_user' })).toBe(true); + }); + + it('refuses everything else, including a lookup to another object', () => { + expect(fieldHoldsUsers({ type: 'text' })).toBe(false); + expect(fieldHoldsUsers({ type: 'lookup', reference: 'sys_team' })).toBe(false); + expect(fieldHoldsUsers({ type: 'master_detail', reference: 'account' })).toBe(false); + expect(fieldHoldsUsers({ type: 'lookup' })).toBe(false); + expect(fieldHoldsUsers(null)).toBe(false); + expect(fieldHoldsUsers('user')).toBe(false); + }); + + it('reads `reference` only — `reference_to` is a spelling the protocol refuses', () => { + expect(fieldHoldsUsers({ type: 'lookup', reference_to: 'sys_user' })).toBe(false); + // Control in the same breath: the accepted spelling on the same shape. + expect(fieldHoldsUsers({ type: 'lookup', reference: 'sys_user' })).toBe(true); + }); +}); + +describe('RecipientPickerField — what the `field` kind says when it cannot offer a list', () => { + it('asks for the shared object before anything else when object_name is unset', async () => { + const ds = schemaDataSource(); + renderFieldPicker(ds, { objectName: '' }); + expect(await screen.findByText('Select an object first.')).toBeInTheDocument(); + expect(ds.getObjectSchema).not.toHaveBeenCalled(); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + }); + + it('says the object has no user columns rather than "No matches"', async () => { + const ds = schemaDataSource({ name: 'invoice', fields: { total: { type: 'number' } } }); + renderFieldPicker(ds); + await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalled()); + + fireEvent.click(screen.getByRole('combobox')); + expect(await screen.findByText('No user fields on this object')).toBeInTheDocument(); + expect(screen.queryByText('No matches')).not.toBeInTheDocument(); + }); + + it('keeps the stored name visible and marks it when it is not a user column', async () => { + const ds = schemaDataSource(); + renderFieldPicker(ds, { value: 'account_team' }); + await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalled()); + + // Marked, not silently dropped: the evaluator grants NOBODY for it, and a + // control that just looked empty would hide a rule that still names it. + expect(await screen.findByText('account_team — not a user field')).toBeInTheDocument(); + }); + + it('degrades to the plain text input when the data source cannot list columns', async () => { + // No `getObjectSchema` — the "nothing breaks" promise the header makes. + const ds = { find: vi.fn().mockResolvedValue({ data: [] }) } as any; + const onChange = vi.fn(); + render( + , + ); + const box = screen.getByRole('textbox') as HTMLInputElement; + expect(box.value).toBe('assignees'); + fireEvent.change(box, { target: { value: 'owner_manager' } }); + expect(onChange).toHaveBeenCalledWith('owner_manager'); + }); + + it('renders the column label, not the raw name, when readonly', async () => { + const ds = schemaDataSource(); + renderFieldPicker(ds, { value: 'assignees', readonly: true }); + expect(await screen.findByText('Assignees')).toBeInTheDocument(); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/fields/src/widgets/useFieldTranslation.ts b/packages/fields/src/widgets/useFieldTranslation.ts index ed85fc1152..71e418f7f6 100644 --- a/packages/fields/src/widgets/useFieldTranslation.ts +++ b/packages/fields/src/widgets/useFieldTranslation.ts @@ -75,6 +75,17 @@ const FIELD_DEFAULTS: Record = { 'fields.recipient.selectBusinessUnit': 'Select a business unit', 'fields.recipient.selectPosition': 'Select a position', 'fields.recipient.selectUnitAndSubordinates': 'Select a business unit', + // objectui#7613 — the `field` recipient kind (maintainer ruling + // objectstack#14103, executor objectstack#15072). It picks a user-valued + // COLUMN of the shared object rather than a record, so its three sentences + // are about columns and none of the per-type placeholders above fits. + // `noUserFields` exists rather than reusing `fields.recipient.empty` + // ("No matches") because an empty list here is not a failed search: it is + // the object having no column the evaluator could read as users, and an + // admin who is not told that has no way to act on it. + 'fields.recipient.selectField': 'Select a user field', + 'fields.recipient.noUserFields': 'No user fields on this object', + 'fields.recipient.fieldNotUserTyped': '{{name}} — not a user field', 'fields.filterCondition.selectObjectFirst': 'Select an object first.', // objectstack#3896 — this used to be 'All records'. An empty criteria never // meant "share everything"; it meant the predicate was missing, and the diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 15929bbb44..8b457fe09a 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -264,6 +264,9 @@ const ar = { selectBusinessUnit: "اختر وحدة عمل", selectPosition: "اختر منصباً", selectUnitAndSubordinates: "اختر وحدة عمل", + selectField: "اختر حقل مستخدم", + noUserFields: "لا توجد حقول مستخدم في هذا الكائن", + fieldNotUserTyped: "{{name}} — ليس حقل مستخدم", }, filterCondition: { selectObjectFirst: "اختر كائناً أولاً.", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 4a904eec21..a91effaf62 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -260,6 +260,9 @@ const de = { selectBusinessUnit: "Geschäftseinheit auswählen", selectPosition: "Position auswählen", selectUnitAndSubordinates: "Geschäftseinheit auswählen", + selectField: "Benutzerfeld auswählen", + noUserFields: "Dieses Objekt hat keine Benutzerfelder", + fieldNotUserTyped: "{{name}} — kein Benutzerfeld", }, filterCondition: { selectObjectFirst: "Wählen Sie zuerst ein Objekt.", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index f20e20352a..4aefcad702 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -296,6 +296,9 @@ const en = { selectBusinessUnit: 'Select a business unit', selectPosition: 'Select a position', selectUnitAndSubordinates: 'Select a business unit', + selectField: 'Select a user field', + noUserFields: 'No user fields on this object', + fieldNotUserTyped: '{{name}} — not a user field', }, filterCondition: { selectObjectFirst: 'Select an object first.', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index b67fae63ab..224ad4967e 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -264,6 +264,9 @@ const es = { selectBusinessUnit: "Seleccionar una unidad de negocio", selectPosition: "Seleccionar un puesto", selectUnitAndSubordinates: "Seleccionar una unidad de negocio", + selectField: "Seleccionar un campo de usuario", + noUserFields: "Este objeto no tiene campos de usuario", + fieldNotUserTyped: "{{name}} — no es un campo de usuario", }, filterCondition: { selectObjectFirst: "Selecciona primero un objeto.", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index d64f5638ac..ed808dbd2d 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -260,6 +260,9 @@ const fr = { selectBusinessUnit: "Sélectionner une unité opérationnelle", selectPosition: "Sélectionner un poste", selectUnitAndSubordinates: "Sélectionner une unité opérationnelle", + selectField: "Sélectionner un champ utilisateur", + noUserFields: "Cet objet n'a aucun champ utilisateur", + fieldNotUserTyped: "{{name}} — pas un champ utilisateur", }, filterCondition: { selectObjectFirst: "Sélectionnez d'abord un objet.", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 5199ceef9f..ef18164a89 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -260,6 +260,9 @@ const ja = { selectBusinessUnit: "事業単位を選択", selectPosition: "役職を選択", selectUnitAndSubordinates: "事業単位を選択", + selectField: "ユーザーフィールドを選択", + noUserFields: "このオブジェクトにユーザーフィールドはありません", + fieldNotUserTyped: "{{name}} — ユーザーフィールドではありません", }, filterCondition: { selectObjectFirst: "先にオブジェクトを選択してください。", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 67577be735..984b969778 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -260,6 +260,9 @@ const ko = { selectBusinessUnit: "사업 단위 선택", selectPosition: "직위 선택", selectUnitAndSubordinates: "사업 단위 선택", + selectField: "사용자 필드 선택", + noUserFields: "이 객체에는 사용자 필드가 없습니다", + fieldNotUserTyped: "{{name}} — 사용자 필드가 아닙니다", }, filterCondition: { selectObjectFirst: "먼저 객체를 선택하세요.", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index f0b15ec16f..6f5078a5aa 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -259,6 +259,9 @@ const pt = { selectBusinessUnit: "Selecionar uma unidade de negócio", selectPosition: "Selecionar um cargo", selectUnitAndSubordinates: "Selecionar uma unidade de negócio", + selectField: "Selecionar um campo de usuário", + noUserFields: "Este objeto não tem campos de usuário", + fieldNotUserTyped: "{{name}} — não é um campo de usuário", }, filterCondition: { selectObjectFirst: "Selecione primeiro um objeto.", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 8bdacb6c2e..b58374786f 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -266,6 +266,9 @@ const ru = { selectBusinessUnit: "Выберите бизнес-подразделение", selectPosition: "Выберите должность", selectUnitAndSubordinates: "Выберите бизнес-подразделение", + selectField: "Выберите поле пользователя", + noUserFields: "В этом объекте нет полей пользователя", + fieldNotUserTyped: "{{name}} — не поле пользователя", }, filterCondition: { selectObjectFirst: "Сначала выберите объект.", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index b68797f23f..f9360c2d3c 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -267,6 +267,9 @@ const zh = { selectBusinessUnit: '请选择业务单元', selectPosition: '请选择岗位', selectUnitAndSubordinates: '请选择业务单元', + selectField: '请选择用户字段', + noUserFields: '此对象没有用户字段', + fieldNotUserTyped: '{{name}} — 不是用户字段', }, filterCondition: { selectObjectFirst: '请先选择对象。',