Форматирование frontend-а приведено к общим правилам с backend-ом с помощью eslint

This commit is contained in:
Pavel Gnedov 2023-10-03 07:48:57 +07:00
parent 0e28eba615
commit 0b82ca564a
31 changed files with 1079 additions and 892 deletions

View file

@ -5,12 +5,12 @@ import App from './App';
import reportWebVitals from './reportWebVitals'; import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot( const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement document.getElementById('root') as HTMLElement,
); );
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<App /> <App />
</React.StrictMode> </React.StrictMode>,
); );
// If you want to start measuring performance in your app, pass a function // If you want to start measuring performance in your app, pass a function

View file

@ -5,32 +5,34 @@ import Css from './issues-list-board.module.css';
import * as IssuesListCardNs from './issues-list-card'; import * as IssuesListCardNs from './issues-list-card';
export type Props = { export type Props = {
store: IBoardStore store: IBoardStore;
} };
export const IssuesListBoard = observer((props: Props): JSX.Element => { export const IssuesListBoard = observer((props: Props): JSX.Element => {
const list: JSX.Element[] = props.store.data.map((issue) => { const list: JSX.Element[] = props.store.data.map((issue) => {
return ( return <IssuesListCardNs.IssuesListCard store={issue} key={issue.id} />;
<IssuesListCardNs.IssuesListCard store={issue} key={issue.id}/> });
); let title: JSX.Element;
}); if (props.store.metainfo.url) {
let title: JSX.Element; title = <a href={props.store.metainfo.url}>{props.store.metainfo.title}</a>;
if (props.store.metainfo.url) { } else {
title = <a href={props.store.metainfo.url}>{props.store.metainfo.title}</a>; title = <>{props.store.metainfo.title}</>;
} else { }
title = <>{props.store.metainfo.title}</>; return (
} <div className={Css.board}>
return ( <div className={Css.boardName}>
<div className={Css.board}> <h2 className={Css.boardHeader} id={props.store.metainfo.title}>
<div className={Css.boardName}> {title}
<h2 className={Css.boardHeader} id={props.store.metainfo.title}>{title}</h2> </h2>
<a href={`#${props.store.metainfo.title}`}> <a href={`#${props.store.metainfo.title}`}>
<img src="/images/anchor BLUE.svg" alt="anchor" className={Css.anchorIcon} /> <img
</a> src="/images/anchor BLUE.svg"
</div> alt="anchor"
<div className={Css.listContainer}> className={Css.anchorIcon}
{list} />
</div> </a>
</div> </div>
); <div className={Css.listContainer}>{list}</div>
</div>
);
}); });

View file

@ -4,21 +4,23 @@ import * as IssuesListStoreNs from './store';
import * as IssuesListBoardsNs from './issues-list-boards'; import * as IssuesListBoardsNs from './issues-list-boards';
export const IssuesListBoardPage = (): JSX.Element => { export const IssuesListBoardPage = (): JSX.Element => {
const params = useParams(); const params = useParams();
const name = params.name as string; const name = params.name as string;
const type = params.type as string; const type = params.type as string;
// DEBUG: begin // DEBUG: begin
console.debug(`Issues list page: type=${type}; name=${name}`); console.debug(`Issues list page: type=${type}; name=${name}`);
useEffect(() => { useEffect(() => {
console.debug(`Issues list page: type=${type}; name=${name}`); console.debug(`Issues list page: type=${type}; name=${name}`);
}); });
// DEBUG: end // DEBUG: end
const store = IssuesListStoreNs.PageStore.create({loaded: false, type: type, name: name}); const store = IssuesListStoreNs.PageStore.create({
IssuesListStoreNs.PageStoreLoadData(store); loaded: false,
type: type,
name: name,
});
IssuesListStoreNs.PageStoreLoadData(store);
return ( return <IssuesListBoardsNs.IssuesListBoards store={store} />;
<IssuesListBoardsNs.IssuesListBoards store={store}/>
);
}; };

View file

@ -7,35 +7,37 @@ import { SetIssuesReadingTimestamp } from '../utils/unreaded-provider';
import * as ServiceActionsButtons from '../utils/service-actions-buttons'; import * as ServiceActionsButtons from '../utils/service-actions-buttons';
export type Props = { export type Props = {
store: IssuesListBoardStore.IPageStore store: IssuesListBoardStore.IPageStore;
}; };
export const IssuesListBoards = observer((props: Props): JSX.Element => { export const IssuesListBoards = observer((props: Props): JSX.Element => {
const data = props.store.data; const data = props.store.data;
if (!props.store.loaded || !data) { if (!props.store.loaded || !data) {
return <div>Loading...</div> return <div>Loading...</div>;
} }
const list: any[] = []; const list: any[] = [];
for (let i = 0; i < data.length; i++) { for (let i = 0; i < data.length; i++) {
const boardData = data[i]; const boardData = data[i];
const key = boardData.metainfo.title; const key = boardData.metainfo.title;
const board = <IssuesListBoardNs.IssuesListBoard store={boardData} key={key}/> const board = (
list.push(board); <IssuesListBoardNs.IssuesListBoard store={boardData} key={key} />
} );
const topRightMenuStore = TopRightMenuNs.Store.create({visible: false}); list.push(board);
const onAllReadItemClick = (e: React.MouseEvent) => { }
e.stopPropagation(); const topRightMenuStore = TopRightMenuNs.Store.create({ visible: false });
SetIssuesReadingTimestamp(props.store.issueIds); const onAllReadItemClick = (e: React.MouseEvent) => {
IssuesListBoardStore.PageStoreLoadData(props.store); e.stopPropagation();
}; SetIssuesReadingTimestamp(props.store.issueIds);
return ( IssuesListBoardStore.PageStoreLoadData(props.store);
<> };
<TopRightMenuNs.TopRightMenu store={topRightMenuStore}> return (
<button onClick={onAllReadItemClick}>Прочитать всё</button> <>
<ServiceActionsButtons.IssuesForceRefreshButton /> <TopRightMenuNs.TopRightMenu store={topRightMenuStore}>
<ServiceActionsButtons.GetIssuesQueueSizeButton /> <button onClick={onAllReadItemClick}>Прочитать всё</button>
</TopRightMenuNs.TopRightMenu> <ServiceActionsButtons.IssuesForceRefreshButton />
{list} <ServiceActionsButtons.GetIssuesQueueSizeButton />
</> </TopRightMenuNs.TopRightMenu>
); {list}
</>
);
}); });

View file

@ -11,44 +11,63 @@ import { SpentHoursToFixed } from '../utils/spent-hours-to-fixed';
import { getStyleObjectFromString } from '../utils/style'; import { getStyleObjectFromString } from '../utils/style';
export type Props = { export type Props = {
store: IIssueStore store: IIssueStore;
}; };
export const defaultPriorityStyleKey = 'priorityStyle'; export const defaultPriorityStyleKey = 'priorityStyle';
export const IssuesListCard = observer((props: Props): JSX.Element => { export const IssuesListCard = observer((props: Props): JSX.Element => {
const unreadedStore = UnreadedFlagNs.CreateStoreFromLocalStorage(props.store); const unreadedStore = UnreadedFlagNs.CreateStoreFromLocalStorage(props.store);
const detailsStore = IssueDetailsDialogNs.Store.create({ const detailsStore = IssueDetailsDialogNs.Store.create({
issue: props.store, issue: props.store,
visible: false, visible: false,
unreadedFlagStore: unreadedStore unreadedFlagStore: unreadedStore,
}); });
const priorityStyle = getStyleObjectFromString(props.store[defaultPriorityStyleKey]); const priorityStyle = getStyleObjectFromString(
const tagsNewLine = (props.store.styledTags && props.store.styledTags.length > 0) ? <br/> : null; props.store[defaultPriorityStyleKey],
return ( );
<div className={Css.todoBlock} onClick={(e) => { e.stopPropagation(); detailsStore.show(); }}> const tagsNewLine =
<IssueDetailsDialogNs.IssueDetailsDialog store={detailsStore} /> props.store.styledTags && props.store.styledTags.length > 0 ? <br /> : null;
<div className={Css.relevanceColor}> return (
<TimePassedNs.TimePassed params={{ fromIssue: { issue: props.store, keyName: 'timePassedClass' } }} /> <div
</div> className={Css.todoBlock}
<div className={Css.importantInformation}> onClick={(e) => {
<span className={Css.issueSubject}> e.stopPropagation();
<IssueHrefNs.IssueHref detailsStore.show();
url={props.store.url?.url || ''} }}
subject={props.store.subject} >
tracker={props.store.tracker?.name || ''} <IssueDetailsDialogNs.IssueDetailsDialog store={detailsStore} />
id={props.store.id} <div className={Css.relevanceColor}>
/> <TimePassedNs.TimePassed
</span> params={{
<span> </span> fromIssue: { issue: props.store, keyName: 'timePassedClass' },
<span className={Css.timeBox}>{SpentHoursToFixed(props.store.total_spent_hours)} / {SpentHoursToFixed(props.store.total_estimated_hours)}</span> }}
{tagsNewLine} />
<TagsNs.Tags params={{ tags: props.store.styledTags }} /> </div>
<div className={Css.positionInfo}> <div className={Css.importantInformation}>
<span className={Css.timeBox}>{props.store.status.name}</span><span> </span> <span className={Css.issueSubject}>
<span className={Css.priorityBox} style={priorityStyle}>{props.store.priority.name}</span> <IssueHrefNs.IssueHref
</div> url={props.store.url?.url || ''}
</div> subject={props.store.subject}
</div> tracker={props.store.tracker?.name || ''}
); id={props.store.id}
/>
</span>
<span> </span>
<span className={Css.timeBox}>
{SpentHoursToFixed(props.store.total_spent_hours)} /{' '}
{SpentHoursToFixed(props.store.total_estimated_hours)}
</span>
{tagsNewLine}
<TagsNs.Tags params={{ tags: props.store.styledTags }} />
<div className={Css.positionInfo}>
<span className={Css.timeBox}>{props.store.status.name}</span>
<span> </span>
<span className={Css.priorityBox} style={priorityStyle}>
{props.store.priority.name}
</span>
</div>
</div>
</div>
);
}); });

