tasks-list.js 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814
  1. /**
  2. * Tasks home / context block: status sections of tasks, a tasklists sidebar,
  3. * saved + ad-hoc filters and an inline task page (URL-synced on web).
  4. *
  5. * Helpers and sub-panels live in `./tasks/`.
  6. */
  7. import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react';
  8. import { Platform } from 'react-native';
  9. import { useTranslation } from 'react-i18next';
  10. import { View, Row, Pressable } from 'app/design/view';
  11. import { Text } from 'app/design/typography';
  12. import { BlockWrapper } from 'app/components/block-wrapper';
  13. import BrowseSimple from 'app/components/elements/browse-simple';
  14. import { components } from 'app/components/registry';
  15. import { Modal, NeoButton } from 'app/design/controls';
  16. import DropdownMenu from 'app/ui/atoms/dropdown-menu';
  17. import { Icon } from 'app/ui/atoms/icon';
  18. import { BlockByDataInt as BlockByData } from 'app/components/block';
  19. import { fetcher } from 'app/lib/fetcher';
  20. import { appSetting, cn, getLayoutName, getPageContentWidth, getPageData } from 'app/lib/util';
  21. import { redirectTo, useRouter } from 'app/lib/hooks/router';
  22. import emitter, { EVENTS } from 'app/context/emitter';
  23. import { useIsDesktop } from 'app/context/measure';
  24. import Confirm from 'app/ui/molecules/dialogs/confirm';
  25. import { isFormResponseComplete } from 'app/lib/form/form-helpers';
  26. import { iconForTaskStatus } from 'app/lib/tasks-meta';
  27. import { Loading } from 'app/customization/loading';
  28. import { NO_CONFIRM, MenuObjectActions, runTaskAction } from './tasks/actions';
  29. import TasklistsPanel from './tasks/tasklists-panel';
  30. import TasksFilterSelector from './tasks/filter-selector';
  31. import {
  32. TASKS_HOME_PATH,
  33. TASKS_LIST_EVENT,
  34. apiUrl,
  35. buildContextBrowseUrl,
  36. buildTasklistBrowseUrl,
  37. buildTasksHomeUrl,
  38. emitTasksListRefresh,
  39. extractContextId,
  40. applyTasklistProgress,
  41. extractTasklists,
  42. fetchTasklistGroups,
  43. findTasklist,
  44. findTasklistGroup,
  45. findParentTasklistGroup,
  46. findTasklistGroupByLists,
  47. findContextTitleInRawLists,
  48. contextTitleFromPayload,
  49. resolveTasksOriginTitle,
  50. flattenListsWithTasks,
  51. getBrowserLocation,
  52. getFormNamesFromBlock,
  53. groupTasksByStatus,
  54. hasListId,
  55. isProfileTasksBrowse,
  56. isSameListId,
  57. isTasklistGroup,
  58. isTaskViewPath,
  59. isTasksHomePath,
  60. isTasksHomeUri,
  61. listIdOrNull,
  62. normalizeTasksListPayload,
  63. patchTaskInLists,
  64. pickBrowseContext,
  65. subscribeBrowserLocation,
  66. tasksHomeQueryFromBrowser,
  67. tasksListTaskFromHistoryState,
  68. taskViewHref,
  69. unwrapTasksListPayload,
  70. usableContextId,
  71. } from './tasks/helpers';
  72. import { loadTaskTimerSessions } from 'app/lib/task-timer-sync';
  73. const isWeb = Platform.OS === 'web';
  74. /* ------------------------------------------------------------------ */
  75. /* Helpers */
  76. /* ------------------------------------------------------------------ */
  77. function filterDropdownItems(filters) {
  78. return (filters?.values || [])
  79. .map((item, index) => {
  80. if (item?.type === 'group_header') {
  81. return { id: `group-${item.value}-${index}`, type: 'group_header', title: item.value };
  82. }
  83. if (item?.key == null) return null;
  84. return { id: item.key, name: String(item.key), title: item.value };
  85. })
  86. .filter(Boolean);
  87. }
  88. /** Browse URL for what is on screen: a tasklist, a context, or the block's own list. */
  89. function resolveListRequestUrl({ browseContext, contextPid, listId, listContextId, fallback }) {
  90. if (hasListId(listId)) {
  91. const url = buildTasklistBrowseUrl(browseContext, listId, listContextId ?? contextPid);
  92. if (url) return url;
  93. }
  94. if (contextPid) {
  95. const url = buildContextBrowseUrl(browseContext, contextPid, listId);
  96. if (url) return url;
  97. }
  98. return fallback;
  99. }
  100. function mergeListsData(prev, next, listId) {
  101. const merged = { ...(prev || {}), ...next };
  102. if (prev?.request_url) merged.request_url = prev.request_url;
  103. if (!hasListId(listId)) {
  104. delete merged.tasklist_id;
  105. delete merged.selected_tasklist_id;
  106. }
  107. return merged;
  108. }
  109. function navigateToPage(router, dest) {
  110. if (!dest) return;
  111. if (isWeb) {
  112. router.push(dest.startsWith('/') ? dest : `/${dest}`);
  113. return;
  114. }
  115. redirectTo(router, dest);
  116. }
  117. /* ------------------------------------------------------------------ */
  118. /* Sub-views */
  119. /* ------------------------------------------------------------------ */
  120. function TaskBreadcrumb({ originTitle, taskTitle, onBack }) {
  121. const { t } = useTranslation();
  122. const sourceTitle = originTitle || t('My Tasks');
  123. return (
  124. <Row
  125. className="items-center flex-wrap min-w-0 gap-1.5 px-1"
  126. // `role`, not `accessibilityRole`: Android throws on roles outside its enum.
  127. role="navigation"
  128. accessibilityLabel={t('Breadcrumb')}
  129. >
  130. <Pressable
  131. onPress={onBack}
  132. className="min-w-0 max-w-[50%] rounded-md px-1 py-0.5 web:hover:bg-muted/50"
  133. accessibilityRole="link"
  134. accessibilityLabel={sourceTitle}
  135. >
  136. <Text numberOfLines={1} className="text-sm leading-5 text-muted-foreground web:hover:text-foreground">
  137. {sourceTitle}
  138. </Text>
  139. </Pressable>
  140. <Icon icon="ChevronRight" size={14} className="shrink-0 text-muted-foreground" />
  141. <Text numberOfLines={1} className="min-w-0 flex-1 text-sm leading-5 font-medium text-foreground">
  142. {taskTitle}
  143. </Text>
  144. </Row>
  145. );
  146. }
  147. function TaskPageView({ pageData, url, breadcrumb, fill }) {
  148. const TaskLayout = components['layout']['task'] || components['layout']['post'];
  149. const { layoutBlocks } = getLayoutName(pageData, pageData?.uri || 'item');
  150. const blocks = layoutBlocks || appSetting('layouts', pageData?.uri)?.blocks;
  151. const pageClasses = {
  152. contentWidth: getPageContentWidth('task', pageData?.uri),
  153. };
  154. if (!TaskLayout) return null;
  155. return (
  156. <TaskLayout
  157. url={url}
  158. layoutName="task"
  159. data={pageData}
  160. blocks={blocks}
  161. pageClasses={pageClasses}
  162. embedded
  163. fill={fill}
  164. breadcrumb={breadcrumb}
  165. />
  166. );
  167. }
  168. function ActiveTaskView({ task, pageData, loading, onBack, originTitle, fill }) {
  169. const { t } = useTranslation();
  170. const NoContent = components['molecule']['no_content'];
  171. const breadcrumb = (
  172. <TaskBreadcrumb
  173. originTitle={originTitle}
  174. taskTitle={task.title || t('Task')}
  175. onBack={onBack}
  176. />
  177. );
  178. if (loading) {
  179. return (
  180. <View className={cn('w-full p-4', fill && 'flex h-full min-h-0 flex-1 flex-col')}>
  181. <View className="shrink-0 pb-3">{breadcrumb}</View>
  182. <View className="items-center justify-center py-12">
  183. <Loading />
  184. </View>
  185. </View>
  186. );
  187. }
  188. if (!pageData) {
  189. return (
  190. <View className="w-full p-4">
  191. <View className="pb-3">{breadcrumb}</View>
  192. <NoContent endpoint={{ request_url: task.url, params: {} }} />
  193. </View>
  194. );
  195. }
  196. return <TaskPageView pageData={pageData} url={task.url} breadcrumb={breadcrumb} fill={fill} />;
  197. }
  198. function StatusSection({ list }) {
  199. const tasks = list.tasks || [];
  200. return (
  201. <View className="gap-1">
  202. <Row className={appSetting('tasks', 'section_title')}>
  203. <Icon icon={iconForTaskStatus(list.list_title)} size={16} className="text-foreground shrink-0" />
  204. <Text className="text-base font-semibold tracking-tight text-foreground">
  205. {list.list_title}
  206. {tasks.length ? ` · ${tasks.length}` : ''}
  207. </Text>
  208. </Row>
  209. {tasks.length ? (
  210. <BrowseSimple
  211. data={{ data: tasks, unit: 'general-content-list', module: 'bx_tasks' }}
  212. blockWrapperProps={{ block: { designbox_id: null } }}
  213. />
  214. ) : null}
  215. </View>
  216. );
  217. }
  218. function filterTriggerButtonProps(label) {
  219. return {
  220. style: 'glass',
  221. controlSize: 'small',
  222. borderShape: 'capsule',
  223. image: 'ListFilter',
  224. label,
  225. accessibilityLabel: label,
  226. };
  227. }
  228. /** Saved-filters dropdown + ad-hoc filter chips. */
  229. function FiltersToolbar({ filters, filterSelectionNonce, onApplyFilter, onSaveFilter, onApplied }) {
  230. const { t } = useTranslation();
  231. const items = filterDropdownItems(filters);
  232. const selectable = items.filter((item) => item.type !== 'group_header');
  233. const selected = selectable.find((item) => item.id === filters?.value)
  234. || selectable.find((item) => item.id === 0)
  235. || selectable[0];
  236. const filterLabel = selected?.title || t('Filter');
  237. if (!filters.request_url_add) {
  238. if (!selectable.length) {
  239. return <NeoButton {...filterTriggerButtonProps(t('Filter'))} />;
  240. }
  241. return (
  242. <DropdownMenu
  243. items={items}
  244. onSelect={onApplyFilter}
  245. stacked
  246. buttonProps={filterTriggerButtonProps(filterLabel)}
  247. />
  248. );
  249. }
  250. return (
  251. <Row className="flex-wrap items-center gap-2 ">
  252. {selectable.length > 1 ? (
  253. <DropdownMenu
  254. items={items}
  255. onSelect={onApplyFilter}
  256. stacked
  257. buttonProps={filterTriggerButtonProps(selected?.title)}
  258. />
  259. ) : null}
  260. <TasksFilterSelector
  261. requestUrlAdd={filters.request_url_add}
  262. requestUrlApply={filters.request_url_apply}
  263. requestUrlSave={filters.request_url_save}
  264. savedFilterId={filters.value}
  265. filterSelectionNonce={filterSelectionNonce}
  266. onSave={onSaveFilter}
  267. onApplied={onApplied}
  268. />
  269. </Row>
  270. );
  271. }
  272. /* ------------------------------------------------------------------ */
  273. /* Block */
  274. /* ------------------------------------------------------------------ */
  275. export default function ElementTasksList({ data, blockWrapperProps }) {
  276. const pageTitle = blockWrapperProps?.extraProps?.pageTitle;
  277. const pageContext = blockWrapperProps?.extraProps?.pageContext;
  278. const { t } = useTranslation();
  279. const isDesktop = useIsDesktop();
  280. const router = useRouter();
  281. const NoContent = components['molecule']['no_content'];
  282. const [listsData, setListsData] = useState(data);
  283. const [rawTasklists, setRawTasklists] = useState(() => extractTasklists(data));
  284. // Progress (done / total) comes from whichever browse lists are loaded; derive
  285. // it instead of syncing state so sidebar and list never disagree.
  286. const tasklists = useMemo(
  287. () => applyTasklistProgress(rawTasklists, listsData?.lists),
  288. [rawTasklists, listsData?.lists]
  289. );
  290. const [selectedTasklistId, setSelectedTasklistId] = useState(
  291. () => data?.tasklist_id ?? data?.selected_tasklist_id ?? listIdOrNull(tasksHomeQueryFromBrowser().listId)
  292. );
  293. const [selectedContextPid, setSelectedContextPid] = useState(() => tasksHomeQueryFromBrowser().contextPid);
  294. const [rememberedContextTitle, setRememberedContextTitle] = useState(null);
  295. const [activeTask, setActiveTask] = useState(null);
  296. const [taskPage, setTaskPage] = useState(null); // { url, data } for the last loaded task
  297. const [formBlock, setFormBlock] = useState(null);
  298. const [showConfirm, setShowConfirm] = useState(NO_CONFIRM);
  299. const [filterSelectionNonce, setFilterSelectionNonce] = useState(0);
  300. const taskRequestIdRef = useRef(0);
  301. const contextRequestIdRef = useRef(0);
  302. const listUrlRef = useRef(getBrowserLocation());
  303. // Read by `applyContextQuery` (stable across tasklist changes on purpose —
  304. // it is a dep of the load effect and must not refetch when groups arrive).
  305. const tasklistsRef = useRef(tasklists);
  306. // Set while this component itself calls pushState/replaceState so the
  307. // location subscription ignores its own writes (otherwise every tasklist
  308. // click fetched twice: once from the handler, once from the caller).
  309. const ownHistoryRef = useRef(false);
  310. useEffect(() => {
  311. tasklistsRef.current = tasklists;
  312. }, [tasklists]);
  313. // Fresh block data (page reload) replaces whatever was fetched since.
  314. const [seenData, setSeenData] = useState(data);
  315. if (seenData !== data) {
  316. setSeenData(data);
  317. setListsData(data);
  318. setRawTasklists(extractTasklists(data));
  319. }
  320. const writeOwnHistory = useCallback((method, state, url) => {
  321. ownHistoryRef.current = true;
  322. try {
  323. window.history[method](state, '', url);
  324. } finally {
  325. ownHistoryRef.current = false;
  326. }
  327. }, []);
  328. const browseContext = pickBrowseContext(data, listsData);
  329. const requestUrl = resolveListRequestUrl({
  330. browseContext,
  331. contextPid: selectedContextPid,
  332. listId: selectedTasklistId,
  333. listContextId: findTasklist(tasklists, selectedTasklistId)?.context_id,
  334. fallback: listsData?.request_url ?? data?.request_url,
  335. });
  336. const taskPageData = activeTask && taskPage?.url === activeTask.url ? taskPage.data : null;
  337. const taskPageLoading = !!activeTask && taskPage?.url !== activeTask.url;
  338. const originContextPid = usableContextId(selectedContextPid)
  339. ?? usableContextId(isWeb ? tasksHomeQueryFromBrowser().contextPid : null)
  340. ?? (isProfileTasksBrowse(listsData || data) ? null : extractContextId(listsData || data));
  341. const originTitle = resolveTasksOriginTitle({
  342. tasklists,
  343. rawLists: data?.lists,
  344. browseLists: originContextPid ? listsData?.lists : null,
  345. contextPid: originContextPid,
  346. listId: selectedTasklistId,
  347. contextTitle: originContextPid ? rememberedContextTitle : null,
  348. pageTitle,
  349. pageContext,
  350. fallback: t('My Tasks'),
  351. });
  352. /* ---- inline task page ------------------------------------------ */
  353. const openTask = useCallback((task, { skipHistory = false } = {}) => {
  354. if (!task?.url) return;
  355. const next = { url: task.url, title: task.title || task.name || '' };
  356. // Put the task's own URL in the bar so it can be copied / shared.
  357. // Origin list is kept in history state; Back and conductor tabs restore it.
  358. if (isWeb && !skipHistory) {
  359. const loc = getBrowserLocation();
  360. if (!isTaskViewPath(loc)) listUrlRef.current = loc;
  361. const dest = taskViewHref(next.url) || loc;
  362. const state = {
  363. ...(window.history.state || {}),
  364. tasksListTask: next,
  365. tasksListOrigin: listUrlRef.current || loc,
  366. };
  367. writeOwnHistory(window.history.state?.tasksListTask ? 'replaceState' : 'pushState', state, dest);
  368. }
  369. setActiveTask(next);
  370. }, [writeOwnHistory]);
  371. const closeTask = useCallback(({ restoreUrl = true, refresh = false } = {}) => {
  372. const shouldBack = restoreUrl && isWeb && !!window.history.state?.tasksListTask;
  373. taskRequestIdRef.current += 1;
  374. setActiveTask(null);
  375. setTaskPage(null);
  376. if (refresh) emitTasksListRefresh();
  377. if (shouldBack) window.history.back();
  378. }, []);
  379. useEffect(() => {
  380. const url = activeTask?.url;
  381. if (!url) return;
  382. const requestId = ++taskRequestIdRef.current;
  383. getPageData(url, false)
  384. .then((response) => ({ url, data: response?.data || null }))
  385. .catch(() => ({ url, data: null }))
  386. .then((page) => {
  387. if (requestId === taskRequestIdRef.current) setTaskPage(page);
  388. });
  389. }, [activeTask?.url]);
  390. /* ---- context / tasklist selection ------------------------------ */
  391. const applyContextQuery = useCallback(async (contextPid, listId) => {
  392. const requestId = ++contextRequestIdRef.current;
  393. setSelectedContextPid(contextPid || null);
  394. setSelectedTasklistId(listIdOrNull(listId));
  395. if (!contextPid) {
  396. setRememberedContextTitle(null);
  397. } else {
  398. const known = findTasklistGroup(tasklistsRef.current, contextPid);
  399. if (known?.title) setRememberedContextTitle(known.title);
  400. }
  401. const alreadyLoaded = !contextPid || (
  402. String(usableContextId(data?.context_id)) === String(contextPid)
  403. && !isProfileTasksBrowse(data)
  404. && !hasListId(listId)
  405. );
  406. if (alreadyLoaded) {
  407. setListsData(data);
  408. return;
  409. }
  410. const url = buildContextBrowseUrl(pickBrowseContext(data), contextPid, listId);
  411. if (!url) return;
  412. const response = await fetcher(apiUrl(url));
  413. if (requestId !== contextRequestIdRef.current) return;
  414. const next = unwrapTasksListPayload(response);
  415. if (next) {
  416. const title = findTasklistGroup(tasklistsRef.current, contextPid)?.title
  417. || findTasklistGroupByLists(tasklistsRef.current, next.lists)?.title
  418. || contextTitleFromPayload(next, response?.data)
  419. || findContextTitleInRawLists(data?.lists, contextPid);
  420. if (title) setRememberedContextTitle(title);
  421. setListsData((prev) => mergeListsData(prev, next, listId));
  422. }
  423. }, [data]);
  424. useEffect(() => {
  425. let cancelled = false;
  426. const load = async () => {
  427. if (isProfileTasksBrowse(data)) {
  428. const groups = extractTasklists(data);
  429. const needsChildren = !groups.length
  430. || groups.some((group) => isTasklistGroup(group) && !group.children.length);
  431. if (needsChildren) {
  432. const fetched = await fetchTasklistGroups(data);
  433. if (!cancelled) setRawTasklists(fetched);
  434. }
  435. }
  436. if (cancelled) return;
  437. const { contextPid, listId } = tasksHomeQueryFromBrowser();
  438. if (contextPid || hasListId(listId)) await applyContextQuery(contextPid, listId);
  439. };
  440. load();
  441. return () => {
  442. cancelled = true;
  443. contextRequestIdRef.current += 1;
  444. };
  445. }, [data, applyContextQuery]);
  446. // No effect syncing `rememberedContextTitle` from `tasklists`: resolveTasksOriginTitle
  447. // already prefers the live group title; the remembered one is only the fallback
  448. // set at selection / fetch time for groups the sidebar does not list.
  449. // Effect event: always sees the latest activeTask / selection without
  450. // re-subscribing, and without writing refs during render.
  451. const onBrowserLocation = useEffectEvent((event) => {
  452. if (ownHistoryRef.current) return;
  453. const url = event.url || getBrowserLocation();
  454. const state = event.state ?? window.history.state;
  455. const queuedTask = tasksListTaskFromHistoryState(state);
  456. if (queuedTask) {
  457. if (activeTask?.url !== queuedTask.url) {
  458. openTask(queuedTask, { skipHistory: true });
  459. }
  460. return;
  461. }
  462. if (isTaskViewPath(url)) return;
  463. // Next.js replaceState on the same list URL is not a user navigation.
  464. // Conductor tab clicks use pushState, including My Tasks while a task is open.
  465. if (activeTask && url === listUrlRef.current && event.method === 'replaceState') {
  466. return;
  467. }
  468. if (activeTask) {
  469. closeTask({ restoreUrl: false, refresh: true });
  470. }
  471. if (!isTasksHomeUri(url) && !isTasksHomePath()) return;
  472. const { contextPid, listId } = tasksHomeQueryFromBrowser();
  473. const nextListId = listIdOrNull(listId);
  474. const sameContext = String(selectedContextPid || '') === String(contextPid || '');
  475. const sameList = (
  476. (!hasListId(selectedTasklistId) && !hasListId(nextListId))
  477. || isSameListId(selectedTasklistId, nextListId)
  478. );
  479. if (!sameContext || !sameList) applyContextQuery(contextPid, listId);
  480. });
  481. useEffect(() => {
  482. if (!isWeb) return;
  483. return subscribeBrowserLocation((event) => onBrowserLocation(event));
  484. }, []);
  485. const selectTasklist = (item) => {
  486. if (item?.id == null) return;
  487. closeTask({ restoreUrl: false });
  488. const parentGroup = isTasklistGroup(item)
  489. ? item
  490. : (findParentTasklistGroup(tasklists, item.id) || findTasklistGroup(tasklists, item.context_id));
  491. if (parentGroup?.title) setRememberedContextTitle(parentGroup.title);
  492. const contextId = item.context_id
  493. ?? parentGroup?.context_id
  494. ?? parentGroup?.id
  495. ?? (isTasklistGroup(item) ? item.id : null)
  496. ?? extractContextId(browseContext);
  497. const listId = isTasklistGroup(item) ? null : item.id;
  498. const alreadySelectedList = !isTasklistGroup(item)
  499. && isSameListId(selectedTasklistId, listId)
  500. && (item.context_id == null || selectedContextPid == null
  501. || String(item.context_id) === String(selectedContextPid));
  502. const alreadySelectedGroup = isTasklistGroup(item)
  503. && String(item.id) === String(selectedContextPid)
  504. && !hasListId(selectedTasklistId);
  505. if (alreadySelectedGroup) {
  506. if (!isTasksHomePath()) {
  507. navigateToPage(router, TASKS_HOME_PATH);
  508. return;
  509. }
  510. if (getBrowserLocation() !== TASKS_HOME_PATH) {
  511. writeOwnHistory('pushState', { tasksHomeQuery: true }, TASKS_HOME_PATH);
  512. }
  513. applyContextQuery(null, null);
  514. return;
  515. }
  516. if (alreadySelectedList) {
  517. const dest = buildTasksHomeUrl(contextId, null);
  518. if (!dest) return;
  519. if (!isTasksHomePath()) {
  520. navigateToPage(router, dest);
  521. return;
  522. }
  523. if (getBrowserLocation() !== dest) {
  524. writeOwnHistory('pushState', { tasksHomeQuery: true }, dest);
  525. }
  526. applyContextQuery(contextId, null);
  527. return;
  528. }
  529. const dest = buildTasksHomeUrl(contextId, listId);
  530. if (!dest) return;
  531. if (!isTasksHomePath()) {
  532. navigateToPage(router, dest);
  533. return;
  534. }
  535. if (getBrowserLocation() === dest) {
  536. setSelectedContextPid(contextId || null);
  537. setSelectedTasklistId(listIdOrNull(listId));
  538. return;
  539. }
  540. writeOwnHistory('pushState', { tasksHomeQuery: true }, dest);
  541. applyContextQuery(contextId, listId);
  542. };
  543. /* ---- refresh + events ------------------------------------------ */
  544. const refreshLists = useCallback(async () => {
  545. if (!requestUrl) return;
  546. const response = await fetcher(apiUrl(requestUrl));
  547. setListsData((prev) => {
  548. const next = normalizeTasksListPayload(response, prev);
  549. if (prev?.request_url && selectedTasklistId != null) next.request_url = prev.request_url;
  550. return next;
  551. });
  552. }, [requestUrl, selectedTasklistId]);
  553. useEffect(() => {
  554. loadTaskTimerSessions();
  555. }, []);
  556. useEffect(() => {
  557. const subscription = emitter.addListener(TASKS_LIST_EVENT, (event) => {
  558. if (event.action === 'reload') refreshLists();
  559. if (event.action === 'patch') {
  560. setListsData((prev) => {
  561. if (!prev?.lists) return prev;
  562. const lists = patchTaskInLists(prev.lists, event);
  563. return lists === prev.lists ? prev : { ...prev, lists };
  564. });
  565. }
  566. if (event.action === 'open' && event.url) openTask({ url: event.url, title: event.title });
  567. if (event.action === 'close') closeTask();
  568. });
  569. return () => subscription.remove();
  570. }, [refreshLists, openTask, closeTask]);
  571. /* ---- modal forms (task add / edit, save filter) ---------------- */
  572. const isSaveFilterForm = !!formBlock?._isSaveFilter;
  573. const finishFormBlock = useCallback(() => {
  574. setFormBlock(null);
  575. if (isSaveFilterForm) setFilterSelectionNonce((nonce) => nonce + 1);
  576. emitTasksListRefresh();
  577. }, [isSaveFilterForm]);
  578. useEffect(() => {
  579. const formNames = getFormNamesFromBlock(formBlock);
  580. if (!formNames.length) return;
  581. const subscriptions = formNames.map((formName) =>
  582. emitter.addListener(EVENTS.form(formName), (event) => {
  583. if (event.action === 'received' && isFormResponseComplete(event.data)) finishFormBlock();
  584. })
  585. );
  586. return () => subscriptions.forEach((subscription) => subscription.remove());
  587. }, [formBlock, finishFormBlock]);
  588. /* ---- filters ---------------------------------------------------- */
  589. const filters = listsData?.filters;
  590. const applySavedFilter = async (item) => {
  591. if (!filters?.request_url_apply) return;
  592. await fetcher(apiUrl(filters.request_url_apply + item.id));
  593. setFilterSelectionNonce((nonce) => nonce + 1);
  594. await refreshLists();
  595. };
  596. const openSaveFilter = async () => {
  597. if (!filters?.request_url_save) return;
  598. const response = await fetcher(apiUrl(filters.request_url_save));
  599. setFormBlock({ content: response.data, designbox_id: 0, title: t('Save'), _isSaveFilter: true });
  600. };
  601. /* ---- render ----------------------------------------------------- */
  602. const statusLists = groupTasksByStatus(flattenListsWithTasks(listsData?.lists || []));
  603. const topActions = listsData?.actions || [];
  604. const modalActions = topActions.filter((action) => action.type === 'modal');
  605. const menuActions = topActions.filter((action) => action.type === 'menu');
  606. const hasHeaderActions = modalActions.length > 0 || menuActions.length > 0;
  607. const actionHandlers = { setFormBlock, setShowConfirm };
  608. const showTasklists = tasklists.length > 0;
  609. const fillViewport = Boolean(blockWrapperProps?.fill) || (isWeb && isTasksHomePath());
  610. const hasToolbar = !!(filters || hasHeaderActions);
  611. const confirmAndForm = (
  612. <>
  613. <Confirm
  614. onVisible={showConfirm.show}
  615. title={showConfirm.title || t('Are you sure?')}
  616. handleCancel={() => setShowConfirm(NO_CONFIRM)}
  617. handleOk={async () => {
  618. const cb = showConfirm.cb;
  619. setShowConfirm(NO_CONFIRM);
  620. if (cb) await cb();
  621. }}
  622. />
  623. {formBlock ? (
  624. <Modal
  625. title={formBlock.title || ' '}
  626. onVisible={!!formBlock}
  627. onClose={() => setFormBlock(null)}
  628. scrollable
  629. transparent
  630. >
  631. <View className="px-4">
  632. <BlockByData block={formBlock} onFormEmpty={finishFormBlock} />
  633. </View>
  634. </Modal>
  635. ) : null}
  636. </>
  637. );
  638. if (activeTask) {
  639. return (
  640. <BlockWrapper
  641. {...blockWrapperProps}
  642. fill={fillViewport || blockWrapperProps?.fill}
  643. showPadding={!fillViewport}
  644. clip={fillViewport}
  645. >
  646. {confirmAndForm}
  647. <ActiveTaskView
  648. task={activeTask}
  649. pageData={taskPageData}
  650. loading={taskPageLoading}
  651. originTitle={originTitle}
  652. fill={fillViewport}
  653. onBack={() => closeTask({ refresh: true })}
  654. />
  655. </BlockWrapper>
  656. );
  657. }
  658. return (
  659. <BlockWrapper
  660. {...blockWrapperProps}
  661. fill={fillViewport || blockWrapperProps?.fill}
  662. showPadding={!fillViewport}
  663. clip={!fillViewport}
  664. >
  665. {confirmAndForm}
  666. {/* Spacing between toolbar / tasklists / sections: tasks-home pads
  667. each part itself (p-4, px-4 pb-4); the embedded block uses gaps. */}
  668. <View className={cn('w-full', fillViewport ? 'h-full min-h-0 flex-1' : 'gap-4')}>
  669. {hasToolbar ? (
  670. <Row className={cn(
  671. 'flex-wrap items-center justify-between gap-3',
  672. fillViewport && 'shrink-0 p-4',
  673. )}>
  674. {filters ? (
  675. <FiltersToolbar
  676. filters={filters}
  677. filterSelectionNonce={filterSelectionNonce}
  678. onApplyFilter={applySavedFilter}
  679. onSaveFilter={openSaveFilter}
  680. onApplied={emitTasksListRefresh}
  681. />
  682. ) : (
  683. <View />
  684. )}
  685. {hasHeaderActions ? (
  686. <Row className="flex-wrap items-center gap-2">
  687. {modalActions.map((action) => (
  688. <NeoButton
  689. key={action.name}
  690. style="glass"
  691. controlSize="small"
  692. label={action.title}
  693. onPress={() => runTaskAction(action, actionHandlers)}
  694. />
  695. ))}
  696. <MenuObjectActions actions={menuActions} setShowConfirm={setShowConfirm} />
  697. </Row>
  698. ) : null}
  699. </Row>
  700. ) : null}
  701. <View className={cn(
  702. 'w-full ',
  703. showTasklists && 'flex flex-col lg:flex-row',
  704. showTasklists && (fillViewport ? 'lg:items-stretch' : 'lg:items-start gap-4'),
  705. fillViewport && 'min-h-0 flex-1',
  706. )}>
  707. {showTasklists ? (
  708. <View className={cn(
  709. 'order-1 w-full shrink-0 lg:order-2 lg:w-auto',
  710. fillViewport && 'lg:h-full lg:min-h-0 px-4 pb-4 lg:pl-0 lg:pr-4',
  711. )}>
  712. <TasklistsPanel
  713. tasklists={tasklists}
  714. selectedId={selectedTasklistId}
  715. selectedContextId={selectedContextPid}
  716. onSelect={selectTasklist}
  717. actionHandlers={actionHandlers}
  718. defaultCollapsed={!isDesktop}
  719. fill={fillViewport}
  720. />
  721. </View>
  722. ) : null}
  723. <View className={cn(
  724. 'min-w-0 gap-1',
  725. showTasklists && 'order-2 lg:order-1 flex-auto',
  726. fillViewport && 'min-h-0 flex-1 web:overflow-y-auto px-4 pb-4',
  727. fillViewport && !hasToolbar && 'pt-4',
  728. )}>
  729. {statusLists.length === 0 ? (
  730. <NoContent endpoint={{ request_url: listsData?.request_url, params: {} }} />
  731. ) : (
  732. statusLists.map((list) => <StatusSection key={list.list_id} list={list} />)
  733. )}
  734. </View>
  735. </View>
  736. </View>
  737. </BlockWrapper>
  738. );
  739. }