feat: port stroy-bot-projects-binder as binder page
Add the "binder" section (?page=binder&projectId=): links a SharePoint project to the TNSU API project catalogue by writing UniqueId0/UniqueName to the "Проекты" list. The class component is rewritten as function components with hooks while keeping the legacy behaviour and texts: the same project GET with odata=nometadata and empty odata-version, MERGE with IF-MATCH * for bind/unbind (no confirmation), catalogue search with 400 ms debounce and 20-per-page paging, card select/deselect, status colours, ru-RU dates and HTML-entity decoding of the address. Fluent UI is replaced with ui-kit. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
// Толщина акцентных полос карточек — как в binder.
|
||||
$accent-bar-width: 3px;
|
||||
$accent-line-height: 2px;
|
||||
// На самых узких экранах карточки компактнее — точка перелома из binder (между $bp-xs и $bp-sm).
|
||||
$compact-card-bp: 480px;
|
||||
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $sp-4;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dismissibleAlert {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: $sp-2;
|
||||
}
|
||||
|
||||
.dismissibleAlertBody {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hairline {
|
||||
@include hairline;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// ---- Карточка проекта SharePoint ----
|
||||
|
||||
.projectCard {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: $sp-6 $sp-7;
|
||||
background: $white;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-lg;
|
||||
box-shadow: $sh-2;
|
||||
|
||||
// Navy-линия сверху.
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: $accent-line-height;
|
||||
background: linear-gradient(90deg, $navy-800, $navy-600 60%, transparent);
|
||||
}
|
||||
|
||||
// Золотая полоса слева.
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: $accent-bar-width;
|
||||
background: $grad-gold-v;
|
||||
}
|
||||
}
|
||||
|
||||
.projectTitle {
|
||||
margin: 0;
|
||||
font-family: $font-serif;
|
||||
font-size: $fs-form-title;
|
||||
font-weight: 600;
|
||||
color: $navy-900;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.projectFields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: $sp-1 $sp-4;
|
||||
margin-top: $sp-3;
|
||||
}
|
||||
|
||||
.fieldLine {
|
||||
min-width: 0;
|
||||
font-size: $fs-label;
|
||||
color: $gray-500;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
font-weight: 600;
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
.bindingRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: $sp-2;
|
||||
margin-top: $sp-3;
|
||||
padding-top: $sp-3;
|
||||
border-top: 1px solid $gray-200;
|
||||
}
|
||||
|
||||
.boundInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: $fs-label;
|
||||
color: $gray-500;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.unbindAction {
|
||||
margin-top: $sp-2;
|
||||
}
|
||||
|
||||
// ---- Поиск по справочнику ----
|
||||
|
||||
.sectionTitle {
|
||||
margin: 0;
|
||||
font-family: $font-serif;
|
||||
font-size: $fs-section-title;
|
||||
font-weight: 600;
|
||||
color: $navy-900;
|
||||
}
|
||||
|
||||
.searchBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $sp-1;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.emptyResults {
|
||||
display: block;
|
||||
padding: $sp-4 0;
|
||||
font-size: $fs-label;
|
||||
text-align: center;
|
||||
color: $gray-400;
|
||||
}
|
||||
|
||||
.results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $sp-2;
|
||||
}
|
||||
|
||||
.resultItem {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $sp-1;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: $sp-3 $sp-4;
|
||||
background: $white;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-md;
|
||||
box-shadow: $sh-1;
|
||||
cursor: pointer;
|
||||
transition: 0.22s;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: $accent-bar-width;
|
||||
background: $grad-gold-v;
|
||||
opacity: 0;
|
||||
transition: 0.22s;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: $sh-3;
|
||||
transform: translateY(-2px);
|
||||
border-color: transparent;
|
||||
|
||||
&::before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.resultItemSelected {
|
||||
background: $gold-50;
|
||||
border-color: $gold-400;
|
||||
|
||||
&::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: $gold-50;
|
||||
border-color: $gold-400;
|
||||
}
|
||||
}
|
||||
|
||||
.resultName {
|
||||
font-size: $fs-body;
|
||||
font-weight: 600;
|
||||
color: $navy-900;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.bindAction {
|
||||
margin-top: $sp-1;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $sp-2;
|
||||
}
|
||||
|
||||
.pageInfo {
|
||||
font-size: $fs-label;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
@include below($bp-sm) {
|
||||
.projectFields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@include below($compact-card-bp) {
|
||||
.projectCard {
|
||||
padding: $sp-3 $sp-4;
|
||||
}
|
||||
|
||||
.resultItem {
|
||||
padding: $sp-2 $sp-3;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import * as React from 'react';
|
||||
import { Alert, Spinner } from '@tnsu/ui-kit';
|
||||
import { useRouter } from '../../app/routing/RouterContext';
|
||||
import { isProjectBound, parseProjectId } from './binderRules';
|
||||
import { ICatalogProject } from './binderTypes';
|
||||
import { useBinderProject } from './useBinderProject';
|
||||
import { useProjectBinding } from './useProjectBinding';
|
||||
import { useCatalogSearch } from './useCatalogSearch';
|
||||
import { ProjectCard } from './ProjectCard';
|
||||
import { CatalogSection } from './CatalogSection';
|
||||
import { DismissibleAlert } from './DismissibleAlert';
|
||||
import { BINDER_TEXTS } from './texts';
|
||||
import styles from './BinderPage.module.scss';
|
||||
|
||||
/** Привязка к справочнику (бывший stroy-bot-projects-binder): связь проекта SharePoint с проектом API. */
|
||||
export const BinderPage: React.FC = () => {
|
||||
const { route } = useRouter();
|
||||
const projectId = parseProjectId(route.projectId);
|
||||
const { project, setProject, isLoading, projectError } = useBinderProject(projectId);
|
||||
const binding = useProjectBinding(project, setProject);
|
||||
const catalog = useCatalogSearch(!isLoading);
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner size="large" label={BINDER_TEXTS.loadingProject} block />;
|
||||
}
|
||||
|
||||
const onBind = (selected: ICatalogProject): void => {
|
||||
binding
|
||||
.bind(selected)
|
||||
.then((isSaved) => {
|
||||
if (isSaved) catalog.clearSelection();
|
||||
})
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
{projectError && <Alert variant="warn">{projectError}</Alert>}
|
||||
{project && (
|
||||
<ProjectCard
|
||||
project={project}
|
||||
isSaving={binding.isSaving}
|
||||
onUnbind={() => {
|
||||
binding.unbind().catch(() => undefined);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{binding.successMessage && (
|
||||
<DismissibleAlert variant="ok" dismissLabel={BINDER_TEXTS.dismiss} onDismiss={binding.clearSuccess}>
|
||||
{binding.successMessage}
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
{binding.saveError && (
|
||||
<DismissibleAlert variant="err" dismissLabel={BINDER_TEXTS.dismiss} onDismiss={binding.clearSaveError}>
|
||||
{binding.saveError}
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
|
||||
<hr className={styles.hairline} />
|
||||
|
||||
<CatalogSection catalog={catalog} isBound={isProjectBound(project)} isSaving={binding.isSaving} onBind={onBind} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as React from 'react';
|
||||
import { Button } from '@tnsu/ui-kit';
|
||||
import { canGoToNextPage, canGoToPreviousPage } from './binderRules';
|
||||
import { BINDER_TEXTS } from './texts';
|
||||
import styles from './BinderPage.module.scss';
|
||||
|
||||
interface Props {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalCount: number;
|
||||
isSearching: boolean;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
/** «← {страница} / {всего страниц} · всего: N →». Во время поиска кнопки неактивны. */
|
||||
export const CatalogPagination: React.FC<Props> = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalCount,
|
||||
isSearching,
|
||||
onPageChange
|
||||
}) => (
|
||||
<div className={styles.pagination}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={!canGoToPreviousPage(currentPage, isSearching)}
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
>
|
||||
{BINDER_TEXTS.previousPage}
|
||||
</Button>
|
||||
<span className={styles.pageInfo}>{BINDER_TEXTS.pageInfo(currentPage, totalPages, totalCount)}</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={!canGoToNextPage(currentPage, totalPages, isSearching)}
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
>
|
||||
{BINDER_TEXTS.nextPage}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,50 @@
|
||||
import * as React from 'react';
|
||||
import { Button } from '@tnsu/ui-kit';
|
||||
import { hasCatalogCoordinates } from './binderRules';
|
||||
import { ICatalogProject } from './binderTypes';
|
||||
import { FieldLine } from './FieldLine';
|
||||
import { BINDER_TEXTS } from './texts';
|
||||
import styles from './BinderPage.module.scss';
|
||||
|
||||
const FIELDS = BINDER_TEXTS.fields;
|
||||
|
||||
interface Props {
|
||||
project: ICatalogProject;
|
||||
isSelected: boolean;
|
||||
isSaving: boolean;
|
||||
onToggle: (project: ICatalogProject) => void;
|
||||
onBind: (project: ICatalogProject) => void;
|
||||
}
|
||||
|
||||
/** Карточка проекта из справочника. Клик — выбрать / снять выбор; у выбранной — «Связать». */
|
||||
export const CatalogResultCard: React.FC<Props> = ({ project, isSelected, isSaving, onToggle, onBind }) => {
|
||||
const onBindClick = (event: React.MouseEvent<HTMLButtonElement>): void => {
|
||||
// Иначе клик дойдёт до карточки и снимет выбор.
|
||||
event.stopPropagation();
|
||||
onBind(project);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={isSelected ? `${styles.resultItem} ${styles.resultItemSelected}` : styles.resultItem}
|
||||
onClick={() => onToggle(project)}
|
||||
>
|
||||
<span className={styles.resultName}>{project.name}</span>
|
||||
{project.code && <FieldLine label={FIELDS.code}>{project.code}</FieldLine>}
|
||||
{project.description && <FieldLine label={FIELDS.description}>{project.description}</FieldLine>}
|
||||
{project.address && <FieldLine label={FIELDS.address}>{project.address}</FieldLine>}
|
||||
{hasCatalogCoordinates(project) && (
|
||||
<FieldLine label={FIELDS.coordinates}>
|
||||
{BINDER_TEXTS.coordinates(project.latitude ?? undefined, project.longitude ?? undefined)}
|
||||
</FieldLine>
|
||||
)}
|
||||
{isSelected && (
|
||||
<div className={styles.bindAction}>
|
||||
<Button variant="primary" disabled={isSaving} onClick={onBindClick}>
|
||||
{isSaving ? BINDER_TEXTS.saving : BINDER_TEXTS.bind}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import * as React from 'react';
|
||||
import { Alert, IconButton, Spinner, TextInput } from '@tnsu/ui-kit';
|
||||
import { ICatalogSearch } from './useCatalogSearch';
|
||||
import { ICatalogProject } from './binderTypes';
|
||||
import { CatalogResultCard } from './CatalogResultCard';
|
||||
import { CatalogPagination } from './CatalogPagination';
|
||||
import { BINDER_TEXTS } from './texts';
|
||||
import styles from './BinderPage.module.scss';
|
||||
|
||||
interface Props {
|
||||
catalog: ICatalogSearch;
|
||||
isBound: boolean;
|
||||
isSaving: boolean;
|
||||
onBind: (project: ICatalogProject) => void;
|
||||
}
|
||||
|
||||
/** Поиск по справочнику API: поле, результаты с выбором, пагинация. */
|
||||
export const CatalogSection: React.FC<Props> = ({ catalog, isBound, isSaving, onBind }) => {
|
||||
const isNothingFound = !catalog.isSearching && catalog.results.length === 0 && catalog.search.trim() !== '';
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className={styles.sectionTitle}>
|
||||
{isBound ? BINDER_TEXTS.searchTitleBound : BINDER_TEXTS.searchTitleUnbound}
|
||||
</h3>
|
||||
<div className={styles.searchBox}>
|
||||
<div className={styles.searchInput}>
|
||||
<TextInput
|
||||
value={catalog.search}
|
||||
onChange={catalog.changeSearch}
|
||||
placeholder={BINDER_TEXTS.searchPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
{catalog.search && (
|
||||
<IconButton
|
||||
icon="close"
|
||||
size="sm"
|
||||
title={BINDER_TEXTS.clearSearch}
|
||||
onClick={() => catalog.changeSearch('')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{catalog.isSearching && <Spinner size="medium" label={BINDER_TEXTS.searching} />}
|
||||
{catalog.searchError && <Alert variant="err">{catalog.searchError}</Alert>}
|
||||
{isNothingFound && <span className={styles.emptyResults}>{BINDER_TEXTS.notFound}</span>}
|
||||
|
||||
<div className={styles.results}>
|
||||
{catalog.results.map((project) => (
|
||||
<CatalogResultCard
|
||||
key={project.oid}
|
||||
project={project}
|
||||
isSelected={catalog.selected?.oid === project.oid}
|
||||
isSaving={isSaving}
|
||||
onToggle={catalog.toggleSelect}
|
||||
onBind={onBind}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{catalog.totalCount > 0 && (
|
||||
<CatalogPagination
|
||||
currentPage={catalog.currentPage}
|
||||
totalPages={catalog.totalPages}
|
||||
totalCount={catalog.totalCount}
|
||||
isSearching={catalog.isSearching}
|
||||
onPageChange={catalog.goToPage}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from 'react';
|
||||
import { Alert, IconButton } from '@tnsu/ui-kit';
|
||||
import styles from './BinderPage.module.scss';
|
||||
|
||||
interface Props {
|
||||
variant: 'warn' | 'ok' | 'err' | 'info';
|
||||
dismissLabel: string;
|
||||
onDismiss: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
// Alert из ui-kit не закрывается, а MessageBar в binder закрывался крестиком — добавляем его рядом.
|
||||
// Такой же компонент есть в pages/resources — кандидат на перенос в shared/components.
|
||||
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,16 @@
|
||||
import * as React from 'react';
|
||||
import styles from './BinderPage.module.scss';
|
||||
|
||||
interface Props {
|
||||
/** Подпись с двоеточием и пробелом, например «Адрес: ». */
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Строка «Подпись: значение» в карточке проекта и в результатах поиска. */
|
||||
export const FieldLine: React.FC<Props> = ({ label, children }) => (
|
||||
<div className={styles.fieldLine}>
|
||||
<span className={styles.fieldLabel}>{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,74 @@
|
||||
import * as React from 'react';
|
||||
import { Badge, BadgeVariant, Button } from '@tnsu/ui-kit';
|
||||
import {
|
||||
decodeHtmlEntities,
|
||||
formatDateRu,
|
||||
getBinderStatusKind,
|
||||
hasProjectCoordinates,
|
||||
isProjectBound
|
||||
} from './binderRules';
|
||||
import { BinderStatusKind, IBinderProject } from './binderTypes';
|
||||
import { FieldLine } from './FieldLine';
|
||||
import { BINDER_TEXTS } from './texts';
|
||||
import styles from './BinderPage.module.scss';
|
||||
|
||||
const FIELDS = BINDER_TEXTS.fields;
|
||||
|
||||
// Цвета статусов как в binder: текущий — зелёный, архивный — золотой, остальные — серый.
|
||||
const STATUS_VARIANT: Record<BinderStatusKind, BadgeVariant> = {
|
||||
current: 'success',
|
||||
archive: 'warning',
|
||||
other: 'muted'
|
||||
};
|
||||
|
||||
interface Props {
|
||||
project: IBinderProject;
|
||||
isSaving: boolean;
|
||||
onUnbind: () => void;
|
||||
}
|
||||
|
||||
/** Карточка проекта SharePoint: поля, состояние привязки и «Отвязать». */
|
||||
export const ProjectCard: React.FC<Props> = ({ project, isSaving, onUnbind }) => {
|
||||
const isBound = isProjectBound(project);
|
||||
|
||||
return (
|
||||
<div className={styles.projectCard}>
|
||||
<h2 className={styles.projectTitle}>{project.title}</h2>
|
||||
<div className={styles.projectFields}>
|
||||
{project.description && <FieldLine label={FIELDS.description}>{project.description}</FieldLine>}
|
||||
{project.address && <FieldLine label={FIELDS.address}>{decodeHtmlEntities(project.address)}</FieldLine>}
|
||||
{project.status && (
|
||||
<FieldLine label={FIELDS.status}>
|
||||
<Badge variant={STATUS_VARIANT[getBinderStatusKind(project.status)]} size="sm">
|
||||
{project.status}
|
||||
</Badge>
|
||||
</FieldLine>
|
||||
)}
|
||||
{project.startDate && <FieldLine label={FIELDS.startDate}>{formatDateRu(project.startDate)}</FieldLine>}
|
||||
{project.endDate && <FieldLine label={FIELDS.endDate}>{formatDateRu(project.endDate)}</FieldLine>}
|
||||
{hasProjectCoordinates(project) && (
|
||||
<FieldLine label={FIELDS.coordinates}>
|
||||
{BINDER_TEXTS.coordinates(project.latitude, project.longitude)}
|
||||
</FieldLine>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.bindingRow}>
|
||||
<Badge variant={isBound ? 'success' : 'danger'}>{isBound ? BINDER_TEXTS.bound : BINDER_TEXTS.unbound}</Badge>
|
||||
{isBound && (
|
||||
<span className={styles.boundInfo}>
|
||||
{BINDER_TEXTS.boundInfo(project.uniqueName ?? '', project.uniqueId ?? '')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isBound && (
|
||||
<div className={styles.unbindAction}>
|
||||
<Button variant="secondary" disabled={isSaving} onClick={onUnbind}>
|
||||
{BINDER_TEXTS.unbind}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
import {
|
||||
bindProject,
|
||||
binderProjectUrl,
|
||||
catalogSearchUrl,
|
||||
fetchBinderProject,
|
||||
searchCatalog,
|
||||
unbindProject
|
||||
} from './binderRepository';
|
||||
import { ICatalogProject } from './binderTypes';
|
||||
import { createFakeHttpClient } from '../../shared/testing/fakeHttpClient';
|
||||
|
||||
const SITE = 'https://tnsukz.sharepoint.com/sites/stroy';
|
||||
const API = 'https://api.tnsu.kz';
|
||||
const PROJECT_ITEM = `${SITE}/_api/web/lists/getbytitle('Проекты')/items(5)`;
|
||||
|
||||
const MERGE_HEADERS = {
|
||||
Accept: 'application/json;odata=nometadata',
|
||||
'Content-type': 'application/json;odata=nometadata',
|
||||
'odata-version': '',
|
||||
'IF-MATCH': '*',
|
||||
'X-HTTP-Method': 'MERGE'
|
||||
};
|
||||
|
||||
const catalogProject: ICatalogProject = {
|
||||
id: 1,
|
||||
oid: 'a1b2-c3',
|
||||
name: 'ЖК Сункар',
|
||||
description: '',
|
||||
address: '',
|
||||
latitude: null,
|
||||
longitude: null
|
||||
};
|
||||
|
||||
describe('проект SharePoint (1:1 с binder)', () => {
|
||||
it('адрес с теми же полями $select', () => {
|
||||
expect(binderProjectUrl(SITE, 5)).toEqual(
|
||||
`${PROJECT_ITEM}?$select=Id,Title,UniqueId0,UniqueName,description,address,` +
|
||||
`startDate,endDate,Status,latitude,longitude`
|
||||
);
|
||||
});
|
||||
|
||||
it('GET с nometadata и пустым odata-version, null → undefined', async () => {
|
||||
const client = createFakeHttpClient([
|
||||
{
|
||||
match: 'items(5)',
|
||||
response: {
|
||||
body: {
|
||||
Id: 5,
|
||||
Title: 'Сункар',
|
||||
UniqueId0: null,
|
||||
UniqueName: null,
|
||||
Status: 'текущий',
|
||||
latitude: 0,
|
||||
longitude: 71.4
|
||||
}
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
const project = await fetchBinderProject(client, SITE, 5);
|
||||
|
||||
expect(client.requests[0]).toEqual({
|
||||
url: binderProjectUrl(SITE, 5),
|
||||
request: { headers: { Accept: 'application/json;odata=nometadata', 'odata-version': '' } }
|
||||
});
|
||||
expect(project).toEqual({
|
||||
id: 5,
|
||||
title: 'Сункар',
|
||||
uniqueId: undefined,
|
||||
uniqueName: undefined,
|
||||
description: undefined,
|
||||
address: undefined,
|
||||
startDate: undefined,
|
||||
endDate: undefined,
|
||||
status: 'текущий',
|
||||
latitude: 0,
|
||||
longitude: 71.4
|
||||
});
|
||||
});
|
||||
|
||||
it('ошибка — «Ошибка загрузки проекта: {status}»', async () => {
|
||||
const client = createFakeHttpClient([{ match: 'items(5)', response: { status: 404 } }]);
|
||||
await expect(fetchBinderProject(client, SITE, 5)).rejects.toThrow('Ошибка загрузки проекта: 404');
|
||||
});
|
||||
});
|
||||
|
||||
describe('справочник API', () => {
|
||||
it('без имени — только страница и размер', () => {
|
||||
expect(catalogSearchUrl(API, '', 1)).toEqual(`${API}/Project?page=1&pageSize=20`);
|
||||
expect(catalogSearchUrl(API, ' ', 2)).toEqual(`${API}/Project?page=2&pageSize=20`);
|
||||
});
|
||||
|
||||
it('с именем — name кодируется целиком, без обрезки пробелов', () => {
|
||||
expect(catalogSearchUrl(API, 'ЖК Сункар ', 3)).toEqual(
|
||||
`${API}/Project?page=3&pageSize=20&name=${encodeURIComponent('ЖК Сункар ')}`
|
||||
);
|
||||
});
|
||||
|
||||
it('GET через клиент API, ответ — страница результатов', async () => {
|
||||
const page = { items: [catalogProject], totalCount: 41, page: 2, pageSize: 20, totalPages: 3 };
|
||||
const client = createFakeHttpClient([{ match: '/Project?', response: { body: page } }]);
|
||||
|
||||
expect(await searchCatalog(client, API, 'сункар', 2)).toEqual(page);
|
||||
expect(client.requests[0]).toEqual({
|
||||
url: `${API}/Project?page=2&pageSize=20&name=%D1%81%D1%83%D0%BD%D0%BA%D0%B0%D1%80`,
|
||||
request: {}
|
||||
});
|
||||
});
|
||||
|
||||
it('ошибка — «Ошибка поиска: {status}»', async () => {
|
||||
const client = createFakeHttpClient([{ match: '/Project?', response: { status: 401 } }]);
|
||||
await expect(searchCatalog(client, API, '', 1)).rejects.toThrow('Ошибка поиска: 401');
|
||||
});
|
||||
});
|
||||
|
||||
describe('привязка / отвязка (MERGE)', () => {
|
||||
it('«Связать» пишет UniqueId0 = oid и UniqueName = name', async () => {
|
||||
const client = createFakeHttpClient([{ match: 'items(5)', response: { status: 204 } }]);
|
||||
|
||||
await bindProject(client, SITE, 5, catalogProject);
|
||||
|
||||
expect(client.requests[0]).toEqual({
|
||||
url: PROJECT_ITEM,
|
||||
request: { method: 'POST', headers: MERGE_HEADERS, body: '{"UniqueId0":"a1b2-c3","UniqueName":"ЖК Сункар"}' }
|
||||
});
|
||||
});
|
||||
|
||||
it('«Отвязать» пишет null в оба поля', async () => {
|
||||
const client = createFakeHttpClient([{ match: 'items(5)', response: { status: 204 } }]);
|
||||
|
||||
await unbindProject(client, SITE, 5);
|
||||
|
||||
expect(client.requests[0]).toEqual({
|
||||
url: PROJECT_ITEM,
|
||||
request: { method: 'POST', headers: MERGE_HEADERS, body: '{"UniqueId0":null,"UniqueName":null}' }
|
||||
});
|
||||
});
|
||||
|
||||
it('тексты ошибок как в binder', async () => {
|
||||
const client = createFakeHttpClient([{ match: 'items(5)', response: { status: 412 } }]);
|
||||
await expect(bindProject(client, SITE, 5, catalogProject)).rejects.toThrow('Ошибка сохранения: 412');
|
||||
await expect(unbindProject(client, SITE, 5)).rejects.toThrow('Ошибка: 412');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { IHttpClient } from '../../services/http/IHttpClient';
|
||||
import { listItemsUrl } from '../../shared/sharepoint/spRest';
|
||||
import { LISTS } from '../../config/lists';
|
||||
import { IBinderProject, ICatalogPage, ICatalogProject, ISpBinderProjectRow } from './binderTypes';
|
||||
import { BINDER_TEXTS } from './texts';
|
||||
|
||||
// Запросы раздела — 1:1 со stroy-bot-projects-binder (_loadSpProject, _doSearch, _handleBind, _handleUnbind).
|
||||
|
||||
/** Поля «Проекты», которые читает раздел (UniqueId0/UniqueName — привязка к справочнику). */
|
||||
const PROJECT_FIELDS = [
|
||||
'Id',
|
||||
'Title',
|
||||
'UniqueId0',
|
||||
'UniqueName',
|
||||
'description',
|
||||
'address',
|
||||
'startDate',
|
||||
'endDate',
|
||||
'Status',
|
||||
'latitude',
|
||||
'longitude'
|
||||
];
|
||||
|
||||
/** Размер страницы справочника — как в binder. */
|
||||
export const CATALOG_PAGE_SIZE = 20;
|
||||
|
||||
/** Заголовки чтения как в binder: nometadata и пустой odata-version (отключает OData 4.0 от SPHttpClient). */
|
||||
export const SP_READ_HEADERS: Record<string, string> = {
|
||||
Accept: 'application/json;odata=nometadata',
|
||||
'odata-version': ''
|
||||
};
|
||||
|
||||
/** Заголовки записи как в binder (написание ключей сохранено). */
|
||||
export const SP_MERGE_HEADERS: Record<string, string> = {
|
||||
Accept: 'application/json;odata=nometadata',
|
||||
'Content-type': 'application/json;odata=nometadata',
|
||||
'odata-version': '',
|
||||
'IF-MATCH': '*',
|
||||
'X-HTTP-Method': 'MERGE'
|
||||
};
|
||||
|
||||
/** SharePoint отвечает на MERGE 204 No Content — это тоже успех. */
|
||||
const NO_CONTENT = 204;
|
||||
|
||||
export function projectItemUrl(siteUrl: string, itemId: number): string {
|
||||
return `${listItemsUrl(siteUrl, LISTS.projects)}(${itemId})`;
|
||||
}
|
||||
|
||||
export function binderProjectUrl(siteUrl: string, projectId: number): string {
|
||||
return `${projectItemUrl(siteUrl, projectId)}?$select=${PROJECT_FIELDS.join(',')}`;
|
||||
}
|
||||
|
||||
/** Пустой запрос (или из пробелов) — весь справочник, иначе фильтр по имени. */
|
||||
export function catalogSearchUrl(apiBaseUrl: string, query: string, page: number): string {
|
||||
const nameParam = query.trim() ? `&name=${encodeURIComponent(query)}` : '';
|
||||
return `${apiBaseUrl}/Project?page=${page}&pageSize=${CATALOG_PAGE_SIZE}${nameParam}`;
|
||||
}
|
||||
|
||||
function toBinderProject(row: ISpBinderProjectRow): IBinderProject {
|
||||
return {
|
||||
id: row.Id,
|
||||
title: row.Title,
|
||||
uniqueId: row.UniqueId0 ?? undefined,
|
||||
uniqueName: row.UniqueName ?? undefined,
|
||||
description: row.description ?? undefined,
|
||||
address: row.address ?? undefined,
|
||||
startDate: row.startDate ?? undefined,
|
||||
endDate: row.endDate ?? undefined,
|
||||
status: row.Status ?? undefined,
|
||||
latitude: row.latitude ?? undefined,
|
||||
longitude: row.longitude ?? undefined
|
||||
};
|
||||
}
|
||||
|
||||
/** Тексты ошибок — как в binder: «Ошибка загрузки проекта: 404» и т.п. (без тела ответа). */
|
||||
export async function fetchBinderProject(
|
||||
client: IHttpClient,
|
||||
siteUrl: string,
|
||||
projectId: number
|
||||
): Promise<IBinderProject> {
|
||||
const response = await client.fetch(binderProjectUrl(siteUrl, projectId), { headers: SP_READ_HEADERS });
|
||||
if (!response.ok) {
|
||||
throw new Error(BINDER_TEXTS.errors.projectLoad(response.status));
|
||||
}
|
||||
return toBinderProject((await response.json()) as ISpBinderProjectRow);
|
||||
}
|
||||
|
||||
export async function searchCatalog(
|
||||
client: IHttpClient,
|
||||
apiBaseUrl: string,
|
||||
query: string,
|
||||
page: number
|
||||
): Promise<ICatalogPage> {
|
||||
const response = await client.fetch(catalogSearchUrl(apiBaseUrl, query, page));
|
||||
if (!response.ok) {
|
||||
throw new Error(BINDER_TEXTS.errors.search(response.status));
|
||||
}
|
||||
return (await response.json()) as ICatalogPage;
|
||||
}
|
||||
|
||||
async function mergeProject(
|
||||
client: IHttpClient,
|
||||
siteUrl: string,
|
||||
itemId: number,
|
||||
body: Record<string, unknown>,
|
||||
errorText: (status: number) => string
|
||||
): Promise<void> {
|
||||
const response = await client.fetch(projectItemUrl(siteUrl, itemId), {
|
||||
method: 'POST',
|
||||
headers: SP_MERGE_HEADERS,
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!response.ok && response.status !== NO_CONTENT) {
|
||||
throw new Error(errorText(response.status));
|
||||
}
|
||||
}
|
||||
|
||||
/** Записывает в «Проекты» id и название проекта из справочника. */
|
||||
export function bindProject(
|
||||
client: IHttpClient,
|
||||
siteUrl: string,
|
||||
itemId: number,
|
||||
catalog: ICatalogProject
|
||||
): Promise<void> {
|
||||
return mergeProject(
|
||||
client,
|
||||
siteUrl,
|
||||
itemId,
|
||||
{ UniqueId0: catalog.oid, UniqueName: catalog.name },
|
||||
BINDER_TEXTS.errors.bind
|
||||
);
|
||||
}
|
||||
|
||||
/** Очищает привязку: SharePoint ждёт именно null, чтобы очистить поле. */
|
||||
export function unbindProject(client: IHttpClient, siteUrl: string, itemId: number): Promise<void> {
|
||||
return mergeProject(client, siteUrl, itemId, { UniqueId0: null, UniqueName: null }, BINDER_TEXTS.errors.unbind);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
canGoToNextPage,
|
||||
canGoToPreviousPage,
|
||||
decodeHtmlEntities,
|
||||
formatDateRu,
|
||||
getBinderStatusKind,
|
||||
hasCatalogCoordinates,
|
||||
hasProjectCoordinates,
|
||||
isProjectBound,
|
||||
parseProjectId,
|
||||
toggleSelection
|
||||
} from './binderRules';
|
||||
import { ICatalogProject } from './binderTypes';
|
||||
import { BINDER_TEXTS } from './texts';
|
||||
|
||||
const catalog = (oid: string, overrides: Partial<ICatalogProject> = {}): ICatalogProject => ({
|
||||
id: 1,
|
||||
oid,
|
||||
name: oid,
|
||||
description: '',
|
||||
address: '',
|
||||
latitude: 43.2,
|
||||
longitude: 76.9,
|
||||
...overrides
|
||||
});
|
||||
|
||||
describe('parseProjectId', () => {
|
||||
it('parseInt как в binder', () => {
|
||||
expect(parseProjectId('5')).toEqual(5);
|
||||
expect(parseProjectId('5abc')).toEqual(5);
|
||||
expect(parseProjectId('abc')).toBeUndefined();
|
||||
expect(parseProjectId(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isProjectBound', () => {
|
||||
it('привязан, только если UniqueId0 не пустой', () => {
|
||||
expect(isProjectBound({ id: 1, title: 'П', uniqueId: 'x' })).toBe(true);
|
||||
expect(isProjectBound({ id: 1, title: 'П', uniqueId: '' })).toBe(false);
|
||||
expect(isProjectBound({ id: 1, title: 'П' })).toBe(false);
|
||||
expect(isProjectBound(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBinderStatusKind', () => {
|
||||
it('текущий — зелёный, архивный — золотой, остальное — серый; регистр не важен', () => {
|
||||
expect(getBinderStatusKind('Текущий')).toEqual('current');
|
||||
expect(getBinderStatusKind('АРХИВНЫЙ')).toEqual('archive');
|
||||
expect(getBinderStatusKind('завершен')).toEqual('other');
|
||||
});
|
||||
|
||||
it('пробелы не обрезаются (как в binder)', () => {
|
||||
expect(getBinderStatusKind(' текущий')).toEqual('other');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDateRu', () => {
|
||||
it('дд.мм.гггг', () => {
|
||||
expect(formatDateRu('2026-03-05T12:00:00Z')).toEqual('05.03.2026');
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeHtmlEntities', () => {
|
||||
it('декодирует сущности и не превращает их в разметку', () => {
|
||||
expect(decodeHtmlEntities('г. Астана, ул. "Кенесары" & 5')).toEqual('г. Астана, ул. "Кенесары" & 5');
|
||||
expect(decodeHtmlEntities('<b>жирный</b>')).toEqual('<b>жирный</b>');
|
||||
expect(decodeHtmlEntities('«Сункар»')).toEqual('«Сункар»');
|
||||
});
|
||||
});
|
||||
|
||||
describe('координаты', () => {
|
||||
it('проект SharePoint: обе координаты, 0 — тоже координата', () => {
|
||||
expect(hasProjectCoordinates({ id: 1, title: 'П', latitude: 0, longitude: 0 })).toBe(true);
|
||||
expect(hasProjectCoordinates({ id: 1, title: 'П', latitude: 51 })).toBe(false);
|
||||
});
|
||||
|
||||
it('справочник: проверка строго на null', () => {
|
||||
expect(hasCatalogCoordinates(catalog('a'))).toBe(true);
|
||||
expect(hasCatalogCoordinates(catalog('a', { latitude: null }))).toBe(false);
|
||||
expect(hasCatalogCoordinates(catalog('a', { longitude: null }))).toBe(false);
|
||||
});
|
||||
|
||||
it('формат «широта, долгота»', () => {
|
||||
expect(BINDER_TEXTS.coordinates(43.2, 76.9)).toEqual('43.2, 76.9');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleSelection', () => {
|
||||
it('клик по другой карточке выбирает её, по выбранной — снимает выбор', () => {
|
||||
const a = catalog('a');
|
||||
const b = catalog('b');
|
||||
expect(toggleSelection(undefined, a)).toBe(a);
|
||||
expect(toggleSelection(a, b)).toBe(b);
|
||||
expect(toggleSelection(a, catalog('a'))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('пагинация', () => {
|
||||
it('«←» неактивна на первой странице и во время поиска', () => {
|
||||
expect(canGoToPreviousPage(1, false)).toBe(false);
|
||||
expect(canGoToPreviousPage(2, false)).toBe(true);
|
||||
expect(canGoToPreviousPage(2, true)).toBe(false);
|
||||
});
|
||||
|
||||
it('«→» неактивна на последней странице и во время поиска', () => {
|
||||
expect(canGoToNextPage(3, 3, false)).toBe(false);
|
||||
expect(canGoToNextPage(2, 3, false)).toBe(true);
|
||||
expect(canGoToNextPage(2, 3, true)).toBe(false);
|
||||
});
|
||||
|
||||
it('подпись «{page} / {total} · всего: N»', () => {
|
||||
expect(BINDER_TEXTS.pageInfo(2, 3, 41)).toEqual('2 / 3 · всего: 41');
|
||||
});
|
||||
});
|
||||
|
||||
describe('тексты сообщений', () => {
|
||||
it('успех привязки и отвязки', () => {
|
||||
expect(BINDER_TEXTS.bindSuccess('ЖК Сункар')).toEqual('Проект успешно привязан к "ЖК Сункар"');
|
||||
expect(BINDER_TEXTS.unbindSuccess).toEqual('Привязка удалена');
|
||||
});
|
||||
|
||||
it('строка привязки «UniqueName · UniqueId0»', () => {
|
||||
expect(BINDER_TEXTS.boundInfo('Сункар', 'a1b2')).toEqual('Сункар · a1b2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { BinderStatusKind, IBinderProject, ICatalogProject } from './binderTypes';
|
||||
|
||||
// Правила раздела «Привязка к справочнику» (stroy-bot-projects-binder) — чистые функции.
|
||||
|
||||
/** projectId из адреса как в binder: parseInt, нечисло — нет проекта. */
|
||||
export function parseProjectId(value: string | undefined): number | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = parseInt(value, 10);
|
||||
return isNaN(parsed) ? undefined : parsed;
|
||||
}
|
||||
|
||||
export function isProjectBound(project: IBinderProject | undefined): boolean {
|
||||
return !!project?.uniqueId;
|
||||
}
|
||||
|
||||
/** Цвет статуса как в binder: только регистр не учитывается (пробелы — учитываются). */
|
||||
export function getBinderStatusKind(status: string): BinderStatusKind {
|
||||
const value = status.toLowerCase();
|
||||
if (value === 'текущий') return 'current';
|
||||
if (value === 'архивный') return 'archive';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/** Дата SharePoint → «дд.мм.гггг» (ru-RU), как в binder. */
|
||||
export function formatDateRu(value: string): string {
|
||||
return new Date(value).toLocaleDateString('ru-RU');
|
||||
}
|
||||
|
||||
/** Адрес в «Проекты» хранится с HTML-сущностями (", « …) — декодируем без вставки разметки. */
|
||||
export function decodeHtmlEntities(value: string): string {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.innerHTML = value;
|
||||
return textarea.value;
|
||||
}
|
||||
|
||||
export function hasProjectCoordinates(project: IBinderProject): boolean {
|
||||
return project.latitude !== undefined && project.longitude !== undefined;
|
||||
}
|
||||
|
||||
/** Как в binder — проверка строго на null: отсутствующее поле выводится как «undefined». */
|
||||
export function hasCatalogCoordinates(project: ICatalogProject): boolean {
|
||||
return project.latitude !== null && project.longitude !== null;
|
||||
}
|
||||
|
||||
/** Клик по выбранной карточке снимает выбор, по другой — выбирает её. */
|
||||
export function toggleSelection(
|
||||
selected: ICatalogProject | undefined,
|
||||
clicked: ICatalogProject
|
||||
): ICatalogProject | undefined {
|
||||
return selected?.oid === clicked.oid ? undefined : clicked;
|
||||
}
|
||||
|
||||
export function canGoToPreviousPage(currentPage: number, isSearching: boolean): boolean {
|
||||
return currentPage > 1 && !isSearching;
|
||||
}
|
||||
|
||||
export function canGoToNextPage(currentPage: number, totalPages: number, isSearching: boolean): boolean {
|
||||
return currentPage < totalPages && !isSearching;
|
||||
}
|
||||
|
||||
/** Текст ошибки как в binder — message исключения. */
|
||||
export function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/** Проект из списка SharePoint «Проекты» в том виде, в каком его показывает раздел. */
|
||||
export interface IBinderProject {
|
||||
/** Id элемента списка «Проекты». */
|
||||
id: number;
|
||||
title: string;
|
||||
/** Id проекта в справочнике API (поле UniqueId0). Пусто — проект не привязан. */
|
||||
uniqueId?: string;
|
||||
/** Название проекта в справочнике API (поле UniqueName). */
|
||||
uniqueName?: string;
|
||||
description?: string;
|
||||
/** Адрес хранится с HTML-сущностями — при выводе декодируется. */
|
||||
address?: string;
|
||||
/** Даты — ISO-строки SharePoint. */
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
status?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Строка «Проекты» в ответе SharePoint (odata=nometadata). Пустые поля приходят как null —
|
||||
* при разборе приводятся к undefined.
|
||||
*/
|
||||
export interface ISpBinderProjectRow {
|
||||
Id: number;
|
||||
Title: string;
|
||||
UniqueId0?: string;
|
||||
UniqueName?: string;
|
||||
description?: string;
|
||||
address?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
Status?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
}
|
||||
|
||||
/** Проект справочника API (`GET /Project`). */
|
||||
export interface ICatalogProject {
|
||||
id: number;
|
||||
oid: string;
|
||||
name: string;
|
||||
code?: string;
|
||||
description: string;
|
||||
address: string;
|
||||
// Внешний API отдаёт null, и старый код проверял именно `!== null` — тип описывает ответ как есть.
|
||||
// eslint-disable-next-line @rushstack/no-new-null
|
||||
latitude: number | null;
|
||||
// eslint-disable-next-line @rushstack/no-new-null
|
||||
longitude: number | null;
|
||||
}
|
||||
|
||||
/** Страница результатов справочника. */
|
||||
export interface ICatalogPage {
|
||||
items: ICatalogProject[];
|
||||
totalCount: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
/** Цвет статуса проекта: текущий — зелёный, архивный — золотой, остальные — серый. */
|
||||
export type BinderStatusKind = 'current' | 'archive' | 'other';
|
||||
@@ -0,0 +1,44 @@
|
||||
// Тексты раздела «Привязка к справочнику» — дословно из stroy-bot-projects-binder.
|
||||
|
||||
/** Разделитель « · » из старой разметки (` · ` между пробелами). */
|
||||
const DOT_SEPARATOR = ' · ';
|
||||
|
||||
export const BINDER_TEXTS = {
|
||||
loadingProject: 'Загрузка проекта...',
|
||||
noProjectId: 'projectId не найден в URL',
|
||||
dismiss: 'Закрыть',
|
||||
fields: {
|
||||
description: 'Описание: ',
|
||||
address: 'Адрес: ',
|
||||
status: 'Статус: ',
|
||||
startDate: 'Начало: ',
|
||||
endDate: 'Окончание: ',
|
||||
coordinates: 'Координаты: ',
|
||||
code: 'Код: '
|
||||
},
|
||||
coordinates: (latitude: number | undefined, longitude: number | undefined): string => `${latitude}, ${longitude}`,
|
||||
bound: 'Привязан',
|
||||
unbound: 'Не привязан',
|
||||
boundInfo: (uniqueName: string, uniqueId: string): string => `${uniqueName}${DOT_SEPARATOR}${uniqueId}`,
|
||||
unbind: 'Отвязать',
|
||||
searchTitleBound: 'Изменить привязку',
|
||||
searchTitleUnbound: 'Выбор проекта из справочника',
|
||||
searchPlaceholder: 'Поиск проекта в справочнике...',
|
||||
clearSearch: 'Очистить',
|
||||
searching: 'Поиск...',
|
||||
notFound: 'Ничего не найдено',
|
||||
bind: 'Связать',
|
||||
saving: 'Сохранение...',
|
||||
previousPage: '←',
|
||||
nextPage: '→',
|
||||
pageInfo: (page: number, totalPages: number, totalCount: number): string =>
|
||||
`${page} / ${totalPages}${DOT_SEPARATOR}всего: ${totalCount}`,
|
||||
bindSuccess: (name: string): string => `Проект успешно привязан к "${name}"`,
|
||||
unbindSuccess: 'Привязка удалена',
|
||||
errors: {
|
||||
projectLoad: (status: number): string => `Ошибка загрузки проекта: ${status}`,
|
||||
search: (status: number): string => `Ошибка поиска: ${status}`,
|
||||
bind: (status: number): string => `Ошибка сохранения: ${status}`,
|
||||
unbind: (status: number): string => `Ошибка: ${status}`
|
||||
}
|
||||
} as const;
|
||||
@@ -0,0 +1,49 @@
|
||||
import * as React from 'react';
|
||||
import { useAppServices } from '../../app/AppServicesContext';
|
||||
import { fetchBinderProject } from './binderRepository';
|
||||
import { errorMessage } from './binderRules';
|
||||
import { IBinderProject } from './binderTypes';
|
||||
import { BINDER_TEXTS } from './texts';
|
||||
|
||||
export interface IBinderProjectState {
|
||||
project: IBinderProject | undefined;
|
||||
setProject: React.Dispatch<React.SetStateAction<IBinderProject | undefined>>;
|
||||
isLoading: boolean;
|
||||
/** Показывается предупреждением; поиск по справочнику при этом всё равно работает (как в binder). */
|
||||
projectError: string | undefined;
|
||||
}
|
||||
|
||||
/** Проект из «Проекты» по projectId из адреса. */
|
||||
export function useBinderProject(projectId: number | undefined): IBinderProjectState {
|
||||
const { sharePoint, settings } = useAppServices();
|
||||
const [project, setProject] = React.useState<IBinderProject | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(true);
|
||||
const [projectError, setProjectError] = React.useState<string | undefined>(undefined);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (projectId === undefined) {
|
||||
setIsLoading(false);
|
||||
setProjectError(BINDER_TEXTS.noProjectId);
|
||||
return undefined;
|
||||
}
|
||||
let isCancelled = false;
|
||||
setIsLoading(true);
|
||||
setProjectError(undefined);
|
||||
fetchBinderProject(sharePoint, settings.siteUrl, projectId)
|
||||
.then((loaded) => {
|
||||
if (isCancelled) return;
|
||||
setProject(loaded);
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (isCancelled) return;
|
||||
setProjectError(errorMessage(error));
|
||||
setIsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [sharePoint, settings.siteUrl, projectId]);
|
||||
|
||||
return { project, setProject, isLoading, projectError };
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import * as React from 'react';
|
||||
import { useAppServices } from '../../app/AppServicesContext';
|
||||
import { searchCatalog } from './binderRepository';
|
||||
import { errorMessage, toggleSelection } from './binderRules';
|
||||
import { ICatalogPage, ICatalogProject } from './binderTypes';
|
||||
|
||||
/** Задержка поиска после ввода — как в binder. */
|
||||
export const SEARCH_DEBOUNCE_MS = 400;
|
||||
|
||||
const FIRST_PAGE = 1;
|
||||
|
||||
export interface ICatalogSearch {
|
||||
search: string;
|
||||
results: ICatalogProject[];
|
||||
isSearching: boolean;
|
||||
searchError: string | undefined;
|
||||
selected: ICatalogProject | undefined;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalCount: number;
|
||||
changeSearch: (value: string) => void;
|
||||
goToPage: (page: number) => void;
|
||||
toggleSelect: (project: ICatalogProject) => void;
|
||||
clearSelection: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Поиск по справочнику API. Первый запрос (весь справочник) — когда isEnabled станет true:
|
||||
* в binder поиск стартовал только после загрузки проекта.
|
||||
*/
|
||||
export function useCatalogSearch(isEnabled: boolean): ICatalogSearch {
|
||||
const { tnsuApi, settings } = useAppServices();
|
||||
const [search, setSearch] = React.useState<string>('');
|
||||
const [results, setResults] = React.useState<ICatalogProject[]>([]);
|
||||
const [isSearching, setIsSearching] = React.useState<boolean>(false);
|
||||
const [searchError, setSearchError] = React.useState<string | undefined>(undefined);
|
||||
const [selected, setSelected] = React.useState<ICatalogProject | undefined>(undefined);
|
||||
const [paging, setPaging] = React.useState<Pick<ICatalogPage, 'page' | 'totalPages' | 'totalCount'>>({
|
||||
page: FIRST_PAGE,
|
||||
totalPages: 1,
|
||||
totalCount: 0
|
||||
});
|
||||
const timerRef = React.useRef<number | undefined>(undefined);
|
||||
|
||||
// Ответы не сверяются с последним запросом — как в binder, побеждает пришедший последним.
|
||||
const runSearch = (query: string, page: number): void => {
|
||||
setIsSearching(true);
|
||||
setSearchError(undefined);
|
||||
searchCatalog(tnsuApi, settings.apiBaseUrl, query, page)
|
||||
.then((data) => {
|
||||
setResults(data.items);
|
||||
setPaging({ page: data.page, totalPages: data.totalPages, totalCount: data.totalCount });
|
||||
setIsSearching(false);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
setSearchError(errorMessage(error));
|
||||
setIsSearching(false);
|
||||
});
|
||||
};
|
||||
|
||||
const hasStartedRef = React.useRef<boolean>(false);
|
||||
React.useEffect(() => {
|
||||
// Только первый запуск после загрузки проекта; дальше поиск идёт по вводу и страницам.
|
||||
if (!isEnabled || hasStartedRef.current) return;
|
||||
hasStartedRef.current = true;
|
||||
runSearch('', FIRST_PAGE);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isEnabled]);
|
||||
|
||||
React.useEffect(() => () => window.clearTimeout(timerRef.current), []);
|
||||
|
||||
const changeSearch = (value: string): void => {
|
||||
setSearch(value);
|
||||
setResults([]);
|
||||
setSelected(undefined);
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = window.setTimeout(() => runSearch(value, FIRST_PAGE), SEARCH_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
return {
|
||||
search,
|
||||
results,
|
||||
isSearching,
|
||||
searchError,
|
||||
selected,
|
||||
currentPage: paging.page,
|
||||
totalPages: paging.totalPages,
|
||||
totalCount: paging.totalCount,
|
||||
changeSearch,
|
||||
goToPage: (page) => runSearch(search, page),
|
||||
toggleSelect: (project) => setSelected((prev) => toggleSelection(prev, project)),
|
||||
clearSelection: () => setSelected(undefined)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import * as React from 'react';
|
||||
import { useAppServices } from '../../app/AppServicesContext';
|
||||
import { bindProject, unbindProject } from './binderRepository';
|
||||
import { errorMessage } from './binderRules';
|
||||
import { IBinderProject, ICatalogProject } from './binderTypes';
|
||||
import { BINDER_TEXTS } from './texts';
|
||||
|
||||
export interface IProjectBinding {
|
||||
isSaving: boolean;
|
||||
successMessage: string | undefined;
|
||||
saveError: string | undefined;
|
||||
/** true — привязка сохранена (страница снимает выбор карточки). */
|
||||
bind: (catalog: ICatalogProject) => Promise<boolean>;
|
||||
unbind: () => Promise<void>;
|
||||
clearSuccess: () => void;
|
||||
clearSaveError: () => void;
|
||||
}
|
||||
|
||||
/** «Связать» / «Отвязать» без подтверждения — как в binder. После успеха карточка проекта обновляется на месте. */
|
||||
export function useProjectBinding(
|
||||
project: IBinderProject | undefined,
|
||||
setProject: React.Dispatch<React.SetStateAction<IBinderProject | undefined>>
|
||||
): IProjectBinding {
|
||||
const { sharePoint, settings } = useAppServices();
|
||||
const [isSaving, setIsSaving] = React.useState<boolean>(false);
|
||||
const [successMessage, setSuccessMessage] = React.useState<string | undefined>(undefined);
|
||||
const [saveError, setSaveError] = React.useState<string | undefined>(undefined);
|
||||
|
||||
const save = async (write: (itemId: number) => Promise<void>, applied: Partial<IBinderProject>): Promise<boolean> => {
|
||||
if (!project) return false;
|
||||
setIsSaving(true);
|
||||
setSaveError(undefined);
|
||||
setSuccessMessage(undefined);
|
||||
try {
|
||||
await write(project.id);
|
||||
setProject((prev) => (prev ? { ...prev, ...applied } : prev));
|
||||
setIsSaving(false);
|
||||
return true;
|
||||
} catch (error) {
|
||||
setIsSaving(false);
|
||||
setSaveError(errorMessage(error));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const bind = async (catalog: ICatalogProject): Promise<boolean> => {
|
||||
const isSaved = await save((itemId) => bindProject(sharePoint, settings.siteUrl, itemId, catalog), {
|
||||
uniqueId: catalog.oid,
|
||||
uniqueName: catalog.name
|
||||
});
|
||||
if (isSaved) setSuccessMessage(BINDER_TEXTS.bindSuccess(catalog.name));
|
||||
return isSaved;
|
||||
};
|
||||
|
||||
const unbind = async (): Promise<void> => {
|
||||
const isSaved = await save((itemId) => unbindProject(sharePoint, settings.siteUrl, itemId), {
|
||||
uniqueId: undefined,
|
||||
uniqueName: undefined
|
||||
});
|
||||
if (isSaved) setSuccessMessage(BINDER_TEXTS.unbindSuccess);
|
||||
};
|
||||
|
||||
return {
|
||||
isSaving,
|
||||
successMessage,
|
||||
saveError,
|
||||
bind,
|
||||
unbind,
|
||||
clearSuccess: () => setSuccessMessage(undefined),
|
||||
clearSaveError: () => setSaveError(undefined)
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user