View file

@ -1,86 +1,93 @@
import { Instance, types } from "mobx-state-tree"; import { Instance, types } from 'mobx-state-tree';
import { RedmineTypes } from "../redmine-types"; import { RedmineTypes } from '../redmine-types';
import axios from "axios"; import axios from 'axios';
export const IssueStore = types.frozen<RedmineTypes.ExtendedIssue>(); export const IssueStore = types.frozen<RedmineTypes.ExtendedIssue>();
export interface IIssueStore extends Instance<typeof IssueStore> {} export type IIssueStore = Instance<typeof IssueStore>;
export const MetaInfoStore = types.model({ export const MetaInfoStore = types.model({
title: types.string, title: types.string,
url: types.maybe(types.string), url: types.maybe(types.string),
rootIssue: types.maybe(types.model({ rootIssue: types.maybe(
id: 0, types.model({
tracker: types.model({ id: 0,
id: 0, tracker: types.model({
name: '' id: 0,
}), name: '',
subject: '' }),
})) subject: '',
}),
),
}); });
export const BoardStore = types.model({ export const BoardStore = types.model({
data: types.array(IssueStore), data: types.array(IssueStore),
metainfo: MetaInfoStore metainfo: MetaInfoStore,
}); });
export interface IBoardStore extends Instance<typeof BoardStore> {} export type IBoardStore = Instance<typeof BoardStore>;
export const PageStore = types.model({ export const PageStore = types
loaded: types.boolean, .model({
type: types.string, loaded: types.boolean,
name: types.string, type: types.string,
data: types.maybeNull( name: types.string,
types.array(BoardStore) data: types.maybeNull(types.array(BoardStore)),
) })
}).actions((self) => { .actions((self) => {
return { return {
setData: (data: any) => { setData: (data: any) => {
self.data = data; self.data = data;
self.loaded = true; self.loaded = true;
} },
}; };
}).views((self) => { })
return { .views((self) => {
get issueIds(): number[] { return {
if (!self.data) return []; get issueIds(): number[] {
const data = self.data; if (!self.data) return [];
const res = [] as number[]; const data = self.data;
for (let i = 0; i < data.length; i++) { const res = [] as number[];
const itemData = data[i]; for (let i = 0; i < data.length; i++) {
for (let j = 0; j < itemData.data.length; j++) { const itemData = data[i];
const issue = itemData.data[j]; for (let j = 0; j < itemData.data.length; j++) {
if (res.indexOf(issue.id) < 0) { const issue = itemData.data[j];
res.push(issue.id); if (res.indexOf(issue.id) < 0) {
} res.push(issue.id);
} }
} }
return res; }
} return res;
}; },
}); };
});
export async function PageStoreLoadData(store: IPageStore): Promise<void> { export async function PageStoreLoadData(store: IPageStore): Promise<void> {
const url = `${process.env.REACT_APP_BACKEND}simple-kanban-board/${store.type}/${store.name}/raw`; const url = `${process.env.REACT_APP_BACKEND}simple-kanban-board/${store.type}/${store.name}/raw`;
const resp = await axios.get(url); const resp = await axios.get(url);
if (!(resp?.data)) return; if (!resp?.data) return;
const data = []; const data = [];
for (let i = 0; i < resp.data.length; i++) { for (let i = 0; i < resp.data.length; i++) {
const item = resp.data[i] as {data: any[], metainfo: Record<string, any>}; const item = resp.data[i] as { data: any[]; metainfo: Record<string, any> };
data.push({ data.push({
metainfo: item.metainfo, metainfo: item.metainfo,
data: item.data ? item.data.map((group: { status: string, count: number, issues: any[] }) => { data: item.data
return group.issues ? item.data
}).flat() : [] .map((group: { status: string; count: number; issues: any[] }) => {
}); return group.issues;
} })
.flat()
: [],
});
}
/* DEBUG: begin */ /* DEBUG: begin */
console.debug(`Issues list board store data: ${JSON.stringify(data)}`); console.debug(`Issues list board store data: ${JSON.stringify(data)}`);
/* DEBUG: end */ /* DEBUG: end */
store.setData(data); store.setData(data);
} }
export interface IPageStore extends Instance<typeof PageStore> {} export type IPageStore = Instance<typeof PageStore>;

View file

@ -5,23 +5,26 @@ import { observer } from 'mobx-react-lite';
import * as KanbanCard from './kanban-card'; import * as KanbanCard from './kanban-card';
export type Props = { export type Props = {
store: Stores.IColumnStore store: Stores.IColumnStore;
} };
export const Column = observer((props: Props) => { export const Column = observer((props: Props) => {
const cards = props.store.cards.map((card) => { const cards = props.store.cards.map((card) => {
return ( return (
<KanbanCard.KanbanCard store={card} key={card.issue.id}></KanbanCard.KanbanCard> <KanbanCard.KanbanCard
); store={card}
}); key={card.issue.id}
return ( ></KanbanCard.KanbanCard>
<div className={ColumnCss.kanbanColumn}> );
<div className={ColumnCss.kanbanHeader}> });
{props.store.status} ({props.store.count}) return (
</div> <div className={ColumnCss.kanbanColumn}>
{cards} <div className={ColumnCss.kanbanHeader}>
</div> {props.store.status} ({props.store.count})
); </div>
{cards}
</div>
);
}); });
export default Column; export default Column;

View file

@ -5,29 +5,29 @@ import { observer } from 'mobx-react-lite';
import Column from './column'; import Column from './column';
export type Props = { export type Props = {
store: IBoardStore store: IBoardStore;
}; };
export const KanbanBoard = observer((props: Props) => { export const KanbanBoard = observer((props: Props) => {
let title: any; let title: any;
if (props.store.metainfo.url) { if (props.store.metainfo.url) {
title = <a href={props.store.metainfo.url}>{props.store.metainfo.title}</a>; title = <a href={props.store.metainfo.url}>{props.store.metainfo.title}</a>;
} else { } else {
title = <>{props.store.metainfo.title}</>; title = <>{props.store.metainfo.title}</>;
} }
const columns = []; const columns = [];
for (let i = 0; i < props.store.data.length; i++) { for (let i = 0; i < props.store.data.length; i++) {
const column = props.store.data[i]; const column = props.store.data[i];
columns.push(<Column store={column}/>) columns.push(<Column store={column} />);
} }
return ( return (
<> <>
<h1 id={props.store.metainfo.title}>{title} <a href={`#${props.store.metainfo.title}`}>#</a></h1> <h1 id={props.store.metainfo.title}>
<div className={KanbanBoardCss.kanbanContainer}> {title} <a href={`#${props.store.metainfo.title}`}>#</a>
{columns} </h1>
</div> <div className={KanbanBoardCss.kanbanContainer}>{columns}</div>
</> </>
); );
}); });
export default KanbanBoard; export default KanbanBoard;

View file

