diff --git a/src/webparts/requestPortal/forms/RecruitingHire/HireAttachmentField.tsx b/src/webparts/requestPortal/forms/RecruitingHire/HireAttachmentField.tsx deleted file mode 100644 index e9d1c83..0000000 --- a/src/webparts/requestPortal/forms/RecruitingHire/HireAttachmentField.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import * as React from 'react'; -import { AttachmentList, FileDropzone, FormField } from '../../shared/ui'; -import { IAttachmentEditor } from '../../shared/attachments/useAttachmentEditor'; -import { attachmentsOfCategory, categoryPrefix, uncategorizedAttachments } from './attachmentCategories'; -import styles from './HireAttachmentField.module.scss'; - -interface Props { - label: string; - editor: IAttachmentEditor; - editable: boolean; - /** Группа новых файлов в editor.pending. */ - groupKey: string; - /** Категория в имени файла; не задана — «прочие» вложения без префикса. */ - category?: string; -} - -/** Поле одной категории вложений: прикреплённые файлы + (в правке) выбор новых. */ -export const HireAttachmentField: React.FC = ({ label, editor, editable, groupKey, category }) => { - const items = category ? attachmentsOfCategory(editor.items, category) : uncategorizedAttachments(editor.items); - // В списке имена показаны без префикса категории — для удаления возвращаем его. - const removeWithPrefix = (name: string): void => editor.remove(category ? categoryPrefix(category) + name : name); - - return ( - - {items.length > 0 && } - {items.length === 0 && !editable && —} - {editable && ( - editor.setPending(groupKey, files)} /> - )} - - ); -}; diff --git a/src/webparts/requestPortal/forms/RecruitingHire/HireAttachments.tsx b/src/webparts/requestPortal/forms/RecruitingHire/HireAttachments.tsx index 54c3595..8c18d5e 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/HireAttachments.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/HireAttachments.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; import { Alert, CollapseSection } from '../../shared/ui'; import { IAttachmentEditor, PrepareFiles } from '../../shared/attachments/useAttachmentEditor'; +import { AttachmentSlotField } from '../../shared/attachments/AttachmentSlotField'; import { ATT_CATEGORY_ID, ATT_CATEGORY_RESUME, prefixFiles, uncategorizedAttachments } from './attachmentCategories'; -import { HireAttachmentField } from './HireAttachmentField'; /** * Группы новых файлов → категория в имени файла (см. attachmentCategories). @@ -37,9 +37,16 @@ export const HireAttachments: React.FC = ({ editor, editable }) => { return ( {editor.error && {editor.error}} - - - {showOther && } + + + {showOther && ( + + )} ); }; diff --git a/src/webparts/requestPortal/forms/RecruitingHire/attachmentCategories.ts b/src/webparts/requestPortal/forms/RecruitingHire/attachmentCategories.ts index a8f8127..a592aea 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/attachmentCategories.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/attachmentCategories.ts @@ -1,50 +1,24 @@ import { IAttachmentInfo } from '../../shared/attachments/attachments.service'; +import { attachmentsWithoutCategories } from '../../shared/attachments/attachmentCategories'; /** - * Категории вложений заявки на прием. Вложения SharePoint — плоская коллекция - * файлов на элементе списка, «категории» у них нет. Поэтому категорию кодируем - * ПРЕФИКСОМ в имени файла: «<Категория> — <исходное имя>». При загрузке имя - * файла переписываем, при показе — группируем по префиксу и префикс срезаем. + * Категории вложений заявки на прием. Механизм подписи префиксом в имени файла — + * общий (shared/attachments/attachmentCategories); здесь только категории этой + * формы. Общие функции реэкспортируются, чтобы импорты формы не менялись. */ export const ATT_CATEGORY_ID = 'Удостоверение'; export const ATT_CATEGORY_RESUME = 'Резюме'; -const SEP = ' — '; - -/** Префикс имени файла для категории: «Удостоверение — ». */ -export function categoryPrefix(category: string): string { - return `${category}${SEP}`; -} - -/** - * Копии File с префиксом категории в имени — для загрузки. File.name неизменяемо, - * поэтому создаём новый File с тем же содержимым и типом, но другим именем. - */ -export function prefixFiles(files: File[], category: string): File[] { - const prefix = categoryPrefix(category); - return files.map((f) => new File([f], prefix + f.name, { type: f.type })); -} - -/** - * Вложения указанной категории, с ОБРЕЗАННЫМ префиксом в имени (для показа). - * url сохраняется как есть; чтобы удалить такой файл, префикс надо вернуть - * обратно (см. categoryPrefix в обработчике удаления формы). - */ -export function attachmentsOfCategory(items: IAttachmentInfo[], category: string): IAttachmentInfo[] { - const prefix = categoryPrefix(category); - return items - .filter((a) => a.fileName.indexOf(prefix) === 0) - .map((a) => ({ url: a.url, fileName: a.fileName.slice(prefix.length) })); -} +export { + categoryPrefix, + prefixFiles, + attachmentsOfCategory +} from '../../shared/attachments/attachmentCategories'; /** * Вложения без известной категории (старые заявки, загруженные до разделения). * Показываем как есть, чтобы файлы не «исчезали» с экрана. */ export function uncategorizedAttachments(items: IAttachmentInfo[]): IAttachmentInfo[] { - const idPrefix = categoryPrefix(ATT_CATEGORY_ID); - const resumePrefix = categoryPrefix(ATT_CATEGORY_RESUME); - return items.filter( - (a) => a.fileName.indexOf(idPrefix) !== 0 && a.fileName.indexOf(resumePrefix) !== 0 - ); + return attachmentsWithoutCategories(items, [ATT_CATEGORY_ID, ATT_CATEGORY_RESUME]); } diff --git a/src/webparts/requestPortal/forms/RecruitingHire/HireAttachmentField.module.scss b/src/webparts/requestPortal/shared/attachments/AttachmentSlotField.module.scss similarity index 58% rename from src/webparts/requestPortal/forms/RecruitingHire/HireAttachmentField.module.scss rename to src/webparts/requestPortal/shared/attachments/AttachmentSlotField.module.scss index 516e793..5878550 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/HireAttachmentField.module.scss +++ b/src/webparts/requestPortal/shared/attachments/AttachmentSlotField.module.scss @@ -1,5 +1,6 @@ @import '../../../../styles/design-tokens'; +// Прочерк вместо пустого слота в режиме чтения. .empty { font-family: $font-sans; font-size: 13.5px; diff --git a/src/webparts/requestPortal/shared/attachments/AttachmentSlotField.tsx b/src/webparts/requestPortal/shared/attachments/AttachmentSlotField.tsx new file mode 100644 index 0000000..a56f073 --- /dev/null +++ b/src/webparts/requestPortal/shared/attachments/AttachmentSlotField.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import { AttachmentList, FileDropzone, FormField } from '../ui'; +import { IAttachmentEditor } from './useAttachmentEditor'; +import { attachmentsOfCategory, attachmentsWithoutCategories, categoryPrefix } from './attachmentCategories'; +import styles from './AttachmentSlotField.module.scss'; + +interface Props { + /** Подпись поля — что за документ. */ + label: string; + editor: IAttachmentEditor; + /** Можно добавлять и удалять файлы (форма подачи / согласующий с полным правом). */ + editable: boolean; + /** Группа новых файлов в editor.pending. */ + groupKey: string; + /** Категория в имени файла (см. attachmentCategories); не задана — «прочие» вложения. */ + category?: string; + /** Для «прочих» (category не задана): категории формы, файлы которых сюда НЕ попадают. */ + knownCategories?: string[]; + /** Звёздочка «обязательно» у подписи; саму проверку делает форма. */ + required?: boolean; +} + +/** + * Поле одного подписанного слота вложений: уже прикреплённые файлы + (в правке) + * выбор новых. Новые файлы копятся в editor.pending[groupKey] и загружаются + * формой при сохранении (editor.uploadPending), с префиксом категории в имени. + */ +export const AttachmentSlotField: React.FC = ({ + label, + editor, + editable, + groupKey, + category, + knownCategories = [], + required +}) => { + const items = category + ? attachmentsOfCategory(editor.items, category) + : attachmentsWithoutCategories(editor.items, knownCategories); + // В списке имена показаны без префикса категории — для удаления возвращаем его. + const removeWithPrefix = (name: string): void => editor.remove(category ? categoryPrefix(category) + name : name); + + return ( + + {items.length > 0 && } + {items.length === 0 && !editable && —} + {editable && ( + editor.setPending(groupKey, files)} /> + )} + + ); +}; diff --git a/src/webparts/requestPortal/shared/attachments/attachmentCategories.test.ts b/src/webparts/requestPortal/shared/attachments/attachmentCategories.test.ts new file mode 100644 index 0000000..dcf3e68 --- /dev/null +++ b/src/webparts/requestPortal/shared/attachments/attachmentCategories.test.ts @@ -0,0 +1,27 @@ +import { + categoryPrefix, + attachmentsOfCategory, + attachmentsWithoutCategories +} from './attachmentCategories'; + +const file = (fileName: string): { fileName: string; url: string } => ({ fileName, url: `/att/${fileName}` }); + +describe('attachmentCategories', () => { + const items = [file('Резюме — cv.pdf'), file('Удостоверение — id.jpg'), file('старый.docx')]; + + it('префикс — подпись и длинное тире с пробелами', () => { + expect(categoryPrefix('Резюме')).toBe('Резюме — '); + }); + + it('файлы категории — с обрезанным префиксом и прежней ссылкой', () => { + expect(attachmentsOfCategory(items, 'Резюме')).toEqual([{ fileName: 'cv.pdf', url: '/att/Резюме — cv.pdf' }]); + }); + + it('без категорий — только файлы, не попавшие ни в одну из известных', () => { + expect(attachmentsWithoutCategories(items, ['Резюме', 'Удостоверение'])).toEqual([file('старый.docx')]); + }); + + it('без известных категорий «прочие» — это все файлы', () => { + expect(attachmentsWithoutCategories(items, [])).toEqual(items); + }); +}); diff --git a/src/webparts/requestPortal/shared/attachments/attachmentCategories.ts b/src/webparts/requestPortal/shared/attachments/attachmentCategories.ts new file mode 100644 index 0000000..83341da --- /dev/null +++ b/src/webparts/requestPortal/shared/attachments/attachmentCategories.ts @@ -0,0 +1,46 @@ +import { IAttachmentInfo } from './attachments.service'; + +/** + * Подписанные вложения («категории»). Вложения SharePoint — плоская коллекция + * файлов на элементе списка, подписи у них нет. Поэтому подпись (что за + * документ) кодируем ПРЕФИКСОМ в имени файла: «<Подпись> — <исходное имя>». + * При загрузке имя файла переписываем, при показе — группируем по префиксу и + * префикс срезаем. Вынесено из «Заявки на приём», где появилось первым. + */ + +const SEP = ' — '; + +/** Префикс имени файла для категории: «Удостоверение — ». */ +export function categoryPrefix(category: string): string { + return `${category}${SEP}`; +} + +/** + * Копии File с префиксом категории в имени — для загрузки. File.name неизменяемо, + * поэтому создаём новый File с тем же содержимым и типом, но другим именем. + */ +export function prefixFiles(files: File[], category: string): File[] { + const prefix = categoryPrefix(category); + return files.map((f) => new File([f], prefix + f.name, { type: f.type })); +} + +/** + * Вложения указанной категории, с ОБРЕЗАННЫМ префиксом в имени (для показа). + * url сохраняется как есть; чтобы удалить такой файл, префикс надо вернуть + * обратно (categoryPrefix + показанное имя). + */ +export function attachmentsOfCategory(items: IAttachmentInfo[], category: string): IAttachmentInfo[] { + const prefix = categoryPrefix(category); + return items + .filter((a) => a.fileName.indexOf(prefix) === 0) + .map((a) => ({ url: a.url, fileName: a.fileName.slice(prefix.length) })); +} + +/** + * Вложения, не относящиеся ни к одной из известных категорий формы (старые + * заявки, «прочие»). Показываем как есть, чтобы файлы не «исчезали» с экрана. + */ +export function attachmentsWithoutCategories(items: IAttachmentInfo[], categories: string[]): IAttachmentInfo[] { + const prefixes = categories.map(categoryPrefix); + return items.filter((a) => !prefixes.some((prefix) => a.fileName.indexOf(prefix) === 0)); +} diff --git a/src/webparts/requestPortal/shared/attachments/useAttachmentEditor.ts b/src/webparts/requestPortal/shared/attachments/useAttachmentEditor.ts index a947b11..de18565 100644 --- a/src/webparts/requestPortal/shared/attachments/useAttachmentEditor.ts +++ b/src/webparts/requestPortal/shared/attachments/useAttachmentEditor.ts @@ -4,10 +4,16 @@ import { friendlyErrorMessage } from '../diagnostics'; /** * Подготовка новых файлов группы к загрузке — напр. префикс категории в имени - * (см. forms/RecruitingHire/attachmentCategories). По умолчанию — как есть. + * (см. attachmentCategories). По умолчанию — как есть. */ export type PrepareFiles = (groupKey: string, files: File[]) => File[]; +/** Куда грузить: список (Title текущей партиции или url архивной) + id элемента. */ +export interface IAttachmentTarget { + listRef: string; + itemId: number; +} + export interface IAttachmentEditor { /** Вложения, уже прикреплённые к заявке. */ items: IAttachmentInfo[]; @@ -17,8 +23,12 @@ export interface IAttachmentEditor { setPending: (groupKey: string, files: File[]) => void; /** Удалить прикреплённый файл сразу из заявки (по полному имени файла в SP). */ remove: (fileName: string) => void; - /** Загрузить все выбранные файлы и перечитать список вложений. */ - uploadPending: (prepare?: PrepareFiles) => Promise; + /** + * Загрузить все выбранные файлы и перечитать список вложений. target — когда + * элемент только что создан и его id ещё не дошёл до хука (сохранение новой + * заявки); не задан — грузим в listRef/itemId хука. + */ + uploadPending: (prepare?: PrepareFiles, target?: IAttachmentTarget) => Promise; /** Ошибка последнего удаления — показать рядом со списком. */ error: string | undefined; } @@ -26,14 +36,16 @@ export interface IAttachmentEditor { const keepAsIs: PrepareFiles = (_groupKey, files) => files; /** - * Правка вложений уже поданной заявки — для согласующего с полным правом - * (см. shared/approval/fullEditRights.ts). Удаление — сразу, как в форме - * автора; новые файлы копятся и загружаются вместе с сохранением полей - * (кнопка «Сохранить» или решение), чтобы не было второй кнопки сохранения. + * Правка вложений заявки: удаление — сразу, новые файлы копятся и загружаются + * вместе с сохранением полей (чтобы не было второй кнопки сохранения). + * Используется в просмотре согласующего с полным правом (см. shared/approval/ + * fullEditRights.ts) и в формах подачи. * * listRef — Title текущей партиции или url архивной (см. attachments.service). + * itemId — undefined, пока новая заявка не сохранена: удалять ещё нечего, а + * загрузка идёт с явным target. */ -export function useAttachmentEditor(listRef: string, itemId: number): IAttachmentEditor { +export function useAttachmentEditor(listRef: string, itemId: number | undefined): IAttachmentEditor { const [items, setItems] = useState([]); const [pending, setPendingState] = useState>({}); const [error, setError] = useState(undefined); @@ -42,22 +54,27 @@ export function useAttachmentEditor(listRef: string, itemId: number): IAttachmen setPendingState((prev) => ({ ...prev, [groupKey]: files })); const remove = (fileName: string): void => { + if (itemId === undefined) return; setError(undefined); deleteAttachment(listRef, itemId, fileName) .then(() => setItems((prev) => prev.filter((a) => a.fileName !== fileName))) .catch((e) => setError(`Не удалось удалить файл «${fileName}»: ${friendlyErrorMessage(e)}`)); }; - const uploadPending = async (prepare: PrepareFiles = keepAsIs): Promise => { + const uploadPending = async (prepare: PrepareFiles = keepAsIs, target?: IAttachmentTarget): Promise => { const files = Object.keys(pending).reduce( (acc, groupKey) => acc.concat(prepare(groupKey, pending[groupKey])), [] ); if (files.length === 0) return; - await uploadAttachments(listRef, itemId, files); + const to = target ?? (itemId === undefined ? undefined : { listRef, itemId }); + if (!to) { + throw new Error('Не удалось прикрепить файлы: заявка ещё не сохранена.'); + } + await uploadAttachments(to.listRef, to.itemId, files); // Загруженные убираем из выбора, иначе повторное сохранение продублирует их. setPendingState({}); - setItems(await getAttachments(listRef, itemId)); + setItems(await getAttachments(to.listRef, to.itemId)); }; return { items, setItems, pending, setPending, remove, uploadPending, error };