fix: нейтральный текст при пустом маршруте в заявке 1С
Tests / frontend-test (push) Successful in 1m19s

This commit is contained in:
Тимур Городецкий
2026-09-25 09:33:38 +05:00
parent 1884007161
commit ab21683377
13 changed files with 61 additions and 22 deletions
+22 -4
View File
@@ -129,13 +129,14 @@ $SITE_COLUMN_GROUP = 'Портал заявок'
# Типы, которые НЕ делаем эталоном (столбцом сайта), а создаём столбцом списка: # Типы, которые НЕ делаем эталоном (столбцом сайта), а создаём столбцом списка:
# - Lookup (эталон привязан к одному списку, а Ticket у каждого свой); # - Lookup (эталон привязан к одному списку, а Ticket у каждого свой);
# - Number с decimals и UserMulti (создаются через XML; web-level XML в PnP падает # - Number с decimals, UserMulti и MultiChoice (создаются через XML; web-level XML
# NRE, и это одноразовые поля конкретной формы — переиспользовать нечего). # в PnP падает NRE, и это одноразовые поля конкретной формы — переиспользовать нечего).
function Test-NeedsListColumn { function Test-NeedsListColumn {
param($Col) param($Col)
return ($Col.type -eq 'Lookup') ` return ($Col.type -eq 'Lookup') `
-or ($Col.type -eq 'Number' -and $null -ne $Col.decimals) ` -or ($Col.type -eq 'Number' -and $null -ne $Col.decimals) `
-or ($Col.type -eq 'User' -and $Col.allowMultiple) -or ($Col.type -eq 'User' -and $Col.allowMultiple) `
-or ($Col.type -eq 'Choice' -and $Col.allowMultiple)
} }
# Создаёт/обновляет СТОЛБЕЦ САЙТА (эталон) — один раз на весь сайт. Только простые # Создаёт/обновляет СТОЛБЕЦ САЙТА (эталон) — один раз на весь сайт. Только простые
@@ -184,6 +185,14 @@ function Add-ListColumn {
param([string]$List, $Col) param([string]$List, $Col)
if (Test-FieldExists -List $List -InternalName $Col.internalName) { if (Test-FieldExists -List $List -InternalName $Col.internalName) {
# Тип существующей колонки скрипт НЕ меняет (данные!) — одиночный выбор в
# множественный переключают вручную: параметры столбца → «флажки».
if ($Col.type -eq 'Choice' -and $Col.allowMultiple) {
$existingType = (Get-PnPField -List $List -Identity $Col.internalName).TypeAsString
if ($existingType -ne 'MultiChoice') {
Write-Warning " [!] $($Col.internalName): в списке одиночный выбор ($existingType), а нужен множественный — переключите столбец на «флажки» вручную"
}
}
if ($Col.type -eq 'Choice' -and $Col.choices) { if ($Col.type -eq 'Choice' -and $Col.choices) {
try { try {
Set-PnPField -List $List -Identity $Col.internalName -Values @{ Choices = [string[]]$Col.choices } | Out-Null Set-PnPField -List $List -Identity $Col.internalName -Values @{ Choices = [string[]]$Col.choices } | Out-Null
@@ -217,7 +226,16 @@ function Add-ListColumn {
Set-PnPField -List $List -Identity $Col.internalName -Values @{ SelectionMode = $mode } | Out-Null Set-PnPField -List $List -Identity $Col.internalName -Values @{ SelectionMode = $mode } | Out-Null
} }
} }
'Choice' { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type Choice -Choices $Col.choices -AddToDefaultView | Out-Null } 'Choice' {
if ($Col.allowMultiple) {
$choicesXml = ($Col.choices | ForEach-Object { "<CHOICE>$(ConvertTo-XmlSafe $_)</CHOICE>" }) -join ''
$xml = "<Field Type='MultiChoice' DisplayName='$(ConvertTo-XmlSafe $Col.title)' Name='$($Col.internalName)' FillInChoice='FALSE'><CHOICES>$choicesXml</CHOICES></Field>"
Add-PnPFieldFromXml -List $List -FieldXml $xml | Out-Null
}
else {
Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type Choice -Choices $Col.choices -AddToDefaultView | Out-Null
}
}
'Url' { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type URL -AddToDefaultView | Out-Null } 'Url' { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type URL -AddToDefaultView | Out-Null }
'Number' { 'Number' {
if ($null -ne $Col.decimals) { if ($null -ne $Col.decimals) {
@@ -8,8 +8,8 @@ import {
Select, Select,
PeopleField, PeopleField,
PeopleFieldMulti, PeopleFieldMulti,
MultiChoiceField,
} from "../../../shared/ui"; } from "../../../shared/ui";
import { MultiChoiceField } from "./MultiChoiceField";
import { import {
ISectionProps, ISectionProps,
PERSON_PLACEHOLDER, PERSON_PLACEHOLDER,
@@ -1,8 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import { CollapseSection, FormField, TextInput, TextArea, Select } from '../../../shared/ui'; import { CollapseSection, FormField, TextInput, TextArea, Select, MultiChoiceField } from '../../../shared/ui';
import { PositionPicker } from '../../../shared/dictionary'; import { PositionPicker } from '../../../shared/dictionary';
import { LanguagesField } from './LanguagesField'; import { LanguagesField } from './LanguagesField';
import { MultiChoiceField } from './MultiChoiceField';
import { import {
ISectionProps, ISectionProps,
EMPLOYMENT_TYPE_OPTIONS, EMPLOYMENT_TYPE_OPTIONS,
@@ -226,7 +226,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
data.TaskName.trim() && data.TaskName.trim() &&
data.WorkType && data.WorkType &&
data.Organization && data.Organization &&
data.DB1C && data.DB1C.length > 0 &&
data.Question.trim() data.Question.trim()
); );
const validate = (): string | undefined => { const validate = (): string | undefined => {
@@ -412,7 +412,10 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
<ApprovalMatrix <ApprovalMatrix
editor={matrixEditor} editor={matrixEditor}
search={(q) => approval.searchUsers(q)} search={(q) => approval.searchUsers(q)}
emptyText="Маршрут для этого вида работ пока не настроен — обратитесь к администратору маршрутов согласования." // Пустой маршрут — штатная ситуация: для части видов работ согласование
// не нужно, заявка сразу уходит в Planner. Сбой загрузки маршрута
// показывается отдельно (matrix.error) и блокирует отправку.
emptyText="Для этого вида работ согласование не требуется — заявка сразу поступит в отдел 1С."
/> />
<SubmitActions <SubmitActions
@@ -68,7 +68,7 @@ function buildPlannerNotes(
lines.push(`Телефон: ${data.Phone}`); lines.push(`Телефон: ${data.Phone}`);
lines.push(`Отдел: ${data.Department}`); lines.push(`Отдел: ${data.Department}`);
lines.push(`Должность: ${data.Position}`); lines.push(`Должность: ${data.Position}`);
lines.push(`База 1С: ${data.DB1C}`); lines.push(`База 1С: ${data.DB1C.join(', ')}`);
lines.push(`Виды работ: ${data.WorkType}`); lines.push(`Виды работ: ${data.WorkType}`);
lines.push(''); lines.push('');
lines.push('Вопрос:'); lines.push('Вопрос:');
@@ -3,10 +3,24 @@ import { dataListTitle } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA } from './model'; import { FORM_ID, IFormData, EMPTY_DATA } from './model';
// TaskName пишется/читается через системную колонку Title (см. provisioning.json/titleColumnName). // TaskName пишется/читается через системную колонку Title (см. provisioning.json/titleColumnName).
// DB1C — отдельно, это множественный выбор (см. toStringArray).
const SCALAR_FIELDS: (keyof IFormData)[] = [ const SCALAR_FIELDS: (keyof IFormData)[] = [
'WorkType', 'Organization', 'DB1C', 'FIO', 'Email', 'Phone', 'Department', 'Position', 'Manager', 'Question', 'WorkType', 'Organization', 'FIO', 'Email', 'Phone', 'Department', 'Position', 'Manager', 'Question',
]; ];
/**
* Значение колонки множественного выбора → string[]. Строка — заявки, сохранённые,
* пока «База 1С» была одиночным выбором (или колонку ещё не переключили);
* { results } — ответ в verbose-формате OData.
*/
function toStringArray(raw: unknown): string[] {
if (Array.isArray(raw)) return raw.filter((v): v is string => typeof v === 'string' && v !== '');
if (raw && typeof raw === 'object' && Array.isArray((raw as { results?: unknown }).results)) {
return toStringArray((raw as { results: unknown[] }).results);
}
return typeof raw === 'string' && raw !== '' ? [raw] : [];
}
export interface ILoadedRequest { export interface ILoadedRequest {
data: IFormData; data: IFormData;
status: string; status: string;
@@ -26,7 +40,7 @@ export async function loadTicketRequest(itemId: number, listId?: string): Promis
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID)); const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const item = (await list const item = (await list
.items.getById(itemId) .items.getById(itemId)
.select(...SCALAR_FIELDS, 'Title', 'RequestStatus', 'RequestUniqueCode')()) as Record<string, unknown>; .select(...SCALAR_FIELDS, 'DB1C', 'Title', 'RequestStatus', 'RequestUniqueCode')()) as Record<string, unknown>;
const data: IFormData = { ...EMPTY_DATA }; const data: IFormData = { ...EMPTY_DATA };
const writable = data as unknown as Record<string, string>; const writable = data as unknown as Record<string, string>;
@@ -36,6 +50,7 @@ export async function loadTicketRequest(itemId: number, listId?: string): Promis
writable[key] = value === null || value === undefined ? '' : String(value); writable[key] = value === null || value === undefined ? '' : String(value);
}); });
data.TaskName = (item.Title as string) || ''; data.TaskName = (item.Title as string) || '';
data.DB1C = toStringArray(item.DB1C);
return { return {
data, data,
@@ -16,7 +16,8 @@ export interface IFormData {
TaskName: string; TaskName: string;
WorkType: string; WorkType: string;
Organization: string; Organization: string;
DB1C: string; /** Базы 1С — множественный выбор (колонка MultiChoice), минимум одна. */
DB1C: string[];
FIO: string; FIO: string;
Email: string; Email: string;
Phone: string; Phone: string;
@@ -45,7 +46,7 @@ export const EMPTY_DATA: IFormData = {
TaskName: '', TaskName: '',
WorkType: '', WorkType: '',
Organization: '', Organization: '',
DB1C: '', DB1C: [],
FIO: '', FIO: '',
Email: '', Email: '',
Phone: '', Phone: '',
@@ -25,7 +25,7 @@
"ОСН Беркут", "ОСН Беркут",
"CT Global" "CT Global"
] }, ] },
{ "internalName": "DB1C", "title": "База 1С", "type": "Choice", "required": true, "choices": [ { "internalName": "DB1C", "title": "База 1С", "type": "Choice", "allowMultiple": true, "required": true, "choices": [
"1С: Зарплата и управление персоналом (ЗУП)", "1С: Зарплата и управление персоналом (ЗУП)",
"1С: Документооборот (ДО)", "1С: Документооборот (ДО)",
"1С: Автотранспортное предприятие (АТП)", "1С: Автотранспортное предприятие (АТП)",
@@ -1,5 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { CollapseSection, FormField, Select, TextArea, TextInput, ISelectOption } from '../../../shared/ui'; import { CollapseSection, FormField, MultiChoiceField, Select, TextArea, TextInput, ISelectOption } from '../../../shared/ui';
import { ISectionProps } from '../model'; import { ISectionProps } from '../model';
import { WORK_TYPE_OPTIONS, ORGANIZATION_OPTIONS, DB1C_OPTIONS, QUESTION_MAX } from '../config'; import { WORK_TYPE_OPTIONS, ORGANIZATION_OPTIONS, DB1C_OPTIONS, QUESTION_MAX } from '../config';
@@ -32,13 +32,12 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set }) => (
placeholder="— выберите —" placeholder="— выберите —"
/> />
</FormField> </FormField>
<FormField label="База 1С" htmlFor="DB1C" required> <FormField label="База 1С" fieldKey="DB1C" required>
<Select <MultiChoiceField
id="DB1C"
value={data.DB1C} value={data.DB1C}
onChange={(v) => set('DB1C', v)} onChange={(v) => set('DB1C', v)}
options={DB1C_SELECT_OPTIONS} options={DB1C_SELECT_OPTIONS}
placeholder="— выберите —" placeholder="Выберите одну или несколько баз"
/> />
</FormField> </FormField>
<FormField label={`Вопрос (${data.Question.length}/${QUESTION_MAX})`} htmlFor="Question" required full> <FormField label={`Вопрос (${data.Question.length}/${QUESTION_MAX})`} htmlFor="Question" required full>
@@ -38,7 +38,10 @@ export interface IColumnDef {
lookupShowField?: string; lookupShowField?: string;
/** Для type === 'User': кого можно выбирать. По умолчанию — как в SharePoint. */ /** Для type === 'User': кого можно выбирать. По умолчанию — как в SharePoint. */
userSelectionMode?: 'PeopleOnly' | 'PeopleAndGroups'; userSelectionMode?: 'PeopleOnly' | 'PeopleAndGroups';
/** Для type === 'User': разрешить выбор нескольких (создаётся колонка UserMulti). */ /**
* Разрешить выбор нескольких: для type === 'User' создаётся колонка UserMulti,
* для type === 'Choice' — MultiChoice (значение в коде — string[]).
*/
allowMultiple?: boolean; allowMultiple?: boolean;
} }
@@ -1,7 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import styles from './MultiChoiceField.module.scss'; import styles from './MultiChoiceField.module.scss';
import { useFormReadOnly, ISelectOption } from '../../../shared/ui'; import { useFormReadOnly, ISelectOption } from '@tnsu/ui-kit';
export interface IMultiChoiceFieldProps { export interface IMultiChoiceFieldProps {
value: string[]; value: string[];
@@ -36,6 +36,7 @@ export type { ISelectOption, IConfirmOptions } from '@tnsu/ui-kit';
export { FormField } from './FormField/FormField'; export { FormField } from './FormField/FormField';
export { FieldChangesProvider } from './FieldChanges/FieldChangesContext'; export { FieldChangesProvider } from './FieldChanges/FieldChangesContext';
export { FieldChangesBanner } from './FieldChanges/FieldChangesBanner'; export { FieldChangesBanner } from './FieldChanges/FieldChangesBanner';
export { MultiChoiceField } from './MultiChoiceField/MultiChoiceField';
export { ItemsTable } from './ItemsTable/ItemsTable'; export { ItemsTable } from './ItemsTable/ItemsTable';
export type { IItemsTableProps } from './ItemsTable/ItemsTable'; export type { IItemsTableProps } from './ItemsTable/ItemsTable';
export type { ILineItem } from './ItemsTable/ILineItem'; export type { ILineItem } from './ItemsTable/ILineItem';