context.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477
  1. import { createContext, useContext, useRef } from 'react';
  2. import { createStore, useStore } from 'zustand';
  3. import { flattenPagesForUniList } from 'app/lib/browse-query';
  4. import { handleFormModal } from 'app/ui/molecules/dialogs/form-modal';
  5. import {
  6. applyGridActionCallback,
  7. applyMenuManageCallback,
  8. performCreditsCheckout,
  9. performGridAction,
  10. resolveActionPageUrl,
  11. toGridFormBlock,
  12. toIdsQuery,
  13. } from './actions';
  14. const CLOSED_CONFIRM = { show: false, cb: null };
  15. /** UNA switcher values flip between these pairs. */
  16. const SWITCHER_NEXT = { active: 'hidden', hidden: 'active', 0: '1', 1: '0' };
  17. /** Rewrite rows of every cached page of the grid's infinite query. */
  18. const patchCachedRows = ({ queryClient, queryKey }, mapRows) => {
  19. if (!queryClient) return;
  20. queryClient.setQueryData(queryKey, (old) => {
  21. if (!old?.pages?.length) return old;
  22. return {
  23. ...old,
  24. pages: old.pages.map((page) => ({
  25. ...page,
  26. data: mapRows(page.data ?? []),
  27. })),
  28. };
  29. });
  30. };
  31. /**
  32. * Grid state context. Unlike the app-wide zustand stores in `app/context`,
  33. * a page can render several grid blocks, so the store is created per provider
  34. * instead of at module level.
  35. *
  36. * Handlers read fresh state via `get()`, so components subscribe to values
  37. * only — never to whole handler dependency sets.
  38. */
  39. const createGridStore = (initial) => createStore((set, get) => ({
  40. /** Column tracks, table width, sortability and `field_id` — see `GridProvider`. */
  41. ...initial,
  42. /** Injected from React — see `useGridData`. */
  43. settings: {},
  44. queryAppend: {},
  45. queryClient: null,
  46. queryKey: [],
  47. reloadGrid: () => {},
  48. t: (key) => key,
  49. // --- rows and selection ------------------------------------------------
  50. // Rows are not mirrored into the store: delete / switcher toggle write
  51. // straight into the react-query cache, so the list has a single source of
  52. // truth and a local patch can never be lost when another page loads.
  53. /** Set of checked row ids — cells subscribe to `selected.has(id)` only. */
  54. selected: new Set(),
  55. /** Current rows across all loaded pages. */
  56. getRows: () => flattenPagesForUniList(get().queryClient?.getQueryData(get().queryKey)),
  57. /** Remove rows after delete / hide_row (does not wait for a reload). */
  58. deleteRows: (idsToRemove) => {
  59. patchCachedRows(get(), (rows) => rows.filter(
  60. (item) => !idsToRemove.some((id) => item.id == id)
  61. ));
  62. },
  63. /** Patch one row in the visible list (processed display cells only). */
  64. updateRow: (rowId, rowData) => {
  65. if (rowId == null || !rowData) return;
  66. const next = { ...rowData, id: rowData.id ?? rowId };
  67. patchCachedRows(get(), (rows) => rows.map((item) => (item.id == rowId ? next : item)));
  68. },
  69. toggleSelected: (id) => set((state) => {
  70. const selected = new Set(state.selected);
  71. if (!selected.delete(id)) {
  72. selected.add(id);
  73. }
  74. return { selected };
  75. }),
  76. getSelectedIds: () => Array.from(get().selected),
  77. // --- overlays ----------------------------------------------------------
  78. // Bottom-sheet form, Stripe / form modals, page-in-modal, confirm dialog
  79. // and message. Actions call the `open*` / `ask*` handlers; `GridModals`
  80. // renders the state.
  81. sheetBlock: null,
  82. formBlock: null,
  83. stripeCheckout: null,
  84. pageData: false,
  85. confirm: CLOSED_CONFIRM,
  86. message: null,
  87. openSheet: (sheetBlock) => set({ sheetBlock }),
  88. openForm: (formBlock) => set({ formBlock }),
  89. openStripe: (stripeCheckout) => set({ stripeCheckout }),
  90. closeStripe: () => set({ stripeCheckout: null }),
  91. setPageData: (pageData) => set({ pageData }),
  92. /** Form-modal close/reload: empty value means "done, refresh grid". */
  93. handlePageModalData: (value) => {
  94. if (!value) {
  95. set({ pageData: false });
  96. get().reloadGrid();
  97. return;
  98. }
  99. set({ pageData: value });
  100. },
  101. /** `GridModals` dismisses the bottom sheet when `sheetBlock` clears. */
  102. closeAll: () => set({ sheetBlock: null, formBlock: null }),
  103. /** After a form in a sheet/modal submits: close overlays and reload the grid. */
  104. closeAndReload: () => {
  105. setTimeout(() => {
  106. get().closeAll();
  107. get().reloadGrid();
  108. }, 100);
  109. },
  110. /** Destructive actions run `cb` only after the user confirms. */
  111. askConfirm: (cb) => set({ confirm: { show: true, cb } }),
  112. cancelConfirm: () => set({ confirm: CLOSED_CONFIRM }),
  113. acceptConfirm: () => {
  114. const { cb } = get().confirm;
  115. set({ confirm: CLOSED_CONFIRM });
  116. cb?.();
  117. },
  118. showMessage: (message) => set({ message }),
  119. hideMessage: () => set({ message: null }),
  120. // --- shared API --------------------------------------------------------
  121. /** Named TemplServiceGrid action (or custom callback URL). */
  122. runGridAction: async (action, params, callback) => {
  123. const { settings, queryAppend } = get();
  124. return await performGridAction({
  125. object: settings.object,
  126. queryAppend,
  127. action,
  128. params,
  129. callback,
  130. });
  131. },
  132. /** Stable wrapper so components never subscribe to the injected `reloadGrid`. */
  133. reload: () => get().reloadGrid(),
  134. /**
  135. * Row / independent `type=modal` and MultiAdd menu callbacks: URL-only
  136. * FormModal, or fetch form JSON (`action` or `callback`) into the sheet.
  137. */
  138. openGridModal: async (data) => {
  139. const { runGridAction, setPageData, handlePageModalData, openSheet } = get();
  140. const pageUrl = resolveActionPageUrl(data);
  141. if (pageUrl && !data.action && !data.callback) {
  142. setPageData('loading');
  143. await handleFormModal({ link: pageUrl }, null, handlePageModalData);
  144. return;
  145. }
  146. const fetchedData = await runGridAction(data.action, data.params, data.callback);
  147. const block = toGridFormBlock(fetchedData.data);
  148. openSheet({
  149. title: block.content[0]?.title || data.title,
  150. block,
  151. });
  152. },
  153. // --- bulk actions ------------------------------------------------------
  154. // UNA `actions.bulk`: calculate, delete, Stripe / credits checkout.
  155. // Handlers read the current selection from the store, so toolbar buttons
  156. // stay stable while rows are being checked.
  157. /** Confirm, then delete checked rows on the server. */
  158. deleteSelected: () => {
  159. get().askConfirm(() => {
  160. const { getSelectedIds, deleteRows, runGridAction } = get();
  161. const selected = getSelectedIds();
  162. deleteRows(selected);
  163. runGridAction('delete', '&' + toIdsQuery(selected));
  164. });
  165. },
  166. /** Bulk `calculate` — show total time from the msg block. */
  167. calculateSelected: async () => {
  168. const { getSelectedIds, runGridAction, showMessage } = get();
  169. const response = await runGridAction('calculate', '&' + toIdsQuery(getSelectedIds()));
  170. const content = Array.isArray(response?.data) ? response.data : [];
  171. const msgItem = content.find((item) => item?.type === 'msg');
  172. if (!msgItem?.data) return;
  173. showMessage('Calculated time: ' + msgItem.data.total_f);
  174. },
  175. /** Bulk checkout: Stripe widget or credits page-in-modal. */
  176. checkoutSelected: async (type, payment_type) => {
  177. const { getSelectedIds, settings, openStripe, runGridAction, showMessage, openForm, t } = get();
  178. const selected = getSelectedIds();
  179. if (type == 'stripe_v3') {
  180. openStripe({
  181. payment_type,
  182. seller_id: settings.query_append.seller_id,
  183. items: selected,
  184. });
  185. }
  186. if (type == 'credits') {
  187. await performCreditsCheckout({
  188. runGridAction,
  189. settings,
  190. selected,
  191. showMessage,
  192. openForm,
  193. t,
  194. });
  195. }
  196. },
  197. // --- row actions -------------------------------------------------------
  198. /** Row `type=modal` sheet / FormModal, or `type=object` Stripe checkout. */
  199. openRowAction: async (data) => {
  200. if (data.type == 'modal') {
  201. await get().openGridModal(data);
  202. }
  203. if (data.type == 'object') {
  204. get().openStripe({
  205. payment_type: data.payment_type,
  206. seller_id: data.seller_id,
  207. items: data.items,
  208. });
  209. }
  210. },
  211. /**
  212. * Row `callback` button: run the named action, then apply UNA's `on_callback`
  213. * (hide row, redirect, patch rows or reload). `redirectRef` points at the
  214. * <Redirect> rendered by that button.
  215. */
  216. runRowCallback: async (itemAction, id, redirectRef) => {
  217. const { runGridAction, deleteRows, updateRow, reload, askConfirm } = get();
  218. const run = async () => {
  219. const response = await runGridAction(
  220. itemAction.name,
  221. '&ids[]=' + itemAction.attr.bx_grid_action_data
  222. );
  223. await applyGridActionCallback({
  224. itemAction,
  225. response,
  226. id,
  227. deleteRows,
  228. updateRow,
  229. reloadGrid: reload,
  230. redirectRef,
  231. });
  232. };
  233. if (itemAction.confirm == '1') {
  234. askConfirm(run);
  235. return;
  236. }
  237. await run();
  238. },
  239. /** Manage-menu item (`display_type=callback`); `setHide` drops just the control. */
  240. runMenuCallback: async (oItem, id, setHide) => {
  241. if (oItem.display_type !== 'callback') return;
  242. const { deleteRows, updateRow, reload, askConfirm } = get();
  243. const run = () => applyMenuManageCallback({
  244. oItem,
  245. id,
  246. deleteRows,
  247. updateRow,
  248. reloadGrid: reload,
  249. setHide,
  250. });
  251. if (oItem.data.confirm == 1) {
  252. askConfirm(run);
  253. return;
  254. }
  255. await run();
  256. },
  257. /**
  258. * Enable/disable a row: optimistic immutable patch, then `enable` API + reload.
  259. *
  260. * The row is looked up by id, not by position — sorting, a delete or the
  261. * next loaded page can shift indexes between render and press.
  262. */
  263. toggleSwitch: async (id) => {
  264. const { getRows, updateRow, runGridAction, reloadGrid } = get();
  265. let bChecked = false;
  266. const currentItem = getRows().find((item) => item.id == id);
  267. if (currentItem?.switcher) {
  268. const nextData = SWITCHER_NEXT[currentItem.switcher.data];
  269. bChecked = nextData == 'active' || nextData == '1';
  270. updateRow(id, {
  271. ...currentItem,
  272. switcher: { ...currentItem.switcher, data: nextData },
  273. });
  274. }
  275. await runGridAction('enable', '&ids[]=' + id + (bChecked ? '&checked=1' : ''));
  276. reloadGrid();
  277. },
  278. /** Persist drag-reorder via `a=reorder`, then reload. */
  279. sortRows: (result) => {
  280. if (!result.destination) return;
  281. const { getRows, settings, runGridAction, reloadGrid } = get();
  282. const updatedData = [...getRows()];
  283. const [removed] = updatedData.splice(result.source.index, 1);
  284. updatedData.splice(result.destination.index, 0, removed);
  285. runGridAction('reorder', '&' + updatedData.map((item) => `${settings.object}_row[]=${item.id}`).join('&'));
  286. reloadGrid();
  287. },
  288. }));
  289. // --- column layout ---------------------------------------------------------
  290. // Flex-none columns need fixed width/minWidth so the header and every body row
  291. // share the same tracks (content-sized flex columns diverge per row and the
  292. // labels stop lining up).
  293. const CONTROL_COL_STYLE = {
  294. checkbox: { flexGrow: 0, flexShrink: 0, width: 44, minWidth: 44 },
  295. order: { flexGrow: 0, flexShrink: 0, width: 36, minWidth: 36 },
  296. switcher: { flexGrow: 0, flexShrink: 0, width: 64, minWidth: 64 },
  297. // Fixed track for row actions (right-aligned); header label stays empty.
  298. actions: { flexGrow: 0, flexShrink: 0, width: 168, minWidth: 168 },
  299. };
  300. /**
  301. * Short meta columns — fixed track (`flex-none`).
  302. * Date-ish words match anywhere (`date_added`), money/period only as whole names.
  303. */
  304. const FLEX_NONE_NAME_RE = /date|time|added|created|changed|^(period|price|amount)$/i;
  305. const META_COL_WIDTH = 108;
  306. const TEXT_COL_MIN_WIDTH = 72;
  307. const ROW_GAP = 8;
  308. const ROW_PAD_X = 16; // px-2 each side
  309. /**
  310. * Text columns (title, provider, …): flex-auto — share leftover space.
  311. * Controls / date / actions: flex-none — fixed tracks.
  312. */
  313. const getColumnStyle = (name) => {
  314. if (CONTROL_COL_STYLE[name]) {
  315. return CONTROL_COL_STYLE[name];
  316. }
  317. if (FLEX_NONE_NAME_RE.test(name)) {
  318. return {
  319. flexGrow: 0,
  320. flexShrink: 0,
  321. width: META_COL_WIDTH,
  322. minWidth: META_COL_WIDTH,
  323. };
  324. }
  325. return {
  326. flexGrow: 1,
  327. flexShrink: 1,
  328. flexBasis: 0,
  329. minWidth: TEXT_COL_MIN_WIDTH,
  330. };
  331. };
  332. /**
  333. * Normalize UNA header: drop `reports`, and insert an empty `actions` column
  334. * when row cells have actions but the header omitted that track.
  335. */
  336. const normalizeGridHeader = (rawHeader, sampleRow) => {
  337. const cols = (rawHeader || []).filter((item) => item?.name != 'reports');
  338. const hasActionsCol = cols.some((c) => c?.name === 'actions');
  339. const rowHasActions =
  340. !!sampleRow &&
  341. Object.values(sampleRow).some((cell) => cell?.type === 'actions');
  342. if (!hasActionsCol && rowHasActions) {
  343. cols.push({ name: 'actions', title: '' });
  344. }
  345. return cols;
  346. };
  347. /**
  348. * Everything the header and the rows need to lay out columns: track styles,
  349. * alignment, labels, plus the minimum table width that makes horizontal scroll
  350. * kick in before columns collapse.
  351. */
  352. const buildGridColumns = (rawHeader, sampleRow) => {
  353. const columns = normalizeGridHeader(rawHeader, sampleRow).map((col) => {
  354. const name = col?.name || '';
  355. const isActions = name === 'actions';
  356. return {
  357. name,
  358. style: getColumnStyle(name),
  359. align: isActions ? 'items-end' : 'items-start',
  360. // Actions keeps a blank label so date/meta columns stay on their tracks;
  361. // the checkbox column has no label at all.
  362. label: isActions ? ' ' : col?.title == 'Select' ? '' : col?.title || '',
  363. };
  364. });
  365. const colsMin = columns.reduce(
  366. (sum, col) => sum + (col.style.minWidth || col.style.width || TEXT_COL_MIN_WIDTH),
  367. 0
  368. );
  369. const gaps = Math.max(0, columns.length - 1) * ROW_GAP;
  370. return {
  371. columns,
  372. tableMinWidth: colsMin + gaps + ROW_PAD_X,
  373. isSortable: columns.some((col) => col.name == 'order'),
  374. };
  375. };
  376. // --- provider --------------------------------------------------------------
  377. const GridContext = createContext(null);
  378. /**
  379. * Creates the store for one grid block. Column layout comes from the page JSON
  380. * and never changes while the block is mounted, so it is computed once here
  381. * instead of being threaded through the table and every row as props.
  382. */
  383. export function GridProvider({ data, children }) {
  384. const storeRef = useRef();
  385. if (!storeRef.current) {
  386. storeRef.current = createGridStore({
  387. ...buildGridColumns(data.header, data.data?.[0]),
  388. fieldId: data.settings?.field_id,
  389. });
  390. }
  391. return (
  392. <GridContext.Provider value={storeRef.current}>
  393. {children}
  394. </GridContext.Provider>
  395. );
  396. }
  397. /** Subscribe to a slice of grid state (`useGrid((state) => state.selected)`). */
  398. export function useGrid(selector) {
  399. const store = useContext(GridContext);
  400. return useStore(store, selector);
  401. }
  402. /** Raw store handle for `setState` / `getState` outside of render. */
  403. export function useGridApi() {
  404. return useContext(GridContext);
  405. }