@ -8,47 +8,51 @@ import axios from 'axios';
import * as ServiceActionsButtons from '../utils/service-actions-buttons'; import * as ServiceActionsButtons from '../utils/service-actions-buttons';
export type Props = { export type Props = {
store: IPageStore store: IPageStore;
} };
export const KanbanBoards = observer((props: Props) => { export const KanbanBoards = observer((props: Props) => {
const data = props.store.data; const data = props.store.data;
if (!props.store.loaded || !data) { if (!props.store.loaded || !data) {
return <div>Loading...</div> return <div>Loading...</div>;
} }
const list: any[] = []; const list: any[] = [];
for (let i = 0; i < data.length; i++) { for (let i = 0; i < data.length; i++) {
const boardData = data[i]; const boardData = data[i];
const key = boardData.metainfo.title; const key = boardData.metainfo.title;
const board = <KB.KanbanBoard store={boardData} key={key} />; const board = <KB.KanbanBoard store={boardData} key={key} />;
list.push(board); list.push(board);
} }
const topRightMenuStore = TopRightMenuNs.Store.create({visible: false}); const topRightMenuStore = TopRightMenuNs.Store.create({ visible: false });
const onAllReadClick = (e: React.MouseEvent) => { const onAllReadClick = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
SetIssuesReadingTimestamp(props.store.issueIds); SetIssuesReadingTimestamp(props.store.issueIds);
PageStoreLoadData(props.store); PageStoreLoadData(props.store);
}; };
let treeRefreshMenuItem: JSX.Element = <></>; let treeRefreshMenuItem: JSX.Element = <></>;
if (props.store.canTreeRefresh) { if (props.store.canTreeRefresh) {
const onTreeRefreshClick = (e: React.MouseEvent) => { const onTreeRefreshClick = (e: React.MouseEvent) => {
if (e.target !== e.currentTarget) return; if (e.target !== e.currentTarget) return;
e.stopPropagation(); e.stopPropagation();
axios.get(`${process.env.REACT_APP_BACKEND}simple-kanban-board/tree/${props.store.name}/refresh`); axios.get(
} `${process.env.REACT_APP_BACKEND}simple-kanban-board/tree/${props.store.name}/refresh`,
treeRefreshMenuItem = <button onClick={onTreeRefreshClick}>Force tree refresh</button>; );
} };
return ( treeRefreshMenuItem = (
<> <button onClick={onTreeRefreshClick}>Force tree refresh</button>
<TopRightMenuNs.TopRightMenu store={topRightMenuStore}> );
<button onClick={onAllReadClick}>Всё прочитано</button> }
{treeRefreshMenuItem} return (
<ServiceActionsButtons.IssuesForceRefreshButton/> <>
<ServiceActionsButtons.GetIssuesQueueSizeButton/> <TopRightMenuNs.TopRightMenu store={topRightMenuStore}>
</TopRightMenuNs.TopRightMenu> <button onClick={onAllReadClick}>Всё прочитано</button>
{list} {treeRefreshMenuItem}
</> <ServiceActionsButtons.IssuesForceRefreshButton />
); <ServiceActionsButtons.GetIssuesQueueSizeButton />
</TopRightMenuNs.TopRightMenu>
{list}
</>
);
}); });
export default KanbanBoards; export default KanbanBoards;

View file

@ -9,22 +9,22 @@ import * as IssueDetailsDialogNs from '../misc-components/issue-details-dialog';
import * as UnreadedFlagNs from '../misc-components/unreaded-flag'; import * as UnreadedFlagNs from '../misc-components/unreaded-flag';
export type Props = { export type Props = {
store: ICardStore store: ICardStore;
}; };
export type TagProps = { export type TagProps = {
style?: string; style?: string;
tag: string; tag: string;
}; };
export const KanbanCardTag = (props: TagProps): JSX.Element => { export const KanbanCardTag = (props: TagProps): JSX.Element => {
const inlineStyle = getStyleObjectFromString(props.style || ''); const inlineStyle = getStyleObjectFromString(props.style || '');
return ( return (
<span className={KanbanCardCss.kanbanCardTag} style={inlineStyle}> <span className={KanbanCardCss.kanbanCardTag} style={inlineStyle}>
{props.tag} {props.tag}
</span> </span>
); );
} };
/** /**
* Какие дальше требования к карточкам? * Какие дальше требования к карточкам?
@ -34,45 +34,59 @@ export const KanbanCardTag = (props: TagProps): JSX.Element => {
*/ */
export const KanbanCard = observer((props: Props) => { export const KanbanCard = observer((props: Props) => {
let tagsSection = <></>; let tagsSection = <></>;
const tagsParams = props.store.params.fields.find((field) => { const tagsParams = props.store.params.fields.find((field) => {
return field.component === 'tags'; return field.component === 'tags';
}); });
console.debug('Tag params:', tagsParams); // DEBUG console.debug('Tag params:', tagsParams); // DEBUG
console.debug('Issue:', props.store.issue); // DEBUG console.debug('Issue:', props.store.issue); // DEBUG
if (tagsParams && props.store.issue[tagsParams.path]) { if (tagsParams && props.store.issue[tagsParams.path]) {
const tags = props.store.issue[tagsParams.path] as TagProps[]; const tags = props.store.issue[tagsParams.path] as TagProps[];
console.debug(`Tags:`, tags); // DEBUG console.debug(`Tags:`, tags); // DEBUG
tagsSection = <TagsNs.Tags params={{tags: tags}}/> tagsSection = <TagsNs.Tags params={{ tags: tags }} />;
} }
const timePassedParams: TimePassedNs.Params = { const timePassedParams: TimePassedNs.Params = {
fromIssue: { fromIssue: {
issue: props.store.issue, issue: props.store.issue,
keyName: 'timePassedClass' keyName: 'timePassedClass',
} },
} };
const unreadedStore = UnreadedFlagNs.CreateStoreFromLocalStorage(props.store.issue); const unreadedStore = UnreadedFlagNs.CreateStoreFromLocalStorage(
const detailsStore = IssueDetailsDialogNs.Store.create({ props.store.issue,
issue: props.store.issue, );
visible: false, const detailsStore = IssueDetailsDialogNs.Store.create({
unreadedFlagStore: unreadedStore issue: props.store.issue,
}); visible: false,
return ( unreadedFlagStore: unreadedStore,
<div className={KanbanCardCss.kanbanCard} onClick={(e) => {e.stopPropagation(); detailsStore.show();}}> });
<IssueDetailsDialogNs.IssueDetailsDialog store={detailsStore} /> return (
<div className={KanbanCardCss.kanbanCardTitle}> <div
<UnreadedFlagNs.UnreadedFlag store={unreadedStore}/> className={KanbanCardCss.kanbanCard}
<TimePassedNs.TimePassed params={timePassedParams}/> onClick={(e) => {
<a href={props.store.issue.url.url}>{props.store.issue.tracker.name} #{props.store.issue.id} - {props.store.issue.subject}</a> e.stopPropagation();
</div> detailsStore.show();
<div>Исп.: {props.store.issue.current_user.name}</div> }}
<div>Прио.: {props.store.issue.priority.name}</div> >
<div>Версия: {props.store.issue.fixed_version?.name || ''}</div> <IssueDetailsDialogNs.IssueDetailsDialog store={detailsStore} />
<div>Прогресс: {props.store.issue.done_ratio}</div> <div className={KanbanCardCss.kanbanCardTitle}>
<div>Трудозатраты: {props.store.issue.total_spent_hours} / {props.store.issue.total_estimated_hours}</div> <UnreadedFlagNs.UnreadedFlag store={unreadedStore} />
{tagsSection} <TimePassedNs.TimePassed params={timePassedParams} />
</div> <a href={props.store.issue.url.url}>
); {props.store.issue.tracker.name} #{props.store.issue.id} -{' '}
{props.store.issue.subject}
</a>
</div>
<div>Исп.: {props.store.issue.current_user.name}</div>
<div>Прио.: {props.store.issue.priority.name}</div>
<div>Версия: {props.store.issue.fixed_version?.name || ''}</div>
<div>Прогресс: {props.store.issue.done_ratio}</div>
<div>
Трудозатраты: {props.store.issue.total_spent_hours} /{' '}
{props.store.issue.total_estimated_hours}
</div>
{tagsSection}
</div>
);
}); });
export default KanbanCard; export default KanbanCard;

View file

