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:
@@ -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=`
|
## 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
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user