@zeniai/client-epic-state 5.1.45 → 5.1.46
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/lib/entity/snackbar/snackbarTypes.d.ts +1 -1
- package/lib/entity/snackbar/snackbarTypes.js +3 -0
- package/lib/entity/task/taskPayload.d.ts +3 -1
- package/lib/entity/task/taskPayload.js +24 -2
- package/lib/entity/task/taskReducer.d.ts +8 -1
- package/lib/entity/task/taskReducer.js +29 -4
- package/lib/entity/task/taskState.d.ts +2 -0
- package/lib/entity/tenant/epic/saveExternalConnectionEpic.d.ts +2 -1
- package/lib/entity/tenant/epic/saveExternalConnectionEpic.js +17 -0
- package/lib/epic.d.ts +6 -3
- package/lib/epic.js +6 -3
- package/lib/esm/entity/snackbar/snackbarTypes.js +3 -0
- package/lib/esm/entity/task/taskPayload.js +24 -2
- package/lib/esm/entity/task/taskReducer.js +28 -3
- package/lib/esm/entity/tenant/epic/saveExternalConnectionEpic.js +17 -0
- package/lib/esm/epic.js +6 -3
- package/lib/esm/index.js +8 -8
- package/lib/esm/view/aiCfoView/aiCfoViewReducer.js +31 -1
- package/lib/esm/view/aiCfoView/epics/fetchSkillsEpic.js +33 -8
- package/lib/esm/view/aiCfoView/epics/submitFeedbackEpic.js +39 -0
- package/lib/esm/view/companyTaskManagerView/epics/fetchCompanyTaskManagerViewEpic.js +3 -0
- package/lib/esm/view/companyTaskManagerView/epics/fetchTaskManagerMetricsEpic.js +7 -1
- package/lib/esm/view/expenseAutomationView/reducers/transactionsViewReducer.js +7 -0
- package/lib/esm/view/taskManager/taskDetailView/epics/createSubTaskEpic.js +96 -0
- package/lib/esm/view/taskManager/taskDetailView/epics/fetchSubTasksEpic.js +53 -0
- package/lib/esm/view/taskManager/taskDetailView/epics/initializeTaskToLocalStoreEpic.js +1 -0
- package/lib/esm/view/taskManager/taskDetailView/epics/saveTaskDetailEpic.js +70 -9
- package/lib/esm/view/taskManager/taskDetailView/taskDetailReducer.js +56 -2
- package/lib/esm/view/taskManager/taskDetailView/taskDetailSelector.js +35 -9
- package/lib/esm/view/taskManager/taskListView/epics/fetchTaskListEpic.js +3 -1
- package/lib/esm/view/taskManager/taskListView/taskList.js +1 -0
- package/lib/esm/view/taskManager/taskListView/taskListReducer.js +310 -18
- package/lib/esm/view/taskManager/taskListView/taskListSelector.js +1 -0
- package/lib/esm/view/taskManager/taskListView/taskListViewHelpers.js +58 -0
- package/lib/esm/view/transactionDetail/epics/saveTransactionHelper.js +22 -0
- package/lib/esm/view/transactionDetail/epics/updateTransactionDetailEpic.js +75 -3
- package/lib/esm/view/transactionDetail/journalEntryLinesViewModel.js +58 -0
- package/lib/esm/view/transactionDetail/transactionDetailLocalDataHelper.js +10 -8
- package/lib/esm/view/transactionDetail/transactionDetailReducer.js +66 -1
- package/lib/index.d.ts +12 -11
- package/lib/index.js +37 -28
- package/lib/view/aiCfoView/aiCfoViewPayload.d.ts +10 -0
- package/lib/view/aiCfoView/aiCfoViewReducer.d.ts +7 -2
- package/lib/view/aiCfoView/aiCfoViewReducer.js +32 -2
- package/lib/view/aiCfoView/aiCfoViewState.d.ts +5 -1
- package/lib/view/aiCfoView/epics/fetchSkillsEpic.js +33 -8
- package/lib/view/aiCfoView/epics/submitFeedbackEpic.d.ts +13 -0
- package/lib/view/aiCfoView/epics/submitFeedbackEpic.js +43 -0
- package/lib/view/companyTaskManagerView/epics/fetchCompanyTaskManagerViewEpic.js +3 -0
- package/lib/view/companyTaskManagerView/epics/fetchTaskManagerMetricsEpic.js +7 -1
- package/lib/view/companyView/types/cockpitTypes.d.ts +3 -0
- package/lib/view/expenseAutomationView/helpers/transactionCategorizationLocalDataHelper.d.ts +1 -1
- package/lib/view/expenseAutomationView/reducers/transactionsViewReducer.js +7 -0
- package/lib/view/taskManager/taskDetailView/epics/createSubTaskEpic.d.ts +9 -0
- package/lib/view/taskManager/taskDetailView/epics/createSubTaskEpic.js +100 -0
- package/lib/view/taskManager/taskDetailView/epics/fetchSubTasksEpic.d.ts +8 -0
- package/lib/view/taskManager/taskDetailView/epics/fetchSubTasksEpic.js +57 -0
- package/lib/view/taskManager/taskDetailView/epics/initializeTaskToLocalStoreEpic.js +1 -0
- package/lib/view/taskManager/taskDetailView/epics/saveTaskDetailEpic.d.ts +2 -2
- package/lib/view/taskManager/taskDetailView/epics/saveTaskDetailEpic.js +69 -8
- package/lib/view/taskManager/taskDetailView/taskDetail.d.ts +15 -0
- package/lib/view/taskManager/taskDetailView/taskDetailReducer.d.ts +14 -2
- package/lib/view/taskManager/taskDetailView/taskDetailReducer.js +57 -3
- package/lib/view/taskManager/taskDetailView/taskDetailSelector.d.ts +3 -1
- package/lib/view/taskManager/taskDetailView/taskDetailSelector.js +33 -7
- package/lib/view/taskManager/taskListView/epics/fetchTaskListEpic.js +3 -1
- package/lib/view/taskManager/taskListView/taskList.d.ts +1 -1
- package/lib/view/taskManager/taskListView/taskList.js +1 -0
- package/lib/view/taskManager/taskListView/taskListPayload.d.ts +4 -0
- package/lib/view/taskManager/taskListView/taskListReducer.d.ts +6 -2
- package/lib/view/taskManager/taskListView/taskListReducer.js +311 -19
- package/lib/view/taskManager/taskListView/taskListSelector.js +1 -0
- package/lib/view/taskManager/taskListView/taskListViewHelpers.d.ts +11 -0
- package/lib/view/taskManager/taskListView/taskListViewHelpers.js +60 -1
- package/lib/view/transactionDetail/epics/saveTransactionHelper.js +22 -0
- package/lib/view/transactionDetail/epics/updateTransactionDetailEpic.d.ts +5 -1
- package/lib/view/transactionDetail/epics/updateTransactionDetailEpic.js +77 -4
- package/lib/view/transactionDetail/journalEntryLinesViewModel.d.ts +27 -0
- package/lib/view/transactionDetail/journalEntryLinesViewModel.js +61 -1
- package/lib/view/transactionDetail/transactionDetailLocalDataHelper.js +10 -8
- package/lib/view/transactionDetail/transactionDetailReducer.d.ts +11 -2
- package/lib/view/transactionDetail/transactionDetailReducer.js +67 -2
- package/lib/view/transactionDetail/transactionDetailState.d.ts +8 -1
- package/lib/view/transactionDetail/transactionDetailTypes.d.ts +2 -0
- package/package.json +1 -1
|
@@ -383,6 +383,36 @@ const aiCfoView = createSlice({
|
|
|
383
383
|
const { chatSessionId } = action.payload;
|
|
384
384
|
delete draft.deleteChatSessionStateByChatSessionId[chatSessionId];
|
|
385
385
|
},
|
|
386
|
+
// AI CFO answer feedback (👍/👎). Fire-and-forget: submitFeedbackEpic POSTs to /1.0/feedback
|
|
387
|
+
// and the server emits the analytics event + routes negatives, so there's no view state to
|
|
388
|
+
// mutate — the reducers are intentional no-ops; prepare carries the params.
|
|
389
|
+
submitFeedback: {
|
|
390
|
+
reducer() { },
|
|
391
|
+
prepare(chatSessionId, chatMessageId, feedbackType, questionText, answerText, model) {
|
|
392
|
+
return {
|
|
393
|
+
payload: {
|
|
394
|
+
chatMessageId,
|
|
395
|
+
chatSessionId,
|
|
396
|
+
feedbackType,
|
|
397
|
+
answerText,
|
|
398
|
+
model,
|
|
399
|
+
questionText,
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
submitFeedbackSuccess: {
|
|
405
|
+
reducer() { },
|
|
406
|
+
prepare(chatMessageId) {
|
|
407
|
+
return { payload: { chatMessageId } };
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
submitFeedbackFailure: {
|
|
411
|
+
reducer() { },
|
|
412
|
+
prepare(chatMessageId, error) {
|
|
413
|
+
return { payload: { chatMessageId, error } };
|
|
414
|
+
},
|
|
415
|
+
},
|
|
386
416
|
clearInput(draft) {
|
|
387
417
|
draft.uiState.currentInput = '';
|
|
388
418
|
},
|
|
@@ -481,5 +511,5 @@ const aiCfoView = createSlice({
|
|
|
481
511
|
},
|
|
482
512
|
},
|
|
483
513
|
});
|
|
484
|
-
export const { submitQuestion, submitQuestionSuccess, submitQuestionFailure, updateResponseState, updateAiCfoViewScrollPosition, updateCurrentInput, clearInput, setSession, createSession, createSessionSuccess, createSessionFailure, createSessionAndSubmit, initializeNewSessionState, createSessionAndSubmitSuccess, createSessionAndSubmitFailure, fetchChatSessionsForUser, fetchChatSessionsForUserSuccess, fetchChatSessionsForUserFailure, fetchChatHistory, fetchChatHistorySuccess, fetchChatHistoryFailure, clearCurrentSessionId, clearLastContextMessage, stopSubmit, stopSubmitSuccess, stopSubmitFailure, stopSubmitQuestion, stopSubmitQuestionSuccess, stopSubmitQuestionFailure, acceptMasterTOS, acceptMasterTOSSuccess, acceptMasterTOSFailure, deleteChatSession, deleteChatSessionSuccess, deleteChatSessionFailure, clearDeleteChatSessionStatus, clearAiCfoView, updateCotCollapsedState, fetchSuggestedQuestions, fetchSuggestedQuestionsSuccess, fetchSuggestedQuestionsFailure, fetchSkills, fetchSkillsSuccess, fetchSkillsFailure, applyAiCfoSidePanelHostPageTransition, clearAiCfoSidePanelHostPageContext, } = aiCfoView.actions;
|
|
514
|
+
export const { submitQuestion, submitQuestionSuccess, submitQuestionFailure, updateResponseState, updateAiCfoViewScrollPosition, updateCurrentInput, submitFeedback, submitFeedbackSuccess, submitFeedbackFailure, clearInput, setSession, createSession, createSessionSuccess, createSessionFailure, createSessionAndSubmit, initializeNewSessionState, createSessionAndSubmitSuccess, createSessionAndSubmitFailure, fetchChatSessionsForUser, fetchChatSessionsForUserSuccess, fetchChatSessionsForUserFailure, fetchChatHistory, fetchChatHistorySuccess, fetchChatHistoryFailure, clearCurrentSessionId, clearLastContextMessage, stopSubmit, stopSubmitSuccess, stopSubmitFailure, stopSubmitQuestion, stopSubmitQuestionSuccess, stopSubmitQuestionFailure, acceptMasterTOS, acceptMasterTOSSuccess, acceptMasterTOSFailure, deleteChatSession, deleteChatSessionSuccess, deleteChatSessionFailure, clearDeleteChatSessionStatus, clearAiCfoView, updateCotCollapsedState, fetchSuggestedQuestions, fetchSuggestedQuestionsSuccess, fetchSuggestedQuestionsFailure, fetchSkills, fetchSkillsSuccess, fetchSkillsFailure, applyAiCfoSidePanelHostPageTransition, clearAiCfoSidePanelHostPageContext, } = aiCfoView.actions;
|
|
485
515
|
export default aiCfoView.reducer;
|
|
@@ -2,14 +2,39 @@ import { of } from 'rxjs';
|
|
|
2
2
|
import { catchError, filter, mergeMap, switchMap } from 'rxjs/operators';
|
|
3
3
|
import { createZeniAPIStatus, isSuccessResponse } from '../../../responsePayload';
|
|
4
4
|
import { fetchSkills, fetchSkillsFailure, fetchSkillsSuccess, } from '../aiCfoViewReducer';
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
5
|
+
const isNonBlankString = (value) => typeof value === 'string' && value.trim() !== '';
|
|
6
|
+
// parameter_hints is a free-form {param: exampleValue(s)} map; flatten its non-blank
|
|
7
|
+
// string values (a single string or an array of them) into display-ready "try: …"
|
|
8
|
+
// phrases. The container check is a runtime guard, not a type check: /1.0/skills is not
|
|
9
|
+
// schema-validated, so a malformed non-object payload would otherwise make Object.values
|
|
10
|
+
// split a string into characters.
|
|
11
|
+
const toExampleHints = (parameterHints) => {
|
|
12
|
+
if (parameterHints == null ||
|
|
13
|
+
typeof parameterHints !== 'object' ||
|
|
14
|
+
Array.isArray(parameterHints)) {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
const hints = Object.values(parameterHints).flatMap((value) => {
|
|
18
|
+
if (isNonBlankString(value)) {
|
|
19
|
+
return [value.trim()];
|
|
20
|
+
}
|
|
21
|
+
if (Array.isArray(value)) {
|
|
22
|
+
return value.filter(isNonBlankString).map((item) => item.trim());
|
|
23
|
+
}
|
|
24
|
+
return [];
|
|
25
|
+
});
|
|
26
|
+
// The menu joins these into one flat "try: a · b" line, so a repeated phrase reads as
|
|
27
|
+
// a broken duplicate to the user — dedupe across parameter keys and array entries.
|
|
28
|
+
return Array.from(new Set(hints));
|
|
29
|
+
};
|
|
30
|
+
const toSkill = (skillSummaryPayload) => ({
|
|
31
|
+
macroId: skillSummaryPayload.macro_id,
|
|
32
|
+
name: skillSummaryPayload.name,
|
|
33
|
+
displayName: skillSummaryPayload.display_name,
|
|
34
|
+
description: skillSummaryPayload.description,
|
|
35
|
+
semver: skillSummaryPayload.semver,
|
|
36
|
+
exampleHints: toExampleHints(skillSummaryPayload.parameter_hints),
|
|
37
|
+
hasScripts: skillSummaryPayload.has_scripts,
|
|
13
38
|
});
|
|
14
39
|
/** GET {aiCfoMicroServiceBaseUrl}/1.0/skills — chat-visible skills for the `/` menu. */
|
|
15
40
|
export const fetchSkillsEpic = (actions$,
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { of } from 'rxjs';
|
|
2
|
+
import { catchError, filter, groupBy, mergeMap, switchMap } from 'rxjs/operators';
|
|
3
|
+
import { createZeniAPIStatus, isSuccessStatus, } from '../../../responsePayload';
|
|
4
|
+
import { submitFeedback, submitFeedbackFailure, submitFeedbackSuccess, } from '../aiCfoViewReducer';
|
|
5
|
+
/**
|
|
6
|
+
* Thin wiring for AI CFO answer feedback (👍/👎). Dispatch `submitFeedback(...)` (defined on the
|
|
7
|
+
* aiCfoView slice) to POST to the AI CFO micro-service `/1.0/feedback` endpoint, which emits the
|
|
8
|
+
* analytics event server-side and routes negatives to the review queue. The capture-prompt UI
|
|
9
|
+
* that dispatches this lands in a fast-follow PR.
|
|
10
|
+
*/
|
|
11
|
+
export const submitFeedbackEpic = (actions$, _state$, zeniAPI) => actions$.pipe(filter(submitFeedback.match),
|
|
12
|
+
// Per-message dedup: group by chatMessageId so rapid repeat clicks on the SAME answer cancel
|
|
13
|
+
// the in-flight request (switchMap) — no duplicate POST / analytics / Slack row — while
|
|
14
|
+
// feedback on different answers still runs concurrently (separate groups under mergeMap).
|
|
15
|
+
groupBy((action) => action.payload.chatMessageId), mergeMap((perMessage$) => perMessage$.pipe(switchMap((action) => {
|
|
16
|
+
const { chatMessageId, chatSessionId, feedbackType, answerText, model, questionText, } = action.payload;
|
|
17
|
+
const body = {
|
|
18
|
+
chat_message_id: chatMessageId,
|
|
19
|
+
chat_session_id: chatSessionId,
|
|
20
|
+
feedback_type: feedbackType,
|
|
21
|
+
...(answerText != null && { answer_text: answerText }),
|
|
22
|
+
...(model != null && { model }),
|
|
23
|
+
...(questionText != null && { question_text: questionText }),
|
|
24
|
+
};
|
|
25
|
+
return zeniAPI
|
|
26
|
+
.postAndGetJSON(`${zeniAPI.apiEndPoints.aiCfoMicroServiceBaseUrl}/1.0/feedback`, body)
|
|
27
|
+
.pipe(
|
|
28
|
+
// Status-only success check (no `data`): /1.0/feedback returns a 2xx with status and no
|
|
29
|
+
// body, so isSuccessResponse (which requires response.data) would wrongly fail it.
|
|
30
|
+
mergeMap((response) => isSuccessStatus(response)
|
|
31
|
+
? of(submitFeedbackSuccess(chatMessageId))
|
|
32
|
+
: of(submitFeedbackFailure(chatMessageId, response.status))), catchError((error) => of(submitFeedbackFailure(chatMessageId, createZeniAPIStatus(
|
|
33
|
+
// message (detailed) first, then the short title — per createZeniAPIStatus(message, title).
|
|
34
|
+
// JSON.stringify(Error) drops the (non-enumerable) message — extract it explicitly.
|
|
35
|
+
'Feedback REST API call errored out: ' +
|
|
36
|
+
(error instanceof Error
|
|
37
|
+
? error.message
|
|
38
|
+
: String(error)), 'Unexpected Error')))));
|
|
39
|
+
}))));
|
|
@@ -18,6 +18,9 @@ export const fetchCompanyTaskManagerViewEpic = (actions$, _state$, zeniAPI) => a
|
|
|
18
18
|
...(filters?.categories != null && filters.categories.length > 0
|
|
19
19
|
? { filter_by: toFilterByPayload(filters) }
|
|
20
20
|
: {}),
|
|
21
|
+
task_group_scopes: filters?.taskGroupScopes != null && filters.taskGroupScopes.length > 0
|
|
22
|
+
? filters.taskGroupScopes
|
|
23
|
+
: ['internal'],
|
|
21
24
|
};
|
|
22
25
|
return zeniAPI
|
|
23
26
|
.getJSON(`${zeniAPI.apiEndPoints.tenantMicroServiceBaseUrl}/1.0/cockpit-panels/tasks-es?query=${encodeURIComponent(JSON.stringify(queryValue))}`)
|
|
@@ -3,7 +3,13 @@ import { catchError, filter, mergeMap, switchMap } from 'rxjs/operators';
|
|
|
3
3
|
import { isSuccessResponse } from '../../../responsePayload';
|
|
4
4
|
import { fetchTaskManagerMetrics, updateTaskManagerMetrics, updateTaskManagerMetricsOnFailure, } from '../companyTaskManagerViewReducer';
|
|
5
5
|
export const fetchTaskManagerMetricsEpic = (actions$, _state$, zeniAPI) => actions$.pipe(filter(fetchTaskManagerMetrics.match), switchMap(() => {
|
|
6
|
-
|
|
6
|
+
// Include subtasks so the cockpit count matches the customer dashboard
|
|
7
|
+
// pending count. The dashboard counts every task (subtasks included);
|
|
8
|
+
// cockpit's default of include_subtasks=false otherwise undercounts.
|
|
9
|
+
const query = JSON.stringify({
|
|
10
|
+
is_filter_by_logged_in_user: true,
|
|
11
|
+
include_subtasks: true,
|
|
12
|
+
});
|
|
7
13
|
return zeniAPI
|
|
8
14
|
.getJSON(`${zeniAPI.apiEndPoints.tenantMicroServiceBaseUrl}/1.0/cockpit-panels/tasks-es-metrics?query=${encodeURIComponent(query)}`)
|
|
9
15
|
.pipe(mergeMap((response) => {
|
|
@@ -168,6 +168,13 @@ const expenseAutomationTransactionsView = createSlice({
|
|
|
168
168
|
if (searchString !== undefined) {
|
|
169
169
|
draft.transactionCategorizationView[selectedTab].uiState.searchString =
|
|
170
170
|
searchString;
|
|
171
|
+
// When search is cleared, drop all sub-tab snapshots. A snapshot
|
|
172
|
+
// captured while search was active holds filtered row IDs — restoring
|
|
173
|
+
// it after the clear would show stale search results instead of the
|
|
174
|
+
// full unfiltered list.
|
|
175
|
+
if (searchString === '' && selectedTab === 'autoCategorized') {
|
|
176
|
+
draft.autoCategorizedSubTabCache = {};
|
|
177
|
+
}
|
|
171
178
|
}
|
|
172
179
|
draft.transactionCategorizationView[selectedTab].error = undefined;
|
|
173
180
|
const monthYearPeriod = {
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { from, of } from 'rxjs';
|
|
2
|
+
import { catchError, filter, mergeMap } from 'rxjs/operators';
|
|
3
|
+
import { convertHHMMStrToMinutes } from '../../../../commonStateTypes/fiscalYearHelpers/formatMinutesToFromHHMM';
|
|
4
|
+
import { openSnackbar } from '../../../../entity/snackbar/snackbarReducer';
|
|
5
|
+
import { appendSubTaskId, updateTasks, } from '../../../../entity/task/taskReducer';
|
|
6
|
+
import { getTaskById } from '../../../../entity/task/taskSelector';
|
|
7
|
+
import { createZeniAPIStatus, isSuccessResponse, } from '../../../../responsePayload';
|
|
8
|
+
import { updateTaskListOnNewTaskCreationSuccess } from '../../taskListView/taskListReducer';
|
|
9
|
+
import { createSubTask, createSubTaskSuccessOrFailure, } from '../taskDetailReducer';
|
|
10
|
+
export const createSubTaskEpic = (actions$, state$, zeniAPI) => actions$.pipe(filter(createSubTask.match), mergeMap((action) => {
|
|
11
|
+
const state = state$.value;
|
|
12
|
+
const parentTask = getTaskById(state.taskState, action.payload.parentTaskId);
|
|
13
|
+
// Subtasks inherit every parent group membership, not just the first,
|
|
14
|
+
// so the subtask surfaces in all the same buckets as its parent.
|
|
15
|
+
const parentTaskGroupIds = parentTask?.taskGroupIds ?? [];
|
|
16
|
+
const parentTaskGroupId = parentTaskGroupIds[0];
|
|
17
|
+
const isTaskListFetched = state.taskListState.fetchState === 'Completed';
|
|
18
|
+
const payload = prepareSubTaskPayload(action.payload, parentTaskGroupIds);
|
|
19
|
+
return zeniAPI
|
|
20
|
+
.postAndGetJSON(`${zeniAPI.apiEndPoints.taskMicroServiceBaseUrl}/1.0/task-manager/tasks`, payload)
|
|
21
|
+
.pipe(mergeMap((response) => {
|
|
22
|
+
if (isSuccessResponse(response) &&
|
|
23
|
+
response.data != null &&
|
|
24
|
+
response.data.tasks.length > 0) {
|
|
25
|
+
const newTaskId = response.data.tasks[0].task_id;
|
|
26
|
+
const actions = [
|
|
27
|
+
updateTasks(response.data.tasks),
|
|
28
|
+
appendSubTaskId({
|
|
29
|
+
parentTaskId: action.payload.parentTaskId,
|
|
30
|
+
subTaskId: newTaskId,
|
|
31
|
+
}),
|
|
32
|
+
createSubTaskSuccessOrFailure({
|
|
33
|
+
fetchState: 'Completed',
|
|
34
|
+
newTaskId,
|
|
35
|
+
}),
|
|
36
|
+
openSnackbar({
|
|
37
|
+
messageSection: 'create_sub_task',
|
|
38
|
+
messageText: 'success',
|
|
39
|
+
type: 'success',
|
|
40
|
+
}),
|
|
41
|
+
];
|
|
42
|
+
// The task list is keyed on parent tasks (no parent_task_id)
|
|
43
|
+
// — subtasks belong to their parent's children list, not the
|
|
44
|
+
// top-level list. Pushing a subtask into the list would
|
|
45
|
+
// surface it as an orphan row at the top of the group.
|
|
46
|
+
// Only fire the list-update for actual parent tasks.
|
|
47
|
+
const createdTask = response.data.tasks[0];
|
|
48
|
+
const isSubtask = createdTask.parent_task_id != null &&
|
|
49
|
+
createdTask.parent_task_id !== '';
|
|
50
|
+
if (!isSubtask &&
|
|
51
|
+
isTaskListFetched === true &&
|
|
52
|
+
parentTaskGroupId != null) {
|
|
53
|
+
actions.push(updateTaskListOnNewTaskCreationSuccess({
|
|
54
|
+
taskGroupId: parentTaskGroupId,
|
|
55
|
+
task: createdTask,
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
return from(actions);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
return of(createSubTaskSuccessOrFailure({
|
|
62
|
+
fetchState: 'Error',
|
|
63
|
+
error: response.status,
|
|
64
|
+
}), openSnackbar({
|
|
65
|
+
messageSection: 'create_sub_task',
|
|
66
|
+
messageText: 'failed',
|
|
67
|
+
type: 'error',
|
|
68
|
+
variables: [
|
|
69
|
+
{
|
|
70
|
+
variableName: '_api-error_',
|
|
71
|
+
variableValue: response.status.message,
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
}), catchError((error) => of(createSubTaskSuccessOrFailure({
|
|
77
|
+
fetchState: 'Error',
|
|
78
|
+
error: createZeniAPIStatus('Unexpected Error', 'Create Sub Task REST API call errored out' +
|
|
79
|
+
JSON.stringify(error)),
|
|
80
|
+
}))));
|
|
81
|
+
}));
|
|
82
|
+
const prepareSubTaskPayload = (subTask, parentTaskGroupIds) => ({
|
|
83
|
+
name: subTask.name,
|
|
84
|
+
assignees: subTask.assignee,
|
|
85
|
+
description: subTask.description,
|
|
86
|
+
priority: subTask.priority,
|
|
87
|
+
status: subTask.status,
|
|
88
|
+
tags: subTask.tagIds,
|
|
89
|
+
time_spent: convertHHMMStrToMinutes(subTask.timeSpent),
|
|
90
|
+
group_assignees: subTask.groupAssignees,
|
|
91
|
+
parent_task_id: subTask.parentTaskId,
|
|
92
|
+
// Inherit every parent group, not just the first — see callsite for
|
|
93
|
+
// the two-scope rationale.
|
|
94
|
+
task_group_ids: parentTaskGroupIds,
|
|
95
|
+
due_date: subTask.dueDate?.format('YYYY-MM-DD') ?? null,
|
|
96
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { of } from 'rxjs';
|
|
2
|
+
import { catchError, filter, mergeMap } from 'rxjs/operators';
|
|
3
|
+
import { setSubTaskIds, updateTasks } from '../../../../entity/task/taskReducer';
|
|
4
|
+
import { createZeniAPIStatus, isSuccessResponse, } from '../../../../responsePayload';
|
|
5
|
+
import { fetchSubTasks, updateSubTasks, updateSubTasksFetchStatus, } from '../taskDetailReducer';
|
|
6
|
+
export const fetchSubTasksEpic = (actions$, _state$, zeniAPI) => actions$.pipe(filter(fetchSubTasks.match),
|
|
7
|
+
// Use mergeMap, not switchMap: switchMap cancels in-flight requests for
|
|
8
|
+
// the previous parent without dispatching success or failure, leaving
|
|
9
|
+
// subTaskListFetchStatusByParentId stuck at In-Progress and blocking the
|
|
10
|
+
// detail page's combined fetchStatus via reduceAnyFetchState. Per-parent
|
|
11
|
+
// races on the same parentTaskId are handled last-write-wins downstream.
|
|
12
|
+
mergeMap((action) => {
|
|
13
|
+
const { parentTaskId } = action.payload;
|
|
14
|
+
const query = JSON.stringify({
|
|
15
|
+
task_type: 'subtasks',
|
|
16
|
+
parent_task_id: parentTaskId,
|
|
17
|
+
});
|
|
18
|
+
return zeniAPI
|
|
19
|
+
.getJSON(`${zeniAPI.apiEndPoints.taskMicroServiceBaseUrl}/1.0/task-manager/tasks?query=${encodeURIComponent(query)}`)
|
|
20
|
+
.pipe(mergeMap((response) => {
|
|
21
|
+
if (isSuccessResponse(response) && response.data != null) {
|
|
22
|
+
const { tasks } = response.data;
|
|
23
|
+
// Keep the parent's `subTasksIds` in sync with the fetched
|
|
24
|
+
// bucket. Without this, the parent entity record can drift
|
|
25
|
+
// out of sync with what the children fetch returned —
|
|
26
|
+
// e.g. a server-side delete won't drop from the parent
|
|
27
|
+
// record until the next list re-fetch.
|
|
28
|
+
const fetchedChildIds = tasks
|
|
29
|
+
.filter((t) => t.parent_task_id === parentTaskId)
|
|
30
|
+
.map((t) => t.task_id);
|
|
31
|
+
return of(updateTasks(tasks), setSubTaskIds({
|
|
32
|
+
parentTaskId,
|
|
33
|
+
subTaskIds: fetchedChildIds,
|
|
34
|
+
}), updateSubTasks({
|
|
35
|
+
parentTaskId,
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
return of(updateSubTasksFetchStatus({
|
|
40
|
+
parentTaskId,
|
|
41
|
+
fetchState: 'Error',
|
|
42
|
+
error: response.status,
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
}), catchError((error) => {
|
|
46
|
+
return of(updateSubTasksFetchStatus({
|
|
47
|
+
parentTaskId,
|
|
48
|
+
fetchState: 'Error',
|
|
49
|
+
error: createZeniAPIStatus('Unexpected Error', 'Fetch Sub Tasks REST API call errored out' +
|
|
50
|
+
JSON.stringify(error)),
|
|
51
|
+
}));
|
|
52
|
+
}));
|
|
53
|
+
}));
|
|
@@ -28,6 +28,7 @@ export const initializeTaskToLocalStoreEpic = (actions$, state$) => actions$.pip
|
|
|
28
28
|
recurringStartDate: task.recurringStartDate,
|
|
29
29
|
timeSpent: task.timeSpent,
|
|
30
30
|
isPrivate: task.isPrivate,
|
|
31
|
+
taskGroupId: task.taskGroupIds[0],
|
|
31
32
|
};
|
|
32
33
|
const actions = [
|
|
33
34
|
saveTaskUpdatesToLocalStore({ taskDetailLocalData, taskId }),
|
|
@@ -6,23 +6,52 @@ import { openSnackbar } from '../../../../entity/snackbar/snackbarReducer';
|
|
|
6
6
|
import { updateTasks } from '../../../../entity/task/taskReducer';
|
|
7
7
|
import { getTaskById } from '../../../../entity/task/taskSelector';
|
|
8
8
|
import { createZeniAPIStatus, isSuccessResponse, } from '../../../../responsePayload';
|
|
9
|
-
import { updateTaskListOnNewTaskCreationSuccess } from '../../taskListView/taskListReducer';
|
|
9
|
+
import { removeTaskFromGroupBucket, updateTaskListOnNewTaskCreationSuccess, } from '../../taskListView/taskListReducer';
|
|
10
10
|
import { saveTaskDetail, saveTaskSuccessOrFailure } from '../taskDetailReducer';
|
|
11
11
|
export const saveTaskDetailEpic = (actions$, state$, zeniAPI) => actions$.pipe(filter(saveTaskDetail.match), mergeMap((action) => {
|
|
12
12
|
const { taskId, taskGroupId } = action.payload;
|
|
13
13
|
const state = state$.value;
|
|
14
14
|
const isTaskListFetched = state.taskListState.fetchState === 'Completed';
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
// On edit, prefer the locally-edited group; fall back to the entity's
|
|
16
|
+
// current group. On create, use the taskGroupId from the action payload.
|
|
17
|
+
const localTaskGroupId = taskId != null
|
|
18
|
+
? state.taskDetailState.editTaskStateById[taskId]?.taskDetailLocalData
|
|
19
|
+
?.taskGroupId
|
|
20
|
+
: undefined;
|
|
21
|
+
// groupWasCleared distinguishes "user explicitly cleared the group"
|
|
22
|
+
// (localTaskGroupId === null → send []) from "user did not touch it"
|
|
23
|
+
// (undefined → fall back to entity). Without this, the `??` fallback
|
|
24
|
+
// would resurrect the stale entity value after a clear.
|
|
25
|
+
const groupWasCleared = taskId != null && localTaskGroupId === null;
|
|
26
|
+
const entityTaskGroupIds = taskId != null
|
|
27
|
+
? (getTaskById(state.taskState, taskId)?.taskGroupIds ?? [])
|
|
28
|
+
: [];
|
|
29
|
+
// Distinguish three save shapes:
|
|
30
|
+
// - cleared → []
|
|
31
|
+
// - user picked a different group locally → [localTaskGroupId]
|
|
32
|
+
// - no local edit → entire entityTaskGroupIds (preserves multi-
|
|
33
|
+
// group membership; without this, unrelated field edits silently
|
|
34
|
+
// drop extra groups).
|
|
35
|
+
const localGroupChanged = localTaskGroupId != null;
|
|
36
|
+
const taskGroupIdsForPayload = groupWasCleared
|
|
37
|
+
? []
|
|
38
|
+
: localGroupChanged
|
|
39
|
+
? [localTaskGroupId]
|
|
40
|
+
: taskId != null
|
|
41
|
+
? entityTaskGroupIds
|
|
42
|
+
: taskGroupId != null
|
|
43
|
+
? [taskGroupId]
|
|
44
|
+
: [];
|
|
45
|
+
// Keep `updatedTaskGroupId` (single id) for downstream list-update
|
|
46
|
+
// dispatches that still bucket against one group at a time.
|
|
47
|
+
const updatedTaskGroupId = taskGroupIdsForPayload[0];
|
|
48
|
+
const payload = prepareTaskPayload(state, taskId, taskGroupIdsForPayload);
|
|
19
49
|
const saveTaskApi = taskId != null
|
|
20
50
|
? zeniAPI.putAndGetJSON(`${zeniAPI.apiEndPoints.taskMicroServiceBaseUrl}/1.0/task-manager/tasks/${taskId}`, payload)
|
|
21
51
|
: zeniAPI.postAndGetJSON(`${zeniAPI.apiEndPoints.taskMicroServiceBaseUrl}/1.0/task-manager/tasks`, payload);
|
|
22
52
|
return saveTaskApi.pipe(mergeMap((response) => {
|
|
23
53
|
if (isSuccessResponse(response) &&
|
|
24
54
|
response.data != null &&
|
|
25
|
-
updatedTaskGroupId != null &&
|
|
26
55
|
response.data.tasks.length > 0) {
|
|
27
56
|
const newTaskId = response.data.tasks[0].task_id;
|
|
28
57
|
const actions = [
|
|
@@ -33,7 +62,24 @@ export const saveTaskDetailEpic = (actions$, state$, zeniAPI) => actions$.pipe(f
|
|
|
33
62
|
taskId,
|
|
34
63
|
}),
|
|
35
64
|
];
|
|
36
|
-
|
|
65
|
+
// Only suppress the list-insert on the live/completed tabs
|
|
66
|
+
// when the saved task's status no longer matches the tab —
|
|
67
|
+
// otherwise doUpdateTaskList writes the row back into the
|
|
68
|
+
// current tab's grouping arrays even after updateTasks moved
|
|
69
|
+
// it to Completed. Archived/snoozed/deleted are owned by
|
|
70
|
+
// their dedicated actions and must keep the pre-PR optimistic
|
|
71
|
+
// update path so their grouping arrays don't go stale.
|
|
72
|
+
const savedTask = response.data.tasks[0];
|
|
73
|
+
const savedIsCompleted = savedTask.status?.code === 'resolved';
|
|
74
|
+
const activeTab = state.taskListState.currentTab;
|
|
75
|
+
const isLiveOrCompletedTab = activeTab === 'live' || activeTab === 'completed';
|
|
76
|
+
const statusMatchesActiveTab = !isLiveOrCompletedTab ||
|
|
77
|
+
(savedIsCompleted && activeTab === 'completed') ||
|
|
78
|
+
(!savedIsCompleted && activeTab === 'live');
|
|
79
|
+
if (isTaskListFetched === true &&
|
|
80
|
+
groupWasCleared === false &&
|
|
81
|
+
updatedTaskGroupId != null &&
|
|
82
|
+
statusMatchesActiveTab === true) {
|
|
37
83
|
actions.push(updateTaskListOnNewTaskCreationSuccess({
|
|
38
84
|
taskGroupId: updatedTaskGroupId,
|
|
39
85
|
task: response.data.tasks[0],
|
|
@@ -42,6 +88,21 @@ export const saveTaskDetailEpic = (actions$, state$, zeniAPI) => actions$.pipe(f
|
|
|
42
88
|
: undefined,
|
|
43
89
|
}));
|
|
44
90
|
}
|
|
91
|
+
else if (isTaskListFetched === true &&
|
|
92
|
+
groupWasCleared === true &&
|
|
93
|
+
taskId != null) {
|
|
94
|
+
// Group was cleared — skip the list-insert action (no
|
|
95
|
+
// destination bucket) and explicitly drop the row from every
|
|
96
|
+
// previously-held group so the list re-renders without the
|
|
97
|
+
// stale entry under the old group until next refetch.
|
|
98
|
+
const previousTask = getTaskById(state.taskState, taskId);
|
|
99
|
+
previousTask?.taskGroupIds.forEach((previousGroupId) => {
|
|
100
|
+
actions.push(removeTaskFromGroupBucket({
|
|
101
|
+
taskId,
|
|
102
|
+
taskGroupId: previousGroupId,
|
|
103
|
+
}));
|
|
104
|
+
});
|
|
105
|
+
}
|
|
45
106
|
return from(actions);
|
|
46
107
|
}
|
|
47
108
|
else {
|
|
@@ -67,7 +128,7 @@ export const saveTaskDetailEpic = (actions$, state$, zeniAPI) => actions$.pipe(f
|
|
|
67
128
|
error: createZeniAPIStatus('Unexpected Error', 'Save Task REST API call errored out' + JSON.stringify(error)),
|
|
68
129
|
}))));
|
|
69
130
|
}));
|
|
70
|
-
const prepareTaskPayload = (state, taskId,
|
|
131
|
+
const prepareTaskPayload = (state, taskId, taskGroupIds) => {
|
|
71
132
|
const { taskDetailState, taskState } = state;
|
|
72
133
|
const syncToken = taskId != null ? (getTaskById(taskState, taskId)?.syncToken ?? '') : '';
|
|
73
134
|
const sourceState = taskId != null
|
|
@@ -96,7 +157,7 @@ const prepareTaskPayload = (state, taskId, taskGroupId) => {
|
|
|
96
157
|
? (localData.recurringDaysOfWeek ?? [])
|
|
97
158
|
: [],
|
|
98
159
|
sync_token: syncToken,
|
|
99
|
-
task_group_ids:
|
|
160
|
+
task_group_ids: taskGroupIds,
|
|
100
161
|
time_spent: convertHHMMStrToMinutes(localData.timeSpent),
|
|
101
162
|
group_assignees: localData.groupAssignees,
|
|
102
163
|
...(taskId == null ? { is_private: localData.isPrivate ?? false } : {}),
|
|
@@ -6,6 +6,11 @@ import { initialTaskDetail, } from './taskDetail';
|
|
|
6
6
|
export const initialState = {
|
|
7
7
|
newTaskState: initialTaskDetail,
|
|
8
8
|
editTaskStateById: {},
|
|
9
|
+
subTaskCreateStatus: {
|
|
10
|
+
fetchState: 'Not-Started',
|
|
11
|
+
error: undefined,
|
|
12
|
+
},
|
|
13
|
+
subTaskListFetchStatusByParentId: {},
|
|
9
14
|
taskHistoryById: {},
|
|
10
15
|
};
|
|
11
16
|
const taskDetailView = createSlice({
|
|
@@ -50,7 +55,7 @@ const taskDetailView = createSlice({
|
|
|
50
55
|
updateEditTaskFetchStatus(draft, action) {
|
|
51
56
|
const { taskId, fetchState, error } = action.payload;
|
|
52
57
|
draft.editTaskStateById[taskId] = {
|
|
53
|
-
...draft.editTaskStateById[taskId],
|
|
58
|
+
...(draft.editTaskStateById[taskId] ?? initialTaskDetail),
|
|
54
59
|
fetchTaskStatus: {
|
|
55
60
|
fetchState,
|
|
56
61
|
error: fetchState === 'Error' ? error : undefined,
|
|
@@ -318,10 +323,59 @@ const taskDetailView = createSlice({
|
|
|
318
323
|
error,
|
|
319
324
|
};
|
|
320
325
|
},
|
|
326
|
+
createSubTask: {
|
|
327
|
+
reducer(draft) {
|
|
328
|
+
draft.subTaskCreateStatus = {
|
|
329
|
+
fetchState: 'In-Progress',
|
|
330
|
+
error: undefined,
|
|
331
|
+
};
|
|
332
|
+
},
|
|
333
|
+
prepare(payload) {
|
|
334
|
+
return { payload };
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
createSubTaskSuccessOrFailure(draft, action) {
|
|
338
|
+
const { fetchState, error } = action.payload;
|
|
339
|
+
draft.subTaskCreateStatus = {
|
|
340
|
+
fetchState,
|
|
341
|
+
error,
|
|
342
|
+
};
|
|
343
|
+
},
|
|
344
|
+
resetSubTaskCreateStatus(draft) {
|
|
345
|
+
draft.subTaskCreateStatus = {
|
|
346
|
+
...initialState.subTaskCreateStatus,
|
|
347
|
+
};
|
|
348
|
+
},
|
|
349
|
+
fetchSubTasks: {
|
|
350
|
+
reducer(draft, action) {
|
|
351
|
+
const { parentTaskId } = action.payload;
|
|
352
|
+
draft.subTaskListFetchStatusByParentId[parentTaskId] = {
|
|
353
|
+
fetchState: 'In-Progress',
|
|
354
|
+
error: undefined,
|
|
355
|
+
};
|
|
356
|
+
},
|
|
357
|
+
prepare(parentTaskId) {
|
|
358
|
+
return { payload: { parentTaskId } };
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
updateSubTasks(draft, action) {
|
|
362
|
+
const { parentTaskId } = action.payload;
|
|
363
|
+
draft.subTaskListFetchStatusByParentId[parentTaskId] = {
|
|
364
|
+
fetchState: 'Completed',
|
|
365
|
+
error: undefined,
|
|
366
|
+
};
|
|
367
|
+
},
|
|
368
|
+
updateSubTasksFetchStatus(draft, action) {
|
|
369
|
+
const { parentTaskId, fetchState, error } = action.payload;
|
|
370
|
+
draft.subTaskListFetchStatusByParentId[parentTaskId] = {
|
|
371
|
+
fetchState,
|
|
372
|
+
error,
|
|
373
|
+
};
|
|
374
|
+
},
|
|
321
375
|
clearTaskDetail(draft) {
|
|
322
376
|
Object.assign(draft, initialState);
|
|
323
377
|
},
|
|
324
378
|
},
|
|
325
379
|
});
|
|
326
|
-
export const { fetchTaskDetailPage, fetchTaskDetail, initializeTaskToLocalStore, updateEditTaskFetchStatus, saveTaskUpdatesToLocalStore, discardTaskUpdatesInLocalStore, saveTaskDetail, archiveTask, archiveTaskSuccessOrFailure, saveTaskSuccessOrFailure, deleteTask, removeTaskDetail, deleteTaskSuccessOrFailure, snoozeTask, snoozeTaskSuccessOrFailure, unsnoozeTask, fetchTaskHistory, updateTaskHistory, updateTaskHistoryFetchStatus, clearTaskDetail, updateCreatedTagToLocalStore, updateDeletedTagToLocalStore, } = taskDetailView.actions;
|
|
380
|
+
export const { fetchTaskDetailPage, fetchTaskDetail, initializeTaskToLocalStore, updateEditTaskFetchStatus, saveTaskUpdatesToLocalStore, discardTaskUpdatesInLocalStore, saveTaskDetail, archiveTask, archiveTaskSuccessOrFailure, saveTaskSuccessOrFailure, createSubTask, createSubTaskSuccessOrFailure, resetSubTaskCreateStatus, fetchSubTasks, updateSubTasks, updateSubTasksFetchStatus, deleteTask, removeTaskDetail, deleteTaskSuccessOrFailure, snoozeTask, snoozeTaskSuccessOrFailure, unsnoozeTask, fetchTaskHistory, updateTaskHistory, updateTaskHistoryFetchStatus, clearTaskDetail, updateCreatedTagToLocalStore, updateDeletedTagToLocalStore, } = taskDetailView.actions;
|
|
327
381
|
export default taskDetailView.reducer;
|