feat: port stroy-bot-project-assigner as resources page

Add the "resources" section (?page=resources&projectId=): professions and
companies assigned to a project, with the same lists, fields, texts and
optimistic add/remove with rollback as the legacy web part.

PnPjs is replaced by SharePoint REST through the app's IHttpClient so the
bundle needs no new dependency: the same $select/$expand/$filter, PnP paging
replaced by following odata.nextLink, adds as POST to items with the same
body, deletes as POST with X-HTTP-Method DELETE and IF-MATCH *. Known legacy
edge cases (temporary negative id) are kept and listed in the checklist.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-15 17:18:19 +05:00
parent 11bf491095
commit 55ce772eaa
15 changed files with 1083 additions and 0 deletions
+9
View File
@@ -201,6 +201,15 @@
- [ ] Оптимистичное добавление / удаление с откатом и ошибкой
- [ ] На узких экранах колонки друг под другом
🐞 Удаление ещё не сохранённой записи (временный отрицательный id) уходит на сервер, падает и откатывается с ошибкой. 🐞 Если запись удалили до ответа на добавление, она пропадает из списка, хотя на сервере создана. 🐞 Два добавления в одну миллисекунду получают одинаковый временный id.
**Сознательные отличия от assigner:**
- PnPjs заменён на SharePoint REST с теми же `$select` / `$expand` / `$filter`. Постраничная загрузка PnP (`for await`) → проход по `@odata.nextLink` / `odata.nextLink` до конца (без `$top`, страницы по умолчанию — 100).
- Добавление: `POST …/items` с тем же телом (`Title`, `projectIdId`, `professionIdId` / `companyIdId`), заголовки `Accept` и `Content-Type: application/json;odata=nometadata` + пустой `odata-version` (как в binder). Удаление: `POST …/items({id})` с `X-HTTP-Method: DELETE` и `IF-MATCH: *` — так удаляет сам PnP.
- Текст после префикса ошибки («Ошибка загрузки данных: …» и т.п.) — сообщение `HttpError` (`{status} {statusText}: {ответ сервера}`), а не формат ошибки PnP.
- Счётчик во вкладке — часть подписи («Профессии (0)»): `count` в Tabs ui-kit скрывает ноль.
- Кнопки «+» / «−» — `Button` ui-kit (с подсказками «Добавить» / «Удалить»), сообщение об ошибке закрывается крестиком рядом с `Alert`.
---
## 8. Привязка к справочнику (stroy-bot-projects-binder) — `?page=binder&projectId=`
@@ -0,0 +1,34 @@
import * as React from 'react';
import { Button } from '@tnsu/ui-kit';
import { IAssignedResource } from './resourcesTypes';
import { RESOURCES_TEXTS } from './texts';
import styles from './ResourcesPage.module.scss';
const TEXTS = RESOURCES_TEXTS.assigned;
interface Props {
items: IAssignedResource[];
onRemove: (itemId: number) => void;
}
/** Правая колонка «Привязаны к проекту». */
export const AssignedPanel: React.FC<Props> = ({ items, onRemove }) => (
<div className={styles.panelColumn}>
<div className={styles.panelTitle}>{TEXTS.title}</div>
{items.length === 0 && <div className={styles.emptyMsg}>{TEXTS.empty}</div>}
{items.map((item) => (
<div key={item.id} className={styles.itemRow}>
<span className={styles.itemName}>{item.title}</span>
<Button
variant="danger"
size="sm"
title={TEXTS.remove}
aria-label={TEXTS.remove}
onClick={() => onRemove(item.id)}
>
−
</Button>
</div>
))}
</div>
);
@@ -0,0 +1,20 @@
import * as React from 'react';
import { Alert, IconButton } from '@tnsu/ui-kit';
import styles from './ResourcesPage.module.scss';
interface Props {
variant: 'warn' | 'ok' | 'err' | 'info';
dismissLabel: string;
onDismiss: () => void;
children: React.ReactNode;
}
// Alert из ui-kit не закрывается, а MessageBar в assigner закрывался крестиком — добавляем его рядом.
export const DismissibleAlert: React.FC<Props> = ({ variant, dismissLabel, onDismiss, children }) => (
<div className={styles.dismissibleAlert}>
<div className={styles.dismissibleAlertBody}>
<Alert variant={variant}>{children}</Alert>
</div>
<IconButton icon="close" title={dismissLabel} size="sm" onClick={onDismiss} />
</div>
);
@@ -0,0 +1,67 @@
import * as React from 'react';
import { Badge, Button, IconButton, TextInput } from '@tnsu/ui-kit';
import { assignedReferenceIds, filterPool, poolEmptyMessage } from './resourcesRules';
import { IAssignedResource, IResourceItem } from './resourcesTypes';
import { RESOURCES_TEXTS } from './texts';
import styles from './ResourcesPage.module.scss';
const TEXTS = RESOURCES_TEXTS.pool;
interface Props {
items: IResourceItem[];
assigned: IAssignedResource[];
onAdd: (item: IResourceItem) => void;
}
/** Левая колонка «Доступные»: поиск по справочнику, уже привязанные помечены и не добавляются. */
export const PoolPanel: React.FC<Props> = ({ items, assigned, onAdd }) => {
const [query, setQuery] = React.useState<string>('');
const filtered = filterPool(items, query);
const addedIds = assignedReferenceIds(assigned);
return (
<div className={styles.panelColumn}>
<div className={styles.panelTitle}>{TEXTS.title}</div>
<div className={styles.searchBox}>
<div className={styles.searchInput}>
<TextInput value={query} onChange={setQuery} placeholder={TEXTS.searchPlaceholder} />
</div>
{query && <IconButton icon="close" size="sm" title={TEXTS.clearSearch} onClick={() => setQuery('')} />}
</div>
<div className={styles.poolScrollArea}>
{filtered.length === 0 && <div className={styles.emptyMsg}>{poolEmptyMessage(query)}</div>}
{filtered.map((item) => {
const isAlreadyAdded = addedIds.has(item.id);
return (
<div key={item.id} className={styles.itemRow}>
<span className={isAlreadyAdded ? styles.itemNameMuted : styles.itemName}>
{item.title}
{isAlreadyAdded && (
<span className={styles.alreadyAddedTag}>
<Badge variant="muted" size="sm">
{TEXTS.alreadyAdded}
</Badge>
</span>
)}
</span>
{!isAlreadyAdded && (
<Button
variant="primary"
size="sm"
title={TEXTS.add}
aria-label={TEXTS.add}
onClick={() => onAdd(item)}
>
+
</Button>
)}
</div>
);
})}
</div>
</div>
);
};
@@ -0,0 +1,176 @@
@import '../../../../styles/design-tokens';
// Длинный справочник прокручивается внутри колонки — как в assigner.
$pool-max-height: 440px;
// Минимальная высота строки — чтобы строки с кнопкой и без неё были одной высоты.
$item-row-min-height: 40px;
// Вертикальный отступ строки списка — как в assigner (между $sp-2 и $sp-3).
$item-row-padding-y: 10px;
.page {
display: flex;
flex-direction: column;
gap: $sp-4;
width: 100%;
line-height: 1.55;
}
.projectHeader {
box-shadow: $sh-2;
@include page-header;
}
.projectTitle {
position: relative;
z-index: 1;
margin: 0;
font-family: $font-serif;
font-size: $fs-page-title;
font-weight: 600;
color: $white;
}
.dismissibleAlert {
display: flex;
align-items: flex-start;
gap: $sp-2;
}
.dismissibleAlertBody {
flex: 1;
min-width: 0;
}
.tabContent {
padding-top: $sp-1;
}
.splitColumns {
display: flex;
align-items: flex-start;
}
.splitLeft {
flex: 1;
min-width: 0;
padding-right: $sp-5;
border-right: 1px solid $gray-200;
}
.splitRight {
flex: 1;
min-width: 0;
padding-left: $sp-5;
}
.panelColumn {
min-width: 0;
}
.panelTitle {
position: relative;
margin: 0 0 $sp-3;
padding-bottom: $sp-2;
font-size: $fs-overline;
font-weight: 600;
letter-spacing: 1.6px;
text-transform: uppercase;
color: $gray-400;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
@include hairline;
}
}
.searchBox {
display: flex;
align-items: center;
gap: $sp-1;
margin-bottom: $sp-2;
}
.searchInput {
flex: 1;
min-width: 0;
}
.poolScrollArea {
max-height: $pool-max-height;
overflow-y: auto;
border: 1px solid $gray-200;
border-radius: $r-sm;
background: $white;
}
.itemRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
min-height: $item-row-min-height;
padding: $item-row-padding-y $sp-3;
border-bottom: 1px solid $gray-100;
transition: background 0.12s;
&:last-child {
border-bottom: none;
}
&:hover {
background: $gold-50;
}
}
.itemName,
.itemNameMuted {
flex: 1;
font-size: $fs-body;
word-break: break-word;
}
.itemName {
font-weight: 500;
color: $gray-700;
}
.itemNameMuted {
color: $gray-400;
}
.alreadyAddedTag {
margin-left: $sp-2;
vertical-align: middle;
}
.emptyMsg {
padding: $sp-4 $sp-3;
font-size: $fs-sm;
font-style: italic;
text-align: center;
color: $gray-400;
}
@include below($bp-md) {
.splitColumns {
flex-direction: column;
}
.splitLeft {
width: 100%;
padding-right: 0;
padding-bottom: $sp-4;
margin-bottom: $sp-4;
border-right: none;
border-bottom: 1px solid $gray-200;
}
.splitRight {
width: 100%;
padding-left: 0;
}
}
@@ -0,0 +1,64 @@
import * as React from 'react';
import { Alert, ITab, Spinner, Tabs } from '@tnsu/ui-kit';
import { useRouter } from '../../app/routing/RouterContext';
import { useProjectResources } from './useProjectResources';
import { SplitPanel } from './SplitPanel';
import { DismissibleAlert } from './DismissibleAlert';
import { ResourceKind } from './resourcesTypes';
import { RESOURCES_TEXTS } from './texts';
import styles from './ResourcesPage.module.scss';
/** Ресурсы проекта (бывший stroy-bot-project-assigner): профессии и компании, привязанные к проекту. */
export const ResourcesPage: React.FC = () => {
const { route } = useRouter();
const projectId = route.projectId;
const resources = useProjectResources(projectId);
const [activeTab, setActiveTab] = React.useState<ResourceKind>('professions');
if (!projectId) {
return <Alert variant="warn">{RESOURCES_TEXTS.noProject}</Alert>;
}
if (resources.isLoading) {
return <Spinner size="large" label={RESOURCES_TEXTS.loading} block />;
}
// Счётчик — часть подписи, а не count из ui-kit: тот прячет ноль, а в assigner было «Профессии (0)».
const tabs: ITab[] = [
{ id: 'professions', label: RESOURCES_TEXTS.tabs.professions(resources.assigned.professions.length) },
{ id: 'companies', label: RESOURCES_TEXTS.tabs.companies(resources.assigned.companies.length) }
];
return (
<div className={styles.page}>
{resources.projectName && (
<div className={styles.projectHeader}>
<h2 className={styles.projectTitle}>{resources.projectName}</h2>
</div>
)}
{resources.error && (
<DismissibleAlert variant="err" dismissLabel={RESOURCES_TEXTS.dismissError} onDismiss={resources.clearError}>
{resources.error}
</DismissibleAlert>
)}
<Tabs tabs={tabs} activeId={activeTab} onChange={(id) => setActiveTab(id as ResourceKind)} />
<div className={styles.tabContent}>
{/* key: при смене вкладки поиск сбрасывается — Pivot в assigner размонтировал неактивную вкладку. */}
<SplitPanel
key={activeTab}
pool={resources.pools[activeTab]}
assigned={resources.assigned[activeTab]}
onAdd={(item) => {
resources.add(activeTab, item).catch(() => undefined);
}}
onRemove={(itemId) => {
resources.remove(activeTab, itemId).catch(() => undefined);
}}
/>
</div>
</div>
);
};
@@ -0,0 +1,24 @@
import * as React from 'react';
import { PoolPanel } from './PoolPanel';
import { AssignedPanel } from './AssignedPanel';
import { IAssignedResource, IResourceItem } from './resourcesTypes';
import styles from './ResourcesPage.module.scss';
interface Props {
pool: IResourceItem[];
assigned: IAssignedResource[];
onAdd: (item: IResourceItem) => void;
onRemove: (itemId: number) => void;
}
/** Две колонки вкладки: «Доступные» слева, «Привязаны к проекту» справа (на узком экране — друг под другом). */
export const SplitPanel: React.FC<Props> = ({ pool, assigned, onAdd, onRemove }) => (
<div className={styles.splitColumns}>
<div className={styles.splitLeft}>
<PoolPanel items={pool} assigned={assigned} onAdd={onAdd} />
</div>
<div className={styles.splitRight}>
<AssignedPanel items={assigned} onRemove={onRemove} />
</div>
</div>
);
@@ -0,0 +1,163 @@
import {
addAssignedResource,
assignedItemsUrl,
assignedItemUrl,
deleteAssignedResource,
fetchAllItems,
fetchProjectResources,
poolItemsUrl,
projectTitleUrl
} from './resourcesRepository';
import { createFakeHttpClient } from '../../shared/testing/fakeHttpClient';
const SITE = 'https://tnsukz.sharepoint.com/sites/stroy';
const items = (title: string): string => `${SITE}/_api/web/lists/getbytitle('${title}')/items`;
describe('адреса запросов (1:1 с PnP-запросами stroy-bot-project-assigner)', () => {
it('название проекта: getById(Number(projectId)).select(Title)', () => {
expect(projectTitleUrl(SITE, '5')).toEqual(`${items('Проекты')}(5)?$select=Title`);
});
it('справочники: select(Id, Title)', () => {
expect(poolItemsUrl(SITE, 'professions')).toEqual(`${items('Профессии')}?$select=Id,Title`);
expect(poolItemsUrl(SITE, 'companies')).toEqual(`${items('Компании')}?$select=Id,Title`);
});
it('привязки: select + expand lookup + filter по projectIdId', () => {
expect(assignedItemsUrl(SITE, 'professions', '5')).toEqual(
`${items('Профессии по проекту')}?$select=Id,Title,professionId/Id&$expand=professionId&$filter=projectIdId eq 5`
);
expect(assignedItemsUrl(SITE, 'companies', '5')).toEqual(
`${items('Компании по проекту')}?$select=Id,Title,companyId/Id&$expand=companyId&$filter=projectIdId eq 5`
);
});
it('элемент привязки по id', () => {
expect(assignedItemUrl(SITE, 'companies', 77)).toEqual(`${items('Компании по проекту')}(77)`);
});
});
describe('fetchAllItems', () => {
it('проходит по @odata.nextLink до последней страницы', async () => {
const next = `${items('Профессии')}?%24skiptoken=Paged%3dTRUE%26p_ID%3d100&%24select=Id,Title`;
const client = createFakeHttpClient([
{ match: 'skiptoken', response: { body: { value: [{ Id: 2, Title: 'Б' }] } } },
{
match: "getbytitle('Профессии')",
response: { body: { value: [{ Id: 1, Title: 'А' }], '@odata.nextLink': next } }
}
]);
const result = await fetchAllItems(client, poolItemsUrl(SITE, 'professions'));
expect(result).toEqual([
{ Id: 1, Title: 'А' },
{ Id: 2, Title: 'Б' }
]);
expect(client.requests.map((r) => r.url)).toEqual([poolItemsUrl(SITE, 'professions'), next]);
});
it('понимает и odata.nextLink (verbose/nometadata)', async () => {
const client = createFakeHttpClient([
{ match: 'page2', response: { body: { value: [{ Id: 2 }] } } },
{ match: 'page1', response: { body: { value: [{ Id: 1 }], 'odata.nextLink': `${SITE}/page2` } } }
]);
expect(await fetchAllItems(client, `${SITE}/page1`)).toEqual([{ Id: 1 }, { Id: 2 }]);
});
});
describe('fetchProjectResources', () => {
it('собирает проект, справочники и привязки; пустой lookup → 0', async () => {
const client = createFakeHttpClient([
{ match: "getbytitle('Проекты')", response: { body: { Title: 'ЖК Сункар' } } },
{ match: "getbytitle('Профессии')", response: { body: { value: [{ Id: 12, Title: 'Бетонщик' }] } } },
{ match: "getbytitle('Компании')", response: { body: { value: [{ Id: 3, Title: 'ТОО Строй' }] } } },
{
match: "getbytitle('Профессии по проекту')",
response: { body: { value: [{ Id: 100, Title: 'Бетонщик', professionId: { Id: 12 } }] } }
},
{
match: "getbytitle('Компании по проекту')",
response: { body: { value: [{ Id: 200, Title: 'Без ссылки', companyId: null }] } }
}
]);
const result = await fetchProjectResources(client, SITE, '5');
expect(result).toEqual({
projectName: 'ЖК Сункар',
pools: { professions: [{ id: 12, title: 'Бетонщик' }], companies: [{ id: 3, title: 'ТОО Строй' }] },
assigned: {
professions: [{ id: 100, title: 'Бетонщик', referenceId: 12 }],
companies: [{ id: 200, title: 'Без ссылки', referenceId: 0 }]
}
});
expect(client.requests).toHaveLength(5);
});
it('ошибка любого запроса пробрасывается', async () => {
const client = createFakeHttpClient([
{ match: "getbytitle('Проекты')", response: { status: 404 } },
{ match: '', response: { body: { value: [] } } }
]);
await expect(fetchProjectResources(client, SITE, '5')).rejects.toThrow('404');
});
});
describe('addAssignedResource', () => {
it('профессия: POST в items с телом как в assigner, возвращает Id', async () => {
const client = createFakeHttpClient([{ match: 'items', response: { status: 201, body: { Id: 101 } } }]);
const id = await addAssignedResource(client, SITE, 'professions', '5', { id: 12, title: 'Бетонщик' });
expect(id).toEqual(101);
expect(client.requests[0]).toEqual({
url: items('Профессии по проекту'),
request: {
method: 'POST',
headers: {
Accept: 'application/json;odata=nometadata',
'Content-Type': 'application/json;odata=nometadata',
'odata-version': ''
},
body: '{"Title":"Бетонщик","projectIdId":5,"professionIdId":12}'
}
});
});
it('компания: поле companyIdId', async () => {
const client = createFakeHttpClient([{ match: 'items', response: { status: 201, body: { Id: 7 } } }]);
await addAssignedResource(client, SITE, 'companies', '5', { id: 3, title: 'ТОО Строй' });
expect(client.requests[0].url).toEqual(items('Компании по проекту'));
expect(client.requests[0].request.body).toEqual('{"Title":"ТОО Строй","projectIdId":5,"companyIdId":3}');
});
it('ошибка сервера пробрасывается', async () => {
const client = createFakeHttpClient([{ match: 'items', response: { status: 500, body: 'boom' } }]);
await expect(addAssignedResource(client, SITE, 'companies', '5', { id: 3, title: 'X' })).rejects.toThrow(
'500'
);
});
});
describe('deleteAssignedResource', () => {
it('POST с X-HTTP-Method: DELETE и IF-MATCH: *', async () => {
const client = createFakeHttpClient([{ match: 'items', response: { status: 200 } }]);
await deleteAssignedResource(client, SITE, 'professions', 100);
expect(client.requests[0]).toEqual({
url: `${items('Профессии по проекту')}(100)`,
request: { method: 'POST', headers: { 'X-HTTP-Method': 'DELETE', 'IF-MATCH': '*' } }
});
});
it('временный отрицательный id уходит на сервер как есть и падает', async () => {
const client = createFakeHttpClient([{ match: 'items', response: { status: 404 } }]);
await expect(deleteAssignedResource(client, SITE, 'companies', -1700000000000)).rejects.toThrow('404');
expect(client.requests[0].url).toEqual(`${items('Компании по проекту')}(-1700000000000)`);
});
});
@@ -0,0 +1,166 @@
import { ensureOk, IHttpClient } from '../../services/http/IHttpClient';
import { getJson, listItemsUrl } from '../../shared/sharepoint/spRest';
import { LISTS, ListTitle } from '../../config/lists';
import {
IAssignedResource,
IProjectResources,
IResourceItem,
ISpAssignedRow,
ISpResourceRow,
ResourceKind
} from './resourcesTypes';
// Запросы раздела — 1:1 со stroy-bot-project-assigner (useProjectResources).
// Там был PnPjs v4; здесь те же запросы через SharePoint REST:
// - fetchAll (async-итератор PnP) → проход по odata.nextLink до конца;
// - items.add → POST в items с тем же телом;
// - item.delete → POST с X-HTTP-Method: DELETE и IF-MATCH: * (так делает сам PnP).
/** Поле lookup на проект в списках привязок (в $filter — `{поле}Id`). */
const PROJECT_LOOKUP_ID_FIELD = 'projectIdId';
interface IResourceListConfig {
/** Справочник: «Профессии» / «Компании». */
poolList: ListTitle;
/** Привязки к проекту: «Профессии по проекту» / «Компании по проекту». */
assignedList: ListTitle;
/** Lookup на справочник: в $select/$expand читается `{поле}/Id`, при записи — `{поле}Id`. */
lookupField: 'professionId' | 'companyId';
}
export const RESOURCE_LISTS: Record<ResourceKind, IResourceListConfig> = {
professions: {
poolList: LISTS.professions,
assignedList: LISTS.professionsByProject,
lookupField: 'professionId'
},
companies: {
poolList: LISTS.companies,
assignedList: LISTS.companiesByProject,
lookupField: 'companyId'
}
};
/**
* Заголовки записи. odata=nometadata — тело без `__metadata` (как отправляет PnP v4);
* пустой odata-version отключает OData 4.0 от SPHttpClient — тот же приём,
* что в stroy-bot-projects-binder, проверенный на этом сайте.
*/
export const SP_WRITE_HEADERS: Record<string, string> = {
Accept: 'application/json;odata=nometadata',
'Content-Type': 'application/json;odata=nometadata',
'odata-version': ''
};
export const SP_DELETE_HEADERS: Record<string, string> = {
'X-HTTP-Method': 'DELETE',
'IF-MATCH': '*'
};
export function projectTitleUrl(siteUrl: string, projectId: string): string {
return `${listItemsUrl(siteUrl, LISTS.projects)}(${Number(projectId)})?$select=Title`;
}
export function poolItemsUrl(siteUrl: string, kind: ResourceKind): string {
return `${listItemsUrl(siteUrl, RESOURCE_LISTS[kind].poolList)}?$select=Id,Title`;
}
export function assignedItemsUrl(siteUrl: string, kind: ResourceKind, projectId: string): string {
const { assignedList, lookupField } = RESOURCE_LISTS[kind];
return (
`${listItemsUrl(siteUrl, assignedList)}` +
`?$select=Id,Title,${lookupField}/Id` +
`&$expand=${lookupField}` +
`&$filter=${PROJECT_LOOKUP_ID_FIELD} eq ${projectId}`
);
}
export function assignedItemUrl(siteUrl: string, kind: ResourceKind, itemId: number): string {
return `${listItemsUrl(siteUrl, RESOURCE_LISTS[kind].assignedList)}(${itemId})`;
}
interface ISpPage<T> {
value: T[];
'odata.nextLink'?: string;
'@odata.nextLink'?: string;
}
/** Все страницы коллекции: SharePoint отдаёт по 100 строк и ссылку на следующую страницу. */
export async function fetchAllItems<T>(client: IHttpClient, url: string): Promise<T[]> {
const items: T[] = [];
let nextUrl: string | undefined = url;
while (nextUrl) {
const page: ISpPage<T> = await getJson<ISpPage<T>>(client, nextUrl);
items.push(...page.value);
nextUrl = page['@odata.nextLink'] ?? page['odata.nextLink'];
}
return items;
}
function toAssigned(row: ISpAssignedRow, kind: ResourceKind): IAssignedResource {
const lookup = row[RESOURCE_LISTS[kind].lookupField];
return { id: row.Id, title: row.Title, referenceId: lookup?.Id ?? 0 };
}
function toResourceItem(row: ISpResourceRow): IResourceItem {
return { id: row.Id, title: row.Title };
}
/** Название проекта, оба справочника и обе привязки — параллельно, как в assigner. */
export async function fetchProjectResources(
client: IHttpClient,
siteUrl: string,
projectId: string
): Promise<IProjectResources> {
const [project, professions, companies, assignedProfessions, assignedCompanies] = await Promise.all([
getJson<{ Title: string }>(client, projectTitleUrl(siteUrl, projectId)),
fetchAllItems<ISpResourceRow>(client, poolItemsUrl(siteUrl, 'professions')),
fetchAllItems<ISpResourceRow>(client, poolItemsUrl(siteUrl, 'companies')),
fetchAllItems<ISpAssignedRow>(client, assignedItemsUrl(siteUrl, 'professions', projectId)),
fetchAllItems<ISpAssignedRow>(client, assignedItemsUrl(siteUrl, 'companies', projectId))
]);
return {
projectName: project.Title,
pools: { professions: professions.map(toResourceItem), companies: companies.map(toResourceItem) },
assigned: {
professions: assignedProfessions.map((row) => toAssigned(row, 'professions')),
companies: assignedCompanies.map((row) => toAssigned(row, 'companies'))
}
};
}
/** Создаёт привязку и возвращает id новой записи. */
export async function addAssignedResource(
client: IHttpClient,
siteUrl: string,
kind: ResourceKind,
projectId: string,
item: IResourceItem
): Promise<number> {
const body = {
Title: item.title,
[PROJECT_LOOKUP_ID_FIELD]: Number(projectId),
[`${RESOURCE_LISTS[kind].lookupField}Id`]: item.id
};
const response = await ensureOk(
await client.fetch(listItemsUrl(siteUrl, RESOURCE_LISTS[kind].assignedList), {
method: 'POST',
headers: SP_WRITE_HEADERS,
body: JSON.stringify(body)
})
);
const created = (await response.json()) as { Id: number };
return created.Id;
}
export async function deleteAssignedResource(
client: IHttpClient,
siteUrl: string,
kind: ResourceKind,
itemId: number
): Promise<void> {
await ensureOk(
await client.fetch(assignedItemUrl(siteUrl, kind, itemId), { method: 'POST', headers: SP_DELETE_HEADERS })
);
}
@@ -0,0 +1,98 @@
import {
appendAssigned,
assignedReferenceIds,
createTempId,
errorText,
filterPool,
poolEmptyMessage,
removeAssigned,
replaceTempId
} from './resourcesRules';
import { IAssignedResource } from './resourcesTypes';
const pool = [
{ id: 1, title: 'Бетонщик' },
{ id: 2, title: 'Арматурщик' },
{ id: 3, title: 'Сварщик' }
];
describe('filterPool', () => {
it('пустой запрос или пробелы — весь список', () => {
expect(filterPool(pool, '')).toEqual(pool);
expect(filterPool(pool, ' ')).toEqual(pool);
});
it('подстрока без учёта регистра', () => {
expect(filterPool(pool, 'ЩИК').map((item) => item.id)).toEqual([1, 2, 3]);
expect(filterPool(pool, 'свар').map((item) => item.id)).toEqual([3]);
});
it('запрос не обрезается — пробел по краям участвует в поиске (как в assigner)', () => {
expect(filterPool(pool, ' свар')).toEqual([]);
});
});
describe('poolEmptyMessage', () => {
it('с текстом поиска — «Ничего не найдено», без — «Нет элементов»', () => {
expect(poolEmptyMessage('xyz')).toEqual('Ничего не найдено');
expect(poolEmptyMessage(' ')).toEqual('Ничего не найдено');
expect(poolEmptyMessage('')).toEqual('Нет элементов');
});
});
describe('assignedReferenceIds («уже добавлено»)', () => {
it('по id справочника, а не по id привязки', () => {
const ids = assignedReferenceIds([
{ id: 100, title: 'Бетонщик', referenceId: 1 },
{ id: -5, title: 'Сварщик', referenceId: 3 }
]);
expect(ids.has(1)).toBe(true);
expect(ids.has(3)).toBe(true);
expect(ids.has(100)).toBe(false);
expect(ids.has(2)).toBe(false);
});
});
describe('оптимистичное добавление / удаление', () => {
const saved: IAssignedResource = { id: 100, title: 'Бетонщик', referenceId: 1 };
it('временный id — минус текущее время', () => {
expect(createTempId(1700000000000)).toEqual(-1700000000000);
expect(createTempId()).toBeLessThan(0);
});
it('добавление: временная запись в конец, затем замена на настоящий id', () => {
const tempId = createTempId(1700000000000);
const optimistic = appendAssigned([saved], { id: tempId, title: 'Сварщик', referenceId: 3 });
expect(optimistic.map((item) => item.id)).toEqual([100, tempId]);
const confirmed = replaceTempId(optimistic, tempId, 101);
expect(confirmed).toEqual([saved, { id: 101, title: 'Сварщик', referenceId: 3 }]);
expect(optimistic[1].id).toEqual(tempId);
});
it('откат добавления — временная запись убирается', () => {
const tempId = createTempId(1700000000000);
const optimistic = appendAssigned([saved], { id: tempId, title: 'Сварщик', referenceId: 3 });
expect(removeAssigned(optimistic, tempId)).toEqual([saved]);
});
it('запись убрали до ответа сервера — замена id ничего не возвращает', () => {
const tempId = createTempId(1700000000000);
expect(replaceTempId([saved], tempId, 101)).toEqual([saved]);
});
it('удаление не мутирует исходный список (он же снимок для отката)', () => {
const snapshot = [saved];
expect(removeAssigned(snapshot, 100)).toEqual([]);
expect(snapshot).toEqual([saved]);
});
});
describe('errorText', () => {
it('префикс + message', () => {
expect(errorText('Ошибка удаления компании: ', new Error('404 Not Found'))).toEqual(
'Ошибка удаления компании: 404 Not Found'
);
});
});
@@ -0,0 +1,55 @@
import { IAssignedResource, IResourceItem } from './resourcesTypes';
import { RESOURCES_TEXTS } from './texts';
// Правила раздела «Ресурсы проекта» (stroy-bot-project-assigner) — чистые функции.
/**
* Поиск по «Доступные»: пустой (или из одних пробелов) запрос — весь список,
* иначе подстрока без учёта регистра. Как в assigner, сам запрос при поиске не обрезается.
*/
export function filterPool(items: IResourceItem[], query: string): IResourceItem[] {
if (query.trim() === '') {
return items;
}
const needle = query.toLowerCase();
return items.filter((item) => item.title.toLowerCase().indexOf(needle) !== -1);
}
/** Пустой список «Доступные»: при любом введённом тексте — «Ничего не найдено». */
export function poolEmptyMessage(query: string): string {
return query ? RESOURCES_TEXTS.pool.notFound : RESOURCES_TEXTS.pool.noItems;
}
/** Id элементов справочника, которые уже привязаны к проекту (метка «уже добавлено»). */
export function assignedReferenceIds(assigned: IAssignedResource[]): Set<number> {
return new Set(assigned.map((item) => item.referenceId));
}
/**
* Временный id для оптимистичного добавления — как в assigner, `-(Date.now())`.
* Два добавления в одну миллисекунду получат одинаковый id — перенесено как есть.
*/
export function createTempId(now: number = Date.now()): number {
return -now;
}
export function appendAssigned(list: IAssignedResource[], item: IAssignedResource): IAssignedResource[] {
return [...list, item];
}
/**
* Сервер создал запись — временный id меняется на настоящий. Если запись уже убрали
* из списка (удаление до ответа сервера), ничего не меняется — как в assigner.
*/
export function replaceTempId(list: IAssignedResource[], tempId: number, realId: number): IAssignedResource[] {
return list.map((item) => (item.id === tempId ? { ...item, id: realId } : item));
}
export function removeAssigned(list: IAssignedResource[], itemId: number): IAssignedResource[] {
return list.filter((item) => item.id !== itemId);
}
/** Текст ошибки как в assigner: префикс + message исключения. */
export function errorText(prefix: string, error: unknown): string {
return prefix + (error instanceof Error ? error.message : String(error));
}
@@ -0,0 +1,40 @@
/** Вкладка раздела: профессии или компании. */
export type ResourceKind = 'professions' | 'companies';
/** Элемент справочника «Профессии» / «Компании». */
export interface IResourceItem {
id: number;
title: string;
}
/** Элемент списка «Профессии по проекту» / «Компании по проекту». */
export interface IAssignedResource {
/** Id элемента списка привязок. Пока запись создаётся — временный отрицательный id. */
id: number;
title: string;
/** Id элемента справочника (lookup professionId / companyId); 0 — lookup пустой. */
referenceId: number;
}
export type ResourceRecord<T> = Record<ResourceKind, T>;
/** Всё, что раздел загружает при открытии. */
export interface IProjectResources {
projectName: string;
pools: ResourceRecord<IResourceItem[]>;
assigned: ResourceRecord<IAssignedResource[]>;
}
/** Строка справочника в ответе SharePoint. */
export interface ISpResourceRow {
Id: number;
Title: string;
}
/** Строка списка привязок в ответе SharePoint (lookup раскрыт через $expand; пустой lookup может прийти как null). */
export interface ISpAssignedRow {
Id: number;
Title: string;
professionId?: { Id: number };
companyId?: { Id: number };
}
@@ -0,0 +1,32 @@
// Тексты раздела «Ресурсы проекта» — дословно из stroy-bot-project-assigner.
export const RESOURCES_TEXTS = {
noProject: 'Проект не найден. Откройте страницу через список проектов.',
loading: 'Загрузка...',
dismissError: 'Закрыть',
tabs: {
professions: (count: number): string => `Профессии (${count})`,
companies: (count: number): string => `Компании (${count})`
},
pool: {
title: 'Доступные',
searchPlaceholder: 'Поиск...',
clearSearch: 'Очистить',
alreadyAdded: 'уже добавлено',
add: 'Добавить',
notFound: 'Ничего не найдено',
noItems: 'Нет элементов'
},
assigned: {
title: 'Привязаны к проекту',
empty: 'Ничего не добавлено',
remove: 'Удалить'
},
errors: {
load: 'Ошибка загрузки данных: ',
addProfession: 'Ошибка добавления профессии: ',
removeProfession: 'Ошибка удаления профессии: ',
addCompany: 'Ошибка добавления компании: ',
removeCompany: 'Ошибка удаления компании: '
}
} as const;
@@ -0,0 +1,66 @@
import * as React from 'react';
import { useAppServices } from '../../app/AppServicesContext';
import { addAssignedResource, deleteAssignedResource } from './resourcesRepository';
import { appendAssigned, createTempId, errorText, removeAssigned, replaceTempId } from './resourcesRules';
import { IAssignedResource, IResourceItem, ResourceKind, ResourceRecord } from './resourcesTypes';
import { RESOURCES_TEXTS } from './texts';
const ERROR_PREFIX: ResourceRecord<{ add: string; remove: string }> = {
professions: { add: RESOURCES_TEXTS.errors.addProfession, remove: RESOURCES_TEXTS.errors.removeProfession },
companies: { add: RESOURCES_TEXTS.errors.addCompany, remove: RESOURCES_TEXTS.errors.removeCompany }
};
interface IAssignedActionsDeps {
projectId: string;
assigned: ResourceRecord<IAssignedResource[]>;
setAssigned: React.Dispatch<React.SetStateAction<ResourceRecord<IAssignedResource[]>>>;
setError: (message: string) => void;
}
export interface IAssignedActions {
add: (kind: ResourceKind, item: IResourceItem) => Promise<void>;
remove: (kind: ResourceKind, itemId: number) => Promise<void>;
}
/**
* Оптимистичные добавление и удаление привязок — как в assigner:
* список меняется сразу, при ошибке откатывается и показывается сообщение.
*/
export function useAssignedActions({
projectId,
assigned,
setAssigned,
setError
}: IAssignedActionsDeps): IAssignedActions {
const { sharePoint, settings } = useAppServices();
const update = (kind: ResourceKind, change: (list: IAssignedResource[]) => IAssignedResource[]): void =>
setAssigned((prev) => ({ ...prev, [kind]: change(prev[kind]) }));
const add = async (kind: ResourceKind, item: IResourceItem): Promise<void> => {
const tempId = createTempId();
update(kind, (list) => appendAssigned(list, { id: tempId, title: item.title, referenceId: item.id }));
try {
const realId = await addAssignedResource(sharePoint, settings.siteUrl, kind, projectId, item);
update(kind, (list) => replaceTempId(list, tempId, realId));
} catch (error) {
update(kind, (list) => removeAssigned(list, tempId));
setError(errorText(ERROR_PREFIX[kind].add, error));
}
};
// Откат — к списку на момент клика (как в assigner). Удаление ещё не сохранённой записи
// (временный отрицательный id) уходит на сервер, падает и тоже откатывается — перенесено как есть.
const remove = async (kind: ResourceKind, itemId: number): Promise<void> => {
const snapshot = assigned[kind];
update(kind, (list) => removeAssigned(list, itemId));
try {
await deleteAssignedResource(sharePoint, settings.siteUrl, kind, itemId);
} catch (error) {
update(kind, () => snapshot);
setError(errorText(ERROR_PREFIX[kind].remove, error));
}
};
return { add, remove };
}
@@ -0,0 +1,69 @@
import * as React from 'react';
import { useAppServices } from '../../app/AppServicesContext';
import { fetchProjectResources } from './resourcesRepository';
import { errorText } from './resourcesRules';
import { IAssignedResource, IResourceItem, ResourceRecord } from './resourcesTypes';
import { IAssignedActions, useAssignedActions } from './useAssignedActions';
import { RESOURCES_TEXTS } from './texts';
const EMPTY_POOLS: ResourceRecord<IResourceItem[]> = { professions: [], companies: [] };
const EMPTY_ASSIGNED: ResourceRecord<IAssignedResource[]> = { professions: [], companies: [] };
export interface IProjectResourcesState extends IAssignedActions {
projectName: string | undefined;
pools: ResourceRecord<IResourceItem[]>;
assigned: ResourceRecord<IAssignedResource[]>;
isLoading: boolean;
error: string | undefined;
clearError: () => void;
}
/**
* Данные раздела. Ошибка загрузки не прячет страницу: как в assigner, показываются
* сообщение и пустые вкладки. Без projectId ничего не грузится.
*/
export function useProjectResources(projectId: string | undefined): IProjectResourcesState {
const { sharePoint, settings } = useAppServices();
const [projectName, setProjectName] = React.useState<string | undefined>(undefined);
const [pools, setPools] = React.useState<ResourceRecord<IResourceItem[]>>(EMPTY_POOLS);
const [assigned, setAssigned] = React.useState<ResourceRecord<IAssignedResource[]>>(EMPTY_ASSIGNED);
const [isLoading, setIsLoading] = React.useState<boolean>(!!projectId);
const [error, setError] = React.useState<string | undefined>(undefined);
React.useEffect(() => {
if (!projectId) {
setIsLoading(false);
return undefined;
}
let isCancelled = false;
setIsLoading(true);
fetchProjectResources(sharePoint, settings.siteUrl, projectId)
.then((loaded) => {
if (isCancelled) return;
setProjectName(loaded.projectName);
setPools(loaded.pools);
setAssigned(loaded.assigned);
setIsLoading(false);
})
.catch((loadError: unknown) => {
if (isCancelled) return;
setError(errorText(RESOURCES_TEXTS.errors.load, loadError));
setIsLoading(false);
});
return () => {
isCancelled = true;
};
}, [sharePoint, settings.siteUrl, projectId]);
const actions = useAssignedActions({ projectId: projectId ?? '', assigned, setAssigned, setError });
return {
projectName,
pools,
assigned,
isLoading,
error,
clearError: () => setError(undefined),
...actions
};
}