@ -2,126 +2,128 @@ import { Instance, types } from 'mobx-state-tree';
import { RedmineTypes } from '../redmine-types'; import { RedmineTypes } from '../redmine-types';
import axios from 'axios'; import axios from 'axios';
export const IssueStore = types.frozen<RedmineTypes.ExtendedIssue>() export const IssueStore = types.frozen<RedmineTypes.ExtendedIssue>();
export interface IIssueStore extends Instance<typeof IssueStore> {} export type IIssueStore = Instance<typeof IssueStore>;
export const ColumnStore = types.model({ export const ColumnStore = types
status: '', .model({
count: 0, status: '',
issues: types.array(IssueStore) count: 0,
}).views((self) => { issues: types.array(IssueStore),
return { })
get cards(): ICardStore[] { .views((self) => {
return self.issues.map(issue => { return {
return CardStore.create({ get cards(): ICardStore[] {
issue: issue return self.issues.map((issue) => {
}) return CardStore.create({
}); issue: issue,
} });
} });
}); },
};
});
export interface IColumnStore extends Instance<typeof ColumnStore> {} export type IColumnStore = Instance<typeof ColumnStore>;
export const MetaInfoStore = types.model({ export const MetaInfoStore = types.model({
title: '', title: '',
url: types.maybe(types.string), url: types.maybe(types.string),
rootIssue: types.maybe(types.model({ rootIssue: types.maybe(
id: 0, types.model({
tracker: types.model({ id: 0,
id: 0, tracker: types.model({
name: '' id: 0,
}), name: '',
subject: '' }),
})) subject: '',
}),
),
}); });
export interface IMetaInfoStore extends Instance<typeof MetaInfoStore> {} export type IMetaInfoStore = Instance<typeof MetaInfoStore>;
export const BoardStore = types.model({ export const BoardStore = types.model({
data: types.array(ColumnStore), data: types.array(ColumnStore),
metainfo: MetaInfoStore metainfo: MetaInfoStore,
}); });
export interface IBoardStore extends Instance<typeof BoardStore> {} export type IBoardStore = Instance<typeof BoardStore>;
export const PageStore = types.model({ export const PageStore = types
loaded: false, .model({
type: '', loaded: false,
name: '', type: '',
data: types.maybeNull( name: '',
types.array(BoardStore) data: types.maybeNull(types.array(BoardStore)),
) })
}).actions(self => { .actions((self) => {
return { return {
setData: (data: any) => { setData: (data: any) => {
self.data = data; self.data = data;
self.loaded = true; self.loaded = true;
} },
}; };
}).views((self) => { })
return { .views((self) => {
get issueIds(): number[] { return {
if (!self.data) return []; get issueIds(): number[] {
const res = [] as number[]; if (!self.data) return [];
for (let i = 0; i < self.data.length; i++) { const res = [] as number[];
const iData = self.data[i]; for (let i = 0; i < self.data.length; i++) {
for (let j = 0; j < iData.data.length; j++) { const iData = self.data[i];
const jData = iData.data[j]; for (let j = 0; j < iData.data.length; j++) {
for (let k = 0; k < jData.issues.length; k++) { const jData = iData.data[j];
const issue = jData.issues[k]; for (let k = 0; k < jData.issues.length; k++) {
if (res.indexOf(issue.id) < 0) { const issue = jData.issues[k];
res.push(issue.id); if (res.indexOf(issue.id) < 0) {
} res.push(issue.id);
} }
} }
} }
return res; }
}, return res;
get canTreeRefresh(): boolean { },
return (self.type === 'tree'); get canTreeRefresh(): boolean {
} return self.type === 'tree';
}; },
}); };
});
export async function PageStoreLoadData(store: IPageStore): Promise<void> { export async function PageStoreLoadData(store: IPageStore): Promise<void> {
const url = `${process.env.REACT_APP_BACKEND}simple-kanban-board/${store.type}/${store.name}/raw`; const url = `${process.env.REACT_APP_BACKEND}simple-kanban-board/${store.type}/${store.name}/raw`;
const resp = await axios.get(url); const resp = await axios.get(url);
if (!(resp?.data)) return; if (!resp?.data) return;
store.setData(resp.data); store.setData(resp.data);
} }
export interface IPageStore extends Instance<typeof PageStore> { } export type IPageStore = Instance<typeof PageStore>;
export type CardField = { export type CardField = {
component: string; component: string;
} & Record<string, any>; } & Record<string, any>;
export const CardParamsStore = types.optional( export const CardParamsStore = types.optional(
types.model({ types.model({
fields: types.array( fields: types.array(types.frozen<CardField>()),
types.frozen<CardField>() autoCollapse: types.boolean,
), }),
autoCollapse: types.boolean {
}), fields: [
{ { component: 'text', label: 'Исп.', path: 'current_user.name' },
fields: [ { component: 'text', label: 'Прио.', path: 'priority.name' },
{ component: 'text', label: 'Исп.', path: 'current_user.name' }, { component: 'text', label: 'Версия', path: 'fixed_version.name' },
{ component: 'text', label: 'Прио.', path: 'priority.name' }, { component: 'text', label: 'Прогресс', path: 'done_ratio' },
{ component: 'text', label: 'Версия', path: 'fixed_version.name' }, { component: 'labor_costs' },
{ component: 'text', label: 'Прогресс', path: 'done_ratio' }, { component: 'tags', label: 'Tags', path: 'styledTags' },
{ component: 'labor_costs' }, ],
{ component: 'tags', label: 'Tags', path: 'styledTags' } autoCollapse: false,
], },
autoCollapse: false,
}
); );
export const CardStore = types.model({ export const CardStore = types.model({
issue: IssueStore, issue: IssueStore,
params: CardParamsStore params: CardParamsStore,
}); });
export interface ICardStore extends Instance<typeof CardStore> {} export type ICardStore = Instance<typeof CardStore>;

View file

@ -9,107 +9,118 @@ import { SetIssueReadingTimestamp } from '../utils/unreaded-provider';
import axios from 'axios'; import axios from 'axios';
import * as Luxon from 'luxon'; import * as Luxon from 'luxon';
export const Store = types.model({ export const Store = types
visible: types.boolean, .model({
issue: types.frozen<RedmineTypes.ExtendedIssue>(), visible: types.boolean,
unreadedFlagStore: types.maybe(UnreadedFlagNs.Store) issue: types.frozen<RedmineTypes.ExtendedIssue>(),
}).actions((self) => { unreadedFlagStore: types.maybe(UnreadedFlagNs.Store),
return { })
hide: () => { .actions((self) => {
console.debug(`Issue details dialog hide: issue_id=${self.issue.id}`); // DEBUG return {
self.visible = false; hide: () => {
}, console.debug(`Issue details dialog hide: issue_id=${self.issue.id}`); // DEBUG
show: () => { self.visible = false;
console.debug(`Issue details dialog show: issue_id=${self.issue.id}`); // DEBUG },
self.visible = true; show: () => {
if (self.unreadedFlagStore) { console.debug(`Issue details dialog show: issue_id=${self.issue.id}`); // DEBUG
self.unreadedFlagStore.read(); self.visible = true;
} else { if (self.unreadedFlagStore) {
SetIssueReadingTimestamp(self.issue.id); self.unreadedFlagStore.read();
} } else {
} SetIssueReadingTimestamp(self.issue.id);
}; }
}).views((self) => { },
return { };
get displayStyle(): React.CSSProperties { })
return {display: self.visible ? 'block' : 'none'}; .views((self) => {
} return {
}; get displayStyle(): React.CSSProperties {
}); return { display: self.visible ? 'block' : 'none' };
},
};
});
export type Props = { export type Props = {
store: Instance<typeof Store> store: Instance<typeof Store>;
}; };
export const IssueDetailsDialog = observer((props: Props): JSX.Element => { export const IssueDetailsDialog = observer((props: Props): JSX.Element => {
const onUpdateClick = (e: React.MouseEvent) => { const onUpdateClick = (e: React.MouseEvent) => {
const url = `${process.env.REACT_APP_BACKEND}redmine-event-emitter/append-issues`; const url = `${process.env.REACT_APP_BACKEND}redmine-event-emitter/append-issues`;
axios.post(url, [props.store.issue.id]); axios.post(url, [props.store.issue.id]);
}; };
const onCloseClick = (e: React.MouseEvent) => { const onCloseClick = (e: React.MouseEvent) => {
if (e.target !== e.currentTarget) return; if (e.target !== e.currentTarget) return;
e.stopPropagation(); e.stopPropagation();
props.store.hide(); props.store.hide();
}; };
return ( return (
<div className={Css.reset}> <div className={Css.reset}>
<div className={Css.modal} style={props.store.displayStyle} onClick={onCloseClick}> <div
<div className={Css.modalContent}> className={Css.modal}
<h1> style={props.store.displayStyle}
<button onClick={onCloseClick}>close</button> onClick={onCloseClick}
<button onClick={onUpdateClick}>force update</button> >
<IssueHrefNs.IssueHref <div className={Css.modalContent}>
id={props.store.issue?.id || -1} <h1>
subject={props.store.issue?.subject || ''} <button onClick={onCloseClick}>close</button>
tracker={props.store.issue?.tracker?.name || ''} <button onClick={onUpdateClick}>force update</button>
url={props.store.issue?.url?.url || ''} <IssueHrefNs.IssueHref
/> id={props.store.issue?.id || -1}
</h1> subject={props.store.issue?.subject || ''}
<hr/> tracker={props.store.issue?.tracker?.name || ''}
<div> url={props.store.issue?.url?.url || ''}
<h2>Описание:</h2> />
<pre> </h1>
{props.store.issue.description} <hr />
</pre> <div>
</div> <h2>Описание:</h2>
<hr/> <pre>{props.store.issue.description}</pre>
<div> </div>
<h2>Комментарии:</h2> <hr />
<Comments details={props.store.issue.journals || []} issue={props.store.issue}/> <div>
</div> <h2>Комментарии:</h2>
</div> <Comments
</div> details={props.store.issue.journals || []}
</div> issue={props.store.issue}
); />
</div>
</div>
</div>
</div>
);
}); });
export const Comments = (props: {details?: RedmineTypes.Journal[], issue: RedmineTypes.ExtendedIssue}): JSX.Element => { export const Comments = (props: {
const comments = props.details?.filter((detail) => { details?: RedmineTypes.Journal[];
return Boolean(detail.notes); issue: RedmineTypes.ExtendedIssue;
}); }): JSX.Element => {
if (!comments) { const comments = props.details?.filter((detail) => {
return <>No comments</> return Boolean(detail.notes);
} });
const list = comments.map((detail) => { if (!comments) {
const key = `issueid_${props.issue.id}_commentid_${detail.id}`; return <>No comments</>;
return <Comment data={detail} key={key}/> }
}); const list = comments.map((detail) => {
return ( const key = `issueid_${props.issue.id}_commentid_${detail.id}`;
<>{list}</> return <Comment data={detail} key={key} />;
); });
} return <>{list}</>;
};
export const Comment = (props: {data: RedmineTypes.Journal}): JSX.Element => { export const Comment = (props: { data: RedmineTypes.Journal }): JSX.Element => {
const date = Luxon.DateTime.fromISO(props.data.created_on).toFormat("dd.MM.yyyy HH:mm"); const date = Luxon.DateTime.fromISO(props.data.created_on).toFormat(
return ( 'dd.MM.yyyy HH:mm',
<> );
<h3><span className={Css.dateField}>{date}</span> {props.data.user.name}:</h3> return (
<div> <>
<pre> <h3>
{props.data.notes || '-'} <span className={Css.dateField}>{date}</span> {props.data.user.name}:
</pre> </h3>
</div> <div>
<hr/> <pre>{props.data.notes || '-'}</pre>
</> </div>
); <hr />
} </>
);
};

