@danikokonn/yarik-frontend-lib 2.0.58 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1 +1,309 @@
1
- # YARIK-frontend-lib
1
+ # YARIK-frontend-lib
2
+
3
+ Библиотека переиспользуемых React-компонентов, утилит и провайдеров.
4
+
5
+ [![NPM](https://img.shields.io/npm/v/@danikokonn/yarik-frontend-lib)](https://www.npmjs.com/package/@danikokonn/yarik-frontend-lib)
6
+ [![License: Apache-2.0](https://img.shields.io/badge/License-Apache--2.0-blue.svg)](LICENSE)
7
+
8
+ ---
9
+
10
+ ## Содержание
11
+
12
+ - [Установка](#установка)
13
+ - [Требования](#требования)
14
+ - [Использование](#использование)
15
+ - [Компоненты](#компоненты)
16
+ - [Провайдеры](#провайдеры)
17
+ - [Утилиты и HTTP-клиент](#утилиты-и-http-клиент)
18
+ - [Разработка](#разработка)
19
+ - [Тестирование](#тестирование)
20
+ - [Публикация](#публикация)
21
+ - [Как дополнять README](#как-дополнять-readme)
22
+
23
+ ---
24
+
25
+ ## Установка
26
+
27
+ ```bash
28
+ npm install @danikokonn/yarik-frontend-lib
29
+ ```
30
+
31
+ ---
32
+
33
+ ## Требования
34
+
35
+ | Зависимость | Версия |
36
+ |-------------|----------|
37
+ | React | >=18 <20 |
38
+ | react-dom | >=18 <20 |
39
+
40
+ MUI, Emotion и остальные зависимости устанавливаются автоматически.
41
+
42
+ ---
43
+
44
+ ## Использование
45
+
46
+ ### Импорт компонентов
47
+
48
+ ```tsx
49
+ import { SmartTable, Navigation, PageWrapper } from '@danikokonn/yarik-frontend-lib';
50
+ ```
51
+
52
+ ### Импорт утилит
53
+
54
+ ```tsx
55
+ import { prettyDatetime, getCSRFToken } from '@danikokonn/yarik-frontend-lib/utils';
56
+ ```
57
+
58
+ ### Импорт HTTP-клиента
59
+
60
+ ```tsx
61
+ import { get, post } from '@danikokonn/yarik-frontend-lib/http';
62
+ ```
63
+
64
+ ### Импорт типов
65
+
66
+ ```tsx
67
+ import type { Dag, DagRun, TaskInstance } from '@danikokonn/yarik-frontend-lib/types';
68
+ ```
69
+
70
+ ### Подключение провайдеров
71
+
72
+ ```tsx
73
+ import { SnackBarProvider, SessionProvider } from '@danikokonn/yarik-frontend-lib';
74
+
75
+ function App() {
76
+ return (
77
+ <SessionProvider>
78
+ <SnackBarProvider>
79
+ {/* ваше приложение */}
80
+ </SnackBarProvider>
81
+ </SessionProvider>
82
+ );
83
+ }
84
+ ```
85
+
86
+ ---
87
+
88
+ ## Компоненты
89
+
90
+ | Компонент | Описание |
91
+ |----------------------------|------------------------------------------------------------------|
92
+ | `ActionDialog` | Диалог подтверждения действия с опциональной формой ввода |
93
+ | `ColumnSearchInput` | Поле поиска для столбца таблицы |
94
+ | `DateTimeRangePicker` | Выбор диапазона дат и времени |
95
+ | `DatetimeRangeInput` | Ввод диапазона дата/время |
96
+ | `Footer` | Нижний колонтитул страницы |
97
+ | `IpAddressInput` | Поле ввода IP-адреса с валидацией |
98
+ | `IpAddressSwitchableInput` | Переключаемое поле ввода IP-адреса |
99
+ | `LocalizedDatetimePicker` | Выбор даты/времени с поддержкой часовых поясов |
100
+ | `Navigation` | Навигационная панель с поддержкой меню |
101
+ | `NumberRangeInput` | Ввод числового диапазона |
102
+ | `PageWrapper` | Обёртка страницы (Navigation + контент + Footer) |
103
+ | `RichFilterTextField` | Расширенное поле фильтрации с историей и подсказками |
104
+ | `SmartTable` | Виртуализированная таблица с сортировкой, пагинацией, фильтрами |
105
+ | `SortBtn` | Кнопка сортировки |
106
+ | `SubtaskItem` | Отображение элемента подзадачи |
107
+ | `TaskLoader` | Компонент состояния загрузки |
108
+ | `TaskStateDialog` | Диалог отображения состояния задачи |
109
+
110
+ ### Иконки
111
+
112
+ Доступны 9 SVG-иконок: `GenerateAndRunIcon`, `GenerateIcon`, `IntegerIcon`, `ProcessingIcon`, `QueueIcon`, `RangeIcon`, `RunIcon`, `TextModeIcon`, `TreeModeIcon`.
113
+
114
+ ```tsx
115
+ import { RunIcon } from '@danikokonn/yarik-frontend-lib/components/icons/RunIcon';
116
+ ```
117
+
118
+ ---
119
+
120
+ ## Провайдеры
121
+
122
+ ### SnackBarProvider
123
+
124
+ Контекст уведомлений на основе notistack.
125
+
126
+ ```tsx
127
+ import { SnackBarProvider, useSnackbarContext } from '@danikokonn/yarik-frontend-lib';
128
+
129
+ function MyComponent() {
130
+ const { enqueueSnackbar } = useSnackbarContext();
131
+
132
+ return (
133
+ <button onClick={() => enqueueSnackbar('Успешно!', { variant: 'success' })}>
134
+ Показать уведомление
135
+ </button>
136
+ );
137
+ }
138
+ ```
139
+
140
+ Доступные варианты: `default`, `error`, `success`, `warning`, `info`.
141
+
142
+ ### SessionProvider
143
+
144
+ Управление состоянием сессии и аутентификации.
145
+
146
+ ### AirflowProvider
147
+
148
+ Контекст интеграции с Apache Airflow.
149
+
150
+ ### DagStateProvider
151
+
152
+ Управление состоянием DAG-рабочих процессов.
153
+
154
+ ---
155
+
156
+ ## Утилиты и HTTP-клиент
157
+
158
+ ### Утилиты (`/utils`)
159
+
160
+ ```tsx
161
+ import {
162
+ prettyDatetime, // форматирование даты/времени для отображения
163
+ formatDate, // форматирование даты
164
+ formatDuration, // форматирование длительности
165
+ getCSRFToken, // получение CSRF-токена
166
+ setCSRFToken, // установка CSRF-токена
167
+ useDebounce, // хук debounce
168
+ gettextTS, // i18n: перевод строки
169
+ ngettextTS, // i18n: перевод с учётом числа
170
+ } from '@danikokonn/yarik-frontend-lib/utils';
171
+ ```
172
+
173
+ ### HTTP-клиент (`/http`)
174
+
175
+ Fetch-обёртка с поддержкой CSRF и автоматическим определением истечения сессии (401).
176
+
177
+ ```tsx
178
+ import { get, post } from '@danikokonn/yarik-frontend-lib/http';
179
+
180
+ const data = await get('/api/endpoint');
181
+ await post('/api/endpoint', { key: 'value' });
182
+ ```
183
+
184
+ ---
185
+
186
+ ## Разработка
187
+
188
+ ### Установка зависимостей
189
+
190
+ ```bash
191
+ npm install
192
+ ```
193
+
194
+ ### Сборка
195
+
196
+ ```bash
197
+ npm run build
198
+ ```
199
+
200
+ Компилирует TypeScript в `dist/`. Декларации типов генерируются автоматически.
201
+
202
+ ### Добавление нового компонента
203
+
204
+ Создайте папку `src/components/<ComponentName>/` со структурой:
205
+
206
+ ```
207
+ src/components/MyComponent/
208
+ ├── MyComponent.tsx # компонент
209
+ ├── MyComponentProps.ts # интерфейс пропсов
210
+ ├── MyComponent.test.tsx # тесты (обязательно, см. раздел «Тестирование»)
211
+ └── index.ts # export { default } from './MyComponent'
212
+ ```
213
+
214
+ Добавьте экспорт в `src/components/index.ts`.
215
+
216
+ ---
217
+
218
+ ## Тестирование
219
+
220
+ Проект использует [Vitest](https://vitest.dev/) + [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/).
221
+
222
+ ### Запуск тестов
223
+
224
+ ```bash
225
+ npm test # watch-режим (разработка)
226
+ npm run test:run # однократный запуск (CI)
227
+ npm run coverage # запуск с отчётом о покрытии
228
+ ```
229
+
230
+ ### Структура тестов
231
+
232
+ Тестовые файлы располагаются рядом с компонентом (`*.test.tsx`) и автоматически подхватываются Vitest.
233
+
234
+ ```
235
+ src/components/SortBtn/
236
+ ├── SortBtn.tsx
237
+ ├── SortBtnProps.ts
238
+ ├── SortBtn.test.tsx ← тесты
239
+ └── index.ts
240
+ ```
241
+
242
+ ### Обязательный плейсхолдер
243
+
244
+ При создании нового компонента **всегда** создавайте файл `<ComponentName>.test.tsx` рядом с компонентом — даже если тесты пока не написаны:
245
+
246
+ ```tsx
247
+ import { render } from '@testing-library/react'
248
+ import MyComponent from './MyComponent'
249
+
250
+ describe('MyComponent', () => {
251
+ /**
252
+ * Проверяем, что компонент рендерится без ошибок с обязательными пропсами.
253
+ * Базовая страховка от синтаксических и runtime-ошибок при первом рендере.
254
+ */
255
+ it.todo('renders correctly')
256
+ })
257
+ ```
258
+
259
+ Это сигнализирует о том, что компонент ещё не покрыт тестами, и не даёт файлу «потеряться».
260
+
261
+ ### Описание тест-кейсов
262
+
263
+ Каждый блок `it` (в том числе `it.todo`) должен предваряться JSDoc-комментарием, объясняющим **что** тестируется и **зачем**. Названия теста недостаточно — комментарий должен отвечать на вопрос «что сломается в продакшене, если этот тест упадёт»:
264
+
265
+ ```tsx
266
+ /**
267
+ * Проверяем, что первый клик из состояния "none" вызывает onToggleSort с направлением "asc".
268
+ * Это начало трёхступенчатого цикла: none → asc → desc → none.
269
+ * Callback должен получать и поле, и новое направление, чтобы родитель мог обновить свой стейт.
270
+ */
271
+ it('calls onToggleSort with asc when order is none', async () => {
272
+ // ...
273
+ })
274
+ ```
275
+
276
+ Правило распространяется на все тесты — и реальные, и заглушки `it.todo`.
277
+
278
+ ### Конфигурация
279
+
280
+ | Файл | Назначение |
281
+ |------|------------|
282
+ | `vitest.config.ts` | Основная конфигурация Vitest |
283
+ | `src/test/setup.ts` | Подключение `@testing-library/jest-dom` |
284
+ | `tsconfig.test.json` | TypeScript-конфигурация для тестов |
285
+
286
+ ---
287
+
288
+ ## Публикация
289
+
290
+ Публикация выполняется автоматически через GitHub Actions при создании релиза.
291
+
292
+ | Тип релиза | NPM-тег |
293
+ |-------------|----------|
294
+ | Pre-release | `test` |
295
+ | Release | `latest` |
296
+
297
+ Версия берётся из git-тега и подставляется в `package.json` во время CI-сборки.
298
+
299
+ ---
300
+
301
+ ## Как дополнять README
302
+
303
+ Добавляйте информацию в соответствующий раздел. Если раздела нет — создайте новый перед этим разделом.
304
+
305
+ **Рекомендации:**
306
+ - Каждый новый компонент — строка в таблицу раздела «Компоненты» с кратким описанием.
307
+ - Каждый новый провайдер — подраздел в «Провайдеры» с примером использования.
308
+ - Новые утилиты — добавить в список импортов раздела «Утилиты».
309
+ - Примеры кода должны быть минимальными и рабочими.
@@ -40,7 +40,7 @@ const RichFilterTextField = ({ filterExpr, filterExprHist: searchHist, fields, o
40
40
  if (cursorPos &&
41
41
  getHints(value, cursorPos, fields, operators) !== currentHintOptions)
42
42
  setFocusIdx(0);
43
- onChange(value /*(v: unknown) => setSearchHist(v as string[])*/);
43
+ onChange(value);
44
44
  };
45
45
  const insertHint = (hint) => {
46
46
  if (cursorPos == null)
@@ -180,7 +180,7 @@ const TaskLoader = ({ style, subTitle, onUnauthorized }) => {
180
180
  successHandler: (resp) => {
181
181
  if (!resp)
182
182
  return;
183
- onUpdateState(true, resp.dagRun, resp.taskInstances);
183
+ onUpdateState(true, resp.dagRun);
184
184
  setActiveTask(null);
185
185
  setLogs([null, null, null]);
186
186
  },
@@ -211,7 +211,7 @@ const TaskLoader = ({ style, subTitle, onUnauthorized }) => {
211
211
  successHandler: (resp) => {
212
212
  if (!resp)
213
213
  return;
214
- onUpdateState(true, resp.dagRun, resp.taskInstances);
214
+ onUpdateState(true, resp.dagRun);
215
215
  setActiveTask(null);
216
216
  setLogs([null, null, null]);
217
217
  setBlockControls(false);
@@ -1,15 +1,16 @@
1
1
  import React from "react";
2
+ type AirflowContextValue = {
3
+ dagId?: string | null;
4
+ airflowAvailable: boolean;
5
+ onChangeDagId(dagId: string | null): void;
6
+ onLostConnection(): void;
7
+ };
2
8
  interface AirflowProviderProps {
3
9
  children?: React.ReactNode;
4
10
  dagId?: string | null;
5
11
  onUnauthorized(): void;
6
12
  }
7
13
  export declare const AirflowProvider: ({ children, dagId: _dagId, onUnauthorized, }: AirflowProviderProps) => import("react/jsx-runtime").JSX.Element;
8
- export declare const useAirflowContext: () => {
9
- dagId?: string | null;
10
- airflowAvailable: boolean;
11
- onChangeDagId(dagId: string | null): void;
12
- onLostConnection(): void;
13
- };
14
+ export declare const useAirflowContext: () => AirflowContextValue;
14
15
  export default AirflowProvider;
15
16
  //# sourceMappingURL=AirflowProvider.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"AirflowProvider.d.ts","sourceRoot":"","sources":["../../src/providers/AirflowProvider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA2D,MAAM,OAAO,CAAC;AAgBhF,UAAU,oBAAoB;IAC5B,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAC3B,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,cAAc,IAAI,IAAI,CAAC;CACxB;AAED,eAAO,MAAM,eAAe,GAAI,8CAI7B,oBAAoB,4CAyEtB,CAAC;AAEF,eAAO,MAAM,iBAAiB;YAhGpB,MAAM,GAAG,IAAI;sBACH,OAAO;yBACJ,MAAM,GAAG,IAAI,GAAG,IAAI;wBACrB,IAAI;CA+FzB,CAAC;AAEF,eAAe,eAAe,CAAC"}
1
+ {"version":3,"file":"AirflowProvider.d.ts","sourceRoot":"","sources":["../../src/providers/AirflowProvider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAON,MAAM,OAAO,CAAC;AAIf,KAAK,mBAAmB,GAAG;IACzB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,gBAAgB,EAAE,OAAO,CAAC;IAC1B,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,CAAC;IAC1C,gBAAgB,IAAI,IAAI,CAAC;CAC1B,CAAC;AAIF,UAAU,oBAAoB;IAC5B,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAC3B,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,cAAc,IAAI,IAAI,CAAC;CACxB;AA+BD,eAAO,MAAM,eAAe,GAAI,8CAI7B,oBAAoB,4CAyDtB,CAAC;AAEF,eAAO,MAAM,iBAAiB,2BAM7B,CAAC;AAEF,eAAe,eAAe,CAAC"}
@@ -1,66 +1,69 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { createContext, useCallback, useContext, useState } from "react";
2
+ import { createContext, useCallback, useContext, useMemo, useRef, useState, } from "react";
3
3
  import useSWR from "swr";
4
4
  import { useSnackbarContext } from "./SnackBarProvider";
5
- const AirflowContext = createContext({
6
- dagId: "",
7
- airflowAvailable: false,
8
- onChangeDagId: () => { },
9
- onLostConnection: () => { },
10
- });
5
+ const AirflowContext = createContext(null);
6
+ const makeFetcher = (onUnauthorized) => async (url) => {
7
+ const res = await fetch(url);
8
+ let err;
9
+ if (res.status === 401) {
10
+ onUnauthorized();
11
+ err = new Error("Unauthorized Airflow connection checking request.");
12
+ err.status = 401;
13
+ }
14
+ else if (!res.ok && res.status !== 404) {
15
+ err = new Error("An error occurred while fetching the data.");
16
+ err.info = await res.json();
17
+ err.status = res.status;
18
+ }
19
+ if (typeof err !== "undefined") {
20
+ throw err;
21
+ }
22
+ return res.json();
23
+ };
11
24
  export const AirflowProvider = ({ children, dagId: _dagId, onUnauthorized, }) => {
12
- const fetcher = async (url) => {
13
- const res = await fetch(url);
14
- if (res.status === 401) {
15
- onUnauthorized();
16
- }
17
- if (!res.ok && res.status !== 404) {
18
- const error = new Error("An error occurred while fetching the data.");
19
- throw { ...error, info: await res.json(), status: res.status };
20
- }
21
- return res.json();
22
- };
25
+ const fetcher = useMemo(() => makeFetcher(onUnauthorized), [onUnauthorized]);
23
26
  const { enqueueSnackbar } = useSnackbarContext();
24
27
  const [dagId, setDagId] = useState(_dagId);
25
- const [firstRun, setFirstRun] = useState(true);
26
- const [previousAirflowAvailable, setPreviousAirflowAvailable] = useState(false);
27
- const { error, mutate } = useSWR("airflow/check_connection", fetcher, {
28
+ // null = первый запрос ещё не завершён
29
+ const prevAvailable = useRef(null);
30
+ const { error, isLoading, mutate } = useSWR("airflow/check_connection", fetcher, {
28
31
  refreshInterval: 10000,
29
32
  onSuccess: () => {
30
- if (firstRun) {
31
- setFirstRun(false);
32
- }
33
- else if (!previousAirflowAvailable) {
33
+ if (prevAvailable.current === false) {
34
34
  enqueueSnackbar("Соединение с Airflow установлено!", "info");
35
35
  }
36
- setPreviousAirflowAvailable(true);
36
+ prevAvailable.current = true;
37
37
  },
38
38
  onError: () => {
39
- if (firstRun) {
40
- enqueueSnackbar(`Не удалось установить соединение с Airflow!`, "warning");
41
- setFirstRun(false);
39
+ if (prevAvailable.current === null) {
40
+ enqueueSnackbar("Не удалось установить соединение с Airflow!", "warning");
42
41
  }
43
- else if (previousAirflowAvailable) {
44
- enqueueSnackbar(`Потеряно соединение с Airflow!`, "warning");
42
+ else if (prevAvailable.current === true) {
43
+ enqueueSnackbar("Потеряно соединение с Airflow!", "warning");
45
44
  }
46
- setPreviousAirflowAvailable(false);
45
+ prevAvailable.current = false;
47
46
  },
48
47
  });
49
- const airflowAvailable = error == null;
50
- const setContext = useCallback((dagId) => {
48
+ const airflowAvailable = !isLoading && error == null;
49
+ const onChangeDagId = useCallback((dagId) => {
51
50
  setDagId(dagId);
52
- }, [dagId, setDagId]);
53
- const onLostConnection = () => {
54
- mutate();
55
- };
51
+ }, []);
52
+ const onLostConnection = useCallback(() => {
53
+ void mutate();
54
+ }, [mutate]);
56
55
  return (_jsx(AirflowContext.Provider, { value: {
57
56
  airflowAvailable,
58
57
  dagId,
59
- onChangeDagId: setContext,
58
+ onChangeDagId,
60
59
  onLostConnection,
61
60
  }, children: children }));
62
61
  };
63
62
  export const useAirflowContext = () => {
64
- return useContext(AirflowContext);
63
+ const ctx = useContext(AirflowContext);
64
+ if (ctx === null) {
65
+ throw new Error("useAirflowContext must be used within AirflowProvider");
66
+ }
67
+ return ctx;
65
68
  };
66
69
  export default AirflowProvider;
@@ -4,19 +4,19 @@ interface DagRunStateProviderProps {
4
4
  children?: React.ReactNode;
5
5
  dagRun?: DagRun;
6
6
  autorun?: boolean;
7
- poolingByUser?: boolean;
7
+ pollingByUser?: boolean;
8
8
  taskInstances?: TaskInstances;
9
9
  autorunHandler?(dagRun: DagRun | null, autorun: boolean): void;
10
10
  onUnauthorized(): void;
11
11
  }
12
- declare const DagRunStateProvider: ({ children, dagRun: _dagRun, autorun: _autorun, poolingByUser, autorunHandler, onUnauthorized, }: DagRunStateProviderProps) => import("react/jsx-runtime").JSX.Element;
12
+ declare const DagRunStateProvider: ({ children, dagRun: _dagRun, autorun: _autorun, pollingByUser, autorunHandler, onUnauthorized, }: DagRunStateProviderProps) => import("react/jsx-runtime").JSX.Element;
13
13
  export declare const useDagRunStateContext: () => {
14
14
  isActive: boolean;
15
15
  autorun: boolean;
16
16
  dagRun: DagRun | null;
17
17
  taskInstances: TaskInstances | null;
18
18
  dagStartedOnce: boolean;
19
- onUpdateState(isActive: boolean, dagRun: DagRun | null, taskInstances: TaskInstances | null): void;
19
+ onUpdateState(isActive: boolean, dagRun: DagRun | null): void;
20
20
  error: boolean;
21
21
  onDagStarted: () => void;
22
22
  activate(): void;
@@ -1 +1 @@
1
- {"version":3,"file":"DagStateProvider.d.ts","sourceRoot":"","sources":["../../src/providers/DagStateProvider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAON,MAAM,OAAO,CAAC;AAEf,OAAO,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAkCjD,UAAU,wBAAwB;IAChC,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAC3B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,cAAc,CAAC,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,EAAE,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;IAC/D,cAAc,IAAI,IAAI,CAAC;CACxB;AAED,QAAA,MAAM,mBAAmB,GAAI,kGAO1B,wBAAwB,4CAuJ1B,CAAC;AAEF,eAAO,MAAM,qBAAqB;cAvMtB,OAAO;aACR,OAAO;YACR,MAAM,GAAG,IAAI;mBACN,aAAa,GAAG,IAAI;oBACnB,OAAO;4BAEX,OAAO,UACT,MAAM,GAAG,IAAI,iBACN,aAAa,GAAG,IAAI,GAClC,IAAI;WACA,OAAO;kBACA,MAAM,IAAI;gBACZ,IAAI;kBACF,IAAI;wBACE,OAAO,GAAG,IAAI;CA2LnC,CAAC;AAEF,eAAe,mBAAmB,CAAC"}
1
+ {"version":3,"file":"DagStateProvider.d.ts","sourceRoot":"","sources":["../../src/providers/DagStateProvider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAON,MAAM,OAAO,CAAC;AAEf,OAAO,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAkBjD,UAAU,wBAAwB;IAChC,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAC3B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,cAAc,CAAC,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,EAAE,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;IAC/D,cAAc,IAAI,IAAI,CAAC;CACxB;AAED,QAAA,MAAM,mBAAmB,GAAI,kGAO1B,wBAAwB,4CAwJ1B,CAAC;AAEF,eAAO,MAAM,qBAAqB;cAxLtB,OAAO;aACR,OAAO;YACR,MAAM,GAAG,IAAI;mBACN,aAAa,GAAG,IAAI;oBACnB,OAAO;4BACC,OAAO,UAAU,MAAM,GAAG,IAAI,GAAG,IAAI;WACtD,OAAO;kBACA,MAAM,IAAI;gBACZ,IAAI;kBACF,IAAI;wBACE,OAAO,GAAG,IAAI;CAsLnC,CAAC;AAEF,eAAe,mBAAmB,CAAC"}
@@ -3,41 +3,38 @@ import { createContext, useCallback, useContext, useEffect, useEffectEvent, useS
3
3
  import useSWR from "swr";
4
4
  import { useAirflowContext } from "./AirflowProvider";
5
5
  import { useSnackbarContext } from "./SnackBarProvider";
6
- const DagRunStateContext = createContext({
7
- isActive: true,
8
- autorun: false,
9
- dagRun: null,
10
- taskInstances: null,
11
- dagStartedOnce: false,
12
- error: false,
13
- onUpdateState: () => { },
14
- onDagStarted: () => { },
15
- fetchState: () => { },
16
- activate: () => { },
17
- setAutorun: () => { },
18
- });
19
- const DagRunStateProvider = ({ children, dagRun: _dagRun, autorun: _autorun, poolingByUser, autorunHandler, onUnauthorized, }) => {
6
+ const DagRunStateContext = createContext(null);
7
+ const DagRunStateProvider = ({ children, dagRun: _dagRun, autorun: _autorun, pollingByUser, autorunHandler, onUnauthorized, }) => {
20
8
  const { airflowAvailable, dagId, onLostConnection } = useAirflowContext();
21
- const [autorun, setAutorun] = useState(_autorun || false);
22
- const [dagRunId, setDagRunId] = useState(_dagRun?.dagRunId);
9
+ const [autorun, setAutorun] = useState(_autorun ?? false);
10
+ const [dagRunId, setDagRunId] = useState(_dagRun?.dagRunId ?? null);
23
11
  const [dagStartedOnce, setDagStartedOnce] = useState(false);
24
12
  const { enqueueSnackbar } = useSnackbarContext();
25
- const defaultFetcher = async ([url, dagId, dagRunId, poolingByUser]) => {
13
+ const defaultFetcher = useCallback(async ([url, dagId, dagRunId, pollingByUser]) => {
26
14
  const requestUrl = new URL("http://foo/" + url);
27
15
  requestUrl.searchParams.append("dag_id", dagId);
28
16
  if (dagRunId)
29
17
  requestUrl.searchParams.append("dag_run_id", dagRunId);
30
- requestUrl.searchParams.append("by_user", String(poolingByUser));
18
+ requestUrl.searchParams.append("by_user", String(pollingByUser));
31
19
  const resultUrl = requestUrl.pathname.substring(1) + requestUrl.search;
32
- const res = await fetch(resultUrl, { signal: AbortSignal.timeout(10000) });
20
+ const res = await fetch(resultUrl, {
21
+ signal: AbortSignal.timeout(10000),
22
+ });
33
23
  if (res.status === 401) {
34
24
  onUnauthorized();
35
25
  }
36
26
  if (!res.ok) {
37
- throw { ...(await res.json()), status: res.status };
27
+ let body = {};
28
+ try {
29
+ body = await res.json();
30
+ }
31
+ catch {
32
+ /* ignore non-JSON bodies (e.g. nginx error pages) */
33
+ }
34
+ throw { ...body, status: res.status };
38
35
  }
39
36
  return res.json();
40
- };
37
+ }, [onUnauthorized]);
41
38
  const [shouldRevalidate, setShouldRevalidate] = useState(true);
42
39
  const activate = useCallback(() => {
43
40
  setShouldRevalidate(true);
@@ -47,12 +44,12 @@ const DagRunStateProvider = ({ children, dagRun: _dagRun, autorun: _autorun, poo
47
44
  "airflow/dag_run",
48
45
  dagId,
49
46
  dagRunId ?? null,
50
- Boolean(poolingByUser),
47
+ Boolean(pollingByUser),
51
48
  ], defaultFetcher, {
52
49
  refreshInterval: shouldRevalidate ? 1000 : 0,
53
50
  onError: (err) => {
54
- if (err?.status !== 404) {
55
- enqueueSnackbar(`Ошибка при обновлении состояния задачи${(dagId && " ") || ""}${dagId || ""}!`, "error");
51
+ if (err?.status !== 404 && err?.status !== 401) {
52
+ enqueueSnackbar(`Ошибка при обновлении состояния задачи${dagId ? ` ${dagId}` : ""}!`, "error");
56
53
  }
57
54
  if (err?.status === 503) {
58
55
  onLostConnection();
@@ -65,15 +62,14 @@ const DagRunStateProvider = ({ children, dagRun: _dagRun, autorun: _autorun, poo
65
62
  });
66
63
  const dagRun = data?.dagRun ?? null;
67
64
  const taskInstances = data?.taskInstances ?? null;
68
- const updDagRunIdId = useEffectEvent((newDagRunId) => {
65
+ const updDagRunId = useEffectEvent((newDagRunId) => {
69
66
  if (dagRunId !== newDagRunId) {
70
67
  setDagRunId(newDagRunId);
71
68
  }
72
69
  });
73
70
  // Обновлять идентификатор запуска при изменении
74
71
  useEffect(() => {
75
- updDagRunIdId(dagRun?.dagRunId ?? null);
76
- console.log("effect 1");
72
+ updDagRunId(dagRun?.dagRunId ?? null);
77
73
  }, [dagRun?.dagRunId ?? null]);
78
74
  const autorunAction = useEffectEvent((dagRun) => {
79
75
  if (autorunHandler) {
@@ -89,23 +85,19 @@ const DagRunStateProvider = ({ children, dagRun: _dagRun, autorun: _autorun, poo
89
85
  if (dagRun?.state === "success") {
90
86
  autorunAction(dagRun);
91
87
  }
92
- console.log("effect 2");
93
88
  }, [dagRun?.state]);
94
- const onUpdateState = (isActive, newDagRun, _taskInstances) => {
89
+ const onUpdateState = (isActive, newDagRun) => {
95
90
  setShouldRevalidate(isActive);
96
- setDagRunId(newDagRun?.dagRunId);
91
+ setDagRunId(newDagRun?.dagRunId ?? null);
97
92
  if (newDagRun == null)
98
93
  mutate();
99
94
  };
100
- /**Вызывать сразу после запуска дага*/
95
+ /** Вызывать сразу после запуска дага */
101
96
  const onDagStarted = () => {
102
- // setIsActive(true);
103
97
  setDagRunId(null);
104
- // setTaskInstances(null);
105
98
  setShouldRevalidate(true);
106
99
  setDagStartedOnce(true);
107
100
  };
108
- const updateState = () => mutate();
109
101
  return (_jsx(DagRunStateContext.Provider, { value: {
110
102
  isActive: shouldRevalidate,
111
103
  autorun,
@@ -116,11 +108,15 @@ const DagRunStateProvider = ({ children, dagRun: _dagRun, autorun: _autorun, poo
116
108
  onDagStarted,
117
109
  activate,
118
110
  error: Boolean(error),
119
- fetchState: updateState,
120
- setAutorun: (autorun) => setAutorun(autorun),
111
+ fetchState: () => mutate(),
112
+ setAutorun,
121
113
  }, children: children }));
122
114
  };
123
115
  export const useDagRunStateContext = () => {
124
- return useContext(DagRunStateContext);
116
+ const ctx = useContext(DagRunStateContext);
117
+ if (!ctx) {
118
+ throw new Error("useDagRunStateContext must be used within DagRunStateProvider");
119
+ }
120
+ return ctx;
125
121
  };
126
122
  export default DagRunStateProvider;
@@ -1,11 +1,9 @@
1
- import React from "react";
2
1
  interface SessionProviderProps {
3
2
  children?: React.ReactNode;
4
3
  pollingInterval: number;
5
- autoRedirect?: boolean;
6
4
  onUnauthorized?(): void;
7
5
  }
8
- declare const SessionProvider: ({ children, pollingInterval, autoRedirect, onUnauthorized, }: SessionProviderProps) => import("react/jsx-runtime").JSX.Element;
6
+ declare const SessionProvider: ({ children, pollingInterval, onUnauthorized, }: SessionProviderProps) => import("react/jsx-runtime").JSX.Element;
9
7
  export declare const useSessionContext: () => {
10
8
  expiresIn: number;
11
9
  };
@@ -1 +1 @@
1
- {"version":3,"file":"SessionProvider.d.ts","sourceRoot":"","sources":["../../src/providers/SessionProvider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAyD,MAAM,OAAO,CAAC;AAU9E,UAAU,oBAAoB;IAC5B,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAC3B,eAAe,EAAE,MAAM,CAAC;IACxB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,cAAc,CAAC,IAAI,IAAI,CAAC;CACzB;AAED,QAAA,MAAM,eAAe,GAAI,8DAKtB,oBAAoB,4CA8CtB,CAAC;AAEF,eAAO,MAAM,iBAAiB;eAjEjB,MAAM;CAmElB,CAAC;AAEF,eAAe,eAAe,CAAC"}
1
+ {"version":3,"file":"SessionProvider.d.ts","sourceRoot":"","sources":["../../src/providers/SessionProvider.tsx"],"names":[],"mappings":"AAIA,UAAU,oBAAoB;IAC5B,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAC3B,eAAe,EAAE,MAAM,CAAC;IACxB,cAAc,CAAC,IAAI,IAAI,CAAC;CACzB;AAQD,QAAA,MAAM,eAAe,GAAI,gDAItB,oBAAoB,4CA8CtB,CAAC;AAEF,eAAO,MAAM,iBAAiB;eAzDjB,MAAM;CA2DlB,CAAC;AAEF,eAAe,eAAe,CAAC"}
@@ -5,24 +5,24 @@ import { useSnackbarContext } from "./SnackBarProvider";
5
5
  const SessionContext = createContext({
6
6
  expiresIn: 0,
7
7
  });
8
- const SessionProvider = ({ children, pollingInterval, autoRedirect, onUnauthorized, }) => {
8
+ const SessionProvider = ({ children, pollingInterval, onUnauthorized, }) => {
9
9
  const { enqueueSnackbar } = useSnackbarContext();
10
10
  const [expires, setExpires] = useState(false);
11
+ const [expiresIn, setExpiresIn] = useState(0);
11
12
  const alertExpire = () => enqueueSnackbar("Сессия завершилась, перезагрузите страницу!", "default");
12
13
  const fetchSessionExpiryAge = () => getRequest({
13
14
  url: "base/check_session",
14
- successHandler: (_resp) => { },
15
+ successHandler: (resp) => {
16
+ setExpiresIn(resp.sessionExpiresIn);
17
+ },
15
18
  errorHandler: (e) => {
16
- if (e?.status === 401 && autoRedirect && onUnauthorized) {
17
- onUnauthorized();
19
+ if (e?.status === 401) {
20
+ onUnauthorized?.();
18
21
  }
19
22
  setExpires(true);
20
23
  alertExpire();
21
24
  },
22
- }).catch((e) => {
23
- if (e?.status === 401 && autoRedirect && onUnauthorized) {
24
- onUnauthorized();
25
- }
25
+ }).catch(() => {
26
26
  setExpires(true);
27
27
  alertExpire();
28
28
  });
@@ -35,7 +35,7 @@ const SessionProvider = ({ children, pollingInterval, autoRedirect, onUnauthoriz
35
35
  return () => clearInterval(interval);
36
36
  }, [expires]);
37
37
  return (_jsx(SessionContext.Provider, { value: {
38
- expiresIn: pollingInterval,
38
+ expiresIn,
39
39
  }, children: children }));
40
40
  };
41
41
  export const useSessionContext = () => {
@@ -1,10 +1,11 @@
1
1
  import React from "react";
2
+ type SnackbarVariant = "default" | "error" | "success" | "warning" | "info";
2
3
  interface SnackBarProps {
3
- children?: React.ReactNode | React.ReactNode[];
4
+ children?: React.ReactNode;
4
5
  }
5
6
  declare const SnackBarProvider: ({ children }: SnackBarProps) => import("react/jsx-runtime").JSX.Element;
6
7
  export declare const useSnackbarContext: () => {
7
- enqueueSnackbar(message: string, variant: "default" | "error" | "success" | "warning" | "info"): void;
8
+ enqueueSnackbar(message: string, variant: SnackbarVariant): void;
8
9
  };
9
10
  export default SnackBarProvider;
10
11
  //# sourceMappingURL=SnackBarProvider.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"SnackBarProvider.d.ts","sourceRoot":"","sources":["../../src/providers/SnackBarProvider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAoC,MAAM,OAAO,CAAC;AAezD,UAAU,aAAa;IACrB,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,GAAG,KAAK,CAAC,SAAS,EAAE,CAAC;CAChD;AA8CD,QAAA,MAAM,gBAAgB,GAAI,cAAc,aAAa,4CAMpD,CAAC;AAEF,eAAO,MAAM,kBAAkB;6BA/DlB,MAAM,WACN,SAAS,GAAG,OAAO,GAAG,SAAS,GAAG,SAAS,GAAG,MAAM,GAC5D,IAAI;CA+DR,CAAC;AAEF,eAAe,gBAAgB,CAAC"}
1
+ {"version":3,"file":"SnackBarProvider.d.ts","sourceRoot":"","sources":["../../src/providers/SnackBarProvider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA0D,MAAM,OAAO,CAAC;AAM/E,KAAK,eAAe,GAAG,SAAS,GAAG,OAAO,GAAG,SAAS,GAAG,SAAS,GAAG,MAAM,CAAC;AAY5E,UAAU,aAAa;IACrB,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;CAC5B;AAoDD,QAAA,MAAM,gBAAgB,GAAI,cAAc,aAAa,4CAMpD,CAAC;AAEF,eAAO,MAAM,kBAAkB;6BAvEJ,MAAM,WAAW,eAAe,GAAG,IAAI;CAyEjE,CAAC;AAEF,eAAe,gBAAgB,CAAC"}
@@ -1,29 +1,36 @@
1
- import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { createContext, useContext } from "react";
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useCallback, useContext, useMemo } from "react";
3
3
  import Button from "@mui/material/Button";
4
4
  import { SnackbarProvider, useSnackbar } from "notistack";
5
5
  const SnackBarContext = createContext({
6
+ // Намеренный no-op по умолчанию: позволяет вызывать useSnackbarContext()
7
+ // вне провайдера без исключения. Это нужно для обратной совместимости:
8
+ // другие провайдеры (AirflowProvider, DagStateProvider) мокируют
9
+ // SnackBarProvider в своих тестах и ожидают, что хук не бросает ошибку.
6
10
  enqueueSnackbar: () => { },
7
11
  });
8
12
  const SnackBarProviderUtilities = ({ children }) => {
9
13
  const { enqueueSnackbar, closeSnackbar } = useSnackbar();
10
- const action = (snackbarId) => (_jsx(_Fragment, { children: _jsx(Button, { size: "small", variant: "text", color: "inherit", onClick: () => {
11
- closeSnackbar(snackbarId);
12
- }, children: "\u0417\u0430\u043A\u0440\u044B\u0442\u044C" }) }));
13
- function handleEnqueueSnackbar(message, variant) {
14
+ // closeSnackbar стабилен (гарантия notistack), поэтому action создаётся один раз.
15
+ const action = useCallback((snackbarId) => (_jsx(Button, { size: "small", variant: "text", color: "inherit", onClick: () => {
16
+ closeSnackbar(snackbarId);
17
+ }, children: "\u0417\u0430\u043A\u0440\u044B\u0442\u044C" })), [closeSnackbar]);
18
+ // enqueueSnackbar и action оба стабильны, поэтому callback создаётся один раз.
19
+ // preventDuplicate не передаём — он уже задан на уровне <SnackbarProvider>.
20
+ const handleEnqueueSnackbar = useCallback((message, variant) => {
14
21
  enqueueSnackbar(message, {
15
- variant: variant,
16
- preventDuplicate: true,
17
- action: action,
22
+ variant,
23
+ action,
18
24
  anchorOrigin: {
19
25
  horizontal: "left",
20
26
  vertical: "bottom",
21
27
  },
22
28
  });
23
- }
24
- return (_jsx(SnackBarContext.Provider, { value: {
25
- enqueueSnackbar: handleEnqueueSnackbar,
26
- }, children: children }));
29
+ }, [enqueueSnackbar, action]);
30
+ // useMemo предотвращает создание нового объекта value на каждом рендере,
31
+ // что вызвало бы лишние ре-рендеры у всех потребителей контекста.
32
+ const contextValue = useMemo(() => ({ enqueueSnackbar: handleEnqueueSnackbar }), [handleEnqueueSnackbar]);
33
+ return (_jsx(SnackBarContext.Provider, { value: contextValue, children: children }));
27
34
  };
28
35
  const SnackBarProvider = ({ children }) => {
29
36
  return (_jsx(SnackbarProvider, { maxSnack: 5, autoHideDuration: 5000, preventDuplicate: true, children: _jsx(SnackBarProviderUtilities, { children: children }) }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danikokonn/yarik-frontend-lib",
3
- "version": "2.0.58",
3
+ "version": "2.1.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "",
6
6
  "author": "",
@@ -14,52 +14,60 @@
14
14
  },
15
15
  "devDependencies": {
16
16
  "@eslint/js": "^9.34.0",
17
+ "@testing-library/jest-dom": "^6.9.1",
18
+ "@testing-library/react": "^16.3.2",
19
+ "@testing-library/user-event": "^14.6.1",
17
20
  "@types/lodash.debounce": "^4.0.9",
18
21
  "@types/moment-duration-format": "^2.2.7",
19
- "@types/react": "^19.2.7",
22
+ "@types/react": "^19.2.14",
20
23
  "@types/react-dom": "^19.2.3",
21
- "eslint": "^9.39.2",
24
+ "@vitest/coverage-v8": "^4.1.0",
25
+ "eslint": "^9.39.4",
22
26
  "eslint-plugin-react": "^7.37.5",
23
27
  "eslint-plugin-unicorn": "^62.0.0",
24
28
  "globals": "^16.5.0",
29
+ "jsdom": "^29.0.1",
25
30
  "prettier": "3.6.2",
26
- "react": "^19.2.3",
27
- "react-dom": "^19.2.3",
31
+ "react": "^19.2.4",
32
+ "react-dom": "^19.2.4",
28
33
  "ts-loader": "^9.5.4",
29
34
  "tss-react": "^4.9.20",
30
35
  "typescript": "^5.9.3",
31
- "typescript-eslint": "^8.49.0"
36
+ "typescript-eslint": "^8.57.1",
37
+ "vitest": "^4.1.0"
32
38
  },
33
39
  "dependencies": {
34
40
  "@emotion/react": "^11.14.0",
35
41
  "@emotion/styled": "^11.14.1",
36
- "@mui/icons-material": "^7.3.6",
37
- "@mui/material": "^7.3.6",
38
- "@mui/x-date-pickers": "^8.22.0",
42
+ "@mui/icons-material": "^7.3.9",
43
+ "@mui/material": "^7.3.9",
44
+ "@mui/x-date-pickers": "^8.27.2",
39
45
  "@tsconfig/strictest": "^2.0.8",
40
- "css-loader": "^7.1.2",
46
+ "css-loader": "^7.1.4",
41
47
  "moment": "^2.30.1",
42
48
  "moment-duration-format": "^2.3.2",
43
- "moment-timezone": "^0.6.0",
49
+ "moment-timezone": "^0.6.1",
44
50
  "notistack": "^3.0.2",
45
51
  "react-imask": "^7.6.1",
46
52
  "react-json-tree": "^0.20.0",
47
- "react-router": "^7.10.1",
48
- "react-virtuoso": "^4.17.0",
53
+ "react-router": "^7.13.1",
54
+ "react-virtuoso": "^4.18.3",
49
55
  "style-loader": "^4.0.0",
50
- "swr": "^2.3.7",
51
- "transliteration": "^2.3.5"
56
+ "swr": "^2.4.1",
57
+ "transliteration": "^2.6.1"
52
58
  },
53
59
  "peerDependencies": {
54
60
  "react": ">=18 <20",
55
61
  "react-dom": ">=18 <20"
56
62
  },
57
63
  "scripts": {
58
- "test": "echo \"Error: no test specified\"",
64
+ "test": "vitest",
65
+ "test:run": "vitest run",
66
+ "coverage": "vitest run --coverage",
59
67
  "build": "tsc"
60
68
  },
61
- "main": ".dist/index.js",
62
- "types": ".dist/index.d.ts",
69
+ "main": "./dist/index.js",
70
+ "types": "./dist/index.d.ts",
63
71
  "exports": {
64
72
  ".": {
65
73
  "import": "./dist/index.js",