diff --git a/src/webparts/requestPortal/components/RequestPortal.module.scss b/src/webparts/requestPortal/components/RequestPortal.module.scss index 9aa1ad4..251ad54 100644 --- a/src/webparts/requestPortal/components/RequestPortal.module.scss +++ b/src/webparts/requestPortal/components/RequestPortal.module.scss @@ -55,21 +55,44 @@ min-height: 78vh; // «присутствие» рейла даже когда контента мало } -// Рейл-колонка: раньше тянулась по высоте .main (align-items: stretch), из-за -// чего высота рейла плавала вместе с содержимым соседней колонки — например, у -// вкладки «Инициированные мной» появляется доп. фильтр по статусу, .main -// становится чуть выше, и градиентный фон Sidebar (посчитанный от своей же -// высоты) заметно менял оттенок. Прибиваем рейл к высоте окна (sticky + 100vh): -// теперь он не зависит от того, что показано рядом. На мобильном брейкпоинте -// сам Sidebar уходит в position:fixed (см. @tnsu/ui-kit) — там эти правила уже -// не действуют. +// Рейл-колонка. +// +// Здесь сходятся два требования, которые по отдельности решаются по-разному: +// +// 1. Градиент рейла не должен менять оттенок от высоты соседней колонки. Раньше +// колонка тянулась по .main (align-items: stretch), и, например, доп. фильтр +// по статусу на вкладке «Инициированные мной» делал .main чуть выше — фон +// Sidebar (градиент от своей же высоты, см. $grad-header в @tnsu/ui-kit) +// заметно менял тон. Поэтому САМ РЕЙЛ прибит к высоте окна (100vh) и от +// содержимого рядом больше не зависит. +// 2. Под рейлом не должно быть белого провала на длинных таблицах. Прежняя +// версия задавала height:100vh самой КОЛОНКЕ — и всё, что ниже первого +// экрана, оставалось непрокрашенным: на длинном списке заявок рейл выглядел +// обрезанным. Ставку на sticky тут держать нельзя: в SharePoint прокручивается +// не окно, а внутренний контейнер страницы, и sticky там нередко не +// включается вовсе — тогда рейл просто уезжает вверх вместе с контентом. +// +// Отсюда разделение: КОЛОНКА тянется во всю высоту раскладки и закрашена +// $navy-800 — это цвет, которым градиент рейла заканчивается (на 85% и до +// конца), поэтому стык с липкой частью не виден. РЕЙЛ внутри — sticky на 100vh +// поверх этой заливки. Не включился sticky — под рейлом всё равно та же +// заливка, а не белизна. +// +// На мобильном брейкпоинте сам Sidebar уходит в position:fixed (см. +// @tnsu/ui-kit) — там эти правила уже не действуют, см. @media ниже. .side { flex: 0 0 auto; display: flex; - position: sticky; - top: 0; - align-self: flex-start; - height: 100vh; + align-self: stretch; + background: $navy-800; + + // Прямой потомок — корень Sidebar из @tnsu/ui-kit (своего класса снаружи не + // видно, поэтому селектор по структуре, а не по имени). + > * { + position: sticky; + top: 0; + height: 100vh; + } } .main { @@ -109,6 +132,20 @@ .mobileNav { display: block; } + + // Снимаем десктопные правила рейла явно. Здесь ui-kit сам делает Sidebar + // position:fixed (top/bottom/left), и наш sticky+100vh спорил бы с ним при + // равной специфичности — исход решался бы порядком подключения стилей. + // Заливка колонки тоже не нужна: колонка схлопнута, drawer красит себя сам. + .side { + align-self: flex-start; + background: none; + + > * { + position: static; + height: auto; + } + } } // Полоса отладочной подмены личности (см. shared/impersonation.ts). Красная и diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx index a6ab522..a8f9988 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx @@ -209,9 +209,13 @@ const Table: React.FC<{ // комментарий — по columnsFor (зависит от раздела и фильтра). Кастомные ячейки // (статус → StatusBadge, дата → формат) задаются через render. const columns: IDataTableColumn[] = [ - { key: 'id', title: 'ID', width: '70px' }, + // Код заявки, а не Id элемента: Id уникален только внутри своей партиции + // (в каждом списке SharePoint нумерация с единицы), поэтому после архивации + // в таблице встречались разные заявки под одним номером. Кода нет у заявок, + // созданных до появления колонки — для них показываем Id, как раньше. + { key: 'code', title: 'Код', width: '130px', render: (r) => r.code || `#${r.id}` }, { key: 'type', title: 'Тип', width: '200px', render: (r) => r.requestTypeName }, - { key: 'title', title: 'Название', render: (r) => r.title || `#${r.id}` }, + { key: 'title', title: 'Название', render: (r) => r.title || r.code || `#${r.id}` }, { key: 'date', title: 'Дата', width: '110px', render: (r) => formatDate(r.created) } ]; if (cols.author) { diff --git a/src/webparts/requestPortal/features/myRequests/SearchView.module.scss b/src/webparts/requestPortal/features/myRequests/SearchView.module.scss index 966ce9d..d44d681 100644 --- a/src/webparts/requestPortal/features/myRequests/SearchView.module.scss +++ b/src/webparts/requestPortal/features/myRequests/SearchView.module.scss @@ -126,7 +126,9 @@ } .colId { - width: 70px; + // Под код заявки («13-2608-42»), а не под Id элемента: прежних 70px на него + // не хватало (см. IMyRequest.code). + width: 130px; color: $gray-400; white-space: nowrap; } diff --git a/src/webparts/requestPortal/features/myRequests/SearchView.tsx b/src/webparts/requestPortal/features/myRequests/SearchView.tsx index 3125a3b..52ff9e9 100644 --- a/src/webparts/requestPortal/features/myRequests/SearchView.tsx +++ b/src/webparts/requestPortal/features/myRequests/SearchView.tsx @@ -67,7 +67,7 @@ const Table: React.FC<{ rows: IMyRequest[]; onOpen: (request: IMyRequest) => voi - + @@ -92,9 +92,10 @@ const Table: React.FC<{ rows: IMyRequest[]; onOpen: (request: IMyRequest) => voi } }} > - + {/* Код заявки, а не Id элемента — см. IMyRequest.code и колонки в RequestsTableView. */} + - +
IDКод Тип Название Дата{r.id}{r.code || `#${r.id}`} {r.requestTypeName}{r.title || `#${r.id}`}{r.title || r.code || `#${r.id}`} {formatDate(r.created)} {[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'} diff --git a/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts b/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts index fd98e0e..6df1e09 100644 --- a/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts +++ b/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts @@ -2,6 +2,18 @@ // Собирается из списка данных SharePoint конкретного типа заявки (см. myRequests.service.ts). export interface IMyRequest { id: number; + /** + * Уникальный код заявки (колонка RequestUniqueCode) — вида «13-2608-42», см. + * shared/requestCode.ts. Именно он показывается пользователю вместо Id + * элемента: Id уникален только ВНУТРИ своей партиции (в каждом списке + * SharePoint нумерация с единицы), поэтому после архивации в одной таблице + * оказывались разные заявки с одинаковым номером. + * + * Пустая строка — заявка создана до появления колонки либо код не успел + * записаться (он проставляется вторым запросом, уже после создания элемента, + * см. saveRequestItem): тогда в таблице показывается Id, как раньше. + */ + code: string; title: string; status: string; authorEmail: string; diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index a3f546a..b6be166 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -617,7 +617,7 @@ async function finalizeApprovalTarget( const ticketRows = (await traced(`ticketRowsRest:${type.name}#${target.listId}`, () => list.items .filter(ticketFilter) - .select('Id', 'Title', 'RequestStatus', 'Created') + .select('Id', 'RequestUniqueCode', 'Title', 'RequestStatus', 'Created') .top(ticketIds.length)() )) as Array>; @@ -995,7 +995,7 @@ async function loadByIds( const rows = (await traced(`loadByIds:${kind}:${type.name}#${target.listId}`, () => list.items .filter(idsChunk.map((id) => `Id eq ${id}`).join(' or ')) - .select('Id', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title') + .select('Id', 'RequestUniqueCode', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title') .expand('Author') .top(idsChunk.length)() )) as Array>; @@ -1036,7 +1036,7 @@ async function getOwnRowsForType(context: WebPartContext, type: IRequestType): P const rows = (await sp.web .getList(target.listId) .items.filter(`AuthorId eq ${me.Id}`) - .select('Id', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title') + .select('Id', 'RequestUniqueCode', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title') .expand('Author') .orderBy('Created', false) .top(OWN_REST_TOP)()) as Array>; @@ -1089,7 +1089,7 @@ async function getRecentlyChangedOwnRowsForType(context: WebPartContext, type: I const rows = (await sp.web .getList(target.listId) .items.filter(`AuthorId eq ${me.Id} and Modified ge '${cutoffIso}'`) - .select('Id', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title') + .select('Id', 'RequestUniqueCode', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title') .expand('Author') .top(OWN_REST_TOP)()) as Array>; @@ -1509,6 +1509,7 @@ function toMyRequest( ): IMyRequest { return { id: r.Id as number, + code: (r.RequestUniqueCode as string) || '', title: (r.Title as string) || '', status: (r.RequestStatus as string) || '', authorEmail: author?.EMail || '', diff --git a/src/webparts/requestPortal/features/myRequests/pageCache.test.ts b/src/webparts/requestPortal/features/myRequests/pageCache.test.ts index 3e5461d..d76b8b0 100644 --- a/src/webparts/requestPortal/features/myRequests/pageCache.test.ts +++ b/src/webparts/requestPortal/features/myRequests/pageCache.test.ts @@ -4,6 +4,7 @@ import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache' function request(over: Partial = {}): IMyRequest { return { id: 1, + code: '1-2608-1', title: 'Заявка №1', status: 'Черновик', authorEmail: 'ivanov@tnsu.kz', @@ -113,6 +114,15 @@ describe('pageSignature', () => { expect(pageSignature(before)).not.toBe(pageSignature(after)); }); + it('появление кода заявки меняет подпись — он показан колонкой «Код»', () => { + // Код проставляется вторым запросом, уже после создания элемента (см. + // saveRequestItem), поэтому первая выдача приходит без него. Не учитывать + // его в подписи — значит оставить в таблице «#1» до перезагрузки страницы. + const before = page({ rows: [request({ code: '' })] }); + const after = page({ rows: [request({ code: '1-2608-1' })] }); + expect(pageSignature(before)).not.toBe(pageSignature(after)); + }); + it('пустая страница имеет стабильную подпись', () => { expect(pageSignature({ rows: [], totalRows: 0 })).toBe(pageSignature({ rows: [], totalRows: 0 })); }); diff --git a/src/webparts/requestPortal/features/myRequests/pageCache.ts b/src/webparts/requestPortal/features/myRequests/pageCache.ts index 7d0fbb9..f320f8b 100644 --- a/src/webparts/requestPortal/features/myRequests/pageCache.ts +++ b/src/webparts/requestPortal/features/myRequests/pageCache.ts @@ -36,10 +36,15 @@ export function writePage(key: string, page: IPage): void { // которые видны в таблице: если они совпали, перерисовывать нечего — иначе при // каждом возврате на вкладку и при каждом фоновом обновлении таблица моргала бы, // перерисовываясь тем же самым содержимым. +// code — тоже видимое поле (колонка «Код»), и его обязательно учитывать: он +// проставляется ВТОРЫМ запросом, уже после создания элемента (см. +// saveRequestItem), поэтому первая выдача может прийти без него. Без code в +// слепке подъехавший позже код совпал бы с прежней подписью, перерисовки не +// случилось бы, и в таблице до перезагрузки страницы висело бы «#5». export function pageSignature(page: IPage): string { return `${page.totalRows}|${page.rows .map((r) => - [r.requestTypeId, r.id, r.title, r.status, r.created, r.authorName, r.decisionComment ?? ''].join('~') + [r.requestTypeId, r.id, r.code, r.title, r.status, r.created, r.authorName, r.decisionComment ?? ''].join('~') ) .join(';')}`; }