View file

@ -1,14 +1,16 @@
import React from 'react'; import React from 'react';
export type Props = { export type Props = {
url: string; url: string;
id: number; id: number;
subject: string; subject: string;
tracker: string; tracker: string;
}; };
export const IssueHref = (props: Props): JSX.Element => { export const IssueHref = (props: Props): JSX.Element => {
return ( return (
<a href={props.url}>{props.tracker} #{props.id} - {props.subject}</a> <a href={props.url}>
); {props.tracker} #{props.id} - {props.subject}
</a>
);
}; };

View file

@ -3,18 +3,18 @@ import { getStyleObjectFromString } from '../utils/style';
import Css from './tag.module.css'; import Css from './tag.module.css';
export type Props = { export type Props = {
style?: string; style?: string;
tag: string; tag: string;
}; };
export const Tag = (props: Props): JSX.Element => { export const Tag = (props: Props): JSX.Element => {
const inlineStyle = getStyleObjectFromString(props.style || ''); const inlineStyle = getStyleObjectFromString(props.style || '');
return ( return (
<> <>
<span> </span> <span> </span>
<span className={Css.tag} style={inlineStyle}> <span className={Css.tag} style={inlineStyle}>
{props.tag} {props.tag}
</span> </span>
</> </>
); );
} };

View file

@ -2,26 +2,28 @@ import React from 'react';
import * as TagNs from './tag'; import * as TagNs from './tag';
export type Params = { export type Params = {
label?: string; label?: string;
tags: TagNs.Props[]; tags: TagNs.Props[];
}; };
export type Props = { export type Props = {
params: Params params: Params;
}; };
export const Tags = (props: Props): JSX.Element => { export const Tags = (props: Props): JSX.Element => {
if (!props.params.tags) { if (!props.params.tags) {
return (<></>); return <></>;
} }
let label = props.params.label || ''; let label = props.params.label || '';
if (label) label = `${label}: `; if (label) label = `${label}: `;
const tags = props.params.tags.map((tag) => { const tags =
return <TagNs.Tag tag={tag.tag} style={tag.style} key={tag.tag}/>; props.params.tags.map((tag) => {
}) || []; return <TagNs.Tag tag={tag.tag} style={tag.style} key={tag.tag} />;
return ( }) || [];
<> return (
{label}{tags} <>
</> {label}
); {tags}
} </>
);
};

View file

