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=`
|
||||
|
||||
@@ -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