@ -3,46 +3,48 @@ import Css from './time-passed.module.css';
import { RedmineTypes } from '../redmine-types'; import { RedmineTypes } from '../redmine-types';
export type Params = { export type Params = {
fromIssue?: { fromIssue?: {
issue: RedmineTypes.ExtendedIssue, issue: RedmineTypes.ExtendedIssue;
keyName: string, keyName: string;
}, };
fromValue?: string fromValue?: string;
}; };
export type Props = { export type Props = {
params: Params params: Params;
}; };
export const TimePassed = (props: Props): JSX.Element => { export const TimePassed = (props: Props): JSX.Element => {
if (!props.params.fromIssue && !props.params.fromValue) { if (!props.params.fromIssue && !props.params.fromValue) {
return <></>; return <></>;
} }
let timePassedClassName = ''; // TODO let timePassedClassName = ''; // TODO
if (props.params.fromIssue) { if (props.params.fromIssue) {
const { issue, keyName } = props.params.fromIssue; const { issue, keyName } = props.params.fromIssue;
timePassedClassName = `${Css.timepassedDot} ${getClassName(issue[keyName])}`; timePassedClassName = `${Css.timepassedDot} ${getClassName(
} else if (props.params.fromValue) { issue[keyName],
timePassedClassName = `${Css.timepassedDot} ${getClassName(props.params.fromValue)}`; )}`;
} } else if (props.params.fromValue) {
return ( timePassedClassName = `${Css.timepassedDot} ${getClassName(
<span className={timePassedClassName}></span> props.params.fromValue,
); )}`;
}
return <span className={timePassedClassName}></span>;
}; };
function getClassName(value: string): string { function getClassName(value: string): string {
switch (value) { switch (value) {
case 'hot': case 'hot':
return Css.hot; return Css.hot;
case 'warm': case 'warm':
return Css.warm; return Css.warm;
case 'comfort': case 'comfort':
return Css.comfort; return Css.comfort;
case 'breezy': case 'breezy':
return Css.breezy; return Css.breezy;
case 'cold': case 'cold':
return Css.cold; return Css.cold;
default: default:
return ''; return '';
} }
} }

View file

@ -3,53 +3,62 @@ import { Instance, types } from 'mobx-state-tree';
import React from 'react'; import React from 'react';
import Css from './top-right-menu.module.css'; import Css from './top-right-menu.module.css';
export const Store = types.model({ export const Store = types
visible: types.boolean .model({
}).views((self) => { visible: types.boolean,
return { })
get style(): React.CSSProperties { .views((self) => {
return { return {
display: self.visible ? 'block' : 'none' get style(): React.CSSProperties {
}; return {
} display: self.visible ? 'block' : 'none',
}; };
}).actions((self) => { },
return { };
show: () => { })
self.visible = true; .actions((self) => {
}, return {
hide: () => { show: () => {
self.visible = false; self.visible = true;
}, },
toggle: () => { hide: () => {
self.visible = !self.visible; self.visible = false;
} },
}; toggle: () => {
}); self.visible = !self.visible;
},
};
});
export type Props = { export type Props = {
store: Instance<typeof Store>; store: Instance<typeof Store>;
children?: any; children?: any;
}; };
export const TopRightMenu = observer((props: Props): JSX.Element => { export const TopRightMenu = observer((props: Props): JSX.Element => {
const menuItems = []; const menuItems = [];
if (props.children.length > 1) { if (props.children.length > 1) {
for (let key = 0; key < props.children.length; key++) { for (let key = 0; key < props.children.length; key++) {
const item = props.children[key]; const item = props.children[key];
menuItems.push(<li key={key}>{item}</li>); menuItems.push(<li key={key}>{item}</li>);
} }
} else if (props.children) { } else if (props.children) {
menuItems.push(<li key={0}>{props.children}</li>) menuItems.push(<li key={0}>{props.children}</li>);
} }
return ( return (
<> <>
<button className={Css.menuButton} onClick={(e) => {e.stopPropagation(); props.store.toggle();}}>Menu</button> <button
<div className={Css.menu} style={props.store.style}> className={Css.menuButton}
<ul> onClick={(e) => {
{menuItems} e.stopPropagation();
</ul> props.store.toggle();
</div> }}
</> >
); Menu
}) </button>
<div className={Css.menu} style={props.store.style}>
<ul>{menuItems}</ul>
</div>
</>
);
});

View file

@ -3,62 +3,76 @@ import Css from './unreaded-flag.module.css';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { Instance, types } from 'mobx-state-tree'; import { Instance, types } from 'mobx-state-tree';
import { RedmineTypes } from '../redmine-types'; import { RedmineTypes } from '../redmine-types';
import { GetIssueReadingTimestamp, SetIssueReadingTimestamp } from '../utils/unreaded-provider'; import {
GetIssueReadingTimestamp,
SetIssueReadingTimestamp,
} from '../utils/unreaded-provider';
export const Store = types.model({ export const Store = types
issue: types.frozen<RedmineTypes.ExtendedIssue>(), .model({
readingTimestamp: types.number issue: types.frozen<RedmineTypes.ExtendedIssue>(),
}).actions((self) => { readingTimestamp: types.number,
return { })
read: () => { .actions((self) => {
self.readingTimestamp = SetIssueReadingTimestamp(self.issue.id); return {
} read: () => {
}; self.readingTimestamp = SetIssueReadingTimestamp(self.issue.id);
}).views((self) => { },
return { };
getUpdatedTimestap(): number { })
if (self.issue.journals) { .views((self) => {
let lastComment: RedmineTypes.Journal | undefined; return {
for (let i = self.issue.journals.length - 1; i >= 0; i--) { getUpdatedTimestap(): number {
const journal = self.issue.journals[i]; if (self.issue.journals) {
if (journal.notes) { let lastComment: RedmineTypes.Journal | undefined;
lastComment = journal; for (let i = self.issue.journals.length - 1; i >= 0; i--) {
break; const journal = self.issue.journals[i];
} if (journal.notes) {
} lastComment = journal;
if (lastComment) { break;
return (new Date(lastComment.created_on)).getTime(); }
} }
} if (lastComment) {
return 0; return new Date(lastComment.created_on).getTime();
}, }
getClassName(): string { }
let className = Css.circle; return 0;
const updatedTimestamp = this.getUpdatedTimestap(); },
if (self.readingTimestamp < updatedTimestamp) { getClassName(): string {
className += ` ${Css.unreaded}`; let className = Css.circle;
} const updatedTimestamp = this.getUpdatedTimestap();
console.debug(`Unreaded flag getClassName: issueId=${self.issue.id}; readingTimestamp=${self.readingTimestamp}; updatedTimestamp=${updatedTimestamp}; className=${className}`); // DEBUG if (self.readingTimestamp < updatedTimestamp) {
return className; className += ` ${Css.unreaded}`;
} }
}; console.debug(
}); `Unreaded flag getClassName: issueId=${self.issue.id}; readingTimestamp=${self.readingTimestamp}; updatedTimestamp=${updatedTimestamp}; className=${className}`,
); // DEBUG
return className;
},
};
});
export function CreateStoreFromLocalStorage(issue: RedmineTypes.ExtendedIssue) { export function CreateStoreFromLocalStorage(issue: RedmineTypes.ExtendedIssue) {
const timestamp = GetIssueReadingTimestamp(issue.id); const timestamp = GetIssueReadingTimestamp(issue.id);
return Store.create({ return Store.create({
issue: issue, issue: issue,
readingTimestamp: timestamp readingTimestamp: timestamp,
}); });
} }
export type Props = { export type Props = {
store: Instance<typeof Store> store: Instance<typeof Store>;
} };
export const UnreadedFlag = observer((props: Props): JSX.Element => { export const UnreadedFlag = observer((props: Props): JSX.Element => {
const className = props.store.getClassName(); const className = props.store.getClassName();
return ( return (
<span className={className} onClick={(e) => {e.stopPropagation(); props.store.read();}}></span> <span
); className={className}
}) onClick={(e) => {
e.stopPropagation();
props.store.read();
}}
></span>
);
});

View file

@ -16,12 +16,23 @@ export const Basement = (props: Props): JSX.Element => {
<div className={BasementCss.basementGrid}> <div className={BasementCss.basementGrid}>
<div className={BasementCss.bottomContacts}> <div className={BasementCss.bottomContacts}>
<a href="/"> <a href="/">
<img src={props.iconUrl} alt="event_emitter_eltex_loc" className={BasementCss.eventEmitterEltexLoc} /> <img
src={props.iconUrl}
alt="event_emitter_eltex_loc"
className={BasementCss.eventEmitterEltexLoc}
/>
<span>redmine-issue-event-emitter</span> <span>redmine-issue-event-emitter</span>
</a> </a>
<p><a href={props.contactUrl}> Проект <p>
<span className={BasementCss.textBoxTextOrange}> Павел Гнедов</span> <a href={props.contactUrl}>
</a></p> {' '}
Проект
<span className={BasementCss.textBoxTextOrange}>
{' '}
Павел Гнедов
</span>
</a>
</p>
</div> </div>
<div className={BasementCss.discuss}> <div className={BasementCss.discuss}>
@ -30,8 +41,12 @@ export const Basement = (props: Props): JSX.Element => {
<p className={BasementCss.discussText}> ОБСУДИТЬ </p> <p className={BasementCss.discussText}> ОБСУДИТЬ </p>
</a> </a>
</div> </div>
<img src={props.characterUrl} width="100" alt="Сharacter" className={BasementCss.character02} /> <img
src={props.characterUrl}
width="100"
alt="Сharacter"
className={BasementCss.character02}
/>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,17 +1,17 @@
import React from 'react'; import React from 'react';
export type Props = { export type Props = {
title: string; title: string;
children?: any; children?: any;
}; };
export const ContentBlock = (props: Props) => { export const ContentBlock = (props: Props) => {
return ( return (
<> <>
<h2>{props.title}</h2> <h2>{props.title}</h2>
{props.children} {props.children}
</> </>
); );
}; };
export default ContentBlock; export default ContentBlock;

View file

@ -2,15 +2,11 @@ import React from 'react';
import ContentCss from './content.module.css'; import ContentCss from './content.module.css';
export type Props = { export type Props = {
children?: any; children?: any;
}; };
export const Content = (props: Props) => { export const Content = (props: Props) => {
return ( return <div className={ContentCss.content}>{props.children}</div>;
<div className={ContentCss.content}>
{props.children}
</div>
);
}; };
export default Content; export default Content;

View file

@ -2,22 +2,26 @@ import React from 'react';
import CoverCss from './cover.module.css'; import CoverCss from './cover.module.css';
export type CoverProps = { export type CoverProps = {
telegramBotUrl: string; telegramBotUrl: string;
}; };
export const Cover = (props: CoverProps) => { export const Cover = (props: CoverProps) => {
return ( return (
<div className={CoverCss.cover}> <div className={CoverCss.cover}>
<img src="/images/Сharacter_01.png" alt="Сharacter" className={CoverCss.character} /> <img
<div className={CoverCss.info}> src="/images/Сharacter_01.png"
<h3>Redmine Issue Event Emitter</h3> alt="Сharacter"
<h1>ОБРАБОТКА И АНАЛИЗ ЗАДАЧ ИЗ "REDMINE"</h1> className={CoverCss.character}
<h4> />
<a href={props.telegramBotUrl}> ссылка на телеграмм бота</a> <div className={CoverCss.info}>
</h4> <h3>Redmine Issue Event Emitter</h3>
</div> <h1>ОБРАБОТКА И АНАЛИЗ ЗАДАЧ ИЗ "REDMINE"</h1>
</div> <h4>
); <a href={props.telegramBotUrl}> ссылка на телеграмм бота</a>
</h4>
</div>
</div>
);
}; };
export default Cover; export default Cover;

View file

@ -2,26 +2,34 @@ import React from 'react';
import NotificationBlockCss from './notification-block.module.css'; import NotificationBlockCss from './notification-block.module.css';
export type Props = { export type Props = {
avatarUrl: string; avatarUrl: string;
taskTitle?: string; taskTitle?: string;
children?: any; children?: any;
}; };
export const NotificationBlock = (props: Props) => { export const NotificationBlock = (props: Props) => {
const taskTitle = props?.taskTitle const taskTitle = props?.taskTitle ? (
? (<span className={NotificationBlockCss.text_box_text_blue}>{props.taskTitle} </span>) <span className={NotificationBlockCss.text_box_text_blue}>
: (<></>); {props.taskTitle}{' '}
return ( </span>
<div className={NotificationBlockCss.message}> ) : (
<img src={props.avatarUrl} alt="event_emitter_eltex_loc" className={NotificationBlockCss.event_emitter_eltex_loc_icon} /> <></>
<div className={NotificationBlockCss.text_box}> );
<p className={NotificationBlockCss.text_box_text}> return (
{taskTitle} <div className={NotificationBlockCss.message}>
{props.children} <img
</p> src={props.avatarUrl}
</div> alt="event_emitter_eltex_loc"
</div> className={NotificationBlockCss.event_emitter_eltex_loc_icon}
); />
<div className={NotificationBlockCss.text_box}>
<p className={NotificationBlockCss.text_box_text}>
{taskTitle}
{props.children}
</p>
</div>
</div>
);
}; };
export default NotificationBlock; export default NotificationBlock;

View file

@ -9,95 +9,132 @@ import StartPageCss from './start-page.module.css';
import TopBar from './top-bar'; import TopBar from './top-bar';
export const StartPageData = { export const StartPageData = {
contact: 'https://t.me/pavelgnedov', contact: 'https://t.me/pavelgnedov',
bot: 'https://t.me/eltex_event_emitter_bot' bot: 'https://t.me/eltex_event_emitter_bot',
}; };
export const StartPage = () => { export const StartPage = () => {
return ( return (
<div className={StartPageCss.startPage}> <div className={StartPageCss.startPage}>
<TopBar contact={StartPageData.contact} /> <TopBar contact={StartPageData.contact} />
<Cover telegramBotUrl={StartPageData.bot} /> <Cover telegramBotUrl={StartPageData.bot} />
<Content> <Content>
<ContentBlock title='Возможности'> <ContentBlock title="Возможности">
<ul> <ul>
<li>Уведомления в реальном времени о событиях из задач - изменения статусов, упоминания комментариев</li> <li>
<li>Генерация и управления отчётами о задачах</li> Уведомления в реальном времени о событиях из задач - изменения
<li>Под капотом приложение фреймворк</li> статусов, упоминания комментариев
</ul> </li>
</ContentBlock> <li>Генерация и управления отчётами о задачах</li>
<ContentBlock title='Функции telegram бота'> <li>Под капотом приложение фреймворк</li>
<ul> </ul>
<li>Последний отчёт для дейли проект ECCM</li> </ContentBlock>
<li>Дополнительные функции для разработчиков <ContentBlock title="Функции telegram бота">
eccm:/current_issues_eccm - список текущих задач по статусам - выбираютсятолько задачи из актуальных версий в статусах, где нужна какая-то реакцияили возможна работа прямо сейчас</li> <ul>
<li>Скриншоты уведомления от бота: <li>Последний отчёт для дейли проект ECCM</li>
Примеры уведомлений о новых задачах и об изменениях статусов:</li> <li>
</ul> Дополнительные функции для разработчиков eccm:/current_issues_eccm
- список текущих задач по статусам - выбираютсятолько задачи из
актуальных версий в статусах, где нужна какая-то реакцияили
возможна работа прямо сейчас
</li>
<li>
Скриншоты уведомления от бота: Примеры уведомлений о новых задачах
и об изменениях статусов:
</li>
</ul>
<NotificationBlock <NotificationBlock
taskTitle='Feature #245005' taskTitle="Feature #245005"
avatarUrl='/images/event_emitter_eltex_loc-49px.png' avatarUrl="/images/event_emitter_eltex_loc-49px.png"
> >
Реализовать поддержку нового протокола: <br/><br/> Реализовать поддержку нового протокола: <br />
Стив Джобс изменил статус задачи с Feedback на Closed <br />
</NotificationBlock> Стив Джобс изменил статус задачи с Feedback на Closed
</NotificationBlock>
<NotificationBlock <NotificationBlock
taskTitle='Feature #241201' taskTitle="Feature #241201"
avatarUrl='/images/event_emitter_eltex_loc-49px.png' avatarUrl="/images/event_emitter_eltex_loc-49px.png"
> >
Добавить поддержку новых моделей: <br/><br/> Добавить поддержку новых моделей: <br />
<br />
Билл Гейтс создал новую задачу и назначил её на вас
</NotificationBlock>
Билл Гейтс создал новую задачу и назначил её на вас <p>
</NotificationBlock> Простые уведомления о движении задач - и больше ничего лишнего.
Пример уведомления по личному упоминанию в задаче:
</p>
<p>Простые уведомления о движении задач - и больше ничего лишнего. <NotificationBlock
Пример уведомления по личному упоминанию в задаче: taskTitle="Question #230033"
</p> avatarUrl="/images/event_emitter_eltex_loc-49px.png"
>
Сергей Брин:
<br />
<br />
@Ларри Пейдж@, у меня есть хорошая идея. Посмотри, пожалуйста, по
описанию к этой задаче.
</NotificationBlock>
<NotificationBlock <NotificationBlock
taskTitle='Question #230033' taskTitle="Bug #191122"
avatarUrl='/images/event_emitter_eltex_loc-49px.png' avatarUrl="/images/event_emitter_eltex_loc-49px.png"
> >
Сергей Брин:<br/><br/> Исправление уязвимости
<br />
<br />
Линус Торвальдс завершил разработку по задаче и передал вам на ревью
<br />
<br />
Кажется получилось поправить проблемку. Глянь мой MR.
</NotificationBlock>
@Ларри Пейдж@, у меня есть хорошая идея. Посмотри, пожалуйста, по описанию к этой задаче. <p>
</NotificationBlock> Можно задавать коллегам вопросы прямо из комментария задачи,
неотрываясь от её содержимого. Уведомление доставится в считанные
минуты с ссылкой на задачу и информацией от кого это уведомление.
</p>
<p>
Пример запроса моих текущих задач с помощью команды
<span className={NotificationBlockCss.text_box_text_blue}>
/current_issues_eccm
</span>
</p>
<NotificationBlock <NotificationBlock avatarUrl="/images/event_emitter_eltex_loc-49px.png">
taskTitle='Bug #191122' Бьёрн Страуструп:
avatarUrl='/images/event_emitter_eltex_loc-49px.png' <br />
> <br />
Исправление уязвимости<br/><br/> Re-opened:
<br />
Линус Торвальдс завершил разработку по задаче и передал вам на ревью<br/><br/> <br />
<span className={NotificationBlockCss.text_box_text_blue}>
Кажется получилось поправить проблемку. Глянь мой MR. {' '}
</NotificationBlock> - Feature #223301:{' '}
</span>
<p>Можно задавать коллегам вопросы прямо из комментария задачи, неотрываясь от её содержимого. Уведомление доставится в считанные минуты с ссылкой на задачу и информацией от кого это уведомление.</p> Дополнить stdlib новыми функциями (прио - P4, версия - C++23)
<p>Пример запроса моих текущих задач с помощью команды <br />
<span className={NotificationBlockCss.text_box_text_blue}>/current_issues_eccm</span> <br />
</p> In Progress:
<br />
<NotificationBlock <br />
avatarUrl='/images/event_emitter_eltex_loc-49px.png' <span className={NotificationBlockCss.text_box_text_blue}>
> {' '}
Бьёрн Страуструп:<br/><br/> - Question #223411:
</span>
Re-opened:<br/><br/> Выпуск релиза C++23 (прио - P4, версия - C++23)
<span className={NotificationBlockCss.text_box_text_blue}> - Feature #223301: </span> </NotificationBlock>
Дополнить stdlib новыми функциями (прио - P4, версия - C++23)<br/><br/> </ContentBlock>
In Progress:<br/><br/> </Content>
<span className={NotificationBlockCss.text_box_text_blue}> - Question #223411:</span> <Basement
Выпуск релиза C++23 (прио - P4, версия - C++23) contactUrl={StartPageData.contact}
</NotificationBlock> characterUrl="/images/Сharacter_02.png"
</ContentBlock> iconUrl="/images/event_emitter_eltex_loc-32px.png"
</Content> />
<Basement contactUrl={StartPageData.contact} characterUrl='/images/Сharacter_02.png' iconUrl='/images/event_emitter_eltex_loc-32px.png'/> </div>
</div> );
);
}; };
export default StartPage; export default StartPage;

View file

@ -3,29 +3,48 @@ import TopBarCss from './top-bar.module.css';
import LogoImg from './event_emitter_eltex_loc-32px.png'; import LogoImg from './event_emitter_eltex_loc-32px.png';
export type TopBarProps = { export type TopBarProps = {
contact: string; contact: string;
children?: any; children?: any;
}; };
const TopBar = (props: TopBarProps): ReactElement => { const TopBar = (props: TopBarProps): ReactElement => {
return ( return (
<div className={TopBarCss.top}> <div className={TopBarCss.top}>
<div className={TopBarCss.containerTitle}> <div className={TopBarCss.containerTitle}>
<div className={TopBarCss.logo}> <div className={TopBarCss.logo}>
<a href="/" className={TopBarCss.logo}> <a href="/" className={TopBarCss.logo}>
<img src={LogoImg} alt="event_emitter_eltex_loc" className={TopBarCss.eventEmitterEltexLoc} /> <img
<span>redmine-issue-event-emitter</span> src={LogoImg}
</a> alt="event_emitter_eltex_loc"
</div> className={TopBarCss.eventEmitterEltexLoc}
/>
<span>redmine-issue-event-emitter</span>
</a>
</div>
{props.children} {props.children}
<p><a href="/" target="_blank"> #документация</a></p> <p>
<p><a href={props.contact} target="_blank" rel="noreferrer"> #контакты</a></p> <a href="/" target="_blank">
<p><a href="https://gnedov.info/" target="_blank" rel="noreferrer"> #блог</a></p> {' '}
</div> #документация
</div> </a>
); </p>
<p>
<a href={props.contact} target="_blank" rel="noreferrer">
{' '}
#контакты
</a>
</p>
<p>
<a href="https://gnedov.info/" target="_blank" rel="noreferrer">
{' '}
#блог
</a>
</p>
</div>
</div>
);
}; };
export default TopBar; export default TopBar;

View file

@ -1,9 +1,7 @@
import React from 'react'; import React from 'react';
export const UnknownPage = () => { export const UnknownPage = () => {
return ( return <p>Unknown page</p>;
<p>Unknown page</p>
)
}; };
export default UnknownPage; export default UnknownPage;

View file

@ -1,14 +1,15 @@
import React from 'react'; import React from 'react';
import { onGetIssuesQueueSizeClick, onIssuesRefreshClick } from './service-actions'; import {
onGetIssuesQueueSizeClick,
onIssuesRefreshClick,
} from './service-actions';
export const IssuesForceRefreshButton = (): JSX.Element => { export const IssuesForceRefreshButton = (): JSX.Element => {
return ( return <button onClick={onIssuesRefreshClick}>Force issues refresh</button>;
<button onClick={onIssuesRefreshClick}>Force issues refresh</button>
);
}; };
export const GetIssuesQueueSizeButton = (): JSX.Element => { export const GetIssuesQueueSizeButton = (): JSX.Element => {
return ( return (
<button onClick={onGetIssuesQueueSizeClick}>Get issues queue size</button> <button onClick={onGetIssuesQueueSizeClick}>Get issues queue size</button>
); );
}; };

View file

@ -1,21 +1,31 @@
import axios from "axios"; import axios from 'axios';
import React from 'react'; import React from 'react';
export const onIssuesRefreshClick = (e: React.MouseEvent) => { export const onIssuesRefreshClick = (e: React.MouseEvent) => {
if (e.target !== e.currentTarget) return; if (e.target !== e.currentTarget) return;
e.stopPropagation(); e.stopPropagation();
const rawInput = prompt("Force issues refresh (delimiters - space, comma, semicolon or tab)", ""); const rawInput = prompt(
if (!rawInput) return; 'Force issues refresh (delimiters - space, comma, semicolon or tab)',
const list = rawInput.split(/[ ,;\t\n\r]/).map(item => Number(item)).filter(item => (Number.isFinite(item) && item > 0)); '',
if (!list) return; );
axios.post(`/redmine-event-emitter/append-issues`, list); if (!rawInput) return;
const list = rawInput
.split(/[ ,;\t\n\r]/)
.map((item) => Number(item))
.filter((item) => Number.isFinite(item) && item > 0);
if (!list) return;
axios.post(`/redmine-event-emitter/append-issues`, list);
}; };
export const onGetIssuesQueueSizeClick = async (e: React.MouseEvent): Promise<void> => { export const onGetIssuesQueueSizeClick = async (
if (e.target !== e.currentTarget) return; e: React.MouseEvent,
e.stopPropagation(); ): Promise<void> => {
const resp = await axios.get(`${process.env.REACT_APP_BACKEND}redmine-event-emitter/get-issues-queue-size`); if (e.target !== e.currentTarget) return;
console.debug(`resp -`, resp); // DEBUG e.stopPropagation();
if (!resp || typeof resp.data !== 'number') return; const resp = await axios.get(
alert(`Issues queue size - ${resp.data}`); `${process.env.REACT_APP_BACKEND}redmine-event-emitter/get-issues-queue-size`,
);
console.debug(`resp -`, resp); // DEBUG
if (!resp || typeof resp.data !== 'number') return;
alert(`Issues queue size - ${resp.data}`);
}; };

View file

@ -3,9 +3,11 @@
* @param a * @param a
* @returns * @returns
*/ */
export const SpentHoursToFixed = (a: number|string|null|undefined): string => { export const SpentHoursToFixed = (
if (a === null || typeof a === 'undefined') return '-'; a: number | string | null | undefined,
const res = (typeof a === 'number') ? a : Number(a); ): string => {
if (!Number.isFinite(res)) return '-'; if (a === null || typeof a === 'undefined') return '-';
return `${parseFloat(res.toFixed(1))}`; const res = typeof a === 'number' ? a : Number(a);
if (!Number.isFinite(res)) return '-';
return `${parseFloat(res.toFixed(1))}`;
}; };

View file

@ -1,24 +1,26 @@
const formatStringToCamelCase = (str: string): string => { const formatStringToCamelCase = (str: string): string => {
const splitted = str.split("-"); const splitted = str.split('-');
if (splitted.length === 1) return splitted[0]; if (splitted.length === 1) return splitted[0];
return ( return (
splitted[0] + splitted[0] +
splitted splitted
.slice(1) .slice(1)
.map(word => word[0].toUpperCase() + word.slice(1)) .map((word) => word[0].toUpperCase() + word.slice(1))
.join("") .join('')
); );
}; };
export const getStyleObjectFromString = (str: string): Record<string, string> => { export const getStyleObjectFromString = (
const style = {} as Record<string, string>; str: string,
str.split(";").forEach(el => { ): Record<string, string> => {
const [property, value] = el.split(":"); const style = {} as Record<string, string>;
if (!property) return; str.split(';').forEach((el) => {
const [property, value] = el.split(':');
if (!property) return;
const formattedProperty = formatStringToCamelCase(property.trim()); const formattedProperty = formatStringToCamelCase(property.trim());
style[formattedProperty] = value.trim(); style[formattedProperty] = value.trim();
}); });
return style; return style;
}; };

View file

@ -1,23 +1,23 @@
export function GetIssueReadingTimestamp(issueId: number): number { export function GetIssueReadingTimestamp(issueId: number): number {
const value = window.localStorage.getItem(getKey(issueId)); const value = window.localStorage.getItem(getKey(issueId));
return value ? Number(value) : 0; return value ? Number(value) : 0;
} }
export function SetIssueReadingTimestamp(issueId: number): number { export function SetIssueReadingTimestamp(issueId: number): number {
const now = (new Date()).getTime(); const now = new Date().getTime();
window.localStorage.setItem(getKey(issueId), String(now)); window.localStorage.setItem(getKey(issueId), String(now));
return now; return now;
} }
export function SetIssuesReadingTimestamp(issueIds: number[]): number { export function SetIssuesReadingTimestamp(issueIds: number[]): number {
const now = (new Date()).getTime(); const now = new Date().getTime();
for (let i = 0; i < issueIds.length; i++) { for (let i = 0; i < issueIds.length; i++) {
const issueId = issueIds[i]; const issueId = issueIds[i];
window.localStorage.setItem(getKey(issueId), String(now)); window.localStorage.setItem(getKey(issueId), String(now));
} }
return now; return now;
} }
function getKey(issueId: number): string { function getKey(issueId: number): string {
return `issue_read_${issueId}`; return `issue_read_${issueId}`;
} }