From 326ecfba185d778a99493e5cb485b73afecb7e87 Mon Sep 17 00:00:00 2001 From: yannis Date: Thu, 16 Jul 2026 17:54:00 +0300 Subject: [PATCH 01/51] feat(ui): add new themed table component --- packages/client/app/theme/index.jsx | 511 +++++++++--------- packages/client/app/ui/shared/Table.tsx | 36 ++ packages/client/package.json | 2 +- .../client/stories/shared/Table.stories.tsx | 101 ++++ packages/client/yarn.lock | 10 +- 5 files changed, 412 insertions(+), 248 deletions(-) create mode 100644 packages/client/app/ui/shared/Table.tsx create mode 100644 packages/client/stories/shared/Table.stories.tsx diff --git a/packages/client/app/theme/index.jsx b/packages/client/app/theme/index.jsx index 67e7666db..23c2691b5 100644 --- a/packages/client/app/theme/index.jsx +++ b/packages/client/app/theme/index.jsx @@ -51,256 +51,283 @@ export const validateColor = colorCode => { const defaultBrandColor1 = '#4a7c59' const defaultBrandColor2 = '#6b7280' +const borderRadius = '3px' +const colorTextReverse = '#fff' +const colorBackgroundHue = '#f4f5f7' + export const makeTheme = ( colorBrand1 = defaultBrandColor1, colorBrand2 = defaultBrandColor2, -) => ({ - // DEPRECATED - unused values are commented out - color: { - black: '#000000', - // gray0: '#000000', - gray5: '#111111', // 7%: colorText - // gray10: '#191919', // 10% - gray20: '#323232', // 20% - gray30: '#4F4F4F', // 30% - // gray35: '#595959', // 35%: colorTextPlaceholder - gray40: '#666666', // 40%: colorIconPrimary - gray50: '#888888', // 53% - gray60: '#A5A5A5', // 65%: near colorBorder:#AAAAAA - gray70: '#BFBFBF', // 75% - gray80: '#DEDEDE', // 87%: colorContainerBorder - gray90: '#E8E8E8', // 91%: colorFurniture - gray95: '#EEEEEE', // 93% - gray97: '#F8F8F9', // 97% - gray99: '#FCFCFD', // 99% - // gray100: '#FFFFFF', - white: '#FFFFFF', - - text: '#111111', - textReverse: '#FFFFFF', - textPlaceholder: '#666666', - - backgroundA: '#FFFFFF', - backgroundB: '#f9fafb', - // backgroundC: '#f4f5f7', - backgroundC: '#f2f2f2', - - brand1: { - shade50: Color(colorBrand1).darken(0.52), - shade25: Color(colorBrand1).darken(0.27), - // shade15: Color(colorBrand1).darken(0.17), - // shade10: Color(colorBrand1).darken(0.11), - base: colorBrand1, - tint10: lightenBy(colorBrand1, 0.1), - tint25: lightenBy(colorBrand1, 0.26), - tint50: lightenBy(colorBrand1, 0.53), - tint70: lightenBy(colorBrand1, 0.73), - tint90: lightenBy(colorBrand1, 0.93), - }, - brand2: { - // shade50: Color(colorBrand2).darken(0.52), - // shade25: Color(colorBrand2).darken(0.27), - // shade15: Color(colorBrand2).darken(0.17), - // shade10: Color(colorBrand2).darken(0.11), - base: colorBrand2, - // tint10: lightenBy(colorBrand2, 0.1), - // tint25: lightenBy(colorBrand2, 0.26), - // tint50: lightenBy(colorBrand2, 0.53), - tint70: lightenBy(colorBrand2, 0.73), - // tint90: lightenBy(colorBrand2, 0.93), - }, +) => { + const colorPrimaryVeryLight = Color(colorBrand1).lighten(1.4).hex() - success: { - // shade50: '#133a0e', - // shade25: '#1b5414', - // shade15: '#25721c', - // shade10: '#2c8a21', - base: '#329a25', - // tint10: '#3bb32b', - // tint25: '#4fcb3e', - // tint50: '#8ddf83', - // tint70: '#bcedb6', - // tint90: '#e3f8e0', - }, - warning: { - shade50: '#6f3f00', - // shade25: '#8e5000', - // shade15: '#ae6200', - shade10: '#c56f00', - base: '#e48100', - // tint10: '#f69414', - // tint25: '#f8ae4c', - tint50: '#f6c88d', - tint70: '#fadfbe', - tint90: '#fdf1df', - }, - error: { - // shade50: '#6f1919', - // shade25: '#8a1e1e', - // shade15: '#a52424', - // shade10: '#bf2828', - base: '#d22b2b', - // tint10: '#d94747', - // tint25: '#e06969', - // tint50: '#e88e8e', - // tint70: '#f0b4b4', - // tint90: '#f8dcdc', - }, - additional: { - // blue: '#71AED2', - // purple: '#615CCF', - // aqua: '#7ED3A6', - green: '#89D46C', - // mustard: '#CCD66E', - }, - }, - - colorBackground: '#FFFFFF', - colorBackgroundHue: '#f4f5f7', - colorBorder: '#A5A5A5', - colorContainerBorder: '#DEDEDE', - colorDisabled: '#bfbfbf', - colorError: '#B53930', - colorFurniture: '#E8E8E8', - colorIconPrimary: '#666666', - colorInfo: '#666666', - colorPrimary: colorBrand1, - colorSecondary: colorBrand2, - colorSecondaryBackground: '#f9fafb', - colorSuccess: '#008800', - colorSuccessDark: '#17510F', - colorSuccessLight: '#d2ffcc', - colorText: '#111111', - colorTextHeading: '#111111', - colorTextPlaceholder: '#666666', - colorTextReverse: '#FFFFFF', - colorWallpaper: '#f2f2f2', - colorWarning: '#C18D33', - colorWarningDark: '#503303', - colorWarningLight: '#fff9ed', - - // fonts - fontInterface: - "Roboto, -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'", - fontHeading: "'Spectral SC', Georgia, 'Times New Roman', serif", - fontReading: - "Roboto, -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'", - fontWriting: - "Roboto, -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'", - - // https://typescale.com (major second) - fontSizeBase: '1rem', - fontSizeBaseSmall: '0.889rem', - fontSizeBaseSmaller: '0.79rem', - fontSizeHeading1: '2.027rem', - fontSizeHeading2: '1.802rem', - fontSizeHeading3: '1.602rem', - fontSizeHeading4: '1.424rem', - fontSizeHeading5: '1.266rem', - fontSizeHeading6: '1.125rem', - - lineHeightBase: '1.5', - lineHeightBaseSmall: '1.5', - lineHeightBaseSmaller: '1.4', - lineHeightHeading6: '1.35', - lineHeightHeading5: '1.3', - lineHeightHeading4: '1.25', - lineHeightHeading3: '1.2', - lineHeightHeading2: '1.15', - lineHeightHeading1: '1.1', - - /* Spacing */ - gridUnit: '4px', - - /* Border */ - borderRadius: '3px', - borderRadiusLarge: '15%', - borderWidth: '1px', // julien: not 0 - borderStyle: 'solid', - - // Does not exist - // $borderColor: var($colorFurniture); - - boxShadow200: '0 2px 6px 0 rgb(0 0 0 / 10%)', - - /* Transition */ - // transitionDuration: '0.2s', // TODO -- julien: not 0.05s - // transitionTimingFunction: 'ease', - // transitionDelay: '0', - /* Breakpoints */ - breakpoints: [480, 768, 1000, 1272], - - cssOverrides: { - Login: { - Logo, + return { + // DEPRECATED - unused values are commented out + color: { + black: '#000000', + // gray0: '#000000', + gray5: '#111111', // 7%: colorText + // gray10: '#191919', // 10% + gray20: '#323232', // 20% + gray30: '#4F4F4F', // 30% + // gray35: '#595959', // 35%: colorTextPlaceholder + gray40: '#666666', // 40%: colorIconPrimary + gray50: '#888888', // 53% + gray60: '#A5A5A5', // 65%: near colorBorder:#AAAAAA + gray70: '#BFBFBF', // 75% + gray80: '#DEDEDE', // 87%: colorContainerBorder + gray90: '#E8E8E8', // 91%: colorFurniture + gray95: '#EEEEEE', // 93% + gray97: '#F8F8F9', // 97% + gray99: '#FCFCFD', // 99% + // gray100: '#FFFFFF', + white: '#FFFFFF', + + text: '#111111', + textReverse: '#FFFFFF', + textPlaceholder: '#666666', + + backgroundA: '#FFFFFF', + backgroundB: '#f9fafb', + // backgroundC: '#f4f5f7', + backgroundC: '#f2f2f2', + + brand1: { + shade50: Color(colorBrand1).darken(0.52), + shade25: Color(colorBrand1).darken(0.27), + // shade15: Color(colorBrand1).darken(0.17), + // shade10: Color(colorBrand1).darken(0.11), + base: colorBrand1, + tint10: lightenBy(colorBrand1, 0.1), + tint25: lightenBy(colorBrand1, 0.26), + tint50: lightenBy(colorBrand1, 0.53), + tint70: lightenBy(colorBrand1, 0.73), + tint90: lightenBy(colorBrand1, 0.93), + }, + brand2: { + // shade50: Color(colorBrand2).darken(0.52), + // shade25: Color(colorBrand2).darken(0.27), + // shade15: Color(colorBrand2).darken(0.17), + // shade10: Color(colorBrand2).darken(0.11), + base: colorBrand2, + // tint10: lightenBy(colorBrand2, 0.1), + // tint25: lightenBy(colorBrand2, 0.26), + // tint50: lightenBy(colorBrand2, 0.53), + tint70: lightenBy(colorBrand2, 0.73), + // tint90: lightenBy(colorBrand2, 0.93), + }, + + success: { + // shade50: '#133a0e', + // shade25: '#1b5414', + // shade15: '#25721c', + // shade10: '#2c8a21', + base: '#329a25', + // tint10: '#3bb32b', + // tint25: '#4fcb3e', + // tint50: '#8ddf83', + // tint70: '#bcedb6', + // tint90: '#e3f8e0', + }, + warning: { + shade50: '#6f3f00', + // shade25: '#8e5000', + // shade15: '#ae6200', + shade10: '#c56f00', + base: '#e48100', + // tint10: '#f69414', + // tint25: '#f8ae4c', + tint50: '#f6c88d', + tint70: '#fadfbe', + tint90: '#fdf1df', + }, + error: { + // shade50: '#6f1919', + // shade25: '#8a1e1e', + // shade15: '#a52424', + // shade10: '#bf2828', + base: '#d22b2b', + // tint10: '#d94747', + // tint25: '#e06969', + // tint50: '#e88e8e', + // tint70: '#f0b4b4', + // tint90: '#f8dcdc', + }, + additional: { + // blue: '#71AED2', + // purple: '#615CCF', + // aqua: '#7ED3A6', + green: '#89D46C', + // mustard: '#CCD66E', + }, }, - ui: { - Action, - ActionGroup, - AppBar, - Button, - Checkbox, - // GlobalStyle, - Radio, - TextField, - Menu, + + colorBackground: '#FFFFFF', + colorBackgroundHue, + colorBorder: '#A5A5A5', + colorContainerBorder: '#DEDEDE', + colorDisabled: '#bfbfbf', + colorError: '#B53930', + colorFurniture: '#E8E8E8', + colorIconPrimary: '#666666', + colorInfo: '#666666', + colorPrimary: colorBrand1, + colorPrimaryVeryLight: colorPrimaryVeryLight, + colorSecondary: colorBrand2, + colorSecondaryBackground: '#f9fafb', + colorSuccess: '#008800', + colorSuccessDark: '#17510F', + colorSuccessLight: '#d2ffcc', + colorText: '#111111', + colorTextHeading: '#111111', + colorTextPlaceholder: '#666666', + colorTextReverse: colorTextReverse, + colorWallpaper: '#f2f2f2', + colorWarning: '#C18D33', + colorWarningDark: '#503303', + colorWarningLight: '#fff9ed', + + // fonts + fontInterface: + "Roboto, -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'", + fontHeading: "'Spectral SC', Georgia, 'Times New Roman', serif", + fontReading: + "Roboto, -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'", + fontWriting: + "Roboto, -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'", + + // https://typescale.com (major second) + fontSizeBase: '1rem', + fontSizeBaseSmall: '0.889rem', + fontSizeBaseSmaller: '0.79rem', + fontSizeHeading1: '2.027rem', + fontSizeHeading2: '1.802rem', + fontSizeHeading3: '1.602rem', + fontSizeHeading4: '1.424rem', + fontSizeHeading5: '1.266rem', + fontSizeHeading6: '1.125rem', + + lineHeightBase: '1.5', + lineHeightBaseSmall: '1.5', + lineHeightBaseSmaller: '1.4', + lineHeightHeading6: '1.35', + lineHeightHeading5: '1.3', + lineHeightHeading4: '1.25', + lineHeightHeading3: '1.2', + lineHeightHeading2: '1.15', + lineHeightHeading1: '1.1', + + /* Spacing */ + gridUnit: '4px', + + /* Border */ + borderRadius: borderRadius, + borderRadiusLarge: '15%', + borderWidth: '1px', // julien: not 0 + borderStyle: 'solid', + + // Does not exist + // $borderColor: var($colorFurniture); + + boxShadow200: '0 2px 6px 0 rgb(0 0 0 / 10%)', + + /* Transition */ + // transitionDuration: '0.2s', // TODO -- julien: not 0.05s + // transitionTimingFunction: 'ease', + // transitionDelay: '0', + /* Breakpoints */ + breakpoints: [480, 768, 1000, 1272], + + antComponents: { + Table: { + headerBg: defaultBrandColor1, + headerColor: colorTextReverse, + headerBorderRadius: borderRadius, + + cellPaddingBlock: '16px', + + headerSortHoverBg: Color(defaultBrandColor1).darken(0.1).hex(), + headerSortActiveBg: Color(defaultBrandColor1).darken(0.1).hex(), + headerIconColor: colorTextReverse, + headerIconHoverColor: colorTextReverse, + rowSelectedBg: colorPrimaryVeryLight, + rowHoverBg: colorBackgroundHue, + rowSelectedHoverBg: Color(colorBackgroundHue).darken(0.08).hex(), + }, }, - Wax: { - MenuButton: css` - color: #111; - margin: 2px; - padding: 1px; - `, - CharactersListComponent: css` - padding: 0; - `, - SpecialCharactersGroup: css` - padding: 0; - `, - GroupTitle: css` - font-size: 85%; - letter-spacing: 2pt; - margin: 4px 12px; - padding: 0; - text-transform: uppercase; - - :first-child { - margin-top: 0; - } - `, - SpecialCharacterButton: css` - align-items: center; - background-color: #eee; - border: none; - border-radius: 9px; - color: #333; - display: flex; - height: auto; - justify-content: center; - margin: 4px; - min-width: 1px; - padding: 0 4px 3px; - width: auto; - - &:hover { - background-color: ${lightenBy('colorPrimary', 0.4)}; - } - - & span { - color: inherit; - display: block; - font-size: 18px; + + cssOverrides: { + Login: { + Logo, + }, + ui: { + Action, + ActionGroup, + AppBar, + Button, + Checkbox, + // GlobalStyle, + Radio, + TextField, + Menu, + }, + Wax: { + MenuButton: css` + color: #111; + margin: 2px; + padding: 1px; + `, + CharactersListComponent: css` + padding: 0; + `, + SpecialCharactersGroup: css` + padding: 0; + `, + GroupTitle: css` + font-size: 85%; + letter-spacing: 2pt; + margin: 4px 12px; + padding: 0; + text-transform: uppercase; + + :first-child { + margin-top: 0; + } + `, + SpecialCharacterButton: css` + align-items: center; + background-color: #eee; + border: none; + border-radius: 9px; + color: #333; + display: flex; + height: auto; + justify-content: center; + margin: 4px; + min-width: 1px; + padding: 0 4px 3px; + width: auto; &:hover { + background-color: ${lightenBy('colorPrimary', 0.4)}; + } + + & span { color: inherit; + display: block; + font-size: 18px; + + &:hover { + color: inherit; + } } - } - `, - CounterInfoComponent: css` - left: -221px; - position: absolute; - width: 300px; - `, + `, + CounterInfoComponent: css` + left: -221px; + position: absolute; + width: 300px; + `, + }, }, - }, -}) + } +} diff --git a/packages/client/app/ui/shared/Table.tsx b/packages/client/app/ui/shared/Table.tsx new file mode 100644 index 000000000..30b14a1c5 --- /dev/null +++ b/packages/client/app/ui/shared/Table.tsx @@ -0,0 +1,36 @@ +import styled from 'styled-components' +import { Table as OriginalTable, th, grid } from '@coko/client' + +const Table = styled(OriginalTable)` + /* stylelint-disable declaration-no-important */ + + .ant-table-wrapper { + border: 1px solid ${th('colorPrimary')}; + border-radius: ${th('borderRadius')}; + overflow: hidden; + } + + .ant-pagination { + justify-content: flex-end; + } + + .ant-table-selection-column { + padding-inline-start: ${grid(4)} !important; + } + + .ant-table-thead th::before { + height: 3em !important; + } + + tbody tr:last-of-type { + > td:first-of-type { + border-bottom-left-radius: ${th('borderRadius')}; + } + + > td:last-of-type { + border-bottom-right-radius: ${th('borderRadius')}; + } + } +` + +export default Table diff --git a/packages/client/package.json b/packages/client/package.json index 53ce71aa9..69e2262b6 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -24,7 +24,7 @@ "@codemirror/lang-css": "^6.2.1", "@codemirror/lang-html": "^6.4.6", "@codemirror/lang-json": "^6.0.1", - "@coko/client": "^2.2.2", + "@coko/client": "^2.3.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", diff --git a/packages/client/stories/shared/Table.stories.tsx b/packages/client/stories/shared/Table.stories.tsx new file mode 100644 index 000000000..306cb26db --- /dev/null +++ b/packages/client/stories/shared/Table.stories.tsx @@ -0,0 +1,101 @@ +/* eslint-disable react-hooks/rules-of-hooks */ + +import { useState } from 'react' +import preview from '../../.storybook/preview' +import Table from '../../app/ui/shared/Table' + +type Row = { + key: string + name: string + role: string + status: string +} + +const meta = preview.meta({ + component: Table, +}) + +const columns = [ + { + title: 'Name', + dataIndex: 'name', + key: 'name', + sorter: (a: Row, b: Row): number => a.name.localeCompare(b.name), + }, + { + title: 'Role', + dataIndex: 'role', + key: 'role', + sorter: (a: Row, b: Row): number => a.role.localeCompare(b.role), + }, + { + title: 'Status', + dataIndex: 'status', + key: 'status', + sorter: (a: Row, b: Row): number => a.status.localeCompare(b.status), + }, +] + +const dataSource: Row[] = [ + { key: '1', name: 'Ada Lovelace', role: 'Author', status: 'Submitted' }, + { key: '2', name: 'Alan Turing', role: 'Reviewer', status: 'In progress' }, + { key: '3', name: 'Grace Hopper', role: 'Editor', status: 'Completed' }, + { key: '4', name: 'Charles Babbage', role: 'Author', status: 'Revising' }, + { + key: '5', + name: 'Katherine Johnson', + role: 'Reviewer', + status: 'Completed', + }, + { key: '6', name: 'Tim Berners-Lee', role: 'Editor', status: 'In progress' }, + { key: '7', name: 'Margaret Hamilton', role: 'Author', status: 'Accepted' }, + { key: '8', name: 'John von Neumann', role: 'Reviewer', status: 'Submitted' }, + { key: '9', name: 'Radia Perlman', role: 'Editor', status: 'Rejected' }, + { key: '10', name: 'Donald Knuth', role: 'Author', status: 'In progress' }, + { key: '11', name: 'Barbara Liskov', role: 'Author', status: 'Submitted' }, + { key: '12', name: 'Vint Cerf', role: 'Reviewer', status: 'Accepted' }, + { key: '13', name: 'Hedy Lamarr', role: 'Editor', status: 'Completed' }, + { + key: '14', + name: 'Claude Shannon', + role: 'Author', + status: 'In progress', + }, + { key: '15', name: 'Frances Allen', role: 'Reviewer', status: 'Rejected' }, +] + +const PAGE_SIZE = 10 + +/** + * This is here mainly for theming purposes. Refer to coko client's storybook + * and ant's documentation for a full refernnce of valid props. + */ + +export const Base = meta.story({ + args: { + columns, + dataSource, + rowSelection: {}, + }, + render: args => { + const [page, setPage] = useState(1) + + const pageData = args.dataSource.slice( + (page - 1) * PAGE_SIZE, + page * PAGE_SIZE, + ) + + return ( + + ) + }, +}) diff --git a/packages/client/yarn.lock b/packages/client/yarn.lock index f034a2bba..678637c5e 100644 --- a/packages/client/yarn.lock +++ b/packages/client/yarn.lock @@ -599,9 +599,9 @@ __metadata: languageName: node linkType: hard -"@coko/client@npm:^2.2.2": - version: 2.2.2 - resolution: "@coko/client@npm:2.2.2" +"@coko/client@npm:^2.3.0": + version: 2.3.0 + resolution: "@coko/client@npm:2.3.0" dependencies: "@ant-design/icons": "npm:^6.2.2" "@dnd-kit/core": "npm:^6.3.1" @@ -633,7 +633,7 @@ __metadata: bin: coko-client-build: ./scripts/coko-client-build.mjs coko-client-dev: ./scripts/coko-client-dev.mjs - checksum: 10c0/22405483f22cb3506042e7e4cee501b394d4f094b8595aa66899e8b82aa61acec1bc1236b511462d4a9ad5caafe726f45ab850ce58d88e7c39698542cb95f9b6 + checksum: 10c0/77f0ed223a020b29fbc7befc5f47d882038382d1ed9af3957af17c9fd52d7e72d3789f720beebbf32e3cdb40ee9bedf2c7b24abca80de474a7de6986f738293d languageName: node linkType: hard @@ -5381,7 +5381,7 @@ __metadata: "@codemirror/lang-css": "npm:^6.2.1" "@codemirror/lang-html": "npm:^6.4.6" "@codemirror/lang-json": "npm:^6.0.1" - "@coko/client": "npm:^2.2.2" + "@coko/client": "npm:^2.3.0" "@dnd-kit/core": "npm:^6.3.1" "@dnd-kit/sortable": "npm:^10.0.0" "@dnd-kit/utilities": "npm:^3.2.2" From 93e62f5deab384804c4a96067d4b2d47c8089bcb Mon Sep 17 00:00:00 2001 From: yannis Date: Tue, 28 Jul 2026 17:05:33 +0300 Subject: [PATCH 02/51] refactor(ui): first pass at new manusccripts table --- .../component-chat/src/MessageContainer.jsx | 2 +- .../component-chat/src/Messages/Messages.jsx | 8 +- .../component-chat/src/Messages/style.jsx | 2 +- .../component-chat/src/VideoChat.jsx | 5 +- .../src/collection/CollectionList.jsx | 6 +- .../src/collection/ui/Active.jsx | 2 +- .../src/ConfigManagerForm.jsx | 22 +- .../components/DashboardSubmissionsPage.jsx | 21 +- .../components/sections/SubmissionsTable.jsx | 261 +++++++++++++++++- .../src/EmailTemplatesPage.jsx | 14 +- .../components/Option.jsx | 34 +-- .../src/handlebarsAutocomplete/helpers.js | 15 - .../src/components/FormSummary.jsx | 12 +- .../src/cell-components/LabelDropdown.jsx | 57 ++-- .../src/cell-components/ReviewStatusDonut.jsx | 28 +- .../cell-components/ReviewerStatusBadge.jsx | 27 +- .../src/cell-components/StatusBadge.jsx | 39 +-- .../src/util/specialColumnProps.js | 15 +- .../component-manuscripts/src/Manuscripts.jsx | 2 + .../src/index.jsx | 41 +-- .../src/components/InviteDeclineModal.jsx | 14 +- .../src/components/KanbanBoard.jsx | 150 +++++----- .../src/components/coar/CoarMessages.jsx | 22 +- .../components/metadata/ReadonlyFieldData.jsx | 12 +- .../src/components/reviewers/KanbanCard.jsx | 10 +- .../src/components/FormTemplate.jsx | 6 +- .../client/app/components/shared/Badge.jsx | 6 - .../app/components/shared/HiddenTabs.jsx | 12 +- .../app/components/shared/LabelBadge.jsx | 2 +- packages/client/app/components/shared/Tag.jsx | 41 --- .../client/app/components/shared/index.js | 1 - .../src/layout/SimpleWaxEditorLayout.jsx | 7 +- .../app/shared/localizeReviewFilterOptions.js | 8 - packages/client/app/theme/index.jsx | 10 +- packages/client/app/ui/base/Menu.tsx | 15 +- packages/client/app/ui/shared/Badge.tsx | 92 ++++++ packages/client/app/ui/shared/Link.tsx | 15 + .../client/app/ui/shared/ManuscriptStatus.tsx | 55 ++++ packages/client/app/ui/shared/Page.tsx | 1 - .../client/app/ui/shared/ReviewerStatus.tsx | 37 +++ packages/client/app/ui/shared/Table.tsx | 32 +++ packages/client/app/ui/shared/_constants.ts | 48 ++++ .../client/config/journal/review-status.js | 38 --- .../client/stories/shared/Badge.stories.tsx | 54 ++++ .../shared/ManuscriptStatus.stories.tsx | 96 +++++++ .../stories/shared/ReviewerStatus.stories.tsx | 42 +++ 46 files changed, 979 insertions(+), 460 deletions(-) delete mode 100644 packages/client/app/components/shared/Tag.jsx delete mode 100644 packages/client/app/shared/localizeReviewFilterOptions.js create mode 100644 packages/client/app/ui/shared/Badge.tsx create mode 100644 packages/client/app/ui/shared/Link.tsx create mode 100644 packages/client/app/ui/shared/ManuscriptStatus.tsx create mode 100644 packages/client/app/ui/shared/ReviewerStatus.tsx create mode 100644 packages/client/app/ui/shared/_constants.ts delete mode 100644 packages/client/config/journal/review-status.js create mode 100644 packages/client/stories/shared/Badge.stories.tsx create mode 100644 packages/client/stories/shared/ManuscriptStatus.stories.tsx create mode 100644 packages/client/stories/shared/ReviewerStatus.stories.tsx diff --git a/packages/client/app/components/component-chat/src/MessageContainer.jsx b/packages/client/app/components/component-chat/src/MessageContainer.jsx index fffd80073..0846e2105 100644 --- a/packages/client/app/components/component-chat/src/MessageContainer.jsx +++ b/packages/client/app/components/component-chat/src/MessageContainer.jsx @@ -16,7 +16,7 @@ const MessageContainer = styled.section` background: rgb(255 255 255); display: flex; flex-direction: column; - height: 100vh; + height: 100%; ${props => props.$channels diff --git a/packages/client/app/components/component-chat/src/Messages/Messages.jsx b/packages/client/app/components/component-chat/src/Messages/Messages.jsx index 312c230d9..bd5516b97 100644 --- a/packages/client/app/components/component-chat/src/Messages/Messages.jsx +++ b/packages/client/app/components/component-chat/src/Messages/Messages.jsx @@ -190,7 +190,13 @@ const Messages = ({ > {manuscriptId ? : ''} {chatRoomId ? : ''} - + { + e.stopPropagation() + toggleDropdown() + }} + /> {openDropdown && ( { return collection && collection.active ? ( - True + True ) : ( - False + False ) }, }, diff --git a/packages/client/app/components/component-cms-manager/src/collection/ui/Active.jsx b/packages/client/app/components/component-cms-manager/src/collection/ui/Active.jsx index 47719d2fa..46880470e 100644 --- a/packages/client/app/components/component-cms-manager/src/collection/ui/Active.jsx +++ b/packages/client/app/components/component-cms-manager/src/collection/ui/Active.jsx @@ -37,7 +37,7 @@ const SpanSlider = styled.span` position: absolute; transition: 0.4s; - ::before { + &::before { background-color: white; border-radius: 50%; bottom: 4px; diff --git a/packages/client/app/components/component-config-manager/src/ConfigManagerForm.jsx b/packages/client/app/components/component-config-manager/src/ConfigManagerForm.jsx index 069d3c47b..e76f746a9 100644 --- a/packages/client/app/components/component-config-manager/src/ConfigManagerForm.jsx +++ b/packages/client/app/components/component-config-manager/src/ConfigManagerForm.jsx @@ -24,7 +24,6 @@ import emailTemplatesToSchema from './helpers' import { EmailTemplatesProvider } from '../../component-email-templates/hooks/EmailTemplatesContext' import NotificationPage from '../../component-notification-event/NotificationPage' import { T } from '../../component-notification-event/misc/constants' -import { getFormBadgeBg } from '../../component-email-templates/src/handlebarsAutocomplete/helpers' import DescriptionField from './ui/DescriptionField' const StyledContainer = styled.div` @@ -105,8 +104,9 @@ const EmailsTabWrapper = styled(StyledSectionContent)` padding: ${grid(2)} ${grid(4)}; span.handlebars { - background-color: ${getFormBadgeBg('common')}; - border-radius: 5px; + background-color: ${th('colorSecondary')}; + color: ${th('colorTextReverse')}; + border-radius: 3px; box-shadow: 0 0 6px 0 #0001, inset 0 0 4px 0 #0002; @@ -114,22 +114,6 @@ const EmailsTabWrapper = styled(StyledSectionContent)` padding: 2px 4px; text-rendering: geometricprecision; } - - span.handlebars.submission-form { - background-color: ${getFormBadgeBg('submission')}; - } - - span.handlebars.review-form { - background-color: ${getFormBadgeBg('review')}; - } - - span.handlebars.decision-form { - background-color: ${getFormBadgeBg('decision')}; - } - - span.handlebars.editors-form { - background-color: ${getFormBadgeBg('editors')}; - } } .wax-surface-scroll { diff --git a/packages/client/app/components/component-dashboard/src/components/DashboardSubmissionsPage.jsx b/packages/client/app/components/component-dashboard/src/components/DashboardSubmissionsPage.jsx index 960ebf194..18fd4de7c 100644 --- a/packages/client/app/components/component-dashboard/src/components/DashboardSubmissionsPage.jsx +++ b/packages/client/app/components/component-dashboard/src/components/DashboardSubmissionsPage.jsx @@ -32,7 +32,7 @@ const DashboardSubmissionsPage = () => { const limit = config?.manuscript?.paginationCount || 10 - const { loading, error, data } = useQuery(DASHBOARD, { + const { loading, error, data, previousData } = useQuery(DASHBOARD, { variables: { wantedRoles, sort: sortName @@ -48,6 +48,8 @@ const DashboardSubmissionsPage = () => { fetchPolicy: 'network-only', }) + const currentData = data ?? previousData + const [updateTab] = useMutation(UPDATE_TAB) useEffect(() => { @@ -58,18 +60,25 @@ const DashboardSubmissionsPage = () => { }) }, []) - if (loading) return + if (loading && !currentData) return if (error) return - return config?.dashboard?.showSections?.includes('submission') ? ( + const showSubmissions = + config?.dashboard?.showSections?.includes('submission') + + if (!showSubmissions) return null + + return ( - ) : null + ) } export default DashboardSubmissionsPage diff --git a/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx b/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx index eb5f1ed43..85ff274d7 100644 --- a/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx +++ b/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx @@ -1,7 +1,15 @@ /* eslint-disable react/prop-types */ import { useMemo, useContext } from 'react' -import { useTranslation } from 'react-i18next' +import { useParams } from 'react-router-dom' +import { useTranslation, Trans } from 'react-i18next' +import styled from 'styled-components' +import get from 'lodash/get' +import { + grid, + // DateParser +} from '@coko/client' + import { authorColumns } from '../../../../../../config/journal/manuscripts' import { extractSortData, @@ -18,6 +26,31 @@ import { SectionHeader, Title, } from '../../../../shared' +import Table from '../../../../../ui/shared/Table' +import Link from '../../../../../ui/shared/Link' +import { convertTimestampToRelativeDateString } from '../../../../../shared/dateUtils' +import ManuscriptStatus from '../../../../../ui/shared/ManuscriptStatus' +import Badge from '../../../../../ui/shared/Badge' + +const centeredColumns = [ + 'shortId', + 'adaState', + '$doi', + 'status', + 'manuscriptVersions', + 'reviewerStatusBadge', + 'statusCounts', +] + +const LinkList = styled.div` + align-items: flex-start; + display: flex; + flex-direction: column; +` + +const TableWrapper = styled.div` + padding: ${grid(2)}; +` const SubmissionsTable = props => { const { @@ -31,6 +64,9 @@ const SubmissionsTable = props => { const config = useContext(ConfigContext) const { urlFrag } = config const { t } = useTranslation() + const { groupName } = useParams() + + const authorProofingEnabled = config.controlPanel?.authorProofingEnabled const fieldDefinitions = useMemo(() => { const fields = submissionForm?.structure?.children ?? [] @@ -74,6 +110,200 @@ const SubmissionsTable = props => { displayProps, ) + const columnKeys = + configColumns && configColumns.length > 0 + ? [...configColumns, 'actions'] + : [ + 'shortId', + 'submission.$title', + 'status', + 'created', + 'updated', + 'actions', + ] + + const specialColumnTitles = useMemo( + () => ({ + 'submission.$doi': t('formBuilder.fieldOpts.doi'), + 'submission.adaState': t('manuscriptsTable.adaState'), + actions: 'Actions', // not in translation! + author: t('manuscriptsTable.Author'), + authorProofingLink: 'Actions', // not in translation! + created: t('manuscriptsTable.Created'), + editor: t('manuscriptsTable.Editor'), + editorLinks: t('manuscriptsTable.Actions'), + lastUpdated: t('manuscriptsTable.lastReviewerStatusUpdate'), + manuscriptVersions: t('manuscriptsTable.Version'), + reviewerLinks: 'Action', // not in translation! + reviewerStatusBadge: t('manuscriptsTable.Your Status'), + // shortId: t('manuscriptsTable.Manuscript number'), + shortId: 'No.', // not in translation! + status: t('manuscriptsTable.Status'), + statusCounts: t('manuscriptsTable.Reviewer Status'), + submitter: t('manuscriptsTable.Author'), // alias of 'author' + titleAndAbstract: t('manuscriptsTable.Title'), + updated: t('manuscriptsTable.Updated'), + }), + [t], + ) + + const actionText = useMemo( + () => ({ + new: t('manuscriptsTable.actions.continueSubmission'), + submitted: t('manuscriptsTable.actions.View'), + revise: t('manuscriptsTable.actions.revise'), + revising: t('manuscriptsTable.actions.continueRevision'), + accepted: t('manuscriptsTable.actions.View'), + rejected: t('manuscriptsTable.actions.View'), + published: t('manuscriptsTable.actions.View'), + assigned: t('manuscriptsTable.actions.View'), + inProgress: t('manuscriptsTable.actions.View'), + completed: t('manuscriptsTable.actions.View'), + underEmbargo: t('manuscriptsTable.actions.View'), + embargoReleased: t('manuscriptsTable.actions.View'), + }), + [t], + ) + + const findColumnTitle = key => { + const formTitle = submissionForm.structure.children.find( + field => field.name === key, + )?.title + + if (formTitle) return formTitle + if (specialColumnTitles[key]) return specialColumnTitles[key] + return key + } + + const tableColumns = columnKeys + .map(key => ({ + title: findColumnTitle(key), + dataIndex: key, + key: key, + align: centeredColumns.includes(key) ? 'center' : 'left', + })) + .map(column => { + if (column.key === 'actions') { + return { + ...column, + render: (_, record) => { + const { id, status, showAuthorProofing } = record + + return ( + + + {actionText[status]} + + + {showAuthorProofing && ( + + {(status === 'assigned' || status === 'inProgress') && + t( + 'dashboardPage.mySubmissions.Provide production feedback', + )} + + {status === 'completed' && + t('dashboardPage.mySubmissions.View production feedback')} + + )} + + ) + }, + } + } + + if ( + ['created', 'updated'].includes(column.key) || + fieldDefinitions[column.key]?.component === 'DatePicker' + ) { + return { + ...column, + render: value => { + // return + if (!value) return null + return convertTimestampToRelativeDateString(value) + }, + } + } + + if (column.key === 'status') { + return { + ...column, + render: value => { + return + }, + } + } + + if (column.key === 'submission.adaStatus') { + return { + ...column, + render: value => { + return {value} + }, + } + } + + if (fieldDefinitions[column.key]?.options) { + return { + ...column, + render: value => { + if (!value) return null + + const option = fieldDefinitions[column.key].options.find( + o => o.value === value, + ) + + if (option?.labelColor) { + return ( + + {option.label ?? value} + + ) + } + + return option?.label ?? value + }, + } + } + + return column + }) + + const dataSource = manuscriptsUserHasCurrentRoleIn.manuscripts.map( + manuscriptObj => { + const manuscript = { ...manuscriptObj } + manuscript.submission = JSON.parse(manuscript.submission) + + const row = tableColumns.reduce((accumulator, current) => { + const property = current.dataIndex + accumulator[property] = get(manuscript, property) + return accumulator + }, {}) + + // #region author-proofing + const authorTeam = manuscript.teams.find(team => team.role === 'author') + + const sortedAuthors = authorTeam?.members + .slice() + .sort( + (a, b) => + Date.parse(new Date(b.created)) - Date.parse(new Date(a.created)), + ) + + row.showAuthorProofing = + authorProofingEnabled && + manuscript.authorFeedback.assignedAuthors?.length > 0 && + sortedAuthors[0]?.user?.id === currentUser.id && + ['assigned', 'inProgress', 'completed'].includes(manuscript.status) + // #endregion author-proofing + + row.key = manuscript.shortId + row.id = manuscript.id + return row + }, + ) + return ( @@ -98,6 +328,35 @@ const SubmissionsTable = props => { setPage={newPage => applyQueryParams({ [URI_PAGENUM_PARAM]: newPage })} totalCount={totalCount} /> + + +
applyQueryParams({ [URI_SEARCH_PARAM]: value })} + pagination={{ + current: Number(page), + pageSize: limit, + total: totalCount, + showTotal: (total, range) => ( + + ), + onChange: newPage => + applyQueryParams({ [URI_PAGENUM_PARAM]: newPage }), + }} + searchPlaceholder={t('common.Enter search terms...')} + showSearch + /> + ) } diff --git a/packages/client/app/components/component-email-templates/src/EmailTemplatesPage.jsx b/packages/client/app/components/component-email-templates/src/EmailTemplatesPage.jsx index 99fd051ce..d50906854 100644 --- a/packages/client/app/components/component-email-templates/src/EmailTemplatesPage.jsx +++ b/packages/client/app/components/component-email-templates/src/EmailTemplatesPage.jsx @@ -1,4 +1,4 @@ -import { Fragment, useContext } from 'react' +import { Fragment, useContext, useEffect } from 'react' import PropTypes from 'prop-types' import { useQuery } from '@apollo/client/react' import { CommsErrorBanner, Spinner } from '../../shared' @@ -11,12 +11,16 @@ import { GET_TEMPLATE_VARIABLES } from '../../../queries' const EmailTemplatesPage = ({ wrapper: Wrapper = Fragment }) => { const config = useContext(ConfigContext) const { groupId } = config - useQuery(GET_TEMPLATE_VARIABLES, { + const { data: templateVariablesData } = useQuery(GET_TEMPLATE_VARIABLES, { variables: { groupId }, - onCompleted: ({ getVariables: variables = [] }) => { - handlebars.store({ variables }) - }, }) + + useEffect(() => { + if (templateVariablesData?.getVariables) { + handlebars.store({ variables: templateVariablesData.getVariables }) + } + }, [templateVariablesData]) + const { loading, error, emailTemplates } = useEmailTemplatesContext() if (loading || !emailTemplates) return diff --git a/packages/client/app/components/component-email-templates/src/handlebarsAutocomplete/components/Option.jsx b/packages/client/app/components/component-email-templates/src/handlebarsAutocomplete/components/Option.jsx index f42d71e3a..b7270437b 100644 --- a/packages/client/app/components/component-email-templates/src/handlebarsAutocomplete/components/Option.jsx +++ b/packages/client/app/components/component-email-templates/src/handlebarsAutocomplete/components/Option.jsx @@ -1,26 +1,13 @@ import PropTypes from 'prop-types' import styled from 'styled-components' import { grid, th } from '@coko/client' -import { keys } from 'lodash' + import { FlexRow } from '../../../../component-cms-manager/src/style' import { BRACKETS_TYPES } from '../constants' import { getBrackets, splitAndCapitalize } from '../helpers' +import Badge from '../../../../../ui/shared/Badge' -// #region styleds ------------------------------------------------------ -// Just a idea to have different colors for each form badge (wich colors may need discussion) -const getFormBadgeBg = ({ $form, theme }) => { - const colorVariations = { - common: theme.color.gray50, - decision: '#d89400', - review: '#803c01', - submission: theme.color.brand1.base, - editors: '#c46b28', - } - - const safeKey = keys(colorVariations).includes($form) ? $form : 'common' - return colorVariations[safeKey] -} - +// #region styled const OptionButton = styled.button` background-color: white; border: none; @@ -62,18 +49,7 @@ const OptionLabel = styled(FlexRow)` } ` -const FormBadge = styled.span` - background-color: ${getFormBadgeBg}; - border-radius: ${th('borderRadius')}; - color: ${th('colorBackground')}; - font-size: ${th('fontSizeBaseSmaller')}; - line-height: 1; - min-width: 80px; - padding: ${grid(1.2)} ${grid(1.6)}; - text-align: center; - text-rendering: optimizelegibility; -` -// #endregion styleds --------------------------------------------------- +// #endregion styled const Option = ({ option, selected = false, select }) => { const { label, value, form, type } = option @@ -94,7 +70,7 @@ const Option = ({ option, selected = false, select }) => { {safeLabel} {displayValue} - {form} + {form} ) diff --git a/packages/client/app/components/component-email-templates/src/handlebarsAutocomplete/helpers.js b/packages/client/app/components/component-email-templates/src/handlebarsAutocomplete/helpers.js index e1eb386f3..1972e4adf 100644 --- a/packages/client/app/components/component-email-templates/src/handlebarsAutocomplete/helpers.js +++ b/packages/client/app/components/component-email-templates/src/handlebarsAutocomplete/helpers.js @@ -1,20 +1,5 @@ import { DROPDOWN_ID } from './constants' -const { keys } = Object - -export const getFormBadgeBg = form => props => { - const colorVariations = { - common: '#f0f0f0', - decision: '#fffacb', - review: '#ffddc2', - submission: props.theme.color.brand1.tint90, - editors: '#fae5b4', - } - - const safeKey = keys(colorVariations).includes(form) ? form : 'common' - return colorVariations[safeKey] -} - export const normalize = str => str?.trim().toLowerCase() export const capitalizeFirst = value => diff --git a/packages/client/app/components/component-formbuilder/src/components/FormSummary.jsx b/packages/client/app/components/component-formbuilder/src/components/FormSummary.jsx index 40587ae17..d81e1af41 100644 --- a/packages/client/app/components/component-formbuilder/src/components/FormSummary.jsx +++ b/packages/client/app/components/component-formbuilder/src/components/FormSummary.jsx @@ -1,10 +1,12 @@ /* eslint-disable react/prop-types */ -import styled, { useTheme } from 'styled-components' +import styled from 'styled-components' import { th } from '@coko/client' import { useTranslation } from 'react-i18next' + import SimpleWaxEditor from '../../../wax-collab/src/SimpleWaxEditor' -import { Icon, Action, LooseRow, LabelBadge } from '../../../shared' +import { Icon, Action, LooseRow } from '../../../shared' +import Badge from '../../../../ui/shared/Badge' const DetailPane = styled.div` background: ${th('colorSecondaryBackground')}; @@ -26,15 +28,15 @@ const RightLooseRow = styled(LooseRow)` ` const FormSummary = ({ form, isActive, openFormSettingsDialog }) => { - const theme = useTheme() const { t } = useTranslation() + return ( {isActive && ( - + {t('formBuilder.Active')} - + )} 0 ? values[0]?.value : undefined, ) + const theme = useTheme() + + /* eslint-disable-next-line no-nested-ternary */ + const iconColor = values[0]?.color + ? Color(values[0].color).isDark() + ? theme.color.textReverse + : theme.color.text + : theme.color.textReverse + + const badgeStyle = values[0]?.color + ? { backgroundColor: values[0].color, color: iconColor } + : undefined + const dropdownRef = useRef(null) const updateManuscript = (versionId, manuscriptDelta) => @@ -144,56 +157,28 @@ const LabelDropdown = ({ role="button" tabindex="0" > - + {values[0]?.displayValue} {isDropdownOpen ? ( unsetCustomStatus(manuscript.id)}> - + - + ) : ( unsetCustomStatus(manuscript.id)}> - + - + )} - + {isDropdownOpen && ( diff --git a/packages/client/app/components/component-manuscripts-table/src/cell-components/ReviewStatusDonut.jsx b/packages/client/app/components/component-manuscripts-table/src/cell-components/ReviewStatusDonut.jsx index 18428a686..98f0a9f9b 100644 --- a/packages/client/app/components/component-manuscripts-table/src/cell-components/ReviewStatusDonut.jsx +++ b/packages/client/app/components/component-manuscripts-table/src/cell-components/ReviewStatusDonut.jsx @@ -1,13 +1,18 @@ /* eslint-disable react/prop-types */ -import styled from 'styled-components' +import styled, { useTheme } from 'styled-components' import { th } from '@coko/client' import { Chart } from 'react-google-charts' import { countBy } from 'lodash' import i18next from 'i18next' import { getMembersOfTeam } from '../../../../shared/manuscriptUtils' -import reviewStatuses from '../../../../../config/journal/review-status' -import localizeReviewFilterOptions from '../../../../shared/localizeReviewFilterOptions' +import { + reviewerStatusValues, + reviewerStatusVariants, + reviewerStatusTranslationKeys, + badgeVariantColorTokens, + badgeDefaultColorToken, +} from '../../../../ui/shared/_constants' const Root = styled.div` font-family: ${th('fontReviewer')}; @@ -63,15 +68,20 @@ const header = [ // TODO Refactor to use recharts instead of react-google-charts const ReviewStatusDonut = ({ manuscript }) => { + const theme = useTheme() const statusOptions = {} - const LocalizedReviewFilterOptions = localizeReviewFilterOptions( - reviewStatuses, - i18next.t, - ) + reviewerStatusValues.forEach(value => { + const variant = reviewerStatusVariants[value] + + const colorToken = variant + ? badgeVariantColorTokens[variant] + : badgeDefaultColorToken - LocalizedReviewFilterOptions.forEach(item => { - statusOptions[item.value] = { text: item.label, color: item.color } + statusOptions[value] = { + text: i18next.t(reviewerStatusTranslationKeys[value]), + color: theme[colorToken], + } }) const filterInvitedUsers = reviewer => { diff --git a/packages/client/app/components/component-manuscripts-table/src/cell-components/ReviewerStatusBadge.jsx b/packages/client/app/components/component-manuscripts-table/src/cell-components/ReviewerStatusBadge.jsx index 395d4dfbc..e9ceee2c3 100644 --- a/packages/client/app/components/component-manuscripts-table/src/cell-components/ReviewerStatusBadge.jsx +++ b/packages/client/app/components/component-manuscripts-table/src/cell-components/ReviewerStatusBadge.jsx @@ -1,35 +1,12 @@ /* eslint-disable react/prop-types */ -import { t } from 'i18next' -import { ConfigurableStatus } from '../../../shared' -import reviewStatuses from '../../../../../config/journal/review-status' -import localizeReviewFilterOptions from '../../../../shared/localizeReviewFilterOptions' +import ReviewerStatus from '../../../../ui/shared/ReviewerStatus' import { findReviewerStatus } from './reviewStatusUtils' const ReviewerStatusBadge = ({ manuscript, currentUser }) => { const status = findReviewerStatus(manuscript, currentUser.id) - const LocalizedReviewFilterOptions = localizeReviewFilterOptions( - reviewStatuses, - t, - ) - - const statusConfig = LocalizedReviewFilterOptions.find( - item => item.value === status, - ) - - if (status === 'closed') { - return Closed - } - - return ( - - {statusConfig.label} - - ) + return } export default ReviewerStatusBadge diff --git a/packages/client/app/components/component-manuscripts-table/src/cell-components/StatusBadge.jsx b/packages/client/app/components/component-manuscripts-table/src/cell-components/StatusBadge.jsx index dcf506f0b..53cacd1b7 100644 --- a/packages/client/app/components/component-manuscripts-table/src/cell-components/StatusBadge.jsx +++ b/packages/client/app/components/component-manuscripts-table/src/cell-components/StatusBadge.jsx @@ -1,48 +1,17 @@ /* eslint-disable react/prop-types */ -import styled, { css } from 'styled-components' -import { grid, th } from '@coko/client' +import styled from 'styled-components' +import { th } from '@coko/client' const Status = styled.span` border-radius: 8px; font-size: ${th('fontSizeBaseSmall')}; font-variant: all-small-caps; - - ${props => - !props.minimal && - css` - padding: ${grid(1)} ${grid(2)}; - `} - - ${props => - props.clickable && - css` - cursor: pointer; - `} - - ${props => - props.color && - css` - background-color: ${props.color}; - `} -` - -const NormalStatus = styled(Status)` - ${props => - props.minimal - ? css` - color: ${th('color.brand1.base')}; - ` - : css` - background-color: ${th('colorWarning')}; - `} ` -const StatusBadgeComponent = ({ manuscript, styles }) => +const StatusBadgeComponent = ({ manuscript }) => manuscript.submission?.adaState ? ( - - {manuscript.submission?.adaState} - + {manuscript.submission?.adaState} ) : null export default StatusBadgeComponent diff --git a/packages/client/app/components/component-manuscripts-table/src/util/specialColumnProps.js b/packages/client/app/components/component-manuscripts-table/src/util/specialColumnProps.js index ec4a429dd..26248b19f 100644 --- a/packages/client/app/components/component-manuscripts-table/src/util/specialColumnProps.js +++ b/packages/client/app/components/component-manuscripts-table/src/util/specialColumnProps.js @@ -19,10 +19,11 @@ import { LastReviewerUpdated, ReviewerStatusBadge, } from '../cell-components' -import reviewFilterOptions from '../../../../../config/journal/review-status' import AuthorProofingLink from '../cell-components/AuthorProofingLink' - -import localizeReviewFilterOptions from '../../../../shared/localizeReviewFilterOptions' +import { + reviewerStatusValues, + reviewerStatusTranslationKeys, +} from '../../../../ui/shared/_constants' /** * buildSpecialColumnProps: Build the special components for specific form fields @@ -50,10 +51,10 @@ const buildSpecialColumnProps = ( updateReviewerStatus, } = specialComponentValues - const LocalizedReviewFilterOptions = localizeReviewFilterOptions( - reviewFilterOptions, - i18next.t, - ) + const LocalizedReviewFilterOptions = reviewerStatusValues.map(value => ({ + value, + label: i18next.t(reviewerStatusTranslationKeys[value]), + })) const specialColumnProps = { shortId: { diff --git a/packages/client/app/components/component-manuscripts/src/Manuscripts.jsx b/packages/client/app/components/component-manuscripts/src/Manuscripts.jsx index 3b4c3ac66..3519b212a 100644 --- a/packages/client/app/components/component-manuscripts/src/Manuscripts.jsx +++ b/packages/client/app/components/component-manuscripts/src/Manuscripts.jsx @@ -45,9 +45,11 @@ import { ConfigContext } from '../../config/src' const OuterContainer = styled(Container)` overflow: hidden; padding: 0; + height: 100%; ` const ManuscriptsColumns = styled(Columns)` + gap: ${grid(4)}; height: 100%; ` diff --git a/packages/client/app/components/component-review-detail-modal/src/index.jsx b/packages/client/app/components/component-review-detail-modal/src/index.jsx index a763ba603..006ced567 100644 --- a/packages/client/app/components/component-review-detail-modal/src/index.jsx +++ b/packages/client/app/components/component-review-detail-modal/src/index.jsx @@ -11,14 +11,7 @@ import YjsContext from '../../provider-yjs/YjsProvider' import { convertTimestampToDateString } from '../../../shared/dateUtils' import { ensureJsonIsParsed } from '../../../shared/objectUtils' import Modal, { SecondaryButton } from '../../component-modal/src/Modal' -import { - ConfigurableStatus, - UserInfo, - UserCombo, - Primary, - Secondary, -} from '../../shared' -import reviewStatuses from '../../../../config/journal/review-status' +import { UserInfo, UserCombo, Primary, Secondary } from '../../shared' import recommendations from '../../../../config/journal/recommendations' import { UserAvatar } from '../../component-avatar/src' import DeleteReviewerModal from '../../component-review/src/components/reviewers/DeleteReviewerModal' @@ -26,14 +19,21 @@ import DeleteInvitationModal from '../../component-review/src/components/reviewe import ReadonlyFieldData from '../../component-review/src/components/metadata/ReadonlyFieldData' import FormTemplate from '../../component-submit/src/components/FormTemplate' import { ConfigContext } from '../../config/src' -import localizeReviewFilterOptions from '../../../shared/localizeReviewFilterOptions' import localizeRecommendations from '../../../shared/localizeRecommendations' +import ReviewerStatus from '../../../ui/shared/ReviewerStatus' +import Badge from '../../../ui/shared/Badge' const Header = styled.div` font-size: 18px; font-weight: 500; ` +const recommendationVariants = { + accept: 'success', + revise: 'warning', + reject: 'error', +} + const ReviewItemContainer = styled.div` display: flex; flex-direction: column; @@ -126,20 +126,11 @@ const ReviewDetailsModal = ( // } }, [review?.id]) - const LocalizedReviewFilterOptions = localizeReviewFilterOptions( - reviewStatuses, - t, - ) - const fallbackStatus = review?.isCollaborative && review?.isLock === true ? 'closed' : 'completed' const statusToDisplay = status ?? fallbackStatus - const statusConfig = LocalizedReviewFilterOptions.find( - item => item.value === statusToDisplay, - ) - let reviewer = null let inviteeName = null @@ -287,12 +278,7 @@ const ReviewDetailsModal = ( )}
{t('modals.reviewReport.Status')}
- - {statusConfig?.label} - +
{review && showForm && (
{t('modals.reviewReport.Recommendation')}
- + {recommendationConfig.label} - + )} diff --git a/packages/client/app/components/component-review/src/components/InviteDeclineModal.jsx b/packages/client/app/components/component-review/src/components/InviteDeclineModal.jsx index b251a65e4..6231b5423 100644 --- a/packages/client/app/components/component-review/src/components/InviteDeclineModal.jsx +++ b/packages/client/app/components/component-review/src/components/InviteDeclineModal.jsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next' import { convertTimestampToDateString } from '../../../../shared/dateUtils' import { UserAvatar } from '../../../component-avatar/src' import Modal from '../../../component-modal/src/Modal' -import { ConfigurableStatus } from '../../../shared' +import Badge from '../../../../ui/shared/Badge' const ModalBody = styled.div` display: flex; @@ -61,10 +61,6 @@ const StyledH4 = styled.h4` font-weight: 600; ` -const DeclinedBadge = styled(ConfigurableStatus)` - background: #c23d20; -` - const TextChange = styled.div` color: ${props => props.gray ? props.theme.color.brand2.base : props.theme.color.text}; @@ -102,13 +98,13 @@ const InviteDeclineModal = ({ invitation, isOpen, onClose }) => { {t('modals.inviteDeclined.Status')} - + {t('modals.inviteDeclined.declinedBadge')} - + {invitation.declinedReason === 'DO_NOT_CONTACT' && ( - + {t('modals.inviteDeclined.Opted Out')} - + )} diff --git a/packages/client/app/components/component-review/src/components/KanbanBoard.jsx b/packages/client/app/components/component-review/src/components/KanbanBoard.jsx index 68a5a2b3e..96fbc088b 100644 --- a/packages/client/app/components/component-review/src/components/KanbanBoard.jsx +++ b/packages/client/app/components/component-review/src/components/KanbanBoard.jsx @@ -15,10 +15,10 @@ import { } from '../../../shared' import ReviewersDeclined from './ReviewersDeclined' import { getMembersOfTeam } from '../../../../shared/manuscriptUtils' -import statuses from '../../../../../config/journal/review-status' -import localizeReviewFilterOptions from '../../../../shared/localizeReviewFilterOptions' import KanbanCard from './reviewers/KanbanCard' import { findReviewFromReviewer } from './reviewers/util' +import ReviewerStatus from '../../../../ui/shared/ReviewerStatus' +import { reviewerStatusValues } from '../../../../ui/shared/_constants' const Kanban = styled.div.attrs({ 'data-testid': 'kanban', @@ -39,17 +39,6 @@ const Column = styled.div(({ $columns }) => ({ width: `calc(${100 / $columns ?? 4}% - 15px)`, })) -const StatusLabel = styled.div` - background-color: ${props => props.$statusColor || '#ffffff'}; - border-radius: 12px; - color: ${props => (props.$lightText ? '#ffffff' : '#000000')}; - display: inline-block; - font-weight: bold; - margin-block: 4px; - margin-inline: 0; - padding: 4px 10px; -` - const CardsWrapper = styled.div.attrs({ 'data-testid': 'kanban-cards-wrapper', })` @@ -185,8 +174,6 @@ const KanbanBoard = ({ } }) - const LocalizedStatusOptions = localizeReviewFilterOptions(statuses, t) - const filterOptions = isAuthorBoard ? ['rejected', 'closed', 'inprogress', 'completed'] : ['rejected', 'closed'] @@ -209,15 +196,15 @@ const KanbanBoard = ({ manuscript.reviews.filter(review => !review.isDecision)) || [] - const getReviewersWithoutDuplicates = (status, someReviewers) => + const getReviewersWithoutDuplicates = (statusValue, someReviewers) => someReviewers .sort( (a, b) => (a.isEmail ? 0 : 1) - (b.isEmail ? 0 : 1), // to prioritize those with email sent ) .filter((reviewer, index) => { const hasTheRightStatus = - reviewer.status === normalizeStatus(status.value) || - (reviewer.status === 'closed' && status.value === 'completed') + reviewer.status === normalizeStatus(statusValue) || + (reviewer.status === 'closed' && statusValue === 'completed') const isDuplicate = !!reviewer.user && @@ -227,12 +214,6 @@ const KanbanBoard = ({ return hasTheRightStatus && !isDuplicate }) - const statusLabel = status => { - return status?.value === 'completed' - ? t('reviewerStatus.completedClosed') - : status?.label - } - const members = isAuthorBoard ? authors : emailAndWebReviewers return ( @@ -250,64 +231,69 @@ const KanbanBoard = ({ - {LocalizedStatusOptions.filter( - status => !filterOptions.includes(status.value.toLowerCase()), - ).map(status => ( - - - {statusLabel(status)} - - - {getReviewersWithoutDuplicates(status, members).map( - reviewer => ( - - ), - )} - - - ))} + {reviewerStatusValues + .filter( + statusValue => + !filterOptions.includes(statusValue.toLowerCase()), + ) + .map(statusValue => ( + + + + {getReviewersWithoutDuplicates(statusValue, members).map( + reviewer => ( + + ), + )} + + + ))} diff --git a/packages/client/app/components/component-review/src/components/coar/CoarMessages.jsx b/packages/client/app/components/component-review/src/components/coar/CoarMessages.jsx index 2fd0b2512..ecb206e43 100644 --- a/packages/client/app/components/component-review/src/components/coar/CoarMessages.jsx +++ b/packages/client/app/components/component-review/src/components/coar/CoarMessages.jsx @@ -6,15 +6,11 @@ import { useTranslation } from 'react-i18next' import { grid, th, DateParser } from '@coko/client' import { values } from 'lodash' import { useNavigate } from 'react-router-dom' -import { - ActionButton, - Collapse, - FlexRow, - Spinner, - Tag, -} from '../../../../shared' + +import { ActionButton, Collapse, FlexRow, Spinner } from '../../../../shared' import { articleStatuses } from '../../../../../globals' import EditPayloadModal from './EditPayloadModal' +import Badge from '../../../../../ui/shared/Badge' const Container = styled.div` align-items: center; @@ -86,7 +82,7 @@ const CoarLabel = ({ const { $title } = JSON.parse(manuscript?.submission || '{}') - let color + let badgeVariant if ( ['Accept'].includes(type) || @@ -94,11 +90,11 @@ const CoarLabel = ({ activityType, ) ) { - color = 'success' + badgeVariant = 'success' } else if (['TentativeAccept', 'TentativeReject'].includes(type)) { - color = 'warning' + badgeVariant = 'warning' } else if (['Reject', 'Undo'].includes(type)) { - color = 'error' + badgeVariant = 'error' } const handleEditPayload = () => { @@ -109,10 +105,10 @@ const CoarLabel = ({
- + {t(`decisionPage.coarTab.${type}`)} {activityType && `: ${t(`decisionPage.coarTab.${activityType}`)}`} - + {$title && ( diff --git a/packages/client/app/components/component-review/src/components/metadata/ReadonlyFieldData.jsx b/packages/client/app/components/component-review/src/components/metadata/ReadonlyFieldData.jsx index 220e27516..8b0794ccb 100644 --- a/packages/client/app/components/component-review/src/components/metadata/ReadonlyFieldData.jsx +++ b/packages/client/app/components/component-review/src/components/metadata/ReadonlyFieldData.jsx @@ -3,13 +3,15 @@ import { DateParser } from '@coko/client' import { get } from 'lodash' + import FormCollaborateWax from '../../../../component-formbuilder/src/components/FormCollaborativeWax' import CollaborativeTextFieldBuilder from '../../../../component-formbuilder/src/components/builderComponents/CollaborativeTextField' import SimpleWaxEditor from '../../../../wax-collab/src/SimpleWaxEditor' import { Affiliation, Email, BadgeContainer } from '../style' -import { Attachment, ColorBadge } from '../../../../shared' +import { Attachment } from '../../../../shared' import ThreadedDiscussion from '../../../../component-formbuilder/src/components/builderComponents/ThreadedDiscussion/ThreadedDiscussion' import LocalContext from '../../../../component-submit/src/components/LocalContext' +import Badge from '../../../../../ui/shared/Badge' const parseIdentifierAndName = id => { const lastIndex = id.lastIndexOf('-') @@ -232,9 +234,13 @@ const ReadonlyFieldData = ({ if (option) { if (option.labelColor) return ( - + {option.label} - + ) return
{option.label}
diff --git a/packages/client/app/components/component-review/src/components/reviewers/KanbanCard.jsx b/packages/client/app/components/component-review/src/components/reviewers/KanbanCard.jsx index 99c9d9fd2..718034bd9 100644 --- a/packages/client/app/components/component-review/src/components/reviewers/KanbanCard.jsx +++ b/packages/client/app/components/component-review/src/components/reviewers/KanbanCard.jsx @@ -2,17 +2,16 @@ import { grid, th } from '@coko/client' import PropTypes from 'prop-types' -import { useState, useContext } from 'react' +import { useState } from 'react' import { Mail } from 'react-feather' import styled from 'styled-components' import { useTranslation } from 'react-i18next' -import { ConfigContext } from '../../../../config/src' import { isCurrentUserCollaborative } from '../review/util' import { convertTimestampToRelativeDateString } from '../../../../../shared/dateUtils' import { UserAvatar } from '../../../../component-avatar/src' import ReviewDetailsModal from '../../../../component-review-detail-modal/src' -import { ColorBadge } from '../../../../shared' +import Badge from '../../../../../ui/shared/Badge' const Card = styled.div.attrs({ 'data-testid': 'kanban-card', @@ -104,7 +103,6 @@ const KanbanCard = ({ }) => { const [open, setOpen] = useState(false) const { t } = useTranslation() - const config = useContext(ConfigContext) const isCollaborative = reviewer.invitedPersonType === 'COLLABORATIVE_REVIEWER' || @@ -166,9 +164,9 @@ const KanbanCard = ({ {isCollaborative && ( - + {t('common.kanban.isCollaborative')} - + )} diff --git a/packages/client/app/components/component-submit/src/components/FormTemplate.jsx b/packages/client/app/components/component-submit/src/components/FormTemplate.jsx index 9c681c184..60b2e0880 100644 --- a/packages/client/app/components/component-submit/src/components/FormTemplate.jsx +++ b/packages/client/app/components/component-submit/src/components/FormTemplate.jsx @@ -14,7 +14,6 @@ import { grid, th } from '@coko/client' import { useTranslation } from 'react-i18next' import { - ColorBadge, Section as Container, Select, FilesUpload, @@ -27,6 +26,7 @@ import { RadioGroup, DatePicker, } from '../../../shared' +import Badge from '../../../../ui/shared/Badge' import { FullWaxField, CollaborativeTextField, @@ -370,9 +370,9 @@ const FormTemplate = ({ /> {isCollaborative && ( - + {t('reviewPage.isCollaborative')} - + )} diff --git a/packages/client/app/components/shared/Badge.jsx b/packages/client/app/components/shared/Badge.jsx index 61919cfce..3d49c7843 100644 --- a/packages/client/app/components/shared/Badge.jsx +++ b/packages/client/app/components/shared/Badge.jsx @@ -36,12 +36,6 @@ export const Status = styled.span.attrs({ text-transform: uppercase; ` -export const ConfigurableStatus = styled(Status)` - background-color: ${p => p.color}; - color: ${p => - p.$lightText ? p.theme.color.textReverse : p.theme.color.text}; -` - export const safeLabel = status => { const unknownFallback = `${i18next.t('msStatus.unknown')} (${status})` return i18next.t(`msStatus.${status}`, unknownFallback) diff --git a/packages/client/app/components/shared/HiddenTabs.jsx b/packages/client/app/components/shared/HiddenTabs.jsx index f274967ef..53070052b 100644 --- a/packages/client/app/components/shared/HiddenTabs.jsx +++ b/packages/client/app/components/shared/HiddenTabs.jsx @@ -4,7 +4,7 @@ /* stylelint-disable custom-property-pattern */ import { useContext, useState, useEffect } from 'react' -import styled from 'styled-components' +import styled, { css } from 'styled-components' import { grid, th, override } from '@coko/client' import { useTranslation } from 'react-i18next' @@ -14,10 +14,8 @@ import RoundIconButton from './RoundIconButton' import { safeCall } from '../../shared/generalUtils' export const CompactChatButton = styled(RoundIconButton)` - height: 33px; margin-top: 0; min-width: 0; - width: 33px; ` export const Tab = styled.div.attrs(props => ({ @@ -49,6 +47,12 @@ export const Tab = styled.div.attrs(props => ({ position: relative; z-index: 6; + ${props => + props.$chat && + css` + padding: ${grid(2.5)}; + `} + & > div { border-bottom: 3px solid ${props => (props.$active ? props.theme.color.brand1.base : 'none')}; @@ -129,7 +133,7 @@ const HiddenTabs = ({ key={key} onClick={() => setActiveKeyAndCallOnChange(key)} > - +
{label || key}
diff --git a/packages/client/app/components/shared/LabelBadge.jsx b/packages/client/app/components/shared/LabelBadge.jsx index a4f178fc0..68f97bad3 100644 --- a/packages/client/app/components/shared/LabelBadge.jsx +++ b/packages/client/app/components/shared/LabelBadge.jsx @@ -10,7 +10,7 @@ const BadgeDiv = ({ color, children, ...rest }) => ( ) /** Displays a badge with rounded corners colored according to props.color */ -export const ColorBadge = styled(BadgeDiv)` +const ColorBadge = styled(BadgeDiv)` border-radius: 8px; display: inline-block; line-height: 1.1em; diff --git a/packages/client/app/components/shared/Tag.jsx b/packages/client/app/components/shared/Tag.jsx deleted file mode 100644 index 16b30f4ae..000000000 --- a/packages/client/app/components/shared/Tag.jsx +++ /dev/null @@ -1,41 +0,0 @@ -/* eslint-disable react/prop-types */ - -import { Tag as AntTag } from 'antd' -import PropTypes from 'prop-types' -import { capitalize } from 'lodash' -import { grid, theme } from '@coko/client' - -const Tag = ({ children, color, fontSize = 'baseSmall', variant }) => { - const colorLabel = color ? `color${capitalize(color)}` : undefined - const fontSizeLabel = `fontSize${capitalize(fontSize)}` - - return ( - - {children} - - ) -} - -Tag.propTypes = { - color: PropTypes.oneOf(['success', 'warning', 'error', undefined]), - fontSize: PropTypes.oneOf([ - 'base', - 'baseSmall', - 'baseSmaller', - 'Heading1', - 'Heading2', - 'Heading3', - 'Heading4', - 'Heading5', - 'Heading6', - ]), -} - -export default Tag diff --git a/packages/client/app/components/shared/index.js b/packages/client/app/components/shared/index.js index 3fc3ce6f6..ac6e0257b 100644 --- a/packages/client/app/components/shared/index.js +++ b/packages/client/app/components/shared/index.js @@ -45,4 +45,3 @@ export { default as AccessErrorPage } from './AccessErrorPage' export { default as Divider } from './Divider' export { default as Alert } from './Alert' export { default as Collapse } from './Collapse' -export { default as Tag } from './Tag' diff --git a/packages/client/app/components/wax-collab/src/layout/SimpleWaxEditorLayout.jsx b/packages/client/app/components/wax-collab/src/layout/SimpleWaxEditorLayout.jsx index f6c5bc2fd..c87cd479f 100644 --- a/packages/client/app/components/wax-collab/src/layout/SimpleWaxEditorLayout.jsx +++ b/packages/client/app/components/wax-collab/src/layout/SimpleWaxEditorLayout.jsx @@ -1,4 +1,4 @@ -/* eslint-disable react/display-name */ +/* eslint-disable react/prop-types, react/display-name */ /* eslint-disable new-cap */ @@ -10,6 +10,7 @@ import { SimpleInfoContainer, SimpleMenu, } from './EditorStyles' +import HandlebarsAutocomplete from '../../../component-email-templates/src/handlebarsAutocomplete/components/HandleBarsAutocomplete' import 'wax-prosemirror-core/dist/index.css' import 'wax-prosemirror-services/dist/index.css' @@ -18,7 +19,7 @@ const CounterInfo = ComponentPlugin('bottomRightInfo') const SimpleWaxEditorLayout = (readonly, dataTestid = null) => - props => ( + ({ useHandlebarsAutocomplete, ...props }) => ( <> {readonly ? ( @@ -43,6 +44,8 @@ const SimpleWaxEditorLayout = )} + {useHandlebarsAutocomplete && } + {!readonly && ( diff --git a/packages/client/app/shared/localizeReviewFilterOptions.js b/packages/client/app/shared/localizeReviewFilterOptions.js deleted file mode 100644 index 51fb43429..000000000 --- a/packages/client/app/shared/localizeReviewFilterOptions.js +++ /dev/null @@ -1,8 +0,0 @@ -const localizeReviewFilterOptions = (reviewFilterOptions, t) => { - const clonedOptions = JSON.parse(JSON.stringify(reviewFilterOptions)) - return clonedOptions.map(option => { - return { ...option, label: t(`reviewerStatus.${option.value}`) } - }) -} - -export default localizeReviewFilterOptions diff --git a/packages/client/app/theme/index.jsx b/packages/client/app/theme/index.jsx index 23c2691b5..e46f54c9b 100644 --- a/packages/client/app/theme/index.jsx +++ b/packages/client/app/theme/index.jsx @@ -210,12 +210,12 @@ export const makeTheme = ( lineHeightBase: '1.5', lineHeightBaseSmall: '1.5', lineHeightBaseSmaller: '1.4', - lineHeightHeading6: '1.35', - lineHeightHeading5: '1.3', - lineHeightHeading4: '1.25', - lineHeightHeading3: '1.2', - lineHeightHeading2: '1.15', lineHeightHeading1: '1.1', + lineHeightHeading2: '1.15', + lineHeightHeading3: '1.2', + lineHeightHeading4: '1.25', + lineHeightHeading5: '1.3', + lineHeightHeading6: '1.35', /* Spacing */ gridUnit: '4px', diff --git a/packages/client/app/ui/base/Menu.tsx b/packages/client/app/ui/base/Menu.tsx index 97eb49635..2a30d3d53 100644 --- a/packages/client/app/ui/base/Menu.tsx +++ b/packages/client/app/ui/base/Menu.tsx @@ -5,6 +5,7 @@ import styled, { css, RuleSet } from 'styled-components' import { grid, th, Link as UILink } from '@coko/client' import Avatar from '../shared/Avatar' +import Badge from '../shared/Badge' import { Home, File, @@ -225,13 +226,9 @@ const UserRoles = styled.div<{ $labelsWrap: boolean }>` overflow: hidden; ` -const UserLabel = styled.span` +const UserLabel = styled(Badge)` background-color: ${th('colorTextReverse')}; color: ${th('colorPrimary')}; - font-size: ${th('fontSizeBaseSmaller')}; - padding: 0 ${grid(2)}; - border-radius: ${th('borderRadius')}; - white-space: nowrap; ` const CollapseIconWrapper = css` @@ -539,12 +536,14 @@ const Menu = (props: MenuProps): ReactNode => { - {isUserAdmin && {t('common.roles.Admin')}} + {isUserAdmin && ( + {t('common.roles.Admin')} + )} {isUserGroupAdmin && ( - {t('common.roles.Group Admin')} + {t('common.roles.Group Admin')} )} {isUserGroupManager && ( - {t('common.roles.Group Manager')} + {t('common.roles.Group Manager')} )} diff --git a/packages/client/app/ui/shared/Badge.tsx b/packages/client/app/ui/shared/Badge.tsx new file mode 100644 index 000000000..7a1cf4c9a --- /dev/null +++ b/packages/client/app/ui/shared/Badge.tsx @@ -0,0 +1,92 @@ +import type { ReactNode, CSSProperties } from 'react' +import styled, { css, RuleSet } from 'styled-components' + +import { grid, th } from '@coko/client' + +import { BadgeVariant } from './_constants' + +const Wrapper = styled.span<{ + $small?: boolean + $variant?: BadgeVariant +}>` + background-color: ${th('colorSecondary')}; + color: ${th('colorTextReverse')}; + border-radius: ${th('borderRadius')}; + white-space: nowrap; + font-family: ${th('fontInterface')}; + display: inline-flex; + align-items: center; + + padding: ${grid(1)} ${grid(4)}; + font-size: ${th('fontSizeBaseSmall')}; + line-height: ${th('lineHeightBaseSmall')}; + + ${(props): RuleSet => + props.$small && + css` + padding: ${grid(1)} ${grid(2)}; + font-size: ${th('fontSizeBaseSmaller')}; + line-height: ${th('lineHeightBaseSmaller')}; + `} + + ${(props): RuleSet => + props.$variant === 'primary' && + css` + background-color: ${th('colorPrimary')}; + `}; + + ${(props): RuleSet => + props.$variant === 'success' && + css` + background-color: ${th('colorSuccess')}; + `}; + + ${(props): RuleSet => + props.$variant === 'error' && + css` + background-color: ${th('colorError')}; + `}; + + ${(props): RuleSet => + props.$variant === 'warning' && + css` + background-color: ${th('colorWarning')}; + `}; + + ${(props): RuleSet => + props.$variant === 'disabled' && + css` + background-color: ${th('colorDisabled')}; + `}; +` + +type BadgeProps = { + children: ReactNode + + className?: string + outlined?: boolean + small?: boolean + style?: CSSProperties + variant?: BadgeVariant +} + +const Badge = ({ + className, + children, + small, + style, + variant, +}: BadgeProps): ReactNode => { + return ( + + {children} + + ) +} + +export default Badge diff --git a/packages/client/app/ui/shared/Link.tsx b/packages/client/app/ui/shared/Link.tsx new file mode 100644 index 000000000..fcef1df8f --- /dev/null +++ b/packages/client/app/ui/shared/Link.tsx @@ -0,0 +1,15 @@ +import styled from 'styled-components' +import { th, Link as UILink } from '@coko/client' + +const Link = styled(UILink)` + /* stylelint-disable declaration-no-important */ + color: ${th('colorPrimary')} !important; + border-bottom: 2px solid transparent; + transition: border 0.2s ease-in-out; + + &:hover { + border-bottom: 2px solid ${th('colorPrimary')}; + } +` + +export default Link diff --git a/packages/client/app/ui/shared/ManuscriptStatus.tsx b/packages/client/app/ui/shared/ManuscriptStatus.tsx new file mode 100644 index 000000000..239f7159d --- /dev/null +++ b/packages/client/app/ui/shared/ManuscriptStatus.tsx @@ -0,0 +1,55 @@ +import { type ReactNode } from 'react' + +import { useTranslation } from 'react-i18next' +import Badge from './Badge' + +const variantMapper = { + inProgress: 'warning', + revise: 'warning', + revising: 'warning', + submitted: 'warning', + underEmbargo: 'warning', + + accepted: 'success', + completed: 'success', + embargoReleased: 'success', + published: 'success', + + rejected: 'error', + unpublished: 'error', +} + +type ManuscriptStatusProps = { + status: + | 'accepted' + | 'assigned' + | 'completed' + | 'embargoReleased' + | 'evaluated' + | 'inProgress' + | 'new' + | 'published' + | 'rejected' + | 'revise' + | 'revising' + | 'submitted' + | 'underEmbargo' + | 'unpublished' + + small?: boolean +} + +const ManuscriptStatus = ({ + status, + small, +}: ManuscriptStatusProps): ReactNode => { + const { t } = useTranslation() + + return ( + + {t(`msStatus.${status}`)} + + ) +} + +export default ManuscriptStatus diff --git a/packages/client/app/ui/shared/Page.tsx b/packages/client/app/ui/shared/Page.tsx index b51dd9d17..eb366ad01 100644 --- a/packages/client/app/ui/shared/Page.tsx +++ b/packages/client/app/ui/shared/Page.tsx @@ -33,7 +33,6 @@ const Content = styled.div` flex: 1; min-height: 0; font-size: ${th('fontSizeBase')}; - padding-bottom: ${grid(6)}; ` type PageProps = { diff --git a/packages/client/app/ui/shared/ReviewerStatus.tsx b/packages/client/app/ui/shared/ReviewerStatus.tsx new file mode 100644 index 000000000..50101264c --- /dev/null +++ b/packages/client/app/ui/shared/ReviewerStatus.tsx @@ -0,0 +1,37 @@ +import { type ReactNode } from 'react' +import { useTranslation } from 'react-i18next' + +import Badge from './Badge' +import { + ReviewerStatusValue, + reviewerStatusTranslationKeys, + reviewerStatusVariants, +} from './_constants' + +type ReviewerStatusProps = { + status: ReviewerStatusValue + + small?: boolean + label?: string +} + +/** + * TO DO + * - drop the label prop when the kanban goes away + */ + +const ReviewerStatus = ({ + status, + small, + label, +}: ReviewerStatusProps): ReactNode => { + const { t } = useTranslation() + + return ( + + {label ?? t(reviewerStatusTranslationKeys[status])} + + ) +} + +export default ReviewerStatus diff --git a/packages/client/app/ui/shared/Table.tsx b/packages/client/app/ui/shared/Table.tsx index 30b14a1c5..31a26eae6 100644 --- a/packages/client/app/ui/shared/Table.tsx +++ b/packages/client/app/ui/shared/Table.tsx @@ -10,18 +10,37 @@ const Table = styled(OriginalTable)` overflow: hidden; } + .ant-table-content { + font-size: ${th('fontSizeBaseSmall')}; + } + .ant-pagination { justify-content: flex-end; } + .ant-pagination-total-text { + margin-right: auto; + } + .ant-table-selection-column { padding-inline-start: ${grid(4)} !important; } + .ant-table-thead { + font-size: ${th('fontSizeBaseSmaller')}; + text-transform: uppercase; + } + .ant-table-thead th::before { height: 3em !important; } + /* necessary because of wax-table-service injecting global css */ + th, + td { + border: 0; + } + tbody tr:last-of-type { > td:first-of-type { border-bottom-left-radius: ${th('borderRadius')}; @@ -31,6 +50,19 @@ const Table = styled(OriginalTable)` border-bottom-right-radius: ${th('borderRadius')}; } } + + .ant-table-cell { + vertical-align: middle; + } + + .ant-input-search input { + box-shadow: none; + transition: box-shadow 0.2s ease; + } + + .ant-input-search input:focus { + box-shadow: 0 0 0 1px ${th('colorPrimary')}; + } ` export default Table diff --git a/packages/client/app/ui/shared/_constants.ts b/packages/client/app/ui/shared/_constants.ts new file mode 100644 index 000000000..0f65d22e8 --- /dev/null +++ b/packages/client/app/ui/shared/_constants.ts @@ -0,0 +1,48 @@ +export type BadgeVariant = + | 'primary' + | 'success' + | 'error' + | 'warning' + | 'disabled' + +export const badgeDefaultColorToken = 'colorSecondary' + +export const badgeVariantColorTokens: Record = { + primary: 'colorPrimary', + success: 'colorSuccess', + error: 'colorError', + warning: 'colorWarning', + disabled: 'colorDisabled', +} + +export const reviewerStatusTranslationKeys = { + invited: 'Invited', + accepted: 'Accepted', + inProgress: 'In Progress', + completed: 'Completed', + closed: 'Closed', + rejected: 'Declined', +} + +export type ReviewerStatusValue = + | 'invited' + | 'accepted' + | 'inProgress' + | 'completed' + | 'closed' + | 'rejected' + +export const reviewerStatusValues = Object.keys( + reviewerStatusTranslationKeys, +) as ReviewerStatusValue[] + +export const reviewerStatusVariants: Partial< + Record +> = { + // invited: '', + accepted: 'primary', + inProgress: 'warning', + completed: 'success', + closed: 'disabled', + rejected: 'error', +} diff --git a/packages/client/config/journal/review-status.js b/packages/client/config/journal/review-status.js deleted file mode 100644 index e6b4f1f5b..000000000 --- a/packages/client/config/journal/review-status.js +++ /dev/null @@ -1,38 +0,0 @@ -export default [ - { - color: '#fff2cd', - label: 'Invited', - value: 'invited', - lightText: false, - }, - { - color: '#d7efd4', - label: 'Accepted', - value: 'accepted', - lightText: false, - }, - { - color: '#ffcc37', - label: 'In Progress', - value: 'inProgress', - lightText: false, - }, - { - color: '#56b984', - label: 'Completed', - value: 'completed', - lightText: false, - }, - { - color: '#eeeeee', - label: 'Closed', - value: 'closed', - lightText: false, - }, - { - color: '#c23d20', - label: 'Declined', - value: 'rejected', - lightText: true, - }, -] diff --git a/packages/client/stories/shared/Badge.stories.tsx b/packages/client/stories/shared/Badge.stories.tsx new file mode 100644 index 000000000..fa67d7e85 --- /dev/null +++ b/packages/client/stories/shared/Badge.stories.tsx @@ -0,0 +1,54 @@ +import preview from '../../.storybook/preview' +import Badge from '../../app/ui/shared/Badge' + +const meta = preview.meta({ + component: Badge, +}) + +export const Base = meta.story({ + args: { + children: 'Enabled', + }, +}) + +export const Small = meta.story({ + args: { + children: 'Enabled', + small: true, + }, +}) + +export const Primary = meta.story({ + args: { + children: 'Enabled', + variant: 'primary', + }, +}) + +export const Success = meta.story({ + args: { + children: 'Enabled', + variant: 'success', + }, +}) + +export const Error = meta.story({ + args: { + children: 'Enabled', + variant: 'error', + }, +}) + +export const Warning = meta.story({ + args: { + children: 'Enabled', + variant: 'warning', + }, +}) + +export const Disabled = meta.story({ + args: { + children: 'Enabled', + variant: 'disabled', + }, +}) diff --git a/packages/client/stories/shared/ManuscriptStatus.stories.tsx b/packages/client/stories/shared/ManuscriptStatus.stories.tsx new file mode 100644 index 000000000..6155c5815 --- /dev/null +++ b/packages/client/stories/shared/ManuscriptStatus.stories.tsx @@ -0,0 +1,96 @@ +import preview from '../../.storybook/preview' +import ManuscriptStatus from '../../app/ui/shared/ManuscriptStatus' + +const meta = preview.meta({ + component: ManuscriptStatus, +}) + +export const Base = meta.story({ + args: { + status: 'evaluated', + }, +}) + +export const Accepted = meta.story({ + args: { + status: 'accepted', + }, +}) + +export const Assigned = meta.story({ + args: { + status: 'assigned', + }, +}) + +export const Completed = meta.story({ + args: { + status: 'completed', + }, +}) + +export const EmbargoReleased = meta.story({ + args: { + status: 'embargoReleased', + }, +}) + +export const Evaluated = meta.story({ + args: { + status: 'evaluated', + }, +}) + +export const InProgress = meta.story({ + args: { + status: 'inProgress', + }, +}) + +export const New = meta.story({ + args: { + status: 'new', + }, +}) + +export const Published = meta.story({ + args: { + status: 'published', + }, +}) + +export const Rejected = meta.story({ + args: { + status: 'rejected', + }, +}) + +export const Revise = meta.story({ + args: { + status: 'revise', + }, +}) + +export const Revising = meta.story({ + args: { + status: 'revising', + }, +}) + +export const Submitted = meta.story({ + args: { + status: 'submitted', + }, +}) + +export const UnderEmbargo = meta.story({ + args: { + status: 'underEmbargo', + }, +}) + +export const Unpublished = meta.story({ + args: { + status: 'unpublished', + }, +}) diff --git a/packages/client/stories/shared/ReviewerStatus.stories.tsx b/packages/client/stories/shared/ReviewerStatus.stories.tsx new file mode 100644 index 000000000..f803e3b83 --- /dev/null +++ b/packages/client/stories/shared/ReviewerStatus.stories.tsx @@ -0,0 +1,42 @@ +import preview from '../../.storybook/preview' +import ReviewerStatus from '../../app/ui/shared/ReviewerStatus' + +const meta = preview.meta({ + component: ReviewerStatus, +}) + +export const Invited = meta.story({ + args: { + status: 'invited', + }, +}) + +export const Accepted = meta.story({ + args: { + status: 'accepted', + }, +}) + +export const InProgress = meta.story({ + args: { + status: 'inProgress', + }, +}) + +export const Completed = meta.story({ + args: { + status: 'completed', + }, +}) + +export const Closed = meta.story({ + args: { + status: 'closed', + }, +}) + +export const Rejected = meta.story({ + args: { + status: 'rejected', + }, +}) From bcd56cf8c13de9da932a8a4ef452215e647fa22b Mon Sep 17 00:00:00 2001 From: yannis Date: Thu, 30 Jul 2026 15:41:41 +0300 Subject: [PATCH 03/51] feat(ui): table search should reset on clear --- packages/client/package.json | 2 +- packages/client/yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/client/package.json b/packages/client/package.json index 69e2262b6..e0be5c7ed 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -24,7 +24,7 @@ "@codemirror/lang-css": "^6.2.1", "@codemirror/lang-html": "^6.4.6", "@codemirror/lang-json": "^6.0.1", - "@coko/client": "^2.3.0", + "@coko/client": "^2.4.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", diff --git a/packages/client/yarn.lock b/packages/client/yarn.lock index 678637c5e..2f79f2b69 100644 --- a/packages/client/yarn.lock +++ b/packages/client/yarn.lock @@ -599,9 +599,9 @@ __metadata: languageName: node linkType: hard -"@coko/client@npm:^2.3.0": - version: 2.3.0 - resolution: "@coko/client@npm:2.3.0" +"@coko/client@npm:^2.4.0": + version: 2.4.0 + resolution: "@coko/client@npm:2.4.0" dependencies: "@ant-design/icons": "npm:^6.2.2" "@dnd-kit/core": "npm:^6.3.1" @@ -633,7 +633,7 @@ __metadata: bin: coko-client-build: ./scripts/coko-client-build.mjs coko-client-dev: ./scripts/coko-client-dev.mjs - checksum: 10c0/77f0ed223a020b29fbc7befc5f47d882038382d1ed9af3957af17c9fd52d7e72d3789f720beebbf32e3cdb40ee9bedf2c7b24abca80de474a7de6986f738293d + checksum: 10c0/8e2d9cfea9f4f96c5aeb46a7ab321d30427f869275bc073d861f85b50e283f600816e48a5053096d764a381308899504b8bffebe741697b4acc00a835d3bf694 languageName: node linkType: hard @@ -5381,7 +5381,7 @@ __metadata: "@codemirror/lang-css": "npm:^6.2.1" "@codemirror/lang-html": "npm:^6.4.6" "@codemirror/lang-json": "npm:^6.0.1" - "@coko/client": "npm:^2.3.0" + "@coko/client": "npm:^2.4.0" "@dnd-kit/core": "npm:^6.3.1" "@dnd-kit/sortable": "npm:^10.0.0" "@dnd-kit/utilities": "npm:^3.2.2" From 8c88bfbfafbd345d0b8f3ae8845b174193d10f2d Mon Sep 17 00:00:00 2001 From: yannis Date: Tue, 4 Aug 2026 11:25:00 +0300 Subject: [PATCH 04/51] refactor(*): remake manuscript search to only match manuscript table data --- .../src/ManuscriptRow.jsx | 35 +- .../client/app/queries/manuscript.queries.ts | 10 +- .../client/app/theme/elements/GlobalStyle.jsx | 4 + .../api/graphql/manuscript/manuscript.graphql | 7 +- .../__tests__/manuscriptUtils.test.ts | 346 ++++++++++++++++++ .../manuscript/manuscript.controllers.js | 7 +- .../controllers/manuscript/manuscriptUtils.js | 116 +++++- .../manuscript/__tests__/migrations.test.ts | 56 +++ ...strict-search-and-resolve-option-labels.ts | 166 +++++++++ 9 files changed, 723 insertions(+), 24 deletions(-) create mode 100644 packages/server/controllers/manuscript/__tests__/manuscriptUtils.test.ts create mode 100644 packages/server/models/manuscript/migrations/1785502335-restrict-search-and-resolve-option-labels.ts diff --git a/packages/client/app/components/component-manuscripts-table/src/ManuscriptRow.jsx b/packages/client/app/components/component-manuscripts-table/src/ManuscriptRow.jsx index acd59fc27..be8a243c5 100644 --- a/packages/client/app/components/component-manuscripts-table/src/ManuscriptRow.jsx +++ b/packages/client/app/components/component-manuscripts-table/src/ManuscriptRow.jsx @@ -1,7 +1,6 @@ /* eslint-disable react/prop-types */ import PropTypes from 'prop-types' -import { sanitize } from 'isomorphic-dompurify' import 'rc-tooltip/assets/bootstrap_white.css' import { useNavigate } from 'react-router-dom' @@ -13,6 +12,16 @@ import { SnippetRow, } from './style' +// ts_headline only ever wraps matches in ...; every snippet source has any other HTML +// stripped before ts_headline runs (see stripHtmlTags in manuscriptUtils.js), so this is safe to +// parse and render as real elements instead of dangerouslySetInnerHTML. +const renderSnippetHtml = html => + html + .split(/<\/?b>/) + .map((part, index) => + index % 2 === 1 ? {part} : part, + ) + const ManuscriptRow = ({ manuscript, columnDefinitions, @@ -45,12 +54,14 @@ const ManuscriptRow = ({ ) }) - const searchSnippet = ( - + const searchSnippets = manuscript.searchSnippets?.length > 0 && ( + <> + {manuscript.searchSnippets.map(({ field, html }) => ( + + {field}: {renderSnippetHtml(html)} + + ))} + ) // Whole Row is clickable @@ -68,7 +79,7 @@ const ManuscriptRow = ({ > {rowCells} - {manuscript.searchSnippet && searchSnippet} + {searchSnippets} ) } @@ -76,13 +87,7 @@ const ManuscriptRow = ({ return ( <> {rowCells} - {manuscript.searchSnippet && ( - - )} + {searchSnippets} ) } diff --git a/packages/client/app/queries/manuscript.queries.ts b/packages/client/app/queries/manuscript.queries.ts index 379f13041..fdac3462f 100644 --- a/packages/client/app/queries/manuscript.queries.ts +++ b/packages/client/app/queries/manuscript.queries.ts @@ -113,7 +113,10 @@ export const GET_MANUSCRIPTS_AND_FORM = gql` id profilePicture } - searchSnippet + searchSnippets { + field + html + } } } @@ -1114,7 +1117,10 @@ export const DASHBOARD = gql` parentId } ${manuscriptFragment} - searchSnippet + searchSnippets { + field + html + } } } ${formForPurposeAndCategoryFragment} diff --git a/packages/client/app/theme/elements/GlobalStyle.jsx b/packages/client/app/theme/elements/GlobalStyle.jsx index 40270d6d1..1ca4710cb 100644 --- a/packages/client/app/theme/elements/GlobalStyle.jsx +++ b/packages/client/app/theme/elements/GlobalStyle.jsx @@ -55,6 +55,10 @@ const globalStyles = css` font-weight: bold; } + mark { + background-color: ${th('colorPrimaryVeryLight')}; + } + /* Ant notifications */ .ant-notification-notice { diff --git a/packages/server/api/graphql/manuscript/manuscript.graphql b/packages/server/api/graphql/manuscript/manuscript.graphql index 71dae74d8..5e6881991 100644 --- a/packages/server/api/graphql/manuscript/manuscript.graphql +++ b/packages/server/api/graphql/manuscript/manuscript.graphql @@ -131,7 +131,7 @@ type Manuscript { publishedArtifacts: [PublishedArtifact!]! formFieldsToPublish: [FormFieldsToPublish!]! searchRank: Float - searchSnippet: String + searchSnippets: [ManuscriptSearchSnippet!] importSourceServer: String tasks: [Task!] hasOverdueTasksForUser: Boolean @@ -140,6 +140,11 @@ type Manuscript { rolesFound: [String!] } +type ManuscriptSearchSnippet { + field: String! + html: String! +} + type ReviewExport { created: DateTime! updated: DateTime diff --git a/packages/server/controllers/manuscript/__tests__/manuscriptUtils.test.ts b/packages/server/controllers/manuscript/__tests__/manuscriptUtils.test.ts new file mode 100644 index 000000000..a89691d0e --- /dev/null +++ b/packages/server/controllers/manuscript/__tests__/manuscriptUtils.test.ts @@ -0,0 +1,346 @@ +import { describe, beforeAll, beforeEach, afterAll, it, expect } from 'vitest' +import { db, config, migrationManager, DbTestUtils } from '@coko/server' + +import Group from '../../../models/group/group.model' +import Manuscript from '../../../models/manuscript/manuscript.model' +import Form from '../../../models/form/form.model' +import { + buildQueryForManuscriptSearchFilterAndOrder, + buildSearchSnippets, +} from '../manuscriptUtils' + +/** Runs buildQueryForManuscriptSearchFilterAndOrder for the given form/search term/group and + * executes it, returning the raw result rows -- the same shape buildSearchSnippets consumes. */ +const search = async (submissionForm, searchValue, groupId): Promise => { + const [query, params] = buildQueryForManuscriptSearchFilterAndOrder( + null, + 0, + 10, + [{ field: 'search', value: searchValue }], + false, + submissionForm, + 0, + null, + groupId, + ) as [string, any[]] + + const { rows } = await db.raw(query, params) + return rows +} + +describe('buildSearchSnippets', () => { + const submissionForm = { + structure: { + children: [ + { name: 'submission.$title', title: 'Title' }, + { name: 'submission.custom', shortDescription: 'Custom Field' }, + { name: 'submission.plain' }, + ], + }, + } + + it('returns an empty list when no field group matched', () => { + // A real row always has every group's column present, just null when unmatched -- a search + // with zero matches still returns a row shaped like this, not an empty object. + const row = { + snippet_field_0: null, + snippet_field_1: null, + snippet_field_2: null, + snippet_manuscript_id: null, + snippet_article_body: null, + } + + expect(buildSearchSnippets(row, submissionForm)).toEqual([]) + }) + + it('includes only the field groups with a non-null snippet, in declaration order', () => { + const row = { + snippet_field_0: 'Wombat title', + snippet_field_1: null, + snippet_field_2: null, + snippet_manuscript_id: null, + snippet_article_body: 'Body text', + } + + expect(buildSearchSnippets(row, submissionForm)).toEqual([ + { field: 'Title', html: 'Wombat title' }, + { field: 'Article body', html: 'Body text' }, + ]) + }) + + it('labels each field with shortDescription, falling back to title, then the raw field key', () => { + const row = { + snippet_field_0: 'a', // maps to $title + snippet_field_1: 'b', // maps to custom + snippet_field_2: 'c', // maps to plain + snippet_manuscript_id: null, + snippet_article_body: null, + } + + expect(buildSearchSnippets(row, submissionForm)).toEqual([ + { field: 'Title', html: 'a' }, + { field: 'Custom Field', html: 'b' }, + { field: 'plain', html: 'c' }, + ]) + }) + + it('always includes the fixed Manuscript ID and Article body groups, even with no form fields', () => { + const emptyForm = { structure: { children: [] } } + const row = { snippet_manuscript_id: '42', snippet_article_body: 'body' } + + expect(buildSearchSnippets(row, emptyForm)).toEqual([ + { field: 'Manuscript ID', html: '42' }, + { field: 'Article body', html: 'body' }, + ]) + }) +}) + +describe('buildQueryForManuscriptSearchFilterAndOrder: placeholder/param safety', () => { + // A structural regression guard: a literal `?` anywhere in the generated SQL (e.g. from a regex + // like `|`, or a JSONB operator) gets miscounted by knex as an extra bind placeholder, + // silently breaking every search. This doesn't require a DB -- it just inspects the built + // query/params shape for several representative form configurations. + + const forms = [ + { structure: { children: [] } }, + { + structure: { + children: [ + { name: 'submission.$title', title: 'Title' }, + { + name: 'submission.type', + title: 'Type', + options: [{ value: 'a', label: 'A' }], + }, + { + name: 'submission.topics', + title: 'Topics', + options: [ + { value: 'a', label: 'A' }, + { value: 'b', label: 'B' }, + ], + }, + ], + }, + }, + ] + + it.each(forms)( + 'produces exactly as many `?` placeholders as bound params', + submissionForm => { + const [query, params] = buildQueryForManuscriptSearchFilterAndOrder( + null, + 0, + 10, + [{ field: 'search', value: 'anything' }], + false, + submissionForm, + 0, + null, + 'some-group-id', + ) as [string, any[]] + + const placeholderCount = (query.match(/\?/g) || []).length + expect(placeholderCount).toBe(params.length) + }, + ) + + // A `?` in the search term itself travels as a bound parameter, never spliced into the SQL + // text -- unlike the two real bugs above (a stray `?` baked into the query-building code: a + // regex, a JSONB operator), a `?` in user-supplied data can't affect knex's placeholder count, + // regardless of which field group's code path it flows through. + it('still balances placeholders and params when the search term itself contains a `?`', () => { + const [query, params] = buildQueryForManuscriptSearchFilterAndOrder( + null, + 0, + 10, + [{ field: 'search', value: 'is it?' }], + false, + forms[1], + 0, + null, + 'some-group-id', + ) as [string, any[]] + + const placeholderCount = (query.match(/\?/g) || []).length + expect(placeholderCount).toBe(params.length) + }) +}) + +describe('buildQueryForManuscriptSearchFilterAndOrder + buildSearchSnippets', () => { + let group: Group + + beforeAll(async () => { + await config.init() + db.init() + await migrationManager.migrate() + }) + + beforeEach(async () => { + await DbTestUtils.clearDb() + group = await Group.insert({}) + }) + + afterAll(async () => { + await db.destroy() + }) + + const titleOnlyForm = { + structure: { + children: [{ name: 'submission.$title', title: 'Title' }], + }, + } + + it('matches and highlights a plain text field', async () => { + const manuscript = await Manuscript.insert({ + groupId: group.id, + submission: { $title: 'Wombat Manuscript Title' }, + }) + + const rows = await search(titleOnlyForm, 'wombat', group.id) + + expect(rows).toHaveLength(1) + expect(rows[0].id).toBe(manuscript.id) + + expect(buildSearchSnippets(rows[0], titleOnlyForm)).toEqual([ + { field: 'Title', html: expect.stringContaining('Wombat') }, + ]) + }) + + it('strips HTML tags from the article body before highlighting', async () => { + await Manuscript.insert({ + groupId: group.id, + submission: {}, + meta: { source: '

Bacon ipsum dolor

' }, + }) + + const rows = await search(titleOnlyForm, 'bacon', group.id) + + expect(rows).toHaveLength(1) + + const snippets = buildSearchSnippets(rows[0], titleOnlyForm) + const articleBody = snippets.find(s => s.field === 'Article body') + + expect(articleBody?.html).toContain('Bacon') + expect(articleBody?.html).not.toContain(' { + const optionsForm = { + structure: { + children: [ + { + name: 'submission.objectType', + title: 'Type', + options: [ + { value: 'ds', label: 'Dataset' }, + { value: 'sw', label: 'Software' }, + ], + }, + ], + }, + } + + // The trigger that populates search_tsvector resolves option values to labels by looking up + // the group's submission form directly from the DB (not from the `optionsForm` object passed + // to `search` below, which only affects the snippet headline) -- so a matching Form row has + // to exist before the manuscript is inserted, or the raw value is all that gets indexed. + // @ts-ignore + await Form.insert({ + groupId: group.id, + purpose: 'submit', + category: 'submission', + structure: optionsForm.structure, + }) + + await Manuscript.insert({ + groupId: group.id, + submission: { objectType: 'ds' }, + }) + + const labelRows = await search(optionsForm, 'dataset', group.id) + expect(labelRows).toHaveLength(1) + + expect(buildSearchSnippets(labelRows[0], optionsForm)).toEqual([ + { field: 'Type', html: expect.stringContaining('Dataset') }, + ]) + + // Searching by the raw stored value produces no snippet for this field, since the headline + // is generated from the resolved label ("Dataset"), not the code ("ds"). + const valueRows = await search(optionsForm, 'ds', group.id) + expect(valueRows).toHaveLength(1) + + const valueSnippets = buildSearchSnippets(valueRows[0], optionsForm) + expect(valueSnippets.find(s => s.field === 'Type')).toBeUndefined() + }) + + it('resolves each value of a multi-select field to its own label', async () => { + const multiSelectForm = { + structure: { + children: [ + { + name: 'submission.topics', + title: 'Topics', + options: [ + { value: 'a', label: 'Alpha' }, + { value: 'b', label: 'Beta' }, + { value: 'c', label: 'Gamma' }, + ], + }, + ], + }, + } + + // See the note in the previous test: the trigger needs a matching Form row in the DB to + // resolve option values to labels when computing search_tsvector. + // @ts-ignore + await Form.insert({ + groupId: group.id, + purpose: 'submit', + category: 'submission', + structure: multiSelectForm.structure, + }) + + await Manuscript.insert({ + groupId: group.id, + submission: { topics: ['a', 'b'] }, + }) + + const rows = await search(multiSelectForm, 'beta', group.id) + expect(rows).toHaveLength(1) + + expect(buildSearchSnippets(rows[0], multiSelectForm)).toEqual([ + { field: 'Topics', html: expect.stringContaining('Beta') }, + ]) + }) + + it('falls back to the raw stored value if it no longer matches any current option', async () => { + const staleOptionForm = { + structure: { + children: [ + { + name: 'submission.legacyField', + title: 'Legacy', + options: [{ value: 'current', label: 'Current option' }], + }, + ], + }, + } + + await Manuscript.insert({ + groupId: group.id, + submission: { legacyField: 'removedoption' }, + }) + + const rows = await search(staleOptionForm, 'removedoption', group.id) + expect(rows).toHaveLength(1) + + expect(buildSearchSnippets(rows[0], staleOptionForm)).toEqual([ + { + field: 'Legacy', + html: expect.stringContaining('removedoption'), + }, + ]) + }) +}) diff --git a/packages/server/controllers/manuscript/manuscript.controllers.js b/packages/server/controllers/manuscript/manuscript.controllers.js index 26a4c3a8f..a242733f0 100644 --- a/packages/server/controllers/manuscript/manuscript.controllers.js +++ b/packages/server/controllers/manuscript/manuscript.controllers.js @@ -25,6 +25,7 @@ const { hasElifeStyleEvaluations, stripConfidentialDataFromReviews, buildQueryForManuscriptSearchFilterAndOrder, + buildSearchSnippets, applyTemplatesToArtifacts, } = require('./manuscriptUtils') @@ -1015,11 +1016,11 @@ const manuscriptsUserHasCurrentRoleIn = async ( totalCount = parseInt(resultRows[0].full_count, 10) } - // Add in searchRank and searchSnippet + // Add in searchRank and searchSnippets const result = resultRows.map(row => ({ ...userManuscriptsWithInfo[row.id], searchRank: row.rank, - searchSnippet: row.snippet, + searchSnippets: buildSearchSnippets(row, submissionForm), })) return { totalCount, manuscripts: result } @@ -1080,7 +1081,7 @@ const paginatedManuscripts = async ( const result = rawQResult.rows.map(row => ({ ...found.find(m => m.id === row.id), searchRank: row.rank, - searchSnippet: row.snippet, + searchSnippets: buildSearchSnippets(row, submissionForm), })) return { totalCount, manuscripts: result } diff --git a/packages/server/controllers/manuscript/manuscriptUtils.js b/packages/server/controllers/manuscript/manuscriptUtils.js index 88f25f1f0..01bbaf51d 100644 --- a/packages/server/controllers/manuscript/manuscriptUtils.js +++ b/packages/server/controllers/manuscript/manuscriptUtils.js @@ -16,6 +16,66 @@ const { const URI_SEARCH_PARAM = 'search' +/** Wraps a SQL text expression to strip HTML tags, so rich-text field values (e.g. an + * AbstractEditor field, or the article body) read as plain text in a headline snippet. Postgres's + * text search parser already treats tags as opaque "tag" tokens for matching purposes (so e.g. + * an attribute like `class="paragraph"` was never matchable), but `ts_headline` still reproduces + * the original markup verbatim around any matched words, so this only affects display. */ +const stripHtmlTags = expression => + `regexp_replace(${expression}, '<[^>]+>', ' ', 'g')` + +/** Builds the list of field groups a search snippet can be labeled with: one per field defined on + * the submission form (so a snippet is labeled with the same field title an editor sees on the + * form, e.g. "Title", "Abstract", "Author names"), plus two fixed groups for manuscript data that + * isn't a submission-form field. `jsonKey` groups pull their text from `submission->jsonKey`; + * fixed groups supply a complete `expression` instead. + * + * Deliberately restricted to data already on the manuscripts row (the submission JSONB, short_id, + * meta.source) so that generating snippets never needs to join out to other tables (teams/ + * team_members/invitations/users), matching what `manuscripts_searchable_text_trigger` populates + * search_tsvector from -- so a search match always has a corresponding visible snippet. */ +const buildSnippetFieldGroups = submissionForm => { + const formFieldGroups = (submissionForm?.structure?.children ?? []) + .filter(field => field.name?.startsWith('submission.')) + .map((field, index) => { + const jsonKey = field.name.slice('submission.'.length) + return { + alias: `snippet_field_${index}`, + field: field.shortDescription || field.title || jsonKey, + jsonKey, + // For Select/RadioGroup/CheckboxGroup-style fields, `submission` stores the option's + // value (an internal code), not its label (what the user sees) -- resolve to the label. + options: + Array.isArray(field.options) && field.options.length > 0 + ? field.options + : undefined, + } + }) + + return [ + ...formFieldGroups, + { + alias: 'snippet_manuscript_id', + field: 'Manuscript ID', + expression: 'm.short_id::text', + }, + { + alias: 'snippet_article_body', + // meta.source is the manuscript's typeset HTML body (from the Wax editor). + field: 'Article body', + expression: stripHtmlTags(`COALESCE(m.meta->>'source', '')`), + }, + ] +} + +/** Turns a raw search-query result row into the labeled snippet list the client renders, + * omitting any field group that didn't actually match the search query. `submissionForm` must be + * the same one used to build the query, so the field groups line up with the row's columns. */ +const buildSearchSnippets = (row, submissionForm) => + buildSnippetFieldGroups(submissionForm) + .filter(({ alias }) => row[alias]) + .map(({ alias, field }) => ({ field, html: row[alias] })) + /** This returns a modified array of reviews, omitting fields or entire reviews marked as * hidden from author, UNLESS the current user is the reviewer the review belongs to. * This does not consider whether the user is a groupManager/admin or editor of the manuscript: @@ -356,9 +416,58 @@ const buildQueryForManuscriptSearchFilterAndOrder = ( searchQuery, ) - addSelect( - `ts_headline('english', m.searchable_text, to_tsquery('english', ?)) AS snippet`, - searchQuery, + buildSnippetFieldGroups(submissionForm).forEach( + ({ jsonKey, expression, alias, options }) => { + let textExpression + let textExpressionParams + + if (jsonKey && options) { + // Resolve stored value(s) to their option label(s), falling back to the raw value if + // it no longer matches any current option. Mirrors the equivalent logic in + // manuscripts_searchable_text_trigger, which does this at write time for matching. + textExpression = stripHtmlTags(`CASE jsonb_typeof(m.submission->?) + WHEN 'array' THEN ( + SELECT string_agg(COALESCE( + (SELECT o->>'label' FROM jsonb_array_elements(?::jsonb) o WHERE o->>'value' = elem.value), + elem.value + ), ' ') + FROM jsonb_array_elements_text(m.submission->?) AS elem(value) + ) + ELSE COALESCE( + (SELECT o->>'label' FROM jsonb_array_elements(?::jsonb) o WHERE o->>'value' = (m.submission->>?)), + m.submission->>? + ) + END`) + + const optionsJson = JSON.stringify(options) + textExpressionParams = [ + jsonKey, + optionsJson, + jsonKey, + optionsJson, + jsonKey, + jsonKey, + ] + } else if (jsonKey) { + textExpression = stripHtmlTags( + `concatenate_text_values(m.submission->?)`, + ) + textExpressionParams = [jsonKey] + } else { + textExpression = expression + textExpressionParams = [] + } + + addSelect( + `CASE WHEN to_tsvector('english', ${textExpression}) @@ to_tsquery('english', ?) + THEN ts_headline('english', ${textExpression}, to_tsquery('english', ?), 'MaxFragments=2, MaxWords=20, MinWords=5') + ELSE NULL END AS ${alias}`, + ...textExpressionParams, + searchQuery, + ...textExpressionParams, + searchQuery, + ) + }, ) addWhere(`m.search_tsvector @@ to_tsquery('english', ?)`, searchQuery) @@ -560,6 +669,7 @@ const applyTemplatesToArtifacts = ( module.exports = { buildQueryForManuscriptSearchFilterAndOrder, + buildSearchSnippets, stripConfidentialDataFromReviews, hasElifeStyleEvaluations, applyTemplatesToArtifacts, diff --git a/packages/server/models/manuscript/__tests__/migrations.test.ts b/packages/server/models/manuscript/__tests__/migrations.test.ts index 7d83f29c3..b52f6d4d2 100644 --- a/packages/server/models/manuscript/__tests__/migrations.test.ts +++ b/packages/server/models/manuscript/__tests__/migrations.test.ts @@ -8,8 +8,22 @@ import { } from '@coko/server' import Manuscript from '../manuscript.model' +import Team from '../../team/team.model' +import User from '../../user/user.model' + import oldContent from './content' +const MIGRATION_BEFORE_SEARCH_CHANGES = '1783674872-remove-menu-pinned.ts' + +const matchesSearch = async (manuscriptId, term): Promise => { + const { rows } = await db.raw( + `select (search_tsvector @@ to_tsquery('english', ?)) as matched from manuscripts where id = ?`, + [term, manuscriptId], + ) + + return rows[0].matched +} + // const oldContent = `

title

hellofoot


  • yes

  • item

` // const oldContent = `

chapter title


notes with drag and drop


item` @@ -105,4 +119,46 @@ describe('Manuscript Migrations', () => { expect(true).toBe(true) }, 600000) + + it('restricts search to manuscripts-table data', async () => { + await migrationManager.migrate({ to: MIGRATION_BEFORE_SEARCH_CHANGES }) + + const manuscript = await Manuscript.insert({ + submission: { $title: 'Wombat Manuscript Title' }, + }) + + const reviewer = await User.insert({ username: 'quokkaville' }) + + const reviewerTeam = await Team.insert({ + objectId: manuscript.id, + objectType: 'manuscript', + role: 'reviewer', + displayName: 'Reviewers', + }) + + await Team.addMember(reviewerTeam.id, reviewer.id) + + // The trigger only fires on manuscript insert/update, so re-touch the manuscript now that + // the reviewer team member exists, to bring search_tsvector up to date with it. + await db.raw('update manuscripts set updated = updated where id = ?', [ + manuscript.id, + ]) + + // Before the migration: both the title and the reviewer's username match. + expect(await matchesSearch(manuscript.id, 'wombat')).toBe(true) + expect(await matchesSearch(manuscript.id, 'quokkaville')).toBe(true) + + await migrationManager.migrate({ step: 1 }) + + // After the migration: the title still matches, but the reviewer no longer does, since + // matching is now restricted to manuscripts-table data. + expect(await matchesSearch(manuscript.id, 'wombat')).toBe(true) + expect(await matchesSearch(manuscript.id, 'quokkaville')).toBe(false) + + await migrationManager.rollback({ step: 1 }) + + // After rolling back: both match again. + expect(await matchesSearch(manuscript.id, 'wombat')).toBe(true) + expect(await matchesSearch(manuscript.id, 'quokkaville')).toBe(true) + }) }) diff --git a/packages/server/models/manuscript/migrations/1785502335-restrict-search-and-resolve-option-labels.ts b/packages/server/models/manuscript/migrations/1785502335-restrict-search-and-resolve-option-labels.ts new file mode 100644 index 000000000..02c140406 --- /dev/null +++ b/packages/server/models/manuscript/migrations/1785502335-restrict-search-and-resolve-option-labels.ts @@ -0,0 +1,166 @@ +import { type Db } from '@coko/server' + +/** + * Rewrites the trigger that builds the search columns so that: + * - we do not search from tables other than manuscripts and the form config + * (eg. team members, invitations, users) + * - custom form fields that have options (eg. radio buttons, select) are + * searchable by their labels (what the users can see) and not their internal + * option values + */ + +const previousTriggerFunctionSql = ` + CREATE OR REPLACE FUNCTION manuscripts_searchable_text_trigger() RETURNS trigger AS $$ + DECLARE + a TEXT; + b TEXT; + c TEXT; + authors JSON; + author JSON; + authorstext TEXT := ''; + member RECORD; + memberstext TEXT := ''; + invitee RECORD; + inviteestext TEXT := ''; + submittertext TEXT := ''; + BEGIN + authors := COALESCE(NULLIF(new.submission->>'$authors',''), '[]')::JSON; + FOR author IN SELECT * FROM json_array_elements(authors) LOOP + authorstext = concat( + authorstext, + concat_ws(', ', + concat_ws(' ', author->>'firstName', author->>'lastName'), + author->>'email', + author->>'affiliation' + ), + '; ' + ); + END LOOP; + + FOR member IN SELECT username, email + FROM teams t, team_members tm, users u + WHERE t.object_id = new.id AND tm.team_id = t.id AND u.id = tm.user_id + LOOP + memberstext := concat(memberstext, member.username, ' ', member.email, '; '); + END LOOP; + + FOR invitee IN SELECT invited_person_name, to_email + FROM invitations i + WHERE i.manuscript_id = new.id + LOOP + inviteestext := concat(inviteestext, invitee.invited_person_name, ' ', invitee.to_email, '; '); + END LOOP; + + submittertext := (SELECT concat_ws(', ', username, email) FROM users WHERE users.id = new.submitter_id); + + a := concat_ws(E'\\n', + new.submission->>'$title', + new.submission->>'$abstract', + new.submission->>'$doi' + ); + b := concat_ws(E'\\n', + authorstext, + memberstext, + inviteestext, + submittertext, + new.short_id, + concatenate_text_values(new.submission - '$authors' - '$title' - '$abstract' - '$doi') + ); + c := concat(new.meta->>'source'); + + new.search_tsvector := + setweight(to_tsvector('english', a), 'A') || + setweight(to_tsvector('english', b), 'B') || + setweight(to_tsvector('english', c), 'C'); + new.searchable_text := concat_ws(E'\\n', a, b, c); + + RETURN new; + END + $$ LANGUAGE plpgsql; +` + +const newTriggerFunctionSql = ` + CREATE OR REPLACE FUNCTION manuscripts_searchable_text_trigger() RETURNS trigger AS $$ + DECLARE + a TEXT; + b TEXT; + c TEXT; + option_labels_text TEXT := ''; + form_fields JSONB; + form_field JSONB; + field_name TEXT; + BEGIN + -- Highest ranking ('A') searchable text includes title, abstract, DOI + a := concat_ws(E'\\n', + new.submission->>'$title', + new.submission->>'$abstract', + new.submission->>'$doi' + ); + + -- Resolve the label(s) of any currently-selected option(s) on fields that have an 'options' + -- list, so users can search by what they see (the label) rather than the stored option code. + SELECT structure->'children' INTO form_fields + FROM forms + WHERE group_id = new.group_id AND purpose = 'submit' AND category = 'submission' + LIMIT 1; + + IF form_fields IS NOT NULL THEN + FOR form_field IN SELECT * FROM jsonb_array_elements(form_fields) LOOP + IF jsonb_typeof(form_field->'options') = 'array' + AND (form_field->>'name') LIKE 'submission.%' THEN + field_name := substring(form_field->>'name' FROM 12); + + IF jsonb_exists(new.submission, field_name) THEN + option_labels_text := concat_ws(E'\\n', option_labels_text, + CASE jsonb_typeof(new.submission->field_name) + WHEN 'array' THEN ( + SELECT string_agg(COALESCE( + (SELECT o->>'label' FROM jsonb_array_elements(form_field->'options') o WHERE o->>'value' = elem.value), + elem.value + ), ' ') + FROM jsonb_array_elements_text(new.submission->field_name) AS elem(value) + ) + ELSE COALESCE( + (SELECT o->>'label' FROM jsonb_array_elements(form_field->'options') o WHERE o->>'value' = (new.submission->>field_name)), + new.submission->>field_name + ) + END + ); + END IF; + END IF; + END LOOP; + END IF; + + -- Middle ranking ('B') searchable text includes the manuscript's short ID, resolved option + -- labels, plus all text fields (and sub-fields) from the submission object not covered above + -- -- including authors. + b := concat_ws(E'\\n', + new.short_id, + option_labels_text, + concatenate_text_values(new.submission - '$title' - '$abstract' - '$doi') + ); + -- Lowest ranking ('C') searchable text is the manuscript's typeset body (Wax editor HTML) + c := concat(new.meta->>'source'); + + new.search_tsvector := + setweight(to_tsvector('english', a), 'A') || + setweight(to_tsvector('english', b), 'B') || + setweight(to_tsvector('english', c), 'C'); + new.searchable_text := concat_ws(E'\\n', a, b, c); + + RETURN new; + END + $$ LANGUAGE plpgsql; +` + +export async function up(db: Db): Promise { + await db.raw(newTriggerFunctionSql) + + // Force the trigger to regenerate search_tsvector/searchable_text on all existing rows. + await db.raw('update manuscripts set updated = updated;') +} + +export async function down(db: Db): Promise { + await db.raw(previousTriggerFunctionSql) + await db.raw('update manuscripts set updated = updated;') +} From 692a53251fc42db4caade34d39e2e9853b5a0850 Mon Sep 17 00:00:00 2001 From: yannis Date: Thu, 6 Aug 2026 11:10:11 +0300 Subject: [PATCH 05/51] feat(ui): table ui search \& filter --- .../components/sections/SubmissionsTable.jsx | 87 +-- packages/client/app/i18n/en/translation.js | 1 + packages/client/app/i18n/es-la/translation.js | 1 + packages/client/app/i18n/fr/translation.js | 1 + packages/client/app/i18n/ru/translation.js | 1 + packages/client/app/ui/base/Icons.tsx | 1 + .../client/app/ui/shared/ManuscriptStatus.tsx | 34 +- .../client/app/ui/shared/ManuscriptsTable.tsx | 478 +++++++++++++ packages/client/app/ui/shared/Table.tsx | 12 + packages/client/package.json | 1 + .../client/stories/base/Icons.stories.tsx | 3 +- .../shared/ManuscriptsTable.stories.tsx | 646 ++++++++++++++++++ packages/client/yarn.lock | 8 + 13 files changed, 1187 insertions(+), 87 deletions(-) create mode 100644 packages/client/app/ui/shared/ManuscriptsTable.tsx create mode 100644 packages/client/stories/shared/ManuscriptsTable.stories.tsx diff --git a/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx b/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx index 85ff274d7..248d01f15 100644 --- a/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx +++ b/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx @@ -2,13 +2,10 @@ import { useMemo, useContext } from 'react' import { useParams } from 'react-router-dom' -import { useTranslation, Trans } from 'react-i18next' +import { useTranslation } from 'react-i18next' import styled from 'styled-components' import get from 'lodash/get' -import { - grid, - // DateParser -} from '@coko/client' +import { grid } from '@coko/client' import { authorColumns } from '../../../../../../config/journal/manuscripts' import { @@ -16,7 +13,7 @@ import { URI_PAGENUM_PARAM, URI_SEARCH_PARAM, } from '../../../../../shared/urlParamUtils' -import ManuscriptsTable from '../../../../component-manuscripts-table/src/ManuscriptsTable' +import LegacyManuscriptsTable from '../../../../component-manuscripts-table/src/ManuscriptsTable' import buildColumnDefinitions from '../../../../component-manuscripts-table/src/util/buildColumnDefinitions' import { ConfigContext } from '../../../../config/src' import { @@ -26,11 +23,8 @@ import { SectionHeader, Title, } from '../../../../shared' -import Table from '../../../../../ui/shared/Table' +import ManuscriptsTable from '../../../../../ui/shared/ManuscriptsTable' import Link from '../../../../../ui/shared/Link' -import { convertTimestampToRelativeDateString } from '../../../../../shared/dateUtils' -import ManuscriptStatus from '../../../../../ui/shared/ManuscriptStatus' -import Badge from '../../../../../ui/shared/Badge' const centeredColumns = [ 'shortId', @@ -216,54 +210,22 @@ const SubmissionsTable = props => { ['created', 'updated'].includes(column.key) || fieldDefinitions[column.key]?.component === 'DatePicker' ) { - return { - ...column, - render: value => { - // return - if (!value) return null - return convertTimestampToRelativeDateString(value) - }, - } + return { ...column, dataType: 'date' } } if (column.key === 'status') { - return { - ...column, - render: value => { - return - }, - } + return { ...column, dataType: 'status' } } if (column.key === 'submission.adaStatus') { - return { - ...column, - render: value => { - return {value} - }, - } + return { ...column, dataType: 'badge' } } if (fieldDefinitions[column.key]?.options) { return { ...column, - render: value => { - if (!value) return null - - const option = fieldDefinitions[column.key].options.find( - o => o.value === value, - ) - - if (option?.labelColor) { - return ( - - {option.label ?? value} - - ) - } - - return option?.label ?? value - }, + dataType: 'options', + options: fieldDefinitions[column.key].options, } } @@ -310,7 +272,7 @@ const SubmissionsTable = props => { {t('dashboardPage.My Submissions')} - @@ -330,31 +292,16 @@ const SubmissionsTable = props => { /> -
+ applyQueryParams({ [URI_PAGENUM_PARAM]: newPage }) + } onSearch={value => applyQueryParams({ [URI_SEARCH_PARAM]: value })} - pagination={{ - current: Number(page), - pageSize: limit, - total: totalCount, - showTotal: (total, range) => ( - - ), - onChange: newPage => - applyQueryParams({ [URI_PAGENUM_PARAM]: newPage }), - }} - searchPlaceholder={t('common.Enter search terms...')} - showSearch + page={Number(page)} + pageSize={limit} + totalCount={totalCount} /> diff --git a/packages/client/app/i18n/en/translation.js b/packages/client/app/i18n/en/translation.js index d49ba053e..f8032a3af 100644 --- a/packages/client/app/i18n/en/translation.js +++ b/packages/client/app/i18n/en/translation.js @@ -192,6 +192,7 @@ const en = { Production: 'Production', 'No results found': 'No results found', pagination: `Showing {{firstResult}} to {{lastResult}} of {{totalCount}} results`, + removeFilter: 'Remove filter', reviewAccept: 'Accept', reviewReject: 'Decline', reviewDo: 'Do Review', diff --git a/packages/client/app/i18n/es-la/translation.js b/packages/client/app/i18n/es-la/translation.js index e025fdc6f..253cabb29 100644 --- a/packages/client/app/i18n/es-la/translation.js +++ b/packages/client/app/i18n/es-la/translation.js @@ -192,6 +192,7 @@ const esLa = { Production: 'Producción', 'No results found': 'No se encontraron resultados', pagination: `Mostrando {{firstResult}} a {{lastResult}} de {{totalCount}} resultados`, + removeFilter: 'Eliminar filtro', reviewAccept: 'Aceptar', reviewReject: 'Rechazar', reviewDo: 'Hacer Revisión', diff --git a/packages/client/app/i18n/fr/translation.js b/packages/client/app/i18n/fr/translation.js index 7acb44aaa..0032457cb 100644 --- a/packages/client/app/i18n/fr/translation.js +++ b/packages/client/app/i18n/fr/translation.js @@ -192,6 +192,7 @@ const fr = { Production: 'Production', 'No results found': 'Aucun résultat trouvé', pagination: `Affichage de {{firstResult}} à {{lastResult}} sur {{totalCount}} résultats`, + removeFilter: 'Supprimer le filtre', reviewAccept: 'Accepter', reviewReject: 'Rejeter', reviewDo: 'Faire la révision', diff --git a/packages/client/app/i18n/ru/translation.js b/packages/client/app/i18n/ru/translation.js index a87a8a108..5aa37a4e4 100644 --- a/packages/client/app/i18n/ru/translation.js +++ b/packages/client/app/i18n/ru/translation.js @@ -192,6 +192,7 @@ const ru = { Production: 'Производство', 'No results found': 'Результаты не найдены', pagination: `Показано {{lastResult}} из {{totalCount}} результата`, + removeFilter: 'Удалить фильтр', pagination_few: `Показано {{lastResult}} из {{totalCount}} результатов`, pagination_many: `Показано {{lastResult}} из {{totalCount}} результатов`, reviewAccept: 'Принять', diff --git a/packages/client/app/ui/base/Icons.tsx b/packages/client/app/ui/base/Icons.tsx index 40911e4e4..64bfab74d 100644 --- a/packages/client/app/ui/base/Icons.tsx +++ b/packages/client/app/ui/base/Icons.tsx @@ -3,6 +3,7 @@ import { type AriaAttributes, type ReactNode } from 'react' export { ArrowRightOutlined as ArrowRight, BarChartOutlined as Report, + CloseOutlined as Close, ControlOutlined as Settings, FileTextOutlined as File, FormOutlined as Form, diff --git a/packages/client/app/ui/shared/ManuscriptStatus.tsx b/packages/client/app/ui/shared/ManuscriptStatus.tsx index 239f7159d..b8ffe00d3 100644 --- a/packages/client/app/ui/shared/ManuscriptStatus.tsx +++ b/packages/client/app/ui/shared/ManuscriptStatus.tsx @@ -19,23 +19,25 @@ const variantMapper = { unpublished: 'error', } -type ManuscriptStatusProps = { - status: - | 'accepted' - | 'assigned' - | 'completed' - | 'embargoReleased' - | 'evaluated' - | 'inProgress' - | 'new' - | 'published' - | 'rejected' - | 'revise' - | 'revising' - | 'submitted' - | 'underEmbargo' - | 'unpublished' +export const MANUSCRIPT_STATUSES = [ + 'accepted', + 'assigned', + 'completed', + 'embargoReleased', + 'evaluated', + 'inProgress', + 'new', + 'published', + 'rejected', + 'revise', + 'revising', + 'submitted', + 'underEmbargo', + 'unpublished', +] as const +type ManuscriptStatusProps = { + status: (typeof MANUSCRIPT_STATUSES)[number] small?: boolean } diff --git a/packages/client/app/ui/shared/ManuscriptsTable.tsx b/packages/client/app/ui/shared/ManuscriptsTable.tsx new file mode 100644 index 000000000..4faf201ca --- /dev/null +++ b/packages/client/app/ui/shared/ManuscriptsTable.tsx @@ -0,0 +1,478 @@ +import { type ReactNode, type MouseEvent, Fragment } from 'react' +import { useTranslation, Trans } from 'react-i18next' +import styled from 'styled-components' +import { th, grid } from '@coko/client' +import { DatePicker, type TableColumnType } from 'antd' +import type { FilterDropdownProps } from 'antd/es/table/interface' +import dayjs from 'dayjs' + +import Table from './Table' +import Badge from './Badge' +import { Close } from '../base/Icons' +import ManuscriptStatus from './ManuscriptStatus' +import ReviewerStatus from './ReviewerStatus' +import { convertTimestampToRelativeDateString } from '../../shared/dateUtils' + +type ManuscriptsTableColumnOption = { + value: string + label?: string + labelColor?: string +} + +export type ManuscriptsTableColumn = { + title: string + dataIndex: string + key: string + align?: 'left' | 'center' | 'right' + /** + * Opts into one of this component's built-in renderers for a common kind of + * value. Ignored if `render` is also supplied. + * + * Candidate future dataTypes (not yet implemented), identified by comparing against the + * legacy component-manuscripts-table cell renderers: + * - 'person': avatar + name + secondary line (e.g. email/ORCID), for user-object columns + * like submitter/editor. See Submitter.jsx. + * - 'flag': boolean -> icon + tooltip, renders nothing when false. See OverdueTooltip.jsx. + * - 'valueList': an array of independently-colored/rich-text values in one cell (richer + * version of 'options' -- handles a list, not a single lookup). See DefaultField.jsx. + * - 'tooltip'/'linkWithTooltip': text with a hover tooltip revealing more detail, optionally + * hyperlinked. See TitleWithAbstractAsTooltip.jsx. + * - 'chart'/'donut': a small chart summarizing categorical counts. See ReviewStatusDonut.jsx. + */ + dataType?: 'date' | 'status' | 'reviewerStatus' | 'badge' | 'options' + options?: ManuscriptsTableColumnOption[] + /** Applies to 'options', 'status', 'reviewerStatus' and 'date' datatypes */ + filterable?: boolean + /** + * Escape hatch for anything else (e.g. an actions column with + * business-specific links) -- takes precedence over dataType. + */ + render?: (value: any, record: any) => ReactNode +} + +export type ManuscriptSearchSnippet = { + field: string + html: string +} + +/** + * Other gaps identified against the legacy component-manuscripts-table cell renderers that are + * NOT candidates for a new dataType (see above), since they aren't a "render this value" concern: + * - Value derivation: some legacy columns (reviewer's own status, last-reviewer-updated date, + * a status overridden by a second "published" field) already render fine via existing + * dataTypes, but need their value computed/aggregated from nested data (e.g. searching teams + * for the current user, or taking a max timestamp) before being handed to a column -- a data + * problem for the caller to solve, not a rendering gap here. See FilterableStatusBadge.jsx, + * ReviewerStatusBadge.jsx, LastReviewerUpdated.jsx. + * - Role-filtered member list: Editors.jsx lists team members whose role isn't author/reviewer; + * generalizable in principle ("names of members matching a role filter") but currently + * hardcodes which roles to exclude rather than taking a parameter. + */ + +/** + * Two more legacy behaviors that are NOT gaps in this component -- the existing `render` escape + * hatch already covers the mechanism (see the Actions column in ManuscriptsTable.stories.tsx and + * in SubmissionsTable.jsx for a working example) -- but the actual business-specific logic still + * needs to be written wherever EditorTable/ReviewerTable/the admin Manuscripts page eventually + * migrate off the legacy table: + * - Action links: Actions.jsx, EditorItemLinks.jsx, AuthorProofingLink.jsx, + * ReviewerItemLinks.jsx, SubmitChevron.jsx -- each needs the full manuscript, current user, + * config, routing, and mutation functions to decide which links/labels to show. + * - Editable label dropdown: LabelsOrSelectButton.jsx/LabelDropdown.jsx -- an interactive, + * mutating widget (inline edit + workflow trigger), not a passive display of a value. + */ + +type ManuscriptsTableProps = { + columns: ManuscriptsTableColumn[] + dataSource: Record[] + page: number + pageSize: number + totalCount: number + onPageChange: (page: number) => void + onSearch: (value: string) => void + bordered?: boolean + loading?: boolean + /** Currently selected filter values, keyed by column key (controlled), e.g. + * { status: ['accepted', 'rejected'] }. Only relevant for columns with `filterable: true`. */ + columnFilters?: Record + onFiltersChange?: (filters: Record) => void +} + +const renderDate = (value: any): ReactNode => { + if (!value) return null + return convertTimestampToRelativeDateString(value) +} + +const renderStatus = (value: any): ReactNode => ( + +) + +const renderReviewerStatus = (value: any): ReactNode => ( + +) + +const renderBadge = (value: any): ReactNode => {value} + +const renderOptions = ( + value: any, + options: ManuscriptsTableColumnOption[] = [], +): ReactNode => { + if (!value) return null + + const option = options.find(o => o.value === value) + + if (option?.labelColor) { + return ( + + {option.label ?? value} + + ) + } + + return option?.label ?? value +} + +const { RangePicker } = DatePicker + +const DateRangeFilterWrapper = styled.div` + padding: ${grid(2)}; +` + +const buildDateRangePresets = (): { + label: string + value: () => [any, any] +}[] => [ + { label: 'Today', value: () => [dayjs(), dayjs()] }, + { + label: 'Yesterday', + value: () => [dayjs().subtract(1, 'day'), dayjs().subtract(1, 'day')], + }, + { label: 'Past 7 days', value: () => [dayjs().subtract(7, 'day'), dayjs()] }, + { + label: 'Past 30 days', + value: () => [dayjs().subtract(30, 'day'), dayjs()], + }, + { + label: 'Past 90 days', + value: () => [dayjs().subtract(90, 'day'), dayjs()], + }, + { label: 'Past year', value: () => [dayjs().subtract(1, 'year'), dayjs()] }, +] + +const DateRangeFilterDropdown = ({ + selectedKeys, + setSelectedKeys, + confirm, +}: FilterDropdownProps): ReactNode => { + const [start, end] = selectedKeys as unknown as [string, string] | [] + + const handleChange = ( + _dates: unknown, + // antd passes null here (not ['', '']) when the range is cleared via the "x" button + dateStrings: [string, string] | null, + ): void => { + setSelectedKeys(dateStrings?.[0] && dateStrings?.[1] ? dateStrings : []) + confirm() + } + + return ( + + + + ) +} + +const SnippetsWrapper = styled.div` + display: flex; + flex-direction: column; + padding: ${grid(2)} ${grid(4)}; + font-size: ${th('fontSizeBaseSmall')}; +` + +const renderHighlightedSnippet = (html: string): ReactNode => { + // replace postgres tags with + const matchTag = /(.*?)<\/b>/g + const parts: ReactNode[] = [] + let cursor = 0 + let match = matchTag.exec(html) + + while (match) { + if (match.index > cursor) parts.push(html.slice(cursor, match.index)) + parts.push({match[1]}) + cursor = matchTag.lastIndex + match = matchTag.exec(html) + } + + if (cursor < html.length) parts.push(html.slice(cursor)) + + return parts.map((part, index) => {part}) +} + +const SearchSnippets = ({ + snippets, +}: { + snippets: ManuscriptSearchSnippet[] +}): ReactNode => ( + + {snippets.map(({ field, html }) => ( +
+ {field}: {renderHighlightedSnippet(html)} +
+ ))} +
+) + +const hasSearchSnippets = (row: Record): boolean => + Array.isArray(row.searchSnippets) && row.searchSnippets.length > 0 + +const resolveColumn = ( + column: ManuscriptsTableColumn, + context: { columnFilters?: Record }, +): TableColumnType> => { + let resolved: ManuscriptsTableColumn = column + + if (!column.render) { + switch (column.dataType) { + case 'date': + resolved = { ...column, render: renderDate } + break + case 'status': + resolved = { ...column, render: renderStatus } + break + case 'reviewerStatus': + resolved = { ...column, render: renderReviewerStatus } + break + case 'badge': + resolved = { ...column, render: renderBadge } + break + case 'options': + resolved = { + ...column, + render: (value: any): ReactNode => + renderOptions(value, column.options), + } + break + default: + break + } + } + + if (column.filterable && column.options) { + return { + ...resolved, + filters: column.options.map(({ value, label }) => ({ + text: label ?? value, + value, + })), + filteredValue: context.columnFilters?.[column.key] ?? null, + } + } + + if (column.filterable && column.dataType === 'date') { + return { + ...resolved, + filterDropdown: (props: FilterDropdownProps) => ( + + ), + filteredValue: context.columnFilters?.[column.key] ?? null, + } + } + + return resolved +} + +const FilterChipsWrapper = styled.div` + display: flex; + flex-wrap: wrap; + gap: ${grid(1)}; + margin-bottom: ${grid(2)}; +` + +const FilterChipCloseButton = styled.button` + display: inline-flex; + margin-left: ${grid(2)}; + background: none; + color: inherit; + cursor: pointer; +` + +const formatChipDate = (value: string): string => + dayjs(value).format('MMM D, YYYY') + +type FilterChip = { + key: string + label: string + onRemove: (event: MouseEvent) => void +} + +const withoutColumnFilter = ( + filters: Record, + columnKey: string, +): Record => + Object.fromEntries( + Object.entries(filters).filter(([key]) => key !== columnKey), + ) + +const buildFilterChips = ( + columns: ManuscriptsTableColumn[], + columnFilters: Record, + onFiltersChange: (filters: Record) => void, +): FilterChip[] => { + const chips: FilterChip[] = [] + + Object.entries(columnFilters).forEach(([columnKey, values]) => { + if (!values || values.length === 0) return + + const column = columns.find(c => c.key === columnKey) + if (!column) return + + if (column.dataType === 'date') { + const [start, end] = values + if (!start || !end) return + + chips.push({ + key: columnKey, + label: `${column.title}: ${formatChipDate(start)} – ${formatChipDate(end)}`, + onRemove: event => { + event.preventDefault() + onFiltersChange(withoutColumnFilter(columnFilters, columnKey)) + }, + }) + return + } + + values.forEach(value => { + const label = column.options?.find(o => o.value === value)?.label ?? value + + chips.push({ + key: `${columnKey}:${value}`, + label: `${column.title}: ${label}`, + onRemove: event => { + event.preventDefault() + const remaining = values.filter(v => v !== value) + + onFiltersChange( + remaining.length > 0 + ? { ...columnFilters, [columnKey]: remaining } + : withoutColumnFilter(columnFilters, columnKey), + ) + }, + }) + }) + }) + + return chips +} + +const ManuscriptsTable = ({ + columns, + dataSource, + page, + pageSize, + totalCount, + onPageChange, + onSearch, + loading = false, + columnFilters, + onFiltersChange, +}: ManuscriptsTableProps): ReactNode => { + const { t } = useTranslation() + + const rowsWithSnippets = dataSource.filter(hasSearchSnippets) + + const expandable = rowsWithSnippets.length > 0 && { + expandedRowKeys: rowsWithSnippets.map(row => row.key), + expandedRowRender: (record: Record): ReactNode => ( + + ), + rowExpandable: hasSearchSnippets, + showExpandColumn: false, + } + + const filterableColumns = columns.filter(column => + Boolean( + column.filterable && (column.options || column.dataType === 'date'), + ), + ) + + // Only reports which values are selected, for every filterable column. + const handleTableChange = ( + _pagination: unknown, + filters: Record, + ): void => { + if (filterableColumns.length === 0 || !onFiltersChange) return + + onFiltersChange( + Object.fromEntries( + filterableColumns.map(column => [ + column.key, + (filters[column.key] ?? []) as string[], + ]), + ), + ) + } + + const resolvedColumns = columns.map(column => + resolveColumn(column, { columnFilters }), + ) + + const filterChips = + columnFilters && onFiltersChange + ? buildFilterChips(columns, columnFilters, onFiltersChange) + : [] + + return ( + <> + {filterChips.length > 0 && ( + + {filterChips.map(chip => ( + + {chip.label} + + + + + ))} + + )} +
( + + ), + onChange: onPageChange, + }} + searchPlaceholder={t('common.Enter search terms...')} + showSearch + /> + + ) +} + +export default ManuscriptsTable diff --git a/packages/client/app/ui/shared/Table.tsx b/packages/client/app/ui/shared/Table.tsx index 31a26eae6..50a510eee 100644 --- a/packages/client/app/ui/shared/Table.tsx +++ b/packages/client/app/ui/shared/Table.tsx @@ -41,6 +41,18 @@ const Table = styled(OriginalTable)` border: 0; } + .ant-table-expanded-row > td { + padding: 0 !important; + } + + .ant-table-filter-trigger:hover { + background-color: ${th('colorTextReverse')} !important; + } + + .ant-table-filter-trigger.active { + background-color: ${th('colorTextReverse')} !important; + } + tbody tr:last-of-type { > td:first-of-type { border-bottom-left-radius: ${th('borderRadius')}; diff --git a/packages/client/package.json b/packages/client/package.json index 2ca280e59..08a3cc211 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -38,6 +38,7 @@ "antd": "^6.3.7", "cheerio": "1.0.0-rc.10", "color": "^3.0.0", + "dayjs": "^1.11.21", "file-saver": "^2.0.5", "file-type": "^20.4.1", "fnv-plus": "^1.3.1", diff --git a/packages/client/stories/base/Icons.stories.tsx b/packages/client/stories/base/Icons.stories.tsx index d3863e7d2..f61b38b1d 100644 --- a/packages/client/stories/base/Icons.stories.tsx +++ b/packages/client/stories/base/Icons.stories.tsx @@ -7,7 +7,7 @@ import * as Icons from '../../app/ui/base/Icons' const Grid = styled.div` display: grid; - grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(125px, 1fr)); gap: 12px; ` @@ -15,6 +15,7 @@ const IconTile = styled.div` display: flex; flex-direction: column; align-items: center; + gap: 12px; padding: 16px; border: 1px solid ${th('colorBorder')}; border-radius: ${th('borderRadius')}; diff --git a/packages/client/stories/shared/ManuscriptsTable.stories.tsx b/packages/client/stories/shared/ManuscriptsTable.stories.tsx new file mode 100644 index 000000000..2f4475ccd --- /dev/null +++ b/packages/client/stories/shared/ManuscriptsTable.stories.tsx @@ -0,0 +1,646 @@ +/* eslint-disable react-hooks/rules-of-hooks */ + +import { useState, type ReactNode } from 'react' + +import preview from '../../.storybook/preview' +import ManuscriptsTable, { + type ManuscriptsTableColumn, +} from '../../app/ui/shared/ManuscriptsTable' +import { MANUSCRIPT_STATUSES } from '../../app/ui/shared/ManuscriptStatus' +import { + reviewerStatusValues, + reviewerStatusTranslationKeys, +} from '../../app/ui/shared/_constants' +import Link from '../../app/ui/shared/Link' + +const manuscriptStatusOptions = MANUSCRIPT_STATUSES.map(status => ({ + value: status, + label: status, +})) + +const reviewerStatusOptions = reviewerStatusValues.map(status => ({ + value: status, + label: reviewerStatusTranslationKeys[status], +})) + +const meta = preview.meta({ + component: ManuscriptsTable, +}) + +type Row = { + key: string + manuscriptNumber: number + title: string + status: string + created: string + updated: string +} + +const columns: ManuscriptsTableColumn[] = [ + { + title: 'No.', + dataIndex: 'manuscriptNumber', + key: 'manuscriptNumber', + align: 'center', + }, + { title: 'Title', dataIndex: 'title', key: 'title' }, + { title: 'Status', dataIndex: 'status', key: 'status', dataType: 'status' }, + { title: 'Created', dataIndex: 'created', key: 'created', dataType: 'date' }, + { title: 'Updated', dataIndex: 'updated', key: 'updated', dataType: 'date' }, + { + title: 'Actions', + dataIndex: 'actions', + key: 'actions', + render: () => View, + }, +] + +const allData: Row[] = [ + { + key: '1', + manuscriptNumber: 101, + title: 'Honey bee colonies benefit from grassland', + status: 'submitted', + created: '2026-07-01', + updated: '2026-07-20', + }, + { + key: '2', + manuscriptNumber: 102, + title: 'A dataset of pollinator visitation rates', + status: 'accepted', + created: '2026-06-15', + updated: '2026-07-18', + }, + { + key: '3', + manuscriptNumber: 103, + title: 'BeeTrack: software for tracking pollinators', + status: 'inProgress', + created: '2026-05-02', + updated: '2026-07-10', + }, + { + key: '4', + manuscriptNumber: 104, + title: 'Neuropsychological effects of urban noise', + status: 'rejected', + created: '2026-04-11', + updated: '2026-06-30', + }, + { + key: '5', + manuscriptNumber: 105, + title: 'Climate variability dataset for the Alps', + status: 'completed', + created: '2026-03-22', + updated: '2026-05-14', + }, +] + +const PAGE_SIZE = 3 + +export const Base = meta.story({ + args: { + columns, + dataSource: allData, + page: 1, + pageSize: PAGE_SIZE, + totalCount: allData.length, + onPageChange: () => {}, + onSearch: () => {}, + }, + render: args => { + const [page, setPage] = useState(1) + const [search, setSearch] = useState('') + const [loading, setLoading] = useState(false) + + const filtered = allData.filter(row => + row.title.toLowerCase().includes(search.toLowerCase()), + ) + + const pageData = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE) + + const handleSearch = (value: string): void => { + setLoading(true) + + setTimeout(() => { + setSearch(value) + setPage(1) + setLoading(false) + }, 1000) + } + + const handlePageChange = (newPage: number): void => { + setLoading(true) + + setTimeout(() => { + setPage(newPage) + setLoading(false) + }, 1000) + } + + return ( + + ) + }, +}) + +const baseColumns: ManuscriptsTableColumn[] = [ + { + title: 'No.', + dataIndex: 'manuscriptNumber', + key: 'manuscriptNumber', + align: 'center', + }, + { title: 'Title', dataIndex: 'title', key: 'title' }, +] + +const baseRow = { + key: '1', + manuscriptNumber: 101, + title: 'Honey bee colonies benefit from grassland', +} + +export const DefaultTextRendering = meta.story({ + args: { + columns: baseColumns, + dataSource: [baseRow], + page: 1, + pageSize: 10, + totalCount: 1, + onPageChange: () => {}, + onSearch: () => {}, + }, +}) + +export const CenteredColumn = meta.story({ + args: { + columns: [baseColumns[0], { ...baseColumns[1], align: 'center' }], + dataSource: [baseRow], + page: 1, + pageSize: 10, + totalCount: 1, + onPageChange: () => {}, + onSearch: () => {}, + }, +}) + +export const AlignRightColumn = meta.story({ + args: { + columns: [baseColumns[0], { ...baseColumns[1], align: 'right' }], + dataSource: [baseRow], + page: 1, + pageSize: 10, + totalCount: 1, + onPageChange: () => {}, + onSearch: () => {}, + }, +}) + +export const BuiltInDateRendering = meta.story({ + args: { + columns: [ + ...baseColumns, + { + title: 'Created', + dataIndex: 'created', + key: 'created', + dataType: 'date', + }, + ], + dataSource: [{ ...baseRow, created: '2026-07-01' }], + page: 1, + pageSize: 10, + totalCount: 1, + onPageChange: () => {}, + onSearch: () => {}, + }, +}) + +/** + * `filterable` on a column with the `date` data type shows a date-range picker. + * No `options` needed here. + */ +export const BuiltInDateRenderingWithFilter = meta.story({ + args: { + columns: [ + ...baseColumns, + { + title: 'Created', + dataIndex: 'created', + key: 'created', + dataType: 'date', + filterable: true, + }, + ], + dataSource: allData, + page: 1, + pageSize: 10, + totalCount: allData.length, + onPageChange: () => {}, + onSearch: () => {}, + }, + render: args => { + const [columnFilters, setColumnFilters] = useState< + Record + >({}) + + const [start, end] = columnFilters.created ?? [] + + const filtered = + start && end + ? allData.filter(row => row.created >= start && row.created <= end) + : allData + + return ( + + ) + }, +}) + +export const BuiltInStatusRendering = meta.story({ + args: { + columns: [ + ...baseColumns, + { + title: 'Status', + dataIndex: 'status', + key: 'status', + dataType: 'status', + }, + ], + dataSource: [{ ...baseRow, status: 'submitted' }], + page: 1, + pageSize: 10, + totalCount: 1, + onPageChange: () => {}, + onSearch: () => {}, + }, +}) + +export const BuiltInStatusRenderingWithFilter = meta.story({ + args: { + columns: [ + ...baseColumns, + { + title: 'Status', + dataIndex: 'status', + key: 'status', + dataType: 'status', + filterable: true, + options: manuscriptStatusOptions, + }, + ], + dataSource: allData, + page: 1, + pageSize: 10, + totalCount: allData.length, + onPageChange: () => {}, + onSearch: () => {}, + }, + render: args => { + const [columnFilters, setColumnFilters] = useState< + Record + >({}) + + const selectedStatuses = columnFilters.status ?? [] + + const filtered = + selectedStatuses.length > 0 + ? allData.filter(row => selectedStatuses.includes(row.status)) + : allData + + return ( + + ) + }, +}) + +export const BuiltInReviewerStatusRendering = meta.story({ + args: { + columns: [ + ...baseColumns, + { + title: 'Reviewer status', + dataIndex: 'reviewerStatus', + key: 'reviewerStatus', + dataType: 'reviewerStatus', + }, + ], + dataSource: [{ ...baseRow, reviewerStatus: 'invited' }], + page: 1, + pageSize: 10, + totalCount: 1, + onPageChange: () => {}, + onSearch: () => {}, + }, +}) + +export const BuiltInReviewerStatusRenderingWithFilter = meta.story({ + args: { + columns: [ + ...baseColumns, + { + title: 'Reviewer status', + dataIndex: 'reviewerStatus', + key: 'reviewerStatus', + dataType: 'reviewerStatus', + filterable: true, + options: reviewerStatusOptions, + }, + ], + dataSource: [ + { ...baseRow, reviewerStatus: 'invited' }, + { + key: '2', + manuscriptNumber: 102, + title: 'A dataset of pollinator visitation rates', + reviewerStatus: 'accepted', + }, + { + key: '3', + manuscriptNumber: 103, + title: 'BeeTrack: software for tracking pollinators', + reviewerStatus: 'completed', + }, + ], + page: 1, + pageSize: 10, + totalCount: 3, + onPageChange: () => {}, + onSearch: () => {}, + }, + render: args => { + const [columnFilters, setColumnFilters] = useState< + Record + >({}) + + const selectedStatuses = columnFilters.reviewerStatus ?? [] + + const allRows = args.dataSource + + const filtered = + selectedStatuses.length > 0 + ? allRows.filter(row => selectedStatuses.includes(row.reviewerStatus)) + : allRows + + return ( + + ) + }, +}) + +export const BuiltInBadgeRendering = meta.story({ + args: { + columns: [ + ...baseColumns, + { + title: 'ADA status', + dataIndex: 'adaStatus', + key: 'adaStatus', + dataType: 'badge', + }, + ], + dataSource: [{ ...baseRow, adaStatus: 'Compliant' }], + page: 1, + pageSize: 10, + totalCount: 1, + onPageChange: () => {}, + onSearch: () => {}, + }, +}) + +const typeOptions = [ + { value: 'dataset', label: 'Dataset', labelColor: '#3f21d4' }, + { value: 'software', label: 'Software', labelColor: '#e901ca' }, + { value: 'article', label: 'Research article' }, +] + +const typeData = [ + { ...baseRow, type: 'dataset' }, + { + key: '2', + manuscriptNumber: 102, + title: 'BeeTrack: software for tracking pollinators', + type: 'software', + }, + { + key: '3', + manuscriptNumber: 103, + title: 'Neuropsychological effects of urban noise', + type: 'article', + }, +] + +export const BuiltInOptionsRendering = meta.story({ + args: { + columns: [ + ...baseColumns, + { + title: 'Type', + dataIndex: 'type', + key: 'type', + dataType: 'options', + options: typeOptions, + }, + ], + dataSource: typeData.slice(0, 2), + page: 1, + pageSize: 10, + totalCount: 2, + onPageChange: () => {}, + onSearch: () => {}, + }, +}) + +/** + * Any column can be `filterable` as long as it has an `options` key. + */ +export const BuiltInOptionsRenderingWithFilter = meta.story({ + args: { + columns: [ + ...baseColumns, + { + title: 'Type', + dataIndex: 'type', + key: 'type', + dataType: 'options', + filterable: true, + options: typeOptions, + }, + ], + dataSource: typeData, + page: 1, + pageSize: 10, + totalCount: typeData.length, + onPageChange: () => {}, + onSearch: () => {}, + }, + render: args => { + const [columnFilters, setColumnFilters] = useState< + Record + >({}) + + const selectedTypes = columnFilters.type ?? [] + + const filtered = + selectedTypes.length > 0 + ? typeData.filter(row => selectedTypes.includes(row.type)) + : typeData + + return ( + + ) + }, +}) + +/** + * Action uses a custom render function + */ +export const CustomRendering = meta.story({ + args: { + columns: [ + ...baseColumns, + { + title: 'Actions', + dataIndex: 'actions', + key: 'actions', + render: (): ReactNode => View, + }, + ], + dataSource: [baseRow], + page: 1, + pageSize: 10, + totalCount: 1, + onPageChange: () => {}, + onSearch: () => {}, + }, +}) + +export const WithFiltersApplied = meta.story({ + args: { + columns: [ + ...baseColumns, + { + title: 'Status', + dataIndex: 'status', + key: 'status', + dataType: 'status', + filterable: true, + options: manuscriptStatusOptions, + }, + { + title: 'Created', + dataIndex: 'created', + key: 'created', + dataType: 'date', + filterable: true, + }, + ], + dataSource: allData, + page: 1, + pageSize: 10, + totalCount: allData.length, + onPageChange: () => {}, + onSearch: () => {}, + }, + render: args => { + const [columnFilters, setColumnFilters] = useState< + Record + >({ + status: ['submitted', 'accepted'], + created: ['2026-06-01', '2026-07-31'], + }) + + const selectedStatuses = columnFilters.status ?? [] + const [start, end] = columnFilters.created ?? [] + + const filtered = allData + .filter(row => + selectedStatuses.length > 0 + ? selectedStatuses.includes(row.status) + : true, + ) + .filter(row => + start && end ? row.created >= start && row.created <= end : true, + ) + + return ( + + ) + }, +}) + +/** + * Show why a row matched + */ + +export const WithSearchSnippets = meta.story({ + args: { + columns: baseColumns, + dataSource: [ + { + ...baseRow, + searchSnippets: [ + { + field: 'Title', + html: 'Honey bee colonies benefit from grassland', + }, + { + field: 'Abstract', + html: '...pollinators depend on nearby grassland habitats for forage during the dry season...', + }, + ], + }, + { + key: '2', + manuscriptNumber: 102, + title: 'A dataset of pollinator visitation rates', + }, + ], + page: 1, + pageSize: 10, + totalCount: 2, + onPageChange: () => {}, + onSearch: () => {}, + }, +}) diff --git a/packages/client/yarn.lock b/packages/client/yarn.lock index 2f79f2b69..2dcf7d1e3 100644 --- a/packages/client/yarn.lock +++ b/packages/client/yarn.lock @@ -4050,6 +4050,13 @@ __metadata: languageName: node linkType: hard +"dayjs@npm:^1.11.21": + version: 1.11.21 + resolution: "dayjs@npm:1.11.21" + checksum: 10c0/bd97dfdc4bfea3c66268635690313828b386faa040fbc1f829ff42a2bd748b72c9d9b3c8f9616ce9e61fcb78923f1461a462c969c54b1084458ae1b715898fb0 + languageName: node + linkType: hard + "debug@npm:4, debug@npm:^4.3.1, debug@npm:^4.3.2": version: 4.3.6 resolution: "debug@npm:4.3.6" @@ -5396,6 +5403,7 @@ __metadata: antd: "npm:^6.3.7" cheerio: "npm:1.0.0-rc.10" color: "npm:^3.0.0" + dayjs: "npm:^1.11.21" file-saver: "npm:^2.0.5" file-type: "npm:^20.4.1" fnv-plus: "npm:^1.3.1" From 71b3e4661e0affd4afe1fb877797697b1c7523e5 Mon Sep 17 00:00:00 2001 From: yannis Date: Thu, 6 Aug 2026 11:32:57 +0300 Subject: [PATCH 06/51] feat(ui): add person datatype to new table --- .../client/app/ui/shared/ManuscriptsTable.tsx | 46 +++++++++++++++++-- .../shared/ManuscriptsTable.stories.tsx | 38 +++++++++++++++ 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/packages/client/app/ui/shared/ManuscriptsTable.tsx b/packages/client/app/ui/shared/ManuscriptsTable.tsx index 4faf201ca..fb2acfd14 100644 --- a/packages/client/app/ui/shared/ManuscriptsTable.tsx +++ b/packages/client/app/ui/shared/ManuscriptsTable.tsx @@ -8,6 +8,7 @@ import dayjs from 'dayjs' import Table from './Table' import Badge from './Badge' +import Avatar from './Avatar' import { Close } from '../base/Icons' import ManuscriptStatus from './ManuscriptStatus' import ReviewerStatus from './ReviewerStatus' @@ -30,8 +31,6 @@ export type ManuscriptsTableColumn = { * * Candidate future dataTypes (not yet implemented), identified by comparing against the * legacy component-manuscripts-table cell renderers: - * - 'person': avatar + name + secondary line (e.g. email/ORCID), for user-object columns - * like submitter/editor. See Submitter.jsx. * - 'flag': boolean -> icon + tooltip, renders nothing when false. See OverdueTooltip.jsx. * - 'valueList': an array of independently-colored/rich-text values in one cell (richer * version of 'options' -- handles a list, not a single lookup). See DefaultField.jsx. @@ -39,7 +38,13 @@ export type ManuscriptsTableColumn = { * hyperlinked. See TitleWithAbstractAsTooltip.jsx. * - 'chart'/'donut': a small chart summarizing categorical counts. See ReviewStatusDonut.jsx. */ - dataType?: 'date' | 'status' | 'reviewerStatus' | 'badge' | 'options' + dataType?: + | 'date' + | 'status' + | 'reviewerStatus' + | 'badge' + | 'options' + | 'person' options?: ManuscriptsTableColumnOption[] /** Applies to 'options', 'status', 'reviewerStatus' and 'date' datatypes */ filterable?: boolean @@ -132,6 +137,38 @@ const renderOptions = ( return option?.label ?? value } +const PersonWrapper = styled.div` + display: flex; + align-items: center; + gap: ${grid(2)}; +` + +const PersonInfo = styled.div` + display: flex; + flex-direction: column; +` + +const PersonIdentifier = styled.span` + color: ${th('colorTextPlaceholder')}; + font-size: ${th('fontSizeBaseSmaller')}; +` + +const renderPerson = (value: any): ReactNode => { + if (!value) return null + + return ( + + + + {value.displayName} + {value.orcid && ( + {`ORCID: ${value.orcid}`} + )} + + + ) +} + const { RangePicker } = DatePicker const DateRangeFilterWrapper = styled.div` @@ -249,6 +286,9 @@ const resolveColumn = ( case 'badge': resolved = { ...column, render: renderBadge } break + case 'person': + resolved = { ...column, render: renderPerson } + break case 'options': resolved = { ...column, diff --git a/packages/client/stories/shared/ManuscriptsTable.stories.tsx b/packages/client/stories/shared/ManuscriptsTable.stories.tsx index 2f4475ccd..89248e46b 100644 --- a/packages/client/stories/shared/ManuscriptsTable.stories.tsx +++ b/packages/client/stories/shared/ManuscriptsTable.stories.tsx @@ -12,6 +12,7 @@ import { reviewerStatusTranslationKeys, } from '../../app/ui/shared/_constants' import Link from '../../app/ui/shared/Link' +import avatarImg from '../assets/avatar_sample.png' const manuscriptStatusOptions = MANUSCRIPT_STATUSES.map(status => ({ value: status, @@ -437,6 +438,43 @@ export const BuiltInBadgeRendering = meta.story({ }, }) +export const BuiltInPersonRendering = meta.story({ + args: { + columns: [ + ...baseColumns, + { + title: 'Submitter', + dataIndex: 'submitter', + key: 'submitter', + dataType: 'person', + }, + ], + dataSource: [ + { + ...baseRow, + submitter: { + displayName: 'Jane Doe', + profilePicture: avatarImg, + orcid: '0000-0001-2345-6789', + }, + }, + { + key: '2', + manuscriptNumber: 102, + title: 'A dataset of pollinator visitation rates', + submitter: { + displayName: 'John Smith', + }, + }, + ], + page: 1, + pageSize: 10, + totalCount: 2, + onPageChange: () => {}, + onSearch: () => {}, + }, +}) + const typeOptions = [ { value: 'dataset', label: 'Dataset', labelColor: '#3f21d4' }, { value: 'software', label: 'Software', labelColor: '#e901ca' }, From a80b8bb6c534f1bfd7d425c5f50abd202a23aa5e Mon Sep 17 00:00:00 2001 From: yannis Date: Fri, 7 Aug 2026 14:25:16 +0300 Subject: [PATCH 07/51] feat(ui): new reviewer status summary in manuscripts table --- packages/client/app/i18n/en/translation.js | 2 + packages/client/app/i18n/es-la/translation.js | 2 + packages/client/app/i18n/fr/translation.js | 2 + packages/client/app/i18n/ru/translation.js | 2 + packages/client/app/ui/base/Icons.tsx | 1 + .../client/app/ui/shared/ManuscriptsTable.tsx | 323 +++++++++++++++++- .../shared/ManuscriptsTable.stories.tsx | 83 +++++ 7 files changed, 408 insertions(+), 7 deletions(-) diff --git a/packages/client/app/i18n/en/translation.js b/packages/client/app/i18n/en/translation.js index f8032a3af..0d4f94eee 100644 --- a/packages/client/app/i18n/en/translation.js +++ b/packages/client/app/i18n/en/translation.js @@ -193,6 +193,8 @@ const en = { 'No results found': 'No results found', pagination: `Showing {{firstResult}} to {{lastResult}} of {{totalCount}} results`, removeFilter: 'Remove filter', + compactView: 'Compact view', + detailedView: 'Detailed view', reviewAccept: 'Accept', reviewReject: 'Decline', reviewDo: 'Do Review', diff --git a/packages/client/app/i18n/es-la/translation.js b/packages/client/app/i18n/es-la/translation.js index 253cabb29..c0ba98891 100644 --- a/packages/client/app/i18n/es-la/translation.js +++ b/packages/client/app/i18n/es-la/translation.js @@ -193,6 +193,8 @@ const esLa = { 'No results found': 'No se encontraron resultados', pagination: `Mostrando {{firstResult}} a {{lastResult}} de {{totalCount}} resultados`, removeFilter: 'Eliminar filtro', + compactView: 'Vista compacta', + detailedView: 'Vista detallada', reviewAccept: 'Aceptar', reviewReject: 'Rechazar', reviewDo: 'Hacer Revisión', diff --git a/packages/client/app/i18n/fr/translation.js b/packages/client/app/i18n/fr/translation.js index 0032457cb..bbef7a8e9 100644 --- a/packages/client/app/i18n/fr/translation.js +++ b/packages/client/app/i18n/fr/translation.js @@ -193,6 +193,8 @@ const fr = { 'No results found': 'Aucun résultat trouvé', pagination: `Affichage de {{firstResult}} à {{lastResult}} sur {{totalCount}} résultats`, removeFilter: 'Supprimer le filtre', + compactView: 'Vue compacte', + detailedView: 'Vue détaillée', reviewAccept: 'Accepter', reviewReject: 'Rejeter', reviewDo: 'Faire la révision', diff --git a/packages/client/app/i18n/ru/translation.js b/packages/client/app/i18n/ru/translation.js index 5aa37a4e4..5aca0b41b 100644 --- a/packages/client/app/i18n/ru/translation.js +++ b/packages/client/app/i18n/ru/translation.js @@ -193,6 +193,8 @@ const ru = { 'No results found': 'Результаты не найдены', pagination: `Показано {{lastResult}} из {{totalCount}} результата`, removeFilter: 'Удалить фильтр', + compactView: 'Компактный вид', + detailedView: 'Подробный вид', pagination_few: `Показано {{lastResult}} из {{totalCount}} результатов`, pagination_many: `Показано {{lastResult}} из {{totalCount}} результатов`, reviewAccept: 'Принять', diff --git a/packages/client/app/ui/base/Icons.tsx b/packages/client/app/ui/base/Icons.tsx index 64bfab74d..77dea6349 100644 --- a/packages/client/app/ui/base/Icons.tsx +++ b/packages/client/app/ui/base/Icons.tsx @@ -9,6 +9,7 @@ export { FormOutlined as Form, HomeOutlined as Home, ReadOutlined as Book, + TableOutlined as Table, UnorderedListOutlined as Tasks, UserOutlined as User, VerticalAlignTopOutlined as ExpandMenu, diff --git a/packages/client/app/ui/shared/ManuscriptsTable.tsx b/packages/client/app/ui/shared/ManuscriptsTable.tsx index fb2acfd14..ace1604e9 100644 --- a/packages/client/app/ui/shared/ManuscriptsTable.tsx +++ b/packages/client/app/ui/shared/ManuscriptsTable.tsx @@ -2,16 +2,30 @@ import { type ReactNode, type MouseEvent, Fragment } from 'react' import { useTranslation, Trans } from 'react-i18next' import styled from 'styled-components' import { th, grid } from '@coko/client' -import { DatePicker, type TableColumnType } from 'antd' +import { + ConfigProvider, + DatePicker, + Radio, + Tooltip, + type TableColumnType, +} from 'antd' import type { FilterDropdownProps } from 'antd/es/table/interface' +import type { RadioChangeEvent } from 'antd/es/radio' import dayjs from 'dayjs' import Table from './Table' import Badge from './Badge' import Avatar from './Avatar' -import { Close } from '../base/Icons' +import { Close, Table as TableIcon, Tasks } from '../base/Icons' import ManuscriptStatus from './ManuscriptStatus' import ReviewerStatus from './ReviewerStatus' +import { + type ReviewerStatusValue, + reviewerStatusTranslationKeys, + reviewerStatusVariants, + badgeVariantColorTokens, + badgeDefaultColorToken, +} from './_constants' import { convertTimestampToRelativeDateString } from '../../shared/dateUtils' type ManuscriptsTableColumnOption = { @@ -36,12 +50,12 @@ export type ManuscriptsTableColumn = { * version of 'options' -- handles a list, not a single lookup). See DefaultField.jsx. * - 'tooltip'/'linkWithTooltip': text with a hover tooltip revealing more detail, optionally * hyperlinked. See TitleWithAbstractAsTooltip.jsx. - * - 'chart'/'donut': a small chart summarizing categorical counts. See ReviewStatusDonut.jsx. */ dataType?: | 'date' | 'status' | 'reviewerStatus' + | 'reviewerStatusSummary' | 'badge' | 'options' | 'person' @@ -97,10 +111,22 @@ type ManuscriptsTableProps = { onSearch: (value: string) => void bordered?: boolean loading?: boolean - /** Currently selected filter values, keyed by column key (controlled), e.g. - * { status: ['accepted', 'rejected'] }. Only relevant for columns with `filterable: true`. */ + /** + * Currently selected filter values, keyed by column key (controlled), e.g. + * { status: ['accepted', 'rejected'] }. Only relevant for columns with + * `filterable: true`. + */ columnFilters?: Record onFiltersChange?: (filters: Record) => void + /** + * Compact/detailed view mode for 'reviewerStatusSummary' columns. + * Defaults to 'detailed'. + */ + reviewerStatusViewMode?: Record + onReviewerStatusViewModeChange?: ( + columnKey: string, + viewMode: 'compact' | 'detailed', + ) => void } const renderDate = (value: any): ReactNode => { @@ -116,6 +142,166 @@ const renderReviewerStatus = (value: any): ReactNode => ( ) +const ReviewerStatusGridWrapper = styled.div` + display: inline-flex; + flex-wrap: wrap; + gap: ${grid(1)}; + max-width: calc(${grid(4)} * 5 + ${grid(1)} * 4); +` + +const ReviewerStatusSquare = styled.span<{ $colorToken: string }>` + display: inline-block; + width: ${grid(4)}; + height: ${grid(4)}; + border-radius: ${th('borderRadius')}; + background-color: ${(props: { theme: any; $colorToken: string }): string => + props.theme[props.$colorToken]}; +` + +const ReviewerStatusTooltipList = styled.div<{ $columns: number }>` + display: grid; + grid-template-columns: ${(props: { $columns: number }): string => + `repeat(${props.$columns}, minmax(0, 1fr))`}; + gap: ${grid(4)}; + padding: ${grid(2)}; +` + +const ReviewerStatusTooltipRow = styled.div` + display: flex; + align-items: center; + gap: ${grid(2)}; + padding-bottom: ${grid(1)}; + margin-bottom: ${grid(1)}; + border-bottom: 1px solid ${th('colorBorder')}; + font-size: ${th('fontSizeBaseSmall')}; +` + +const ReviewerStatusTooltipSwatch = styled.span<{ $colorToken: string }>` + flex-shrink: 0; + width: ${grid(3)}; + height: ${grid(3)}; + border-radius: ${th('borderRadius')}; + background-color: ${(props: { theme: any; $colorToken: string }): string => + props.theme[props.$colorToken]}; +` + +const ReviewerStatusTooltipGroup = styled.div` + display: flex; + flex-direction: column; + gap: ${grid(1)}; +` + +const ReviewerStatusTooltipMembers = styled.div` + display: flex; + flex-direction: column; + font-size: ${th('fontSizeBaseSmaller')}; +` + +const ReviewerStatusTooltipCompactList = styled.div` + display: flex; + flex-direction: column; + gap: ${grid(1)}; + padding: ${grid(2)}; + font-size: ${th('fontSizeBaseSmall')}; +` + +const ReviewerStatusTooltipCompactRow = styled.div` + display: flex; + align-items: center; + gap: ${grid(2)}; +` + +const resolveReviewerStatusColorToken = ( + status: ReviewerStatusValue, +): string => { + const variant = reviewerStatusVariants[status] + return variant ? badgeVariantColorTokens[variant] : badgeDefaultColorToken +} + +type ReviewerStatusEntry = { + status: ReviewerStatusValue + name: string +} + +const ReviewerStatusSummary = ({ + value, + isCompact, +}: { + value: any + isCompact: boolean +}): ReactNode => { + const { t } = useTranslation() + + if (!Array.isArray(value) || value.length === 0) return null + + const reviewers = value as ReviewerStatusEntry[] + + const groups: Partial> = {} + + reviewers.forEach(reviewer => { + groups[reviewer.status] = [...(groups[reviewer.status] ?? []), reviewer] + }) + + const groupEntries = Object.entries(groups) as [ + ReviewerStatusValue, + ReviewerStatusEntry[], + ][] + + const content = isCompact ? ( + + {groupEntries.map(([status, members]) => ( + + + + {members.length}{' '} + {t(reviewerStatusTranslationKeys[status])} + + + ))} + + ) : ( + + {groupEntries.map(([status, members]) => ( + + + + + {members.length}{' '} + {t(reviewerStatusTranslationKeys[status])} + + + + {members.map(member => ( + {member.name} + ))} + + + ))} + + ) + + return ( + + + + {reviewers.map(({ status, name }) => ( + + ))} + + + + ) +} + const renderBadge = (value: any): ReactNode => {value} const renderOptions = ( @@ -266,9 +452,91 @@ const SearchSnippets = ({ const hasSearchSnippets = (row: Record): boolean => Array.isArray(row.searchSnippets) && row.searchSnippets.length > 0 +const ReviewerStatusColumnHeaderWrapper = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: ${grid(2)}; +` + +const RadioGroup = styled(Radio.Group)` + /* stylelint-disable declaration-no-important */ + .ant-radio-button-wrapper { + background-color: ${th('colorPrimary')} !important; + color: ${th('colorTextReverse')} !important; + border: 1px solid transparent; + transition: border 0.2s ease; + + span[role='img'] { + margin-top: 3px; + } + + &:hover { + border: 1px solid ${th('colorTextReverse')}; + } + } + + .ant-radio-button-wrapper-checked { + background-color: ${th('colorTextReverse')} !important; + color: ${th('colorPrimary')} !important; + border-color: ${th('colorTextReverse')} !important; + } +` + +const ReviewerStatusColumnHeader = ({ + title, + isCompact, + onChange, +}: { + title: string + isCompact: boolean + onChange: (isCompact: boolean) => void +}): ReactNode => { + const { t } = useTranslation() + + return ( + + {title} + + onChange(event.target.value === 'compact') + } + optionType="button" + size="small" + value={isCompact ? 'compact' : 'detailed'} + > + + + + + + + + + + + + + ) +} + const resolveColumn = ( column: ManuscriptsTableColumn, - context: { columnFilters?: Record }, + context: { + columnFilters?: Record + reviewerStatusViewMode?: Record + onReviewerStatusViewModeChange?: ( + columnKey: string, + viewMode: 'compact' | 'detailed', + ) => void + }, ): TableColumnType> => { let resolved: ManuscriptsTableColumn = column @@ -283,6 +551,20 @@ const resolveColumn = ( case 'reviewerStatus': resolved = { ...column, render: renderReviewerStatus } break + case 'reviewerStatusSummary': + resolved = { + ...column, + render: (value: any): ReactNode => ( + + ), + } + break case 'badge': resolved = { ...column, render: renderBadge } break @@ -322,6 +604,27 @@ const resolveColumn = ( } } + if (column.dataType === 'reviewerStatusSummary') { + return { + ...resolved, + title: ( + + context.onReviewerStatusViewModeChange?.( + column.key, + isCompact ? 'compact' : 'detailed', + ) + } + title={column.title} + /> + ), + } + } + return resolved } @@ -419,6 +722,8 @@ const ManuscriptsTable = ({ loading = false, columnFilters, onFiltersChange, + reviewerStatusViewMode, + onReviewerStatusViewModeChange, }: ManuscriptsTableProps): ReactNode => { const { t } = useTranslation() @@ -457,7 +762,11 @@ const ManuscriptsTable = ({ } const resolvedColumns = columns.map(column => - resolveColumn(column, { columnFilters }), + resolveColumn(column, { + columnFilters, + reviewerStatusViewMode, + onReviewerStatusViewModeChange, + }), ) const filterChips = diff --git a/packages/client/stories/shared/ManuscriptsTable.stories.tsx b/packages/client/stories/shared/ManuscriptsTable.stories.tsx index 89248e46b..7e23b20bb 100644 --- a/packages/client/stories/shared/ManuscriptsTable.stories.tsx +++ b/packages/client/stories/shared/ManuscriptsTable.stories.tsx @@ -418,6 +418,89 @@ export const BuiltInReviewerStatusRenderingWithFilter = meta.story({ }, }) +/** + * One square per reviewer, colored by status. Hover the grid for a tooltip grouping reviewers + * by status, with each reviewer's name listed underneath their group's total. + */ +export const BuiltInReviewerStatusSummaryRendering = meta.story({ + args: { + columns: [ + ...baseColumns, + { + title: 'Reviewers', + dataIndex: 'reviewerStatuses', + key: 'reviewerStatuses', + dataType: 'reviewerStatusSummary', + }, + ], + dataSource: [ + { + ...baseRow, + reviewerStatuses: [ + { status: 'completed', name: 'Maria Alvarez' }, + { status: 'completed', name: 'Tom Ellison' }, + { status: 'invited', name: 'Priya Nair' }, + { status: 'rejected', name: 'Sam Okafor' }, + ], + }, + { + key: '2', + manuscriptNumber: 102, + title: 'A dataset of pollinator visitation rates', + reviewerStatuses: [ + { status: 'accepted', name: 'Lucas Bergmann' }, + { status: 'accepted', name: 'Naledi Dube' }, + ], + }, + { + key: '3', + manuscriptNumber: 103, + title: 'BeeTrack: software for tracking pollinators', + reviewerStatuses: [], + }, + { + key: '4', + manuscriptNumber: 104, + title: 'Neuropsychological effects of urban noise', + reviewerStatuses: [ + { status: 'completed', name: 'Maria Alvarez' }, + { status: 'completed', name: 'Tom Ellison' }, + { status: 'completed', name: 'Priya Nair' }, + { status: 'accepted', name: 'Sam Okafor' }, + { status: 'accepted', name: 'Lucas Bergmann' }, + { status: 'inProgress', name: 'Naledi Dube' }, + { status: 'invited', name: 'Haruto Sato' }, + { status: 'invited', name: 'Elena Petrova' }, + { status: 'rejected', name: 'David Kim' }, + ], + }, + ], + page: 1, + pageSize: 10, + totalCount: 4, + onPageChange: () => {}, + onSearch: () => {}, + }, + render: args => { + const [reviewerStatusViewMode, setReviewerStatusViewMode] = useState< + Record + >({}) + + return ( + + setReviewerStatusViewMode(previous => ({ + ...previous, + [columnKey]: viewMode, + })) + } + reviewerStatusViewMode={reviewerStatusViewMode} + /> + ) + }, +}) + export const BuiltInBadgeRendering = meta.story({ args: { columns: [ From 58d7f0c0154f8ae8c8ef2b2d7347fd08ab50f418 Mon Sep 17 00:00:00 2001 From: yannis Date: Fri, 7 Aug 2026 17:32:00 +0300 Subject: [PATCH 08/51] feat(ui): add title rendering options to new table --- packages/client/app/i18n/en/translation.js | 4 + packages/client/app/i18n/es-la/translation.js | 4 + packages/client/app/i18n/fr/translation.js | 4 + packages/client/app/i18n/ru/translation.js | 4 + packages/client/app/ui/base/Icons.tsx | 35 +++-- .../client/app/ui/shared/ManuscriptsTable.tsx | 135 +++++++++++++++++- .../shared/ManuscriptsTable.stories.tsx | 65 +++++++++ 7 files changed, 236 insertions(+), 15 deletions(-) diff --git a/packages/client/app/i18n/en/translation.js b/packages/client/app/i18n/en/translation.js index 0d4f94eee..f7889fe9e 100644 --- a/packages/client/app/i18n/en/translation.js +++ b/packages/client/app/i18n/en/translation.js @@ -194,6 +194,10 @@ const en = { pagination: `Showing {{firstResult}} to {{lastResult}} of {{totalCount}} results`, removeFilter: 'Remove filter', compactView: 'Compact view', + overdueTasks: 'Overdue tasks', + noAbstract: 'No abstract provided', + abstractHeader: 'Abstract', + showAbstract: 'Show abstract', detailedView: 'Detailed view', reviewAccept: 'Accept', reviewReject: 'Decline', diff --git a/packages/client/app/i18n/es-la/translation.js b/packages/client/app/i18n/es-la/translation.js index c0ba98891..4344f41db 100644 --- a/packages/client/app/i18n/es-la/translation.js +++ b/packages/client/app/i18n/es-la/translation.js @@ -194,6 +194,10 @@ const esLa = { pagination: `Mostrando {{firstResult}} a {{lastResult}} de {{totalCount}} resultados`, removeFilter: 'Eliminar filtro', compactView: 'Vista compacta', + overdueTasks: 'Tareas vencidas', + noAbstract: 'No se proporcionó ningún resumen', + abstractHeader: 'Resumen', + showAbstract: 'Mostrar resumen', detailedView: 'Vista detallada', reviewAccept: 'Aceptar', reviewReject: 'Rechazar', diff --git a/packages/client/app/i18n/fr/translation.js b/packages/client/app/i18n/fr/translation.js index bbef7a8e9..02b7f73d3 100644 --- a/packages/client/app/i18n/fr/translation.js +++ b/packages/client/app/i18n/fr/translation.js @@ -194,6 +194,10 @@ const fr = { pagination: `Affichage de {{firstResult}} à {{lastResult}} sur {{totalCount}} résultats`, removeFilter: 'Supprimer le filtre', compactView: 'Vue compacte', + overdueTasks: 'Tâches en retard', + noAbstract: 'Aucun résumé fourni', + abstractHeader: 'Résumé', + showAbstract: 'Afficher le résumé', detailedView: 'Vue détaillée', reviewAccept: 'Accepter', reviewReject: 'Rejeter', diff --git a/packages/client/app/i18n/ru/translation.js b/packages/client/app/i18n/ru/translation.js index 5aca0b41b..e1fd186b0 100644 --- a/packages/client/app/i18n/ru/translation.js +++ b/packages/client/app/i18n/ru/translation.js @@ -194,6 +194,10 @@ const ru = { pagination: `Показано {{lastResult}} из {{totalCount}} результата`, removeFilter: 'Удалить фильтр', compactView: 'Компактный вид', + overdueTasks: 'Просроченные задачи', + noAbstract: 'Аннотация не предоставлена', + abstractHeader: 'Аннотация', + showAbstract: 'Показать аннотацию', detailedView: 'Подробный вид', pagination_few: `Показано {{lastResult}} из {{totalCount}} результатов`, pagination_many: `Показано {{lastResult}} из {{totalCount}} результатов`, diff --git a/packages/client/app/ui/base/Icons.tsx b/packages/client/app/ui/base/Icons.tsx index 77dea6349..97387d50f 100644 --- a/packages/client/app/ui/base/Icons.tsx +++ b/packages/client/app/ui/base/Icons.tsx @@ -1,4 +1,5 @@ import { type AriaAttributes, type ReactNode } from 'react' +import styled from 'styled-components' export { ArrowRightOutlined as ArrowRight, @@ -8,6 +9,7 @@ export { FileTextOutlined as File, FormOutlined as Form, HomeOutlined as Home, + InfoCircleOutlined as Info, ReadOutlined as Book, TableOutlined as Table, UnorderedListOutlined as Tasks, @@ -20,13 +22,24 @@ type IconProps = { 'aria-hidden'?: AriaAttributes['aria-hidden'] } +const IconWrapper = styled.span` + line-height: 0; + display: inline-flex; + vertical-align: middle; +` + export const Coar = ({ className, 'aria-hidden': ariaHidden, }: IconProps): ReactNode => { return ( - - + + - + ) } @@ -51,13 +64,13 @@ export const SemanticScholar = ({ 'aria-hidden': ariaHidden, }: IconProps): ReactNode => { return ( - - + + - + ) } diff --git a/packages/client/app/ui/shared/ManuscriptsTable.tsx b/packages/client/app/ui/shared/ManuscriptsTable.tsx index ace1604e9..5a06f96f8 100644 --- a/packages/client/app/ui/shared/ManuscriptsTable.tsx +++ b/packages/client/app/ui/shared/ManuscriptsTable.tsx @@ -16,7 +16,14 @@ import dayjs from 'dayjs' import Table from './Table' import Badge from './Badge' import Avatar from './Avatar' -import { Close, Table as TableIcon, Tasks } from '../base/Icons' +import { + Close, + Coar, + Info, + SemanticScholar, + Table as TableIcon, + Tasks, +} from '../base/Icons' import ManuscriptStatus from './ManuscriptStatus' import ReviewerStatus from './ReviewerStatus' import { @@ -45,11 +52,8 @@ export type ManuscriptsTableColumn = { * * Candidate future dataTypes (not yet implemented), identified by comparing against the * legacy component-manuscripts-table cell renderers: - * - 'flag': boolean -> icon + tooltip, renders nothing when false. See OverdueTooltip.jsx. * - 'valueList': an array of independently-colored/rich-text values in one cell (richer * version of 'options' -- handles a list, not a single lookup). See DefaultField.jsx. - * - 'tooltip'/'linkWithTooltip': text with a hover tooltip revealing more detail, optionally - * hyperlinked. See TitleWithAbstractAsTooltip.jsx. */ dataType?: | 'date' @@ -59,9 +63,15 @@ export type ManuscriptsTableColumn = { | 'badge' | 'options' | 'person' + | 'title' options?: ManuscriptsTableColumnOption[] /** Applies to 'options', 'status', 'reviewerStatus' and 'date' datatypes */ filterable?: boolean + /** + * Applies to the 'title' datatype -- shows an info icon that opens a tooltip + * with the manuscript's abstract on click. + */ + showAbstract?: boolean /** * Escape hatch for anything else (e.g. an actions column with * business-specific links) -- takes precedence over dataType. @@ -355,6 +365,115 @@ const renderPerson = (value: any): ReactNode => { ) } +const TitleWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + gap: ${grid(1)}; +` + +const TitleRow = styled.div` + display: flex; + align-items: center; + gap: ${grid(3)}; +` + +const CoarIcon = styled(Coar)` + margin-top: -2px; +` + +const TitleAbstractButton = styled.button` + display: inline-flex; + align-items: center; + padding: 0; + border: 0; + background: none; + color: inherit; + cursor: pointer; +` + +const TitleAbstractTooltipContent = styled.div` + padding: ${grid(4)}; +` + +const TitleAbstractTooltipHeader = styled.div` + border-bottom: 1px solid ${th('colorTextReverse')}; + margin-bottom: ${grid(1)}; +` + +const ABSTRACT_WORD_LIMIT = 60 + +const truncateAbstract = (abstract: string): string => { + const words = abstract.trim().split(/\s+/) + if (words.length <= ABSTRACT_WORD_LIMIT) return abstract + return `${words.slice(0, ABSTRACT_WORD_LIMIT).join(' ')}...` +} + +type TitleCellValue = { + title: string + hasOverdueTasks?: boolean + importSource?: 'coar' | 'semanticScholar' + abstract?: string +} + +const TitleCell = ({ + value, + showAbstract, +}: { + value: TitleCellValue + showAbstract?: boolean +}): ReactNode => { + const { t } = useTranslation() + + if (!value) return null + + return ( + + + {value.importSource === 'coar' && } + {value.importSource === 'semanticScholar' && ( + + )} + + {value.title} + + {showAbstract && ( + + + + {t('manuscriptsTable.abstractHeader')} + + {value.abstract + ? truncateAbstract(value.abstract) + : t('manuscriptsTable.noAbstract')} + + } + trigger={['click']} + > + + + + + + )} + + + {value.hasOverdueTasks && ( + + {t('manuscriptsTable.overdueTasks')} + + )} + + ) +} + const { RangePicker } = DatePicker const DateRangeFilterWrapper = styled.div` @@ -571,6 +690,14 @@ const resolveColumn = ( case 'person': resolved = { ...column, render: renderPerson } break + case 'title': + resolved = { + ...column, + render: (value: any): ReactNode => ( + + ), + } + break case 'options': resolved = { ...column, diff --git a/packages/client/stories/shared/ManuscriptsTable.stories.tsx b/packages/client/stories/shared/ManuscriptsTable.stories.tsx index 7e23b20bb..af72c18dc 100644 --- a/packages/client/stories/shared/ManuscriptsTable.stories.tsx +++ b/packages/client/stories/shared/ManuscriptsTable.stories.tsx @@ -207,6 +207,70 @@ export const AlignRightColumn = meta.story({ }, }) +/** + * Combines a title with an optional import-source icon, an "Overdue tasks" + * badge, and (with `showAbstract`) a click-triggered info tooltip showing the + * abstract. + */ +export const BuiltInTitleRendering = meta.story({ + args: { + columns: [ + baseColumns[0], + { + title: 'Title', + dataIndex: 'titleInfo', + key: 'titleInfo', + dataType: 'title', + showAbstract: true, + }, + ], + dataSource: [ + { + key: '1', + manuscriptNumber: 101, + titleInfo: { + title: 'Honey bee colonies benefit from grassland', + hasOverdueTasks: true, + abstract: + 'Grassland habitats bordering agricultural land are increasingly recognized as a critical resource for pollinator health, yet the mechanisms behind this benefit remain poorly understood. In this study, we tracked twenty honey bee colonies over two foraging seasons across sites with varying proportions of adjacent grassland cover. Colonies near extensive grassland showed significantly higher overwintering survival, greater brood production, and lower incidence of common parasites compared to colonies in predominantly monoculture landscapes. Pollen analysis revealed a more diverse foraging diet in grassland-adjacent colonies, suggesting that floral diversity rather than raw forage volume drives the observed health benefits. These findings support targeted grassland conservation and restoration as a practical intervention for improving pollinator resilience in agricultural regions.', + }, + }, + { + key: '2', + manuscriptNumber: 102, + titleInfo: { + title: 'A dataset of pollinator visitation rates', + importSource: 'coar', + abstract: + 'We present a multi-year dataset of pollinator visitation rates collected across forty field sites spanning three biogeographic regions. Observations were recorded using a standardized timed-count protocol, capturing visitation frequency, pollinator identity to genus level, and flowering plant species for each interaction. The dataset includes over two hundred thousand individual visitation records alongside site-level metadata on land use, climate, and floral community composition. We anticipate this resource will support comparative analyses of pollinator community structure, phenological mismatch under climate change, and the design of pollinator-friendly agricultural policy. Data are provided in a normalized, analysis-ready format with accompanying documentation and quality-control flags for each observation.', + }, + }, + { + key: '3', + manuscriptNumber: 103, + titleInfo: { + title: 'BeeTrack: software for tracking pollinators', + importSource: 'semanticScholar', + abstract: + 'BeeTrack is an open-source computer vision toolkit for automated tracking of individual pollinators in field video recordings. Built on a lightweight object-detection backbone, BeeTrack identifies and tracks multiple insects simultaneously under variable outdoor lighting conditions, exporting per-individual trajectories, flower-visit durations, and inter-flower flight paths. The software includes a graphical annotation interface for rapid model fine-tuning on new sites or species, and a batch-processing pipeline suitable for large-scale monitoring campaigns. In benchmark comparisons against manual observation, BeeTrack matched expert-annotated visitation counts within a five percent margin while reducing analysis time by over ninety percent, making continuous, fine-grained pollinator monitoring practical at landscape scale.', + }, + }, + { + key: '4', + manuscriptNumber: 104, + titleInfo: { + title: 'Neuropsychological effects of urban noise', + }, + }, + ], + page: 1, + pageSize: 10, + totalCount: 4, + onPageChange: () => {}, + onSearch: () => {}, + }, +}) + export const BuiltInDateRendering = meta.story({ args: { columns: [ @@ -421,6 +485,7 @@ export const BuiltInReviewerStatusRenderingWithFilter = meta.story({ /** * One square per reviewer, colored by status. Hover the grid for a tooltip grouping reviewers * by status, with each reviewer's name listed underneath their group's total. + * Be careful with usage of this datatype as it can expose the reviewer names. */ export const BuiltInReviewerStatusSummaryRendering = meta.story({ args: { From 7a812c15441dcb16dec953b994604a7f6337a652 Mon Sep 17 00:00:00 2001 From: yannis Date: Mon, 10 Aug 2026 12:40:59 +0300 Subject: [PATCH 09/51] feat(ui): multi value options in new table --- .../client/app/ui/shared/ManuscriptsTable.tsx | 43 +++++++++--- packages/client/app/ui/shared/Table.tsx | 6 +- .../shared/ManuscriptsTable.stories.tsx | 69 +++++++++++++++++++ 3 files changed, 106 insertions(+), 12 deletions(-) diff --git a/packages/client/app/ui/shared/ManuscriptsTable.tsx b/packages/client/app/ui/shared/ManuscriptsTable.tsx index 5a06f96f8..3255ff6be 100644 --- a/packages/client/app/ui/shared/ManuscriptsTable.tsx +++ b/packages/client/app/ui/shared/ManuscriptsTable.tsx @@ -49,11 +49,6 @@ export type ManuscriptsTableColumn = { /** * Opts into one of this component's built-in renderers for a common kind of * value. Ignored if `render` is also supplied. - * - * Candidate future dataTypes (not yet implemented), identified by comparing against the - * legacy component-manuscripts-table cell renderers: - * - 'valueList': an array of independently-colored/rich-text values in one cell (richer - * version of 'options' -- handles a list, not a single lookup). See DefaultField.jsx. */ dataType?: | 'date' @@ -314,23 +309,49 @@ const ReviewerStatusSummary = ({ const renderBadge = (value: any): ReactNode => {value} +const renderSingleOption = ( + value: string, + options: ManuscriptsTableColumnOption[], +): ReactNode => { + const option = options.find(o => o.value === value) + + return ( + + {option?.label ?? value} + + ) +} + +const OptionsListWrapper = styled.div` + display: flex; + flex-wrap: wrap; + gap: ${grid(1)}; +` + const renderOptions = ( value: any, options: ManuscriptsTableColumnOption[] = [], ): ReactNode => { if (!value) return null - const option = options.find(o => o.value === value) + if (Array.isArray(value)) { + if (value.length === 0) return null - if (option?.labelColor) { return ( - - {option.label ?? value} - + + {value.map(v => ( + {renderSingleOption(v, options)} + ))} + ) } - return option?.label ?? value + return renderSingleOption(value, options) } const PersonWrapper = styled.div` diff --git a/packages/client/app/ui/shared/Table.tsx b/packages/client/app/ui/shared/Table.tsx index 50a510eee..69bdcfe09 100644 --- a/packages/client/app/ui/shared/Table.tsx +++ b/packages/client/app/ui/shared/Table.tsx @@ -31,8 +31,12 @@ const Table = styled(OriginalTable)` text-transform: uppercase; } + th.ant-table-cell { + padding: ${grid(3)} ${grid(4)} !important; + } + .ant-table-thead th::before { - height: 3em !important; + height: 2.7em !important; } /* necessary because of wax-table-service injecting global css */ diff --git a/packages/client/stories/shared/ManuscriptsTable.stories.tsx b/packages/client/stories/shared/ManuscriptsTable.stories.tsx index af72c18dc..a009c45f8 100644 --- a/packages/client/stories/shared/ManuscriptsTable.stories.tsx +++ b/packages/client/stories/shared/ManuscriptsTable.stories.tsx @@ -627,6 +627,7 @@ const typeOptions = [ { value: 'dataset', label: 'Dataset', labelColor: '#3f21d4' }, { value: 'software', label: 'Software', labelColor: '#e901ca' }, { value: 'article', label: 'Research article' }, + { value: 'preprint', label: 'Preprint' }, ] const typeData = [ @@ -713,6 +714,74 @@ export const BuiltInOptionsRenderingWithFilter = meta.story({ }, }) +const multiValueTypeData = [ + { ...baseRow, types: ['dataset', 'software'] }, + { + key: '2', + manuscriptNumber: 102, + title: 'BeeTrack: software for tracking pollinators', + types: ['software'], + }, + { + key: '3', + manuscriptNumber: 103, + title: 'Neuropsychological effects of urban noise', + types: [], + }, + { + key: '4', + manuscriptNumber: 104, + title: 'Climate variability dataset for the Alps', + types: ['article', 'preprint'], + }, +] + +export const BuiltInOptionsRenderingWithMultipleValues = meta.story({ + args: { + columns: [ + ...baseColumns, + { + title: 'Types', + dataIndex: 'types', + key: 'types', + dataType: 'options', + filterable: true, + options: typeOptions, + }, + ], + dataSource: multiValueTypeData, + page: 1, + pageSize: 10, + totalCount: multiValueTypeData.length, + onPageChange: () => {}, + onSearch: () => {}, + }, + render: args => { + const [columnFilters, setColumnFilters] = useState< + Record + >({}) + + const selectedTypes = columnFilters.types ?? [] + + const filtered = + selectedTypes.length > 0 + ? multiValueTypeData.filter(row => + row.types.some(type => selectedTypes.includes(type)), + ) + : multiValueTypeData + + return ( + + ) + }, +}) + /** * Action uses a custom render function */ From 6622396a06ef3041593b840abb9571d37f7c67b0 Mon Sep 17 00:00:00 2001 From: yannis Date: Mon, 10 Aug 2026 13:43:52 +0300 Subject: [PATCH 10/51] feat(ui): handle rich text in the new table --- .../components/sections/SubmissionsTable.jsx | 24 +++++++++++ .../client/app/ui/shared/ManuscriptsTable.tsx | 23 +++++++++- .../shared/ManuscriptsTable.stories.tsx | 42 ++++++++++++++++++- 3 files changed, 86 insertions(+), 3 deletions(-) diff --git a/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx b/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx index 248d01f15..af7195ea9 100644 --- a/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx +++ b/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx @@ -36,6 +36,14 @@ const centeredColumns = [ 'statusCounts', ] +const importSourceFor = manuscript => { + if (manuscript.importSourceServer === 'COAR') return 'coar' + if (manuscript.importSourceServer === 'semantic-scholar') + return 'semanticScholar' + + return undefined +} + const LinkList = styled.div` align-items: flex-start; display: flex; @@ -213,6 +221,10 @@ const SubmissionsTable = props => { return { ...column, dataType: 'date' } } + if (column.key === 'submission.$title') { + return { ...column, dataType: 'title', showAbstract: true } + } + if (column.key === 'status') { return { ...column, dataType: 'status' } } @@ -239,6 +251,18 @@ const SubmissionsTable = props => { const row = tableColumns.reduce((accumulator, current) => { const property = current.dataIndex + + if (property === 'submission.$title') { + accumulator[property] = { + title: get(manuscript, property), + hasOverdueTasks: manuscript.hasOverdueTasksForUser, + importSource: importSourceFor(manuscript), + abstract: manuscript.submission?.$abstract, + } + + return accumulator + } + accumulator[property] = get(manuscript, property) return accumulator }, {}) diff --git a/packages/client/app/ui/shared/ManuscriptsTable.tsx b/packages/client/app/ui/shared/ManuscriptsTable.tsx index 3255ff6be..8013e6128 100644 --- a/packages/client/app/ui/shared/ManuscriptsTable.tsx +++ b/packages/client/app/ui/shared/ManuscriptsTable.tsx @@ -12,6 +12,7 @@ import { import type { FilterDropdownProps } from 'antd/es/table/interface' import type { RadioChangeEvent } from 'antd/es/radio' import dayjs from 'dayjs' +import DOMPurify from 'isomorphic-dompurify' import Table from './Table' import Badge from './Badge' @@ -59,6 +60,7 @@ export type ManuscriptsTableColumn = { | 'options' | 'person' | 'title' + | 'richText' options?: ManuscriptsTableColumnOption[] /** Applies to 'options', 'status', 'reviewerStatus' and 'date' datatypes */ filterable?: boolean @@ -309,6 +311,20 @@ const ReviewerStatusSummary = ({ const renderBadge = (value: any): ReactNode => {value} +const RICH_TEXT_PREFIX_REGEX = /^\s*/ + +const renderPlainOrRichText = (value: any): ReactNode => { + if (!value || !RICH_TEXT_PREFIX_REGEX.test(value)) return value || null + return ( + + ) +} + +const stripHtml = (html: string): string => + DOMPurify.sanitize(html, { ALLOWED_TAGS: [] }) + +const renderRichText = (value: any): ReactNode => renderPlainOrRichText(value) + const renderSingleOption = ( value: string, options: ManuscriptsTableColumnOption[], @@ -456,7 +472,7 @@ const TitleCell = ({ )} - {value.title} + {renderPlainOrRichText(value.title)} {showAbstract && ( {value.abstract - ? truncateAbstract(value.abstract) + ? truncateAbstract(stripHtml(value.abstract)) : t('manuscriptsTable.noAbstract')} } @@ -708,6 +724,9 @@ const resolveColumn = ( case 'badge': resolved = { ...column, render: renderBadge } break + case 'richText': + resolved = { ...column, render: renderRichText } + break case 'person': resolved = { ...column, render: renderPerson } break diff --git a/packages/client/stories/shared/ManuscriptsTable.stories.tsx b/packages/client/stories/shared/ManuscriptsTable.stories.tsx index a009c45f8..edb4190a4 100644 --- a/packages/client/stories/shared/ManuscriptsTable.stories.tsx +++ b/packages/client/stories/shared/ManuscriptsTable.stories.tsx @@ -262,10 +262,50 @@ export const BuiltInTitleRendering = meta.story({ title: 'Neuropsychological effects of urban noise', }, }, + { + key: '5', + manuscriptNumber: 105, + titleInfo: { + title: + '

Climate variability dataset for the Alps

', + }, + }, ], page: 1, pageSize: 10, - totalCount: 4, + totalCount: 5, + onPageChange: () => {}, + onSearch: () => {}, + }, +}) + +export const BuiltInRichTextRendering = meta.story({ + args: { + columns: [ + ...baseColumns, + { + title: 'Notes', + dataIndex: 'notes', + key: 'notes', + dataType: 'richText', + }, + ], + dataSource: [ + { + ...baseRow, + notes: + '

Reviewers should pay close attention to the methodology section.

', + }, + { + key: '2', + manuscriptNumber: 102, + title: 'A dataset of pollinator visitation rates', + notes: 'Plain text notes with no formatting.', + }, + ], + page: 1, + pageSize: 10, + totalCount: 2, onPageChange: () => {}, onSearch: () => {}, }, From 8c3b4f88984b97dd2c81318c022481eabf6c5b32 Mon Sep 17 00:00:00 2001 From: yannis Date: Tue, 11 Aug 2026 10:33:28 +0300 Subject: [PATCH 11/51] feat(ui): add editable option to new table --- .../client/app/ui/shared/ManuscriptsTable.tsx | 77 ++++++++++++++++-- .../shared/ManuscriptsTable.stories.tsx | 80 +++++++++++++++++++ 2 files changed, 151 insertions(+), 6 deletions(-) diff --git a/packages/client/app/ui/shared/ManuscriptsTable.tsx b/packages/client/app/ui/shared/ManuscriptsTable.tsx index 8013e6128..6a13c677d 100644 --- a/packages/client/app/ui/shared/ManuscriptsTable.tsx +++ b/packages/client/app/ui/shared/ManuscriptsTable.tsx @@ -1,7 +1,7 @@ import { type ReactNode, type MouseEvent, Fragment } from 'react' import { useTranslation, Trans } from 'react-i18next' import styled from 'styled-components' -import { th, grid } from '@coko/client' +import { th, grid, Select } from '@coko/client' import { ConfigProvider, DatePicker, @@ -69,6 +69,11 @@ export type ManuscriptsTableColumn = { * with the manuscript's abstract on click. */ showAbstract?: boolean + /** + * Applies to the 'options' datatype, single-value case only. + * Renders a select dropdown instead of a static badge. + */ + editable?: boolean /** * Escape hatch for anything else (e.g. an actions column with * business-specific links) -- takes precedence over dataType. @@ -96,7 +101,7 @@ export type ManuscriptSearchSnippet = { */ /** - * Two more legacy behaviors that are NOT gaps in this component -- the existing `render` escape + * One more legacy behavior that is NOT a gap in this component -- the existing `render` escape * hatch already covers the mechanism (see the Actions column in ManuscriptsTable.stories.tsx and * in SubmissionsTable.jsx for a working example) -- but the actual business-specific logic still * needs to be written wherever EditorTable/ReviewerTable/the admin Manuscripts page eventually @@ -104,8 +109,6 @@ export type ManuscriptSearchSnippet = { * - Action links: Actions.jsx, EditorItemLinks.jsx, AuthorProofingLink.jsx, * ReviewerItemLinks.jsx, SubmitChevron.jsx -- each needs the full manuscript, current user, * config, routing, and mutation functions to decide which links/labels to show. - * - Editable label dropdown: LabelsOrSelectButton.jsx/LabelDropdown.jsx -- an interactive, - * mutating widget (inline edit + workflow trigger), not a passive display of a value. */ type ManuscriptsTableProps = { @@ -134,6 +137,11 @@ type ManuscriptsTableProps = { columnKey: string, viewMode: 'compact' | 'detailed', ) => void + /** + * Fired when a value is picked or cleared in an `editable` 'options' column. `id` is the + * changed manuscript's `record.id`; `value` is `null` when cleared. + */ + onOptionChange?: (columnKey: string, id: string, value: string | null) => void } const renderDate = (value: any): ReactNode => { @@ -370,6 +378,49 @@ const renderOptions = ( return renderSingleOption(value, options) } +const EditableOptionSelect = styled(Select)` + .ant-select { + padding-block: ${grid(1)}; + } +` + +/** + * Single-value only - not meant for the array/multi-select case. + */ +const renderEditableOption = ( + value: any, + record: Record, + column: ManuscriptsTableColumn, + onOptionChange: + | ((columnKey: string, id: string, value: string | null) => void) + | undefined, +): ReactNode => { + const options = column.options ?? [] + const id = record.id + + return ( + + renderSingleOption(selectedValue as string, options) + } + onChange={(newValue: string | undefined): void => + onOptionChange?.(column.key, id, newValue ?? null) + } + onClick={(event: MouseEvent): void => event.stopPropagation()} + optionRender={option => + renderSingleOption(option.value as string, options) + } + options={options.map(option => ({ + value: option.value, + label: option.label ?? option.value, + }))} + size="small" + value={value || undefined} + /> + ) +} + const PersonWrapper = styled.div` display: flex; align-items: center; @@ -692,6 +743,11 @@ const resolveColumn = ( columnKey: string, viewMode: 'compact' | 'detailed', ) => void + onOptionChange?: ( + columnKey: string, + id: string, + value: string | null, + ) => void }, ): TableColumnType> => { let resolved: ManuscriptsTableColumn = column @@ -741,8 +797,15 @@ const resolveColumn = ( case 'options': resolved = { ...column, - render: (value: any): ReactNode => - renderOptions(value, column.options), + render: (value: any, record: Record): ReactNode => + column.editable + ? renderEditableOption( + value, + record, + column, + context.onOptionChange, + ) + : renderOptions(value, column.options), } break default: @@ -891,6 +954,7 @@ const ManuscriptsTable = ({ onFiltersChange, reviewerStatusViewMode, onReviewerStatusViewModeChange, + onOptionChange, }: ManuscriptsTableProps): ReactNode => { const { t } = useTranslation() @@ -933,6 +997,7 @@ const ManuscriptsTable = ({ columnFilters, reviewerStatusViewMode, onReviewerStatusViewModeChange, + onOptionChange, }), ) diff --git a/packages/client/stories/shared/ManuscriptsTable.stories.tsx b/packages/client/stories/shared/ManuscriptsTable.stories.tsx index edb4190a4..9d9c276ae 100644 --- a/packages/client/stories/shared/ManuscriptsTable.stories.tsx +++ b/packages/client/stories/shared/ManuscriptsTable.stories.tsx @@ -822,6 +822,86 @@ export const BuiltInOptionsRenderingWithMultipleValues = meta.story({ }, }) +const customStatusOptions = [ + { + value: 'readyToEvaluate', + label: 'Ready to evaluate', + labelColor: '#e0a800', + }, + { value: 'evaluated', label: 'Evaluated', labelColor: '#3f21d4' }, + { value: 'readyToPublish', label: 'Ready to publish', labelColor: '#1a8917' }, +] + +export const BuiltInOptionsRenderingEditable = meta.story({ + args: { + columns: [ + ...baseColumns, + { + title: 'Custom status', + dataIndex: 'customStatus', + key: 'customStatus', + dataType: 'options', + editable: true, + filterable: true, + options: customStatusOptions, + }, + ], + dataSource: [ + { ...baseRow, id: 'ms-1', customStatus: 'readyToEvaluate' }, + { + key: '2', + id: 'ms-2', + manuscriptNumber: 102, + title: 'A dataset of pollinator visitation rates', + customStatus: null, + }, + { + key: '3', + id: 'ms-3', + manuscriptNumber: 103, + title: 'BeeTrack: software for tracking pollinators', + customStatus: 'evaluated', + }, + ], + page: 1, + pageSize: 10, + totalCount: 3, + onPageChange: () => {}, + onSearch: () => {}, + }, + render: args => { + const [dataSource, setDataSource] = useState(args.dataSource) + + const [columnFilters, setColumnFilters] = useState< + Record + >({}) + + const selectedStatuses = columnFilters.customStatus ?? [] + + const filtered = + selectedStatuses.length > 0 + ? dataSource.filter(row => selectedStatuses.includes(row.customStatus)) + : dataSource + + return ( + + setDataSource(previous => + previous.map(row => + row.id === id ? { ...row, [columnKey]: value } : row, + ), + ) + } + totalCount={filtered.length} + /> + ) + }, +}) + /** * Action uses a custom render function */ From c1d0f188c15710e8279de46932d93ccab85d0f96 Mon Sep 17 00:00:00 2001 From: yannis Date: Wed, 12 Aug 2026 11:46:06 +0300 Subject: [PATCH 12/51] feat(ui): selection actions in new table --- packages/client/app/i18n/en/translation.js | 3 + packages/client/app/i18n/es-la/translation.js | 3 + packages/client/app/i18n/fr/translation.js | 3 + packages/client/app/i18n/ru/translation.js | 3 + .../client/app/ui/shared/ManuscriptsTable.tsx | 152 +++++++++++++++--- packages/client/app/ui/shared/Table.tsx | 4 - packages/client/package.json | 2 +- .../client/stories/shared/Button.stories.tsx | 37 +++++ .../shared/ManuscriptsTable.stories.tsx | 90 +++++++++++ packages/client/yarn.lock | 10 +- 10 files changed, 279 insertions(+), 28 deletions(-) create mode 100644 packages/client/stories/shared/Button.stories.tsx diff --git a/packages/client/app/i18n/en/translation.js b/packages/client/app/i18n/en/translation.js index f7889fe9e..2fb225162 100644 --- a/packages/client/app/i18n/en/translation.js +++ b/packages/client/app/i18n/en/translation.js @@ -198,6 +198,9 @@ const en = { noAbstract: 'No abstract provided', abstractHeader: 'Abstract', showAbstract: 'Show abstract', + selectedCount: '{{count}} selected', + confirmArchive: 'Archive {{count}} manuscripts?', + confirmUnarchive: 'Unarchive {{count}} manuscripts?', detailedView: 'Detailed view', reviewAccept: 'Accept', reviewReject: 'Decline', diff --git a/packages/client/app/i18n/es-la/translation.js b/packages/client/app/i18n/es-la/translation.js index 4344f41db..8daeccf0d 100644 --- a/packages/client/app/i18n/es-la/translation.js +++ b/packages/client/app/i18n/es-la/translation.js @@ -198,6 +198,9 @@ const esLa = { noAbstract: 'No se proporcionó ningún resumen', abstractHeader: 'Resumen', showAbstract: 'Mostrar resumen', + selectedCount: '{{count}} seleccionados', + confirmArchive: '¿Archivar {{count}} manuscritos?', + confirmUnarchive: '¿Desarchivar {{count}} manuscritos?', detailedView: 'Vista detallada', reviewAccept: 'Aceptar', reviewReject: 'Rechazar', diff --git a/packages/client/app/i18n/fr/translation.js b/packages/client/app/i18n/fr/translation.js index 02b7f73d3..4785948de 100644 --- a/packages/client/app/i18n/fr/translation.js +++ b/packages/client/app/i18n/fr/translation.js @@ -198,6 +198,9 @@ const fr = { noAbstract: 'Aucun résumé fourni', abstractHeader: 'Résumé', showAbstract: 'Afficher le résumé', + selectedCount: '{{count}} sélectionné(s)', + confirmArchive: 'Archiver {{count}} manuscrits ?', + confirmUnarchive: 'Désarchiver {{count}} manuscrits ?', detailedView: 'Vue détaillée', reviewAccept: 'Accepter', reviewReject: 'Rejeter', diff --git a/packages/client/app/i18n/ru/translation.js b/packages/client/app/i18n/ru/translation.js index e1fd186b0..9b273b548 100644 --- a/packages/client/app/i18n/ru/translation.js +++ b/packages/client/app/i18n/ru/translation.js @@ -198,6 +198,9 @@ const ru = { noAbstract: 'Аннотация не предоставлена', abstractHeader: 'Аннотация', showAbstract: 'Показать аннотацию', + selectedCount: 'Выбрано: {{count}}', + confirmArchive: 'Архивировать {{count}} рукописей?', + confirmUnarchive: 'Разархивировать {{count}} рукописей?', detailedView: 'Подробный вид', pagination_few: `Показано {{lastResult}} из {{totalCount}} результатов`, pagination_many: `Показано {{lastResult}} из {{totalCount}} результатов`, diff --git a/packages/client/app/ui/shared/ManuscriptsTable.tsx b/packages/client/app/ui/shared/ManuscriptsTable.tsx index 6a13c677d..973908a4e 100644 --- a/packages/client/app/ui/shared/ManuscriptsTable.tsx +++ b/packages/client/app/ui/shared/ManuscriptsTable.tsx @@ -1,7 +1,15 @@ -import { type ReactNode, type MouseEvent, Fragment } from 'react' +import { type ReactNode, type MouseEvent, Fragment, useState } from 'react' import { useTranslation, Trans } from 'react-i18next' import styled from 'styled-components' -import { th, grid, Select } from '@coko/client' +import { + th, + grid, + Select, + Button, + ButtonGroup, + Modal, + Radio as RadioGroupInput, +} from '@coko/client' import { ConfigProvider, DatePicker, @@ -10,7 +18,6 @@ import { type TableColumnType, } from 'antd' import type { FilterDropdownProps } from 'antd/es/table/interface' -import type { RadioChangeEvent } from 'antd/es/radio' import dayjs from 'dayjs' import DOMPurify from 'isomorphic-dompurify' @@ -100,17 +107,6 @@ export type ManuscriptSearchSnippet = { * hardcodes which roles to exclude rather than taking a parameter. */ -/** - * One more legacy behavior that is NOT a gap in this component -- the existing `render` escape - * hatch already covers the mechanism (see the Actions column in ManuscriptsTable.stories.tsx and - * in SubmissionsTable.jsx for a working example) -- but the actual business-specific logic still - * needs to be written wherever EditorTable/ReviewerTable/the admin Manuscripts page eventually - * migrate off the legacy table: - * - Action links: Actions.jsx, EditorItemLinks.jsx, AuthorProofingLink.jsx, - * ReviewerItemLinks.jsx, SubmitChevron.jsx -- each needs the full manuscript, current user, - * config, routing, and mutation functions to decide which links/labels to show. - */ - type ManuscriptsTableProps = { columns: ManuscriptsTableColumn[] dataSource: Record[] @@ -142,6 +138,17 @@ type ManuscriptsTableProps = { * changed manuscript's `record.id`; `value` is `null` when cleared. */ onOptionChange?: (columnKey: string, id: string, value: string | null) => void + /** + * Enables row selection checkboxes. + */ + selectable?: boolean + /** Only visible is selectable is true. */ + showArchiveActions?: boolean + /** Only visible is selectable is true. */ + showDownloadAction?: boolean + onArchiveSelected?: (ids: string[]) => void + onUnarchiveSelected?: (ids: string[]) => void + onDownloadSelected?: (ids: string[]) => void } const renderDate = (value: any): ReactNode => { @@ -666,7 +673,7 @@ const ReviewerStatusColumnHeaderWrapper = styled.div` gap: ${grid(2)}; ` -const RadioGroup = styled(Radio.Group)` +const RadioGroup = styled(RadioGroupInput)` /* stylelint-disable declaration-no-important */ .ant-radio-button-wrapper { background-color: ${th('colorPrimary')} !important; @@ -706,9 +713,7 @@ const ReviewerStatusColumnHeader = ({ {title} - onChange(event.target.value === 'compact') - } + onChange={(value: string): void => onChange(value === 'compact')} optionType="button" size="small" value={isCompact ? 'compact' : 'detailed'} @@ -941,6 +946,18 @@ const buildFilterChips = ( return chips } +const SelectionActionsWrapper = styled.div` + display: flex; + align-items: center; + gap: ${grid(2)}; + margin-bottom: ${grid(2)}; +` + +const SelectionCount = styled.span` + font-size: ${th('fontSizeBaseSmall')}; + color: ${th('colorTextPlaceholder')}; +` + const ManuscriptsTable = ({ columns, dataSource, @@ -955,8 +972,16 @@ const ManuscriptsTable = ({ reviewerStatusViewMode, onReviewerStatusViewModeChange, onOptionChange, + selectable = false, + showArchiveActions = false, + showDownloadAction = false, + onArchiveSelected, + onUnarchiveSelected, + onDownloadSelected, }: ManuscriptsTableProps): ReactNode => { const { t } = useTranslation() + const [modal, modalContextHolder] = Modal.useModal() + const [selectedRowIds, setSelectedRowIds] = useState([]) const rowsWithSnippets = dataSource.filter(hasSearchSnippets) @@ -1006,8 +1031,98 @@ const ManuscriptsTable = ({ ? buildFilterChips(columns, columnFilters, onFiltersChange) : [] + const selectedRows = dataSource.filter(row => selectedRowIds.includes(row.id)) + + const showSelectionActions = + selectable && (showArchiveActions || showDownloadAction) + + const hasSelection = selectedRows.length > 0 + const allSelectedAreArchived = + hasSelection && selectedRows.every(row => row.archived) + const noneSelectedAreArchived = + hasSelection && selectedRows.every(row => !row.archived) + + const rowSelection = selectable + ? { + selectedRowKeys: selectedRows.map(row => row.key), + onChange: (_keys: unknown[], rows: Record[]): void => + setSelectedRowIds(rows.map(row => row.id)), + } + : undefined + + const handleArchiveClick = (): void => { + modal.confirm({ + content: t('manuscriptsTable.confirmArchive', { + count: selectedRows.length, + }), + okText: t('common.OK'), + cancelText: t('common.Cancel'), + onOk: () => { + onArchiveSelected?.(selectedRows.map(row => row.id)) + setSelectedRowIds([]) + }, + }) + } + + const handleUnarchiveClick = (): void => { + modal.confirm({ + content: t('manuscriptsTable.confirmUnarchive', { + count: selectedRows.length, + }), + okText: t('common.OK'), + cancelText: t('common.Cancel'), + onOk: () => { + onUnarchiveSelected?.(selectedRows.map(row => row.id)) + setSelectedRowIds([]) + }, + }) + } + + const handleDownloadClick = (): void => { + onDownloadSelected?.(selectedRows.map(row => row.id)) + } + return ( <> + {modalContextHolder} + {showSelectionActions && ( + + + {t('manuscriptsTable.selectedCount', { + count: selectedRows.length, + })} + + + {showArchiveActions && ( + + )} + {showArchiveActions && ( + + )} + {showDownloadAction && ( + + )} + + + )} {filterChips.length > 0 && ( {filterChips.map(chip => ( @@ -1049,6 +1164,7 @@ const ManuscriptsTable = ({ ), onChange: onPageChange, }} + rowSelection={rowSelection} searchPlaceholder={t('common.Enter search terms...')} showSearch /> diff --git a/packages/client/app/ui/shared/Table.tsx b/packages/client/app/ui/shared/Table.tsx index 69bdcfe09..3a9c30c35 100644 --- a/packages/client/app/ui/shared/Table.tsx +++ b/packages/client/app/ui/shared/Table.tsx @@ -22,10 +22,6 @@ const Table = styled(OriginalTable)` margin-right: auto; } - .ant-table-selection-column { - padding-inline-start: ${grid(4)} !important; - } - .ant-table-thead { font-size: ${th('fontSizeBaseSmaller')}; text-transform: uppercase; diff --git a/packages/client/package.json b/packages/client/package.json index 08a3cc211..b58bda36d 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -27,7 +27,7 @@ "@codemirror/lang-css": "^6.2.1", "@codemirror/lang-html": "^6.4.6", "@codemirror/lang-json": "^6.0.1", - "@coko/client": "^2.4.0", + "@coko/client": "^2.5.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", diff --git a/packages/client/stories/shared/Button.stories.tsx b/packages/client/stories/shared/Button.stories.tsx new file mode 100644 index 000000000..108d4fd03 --- /dev/null +++ b/packages/client/stories/shared/Button.stories.tsx @@ -0,0 +1,37 @@ +import { Button } from '@coko/client' + +import preview from '../../.storybook/preview' + +const meta = preview.meta({ + component: Button, +}) + +export const Primary = meta.story({ + args: { + type: 'primary', + children: 'Primary', + }, +}) + +export const Secondary = meta.story({ + args: { + type: 'default', + children: 'Secondary', + }, +}) + +export const Medium = meta.story({ + args: { + type: 'primary', + size: 'medium', + children: 'Medium', + }, +}) + +export const Small = meta.story({ + args: { + type: 'primary', + size: 'small', + children: 'Small', + }, +}) diff --git a/packages/client/stories/shared/ManuscriptsTable.stories.tsx b/packages/client/stories/shared/ManuscriptsTable.stories.tsx index 9d9c276ae..c6e2a1572 100644 --- a/packages/client/stories/shared/ManuscriptsTable.stories.tsx +++ b/packages/client/stories/shared/ManuscriptsTable.stories.tsx @@ -985,6 +985,96 @@ export const WithFiltersApplied = meta.story({ }, }) +const initialSelectableData = [ + { + key: '1', + id: 'ms-1', + manuscriptNumber: 101, + title: 'Honey bee colonies benefit from grassland', + status: 'submitted', + archived: false, + }, + { + key: '2', + id: 'ms-2', + manuscriptNumber: 102, + title: 'A dataset of pollinator visitation rates', + status: 'accepted', + archived: false, + }, + { + key: '3', + id: 'ms-3', + manuscriptNumber: 103, + title: 'BeeTrack: software for tracking pollinators', + status: 'rejected', + archived: true, + }, +] + +export const WithSelectableRows = meta.story({ + args: { + columns: [ + ...baseColumns, + { + title: 'Status', + dataIndex: 'status', + key: 'status', + dataType: 'status', + filterable: true, + options: manuscriptStatusOptions, + }, + ], + dataSource: initialSelectableData, + page: 1, + pageSize: 10, + totalCount: initialSelectableData.length, + onPageChange: () => {}, + onSearch: () => {}, + selectable: true, + showArchiveActions: true, + showDownloadAction: true, + }, + render: args => { + const [dataSource, setDataSource] = useState(initialSelectableData) + + const [columnFilters, setColumnFilters] = useState< + Record + >({}) + + const setArchived = (ids: string[], archived: boolean): void => { + setDataSource(previous => + previous.map(row => + ids.includes(row.id) ? { ...row, archived } : row, + ), + ) + } + + const selectedStatuses = columnFilters.status ?? [] + + const filtered = + selectedStatuses.length > 0 + ? dataSource.filter(row => selectedStatuses.includes(row.status)) + : dataSource + + return ( + setArchived(ids, true)} + onDownloadSelected={ids => + // eslint-disable-next-line no-console + console.log('Download JSON for:', ids) + } + onFiltersChange={setColumnFilters} + onUnarchiveSelected={ids => setArchived(ids, false)} + totalCount={filtered.length} + /> + ) + }, +}) + /** * Show why a row matched */ diff --git a/packages/client/yarn.lock b/packages/client/yarn.lock index 2dcf7d1e3..0d3a97b93 100644 --- a/packages/client/yarn.lock +++ b/packages/client/yarn.lock @@ -599,9 +599,9 @@ __metadata: languageName: node linkType: hard -"@coko/client@npm:^2.4.0": - version: 2.4.0 - resolution: "@coko/client@npm:2.4.0" +"@coko/client@npm:^2.5.0": + version: 2.5.0 + resolution: "@coko/client@npm:2.5.0" dependencies: "@ant-design/icons": "npm:^6.2.2" "@dnd-kit/core": "npm:^6.3.1" @@ -633,7 +633,7 @@ __metadata: bin: coko-client-build: ./scripts/coko-client-build.mjs coko-client-dev: ./scripts/coko-client-dev.mjs - checksum: 10c0/8e2d9cfea9f4f96c5aeb46a7ab321d30427f869275bc073d861f85b50e283f600816e48a5053096d764a381308899504b8bffebe741697b4acc00a835d3bf694 + checksum: 10c0/57cb9c4393f0c623cf04955759171ee7c122de25ba6c743778baacb7c9db168573d47dd7573ec935b60bceb96026c560d72e1527b192a5c8b016d5efce29ee36 languageName: node linkType: hard @@ -5388,7 +5388,7 @@ __metadata: "@codemirror/lang-css": "npm:^6.2.1" "@codemirror/lang-html": "npm:^6.4.6" "@codemirror/lang-json": "npm:^6.0.1" - "@coko/client": "npm:^2.4.0" + "@coko/client": "npm:^2.5.0" "@dnd-kit/core": "npm:^6.3.1" "@dnd-kit/sortable": "npm:^10.0.0" "@dnd-kit/utilities": "npm:^3.2.2" From f9833eac171c51df5d125b6e022204f9b0564b5a Mon Sep 17 00:00:00 2001 From: yannis Date: Wed, 12 Aug 2026 14:02:00 +0300 Subject: [PATCH 13/51] feat(ui): add view archived toggle to new table --- .../client/app/ui/shared/ManuscriptsTable.tsx | 114 ++++++++++++------ .../shared/ManuscriptsTable.stories.tsx | 4 + 2 files changed, 80 insertions(+), 38 deletions(-) diff --git a/packages/client/app/ui/shared/ManuscriptsTable.tsx b/packages/client/app/ui/shared/ManuscriptsTable.tsx index 973908a4e..b7ce0322d 100644 --- a/packages/client/app/ui/shared/ManuscriptsTable.tsx +++ b/packages/client/app/ui/shared/ManuscriptsTable.tsx @@ -9,6 +9,7 @@ import { ButtonGroup, Modal, Radio as RadioGroupInput, + Switch, } from '@coko/client' import { ConfigProvider, @@ -149,6 +150,9 @@ type ManuscriptsTableProps = { onArchiveSelected?: (ids: string[]) => void onUnarchiveSelected?: (ids: string[]) => void onDownloadSelected?: (ids: string[]) => void + showViewArchivedToggle?: boolean + viewingArchived?: boolean + onViewingArchivedChange?: (viewingArchived: boolean) => void } const renderDate = (value: any): ReactNode => { @@ -953,6 +957,10 @@ const SelectionActionsWrapper = styled.div` margin-bottom: ${grid(2)}; ` +const ViewArchivedToggleWrapper = styled.div` + margin-left: auto; +` + const SelectionCount = styled.span` font-size: ${th('fontSizeBaseSmall')}; color: ${th('colorTextPlaceholder')}; @@ -978,6 +986,9 @@ const ManuscriptsTable = ({ onArchiveSelected, onUnarchiveSelected, onDownloadSelected, + showViewArchivedToggle = false, + viewingArchived = false, + onViewingArchivedChange, }: ManuscriptsTableProps): ReactNode => { const { t } = useTranslation() const [modal, modalContextHolder] = Modal.useModal() @@ -1026,15 +1037,29 @@ const ManuscriptsTable = ({ }), ) - const filterChips = - columnFilters && onFiltersChange + const filterChips = [ + ...(columnFilters && onFiltersChange ? buildFilterChips(columns, columnFilters, onFiltersChange) - : [] + : []), + ...(viewingArchived + ? [ + { + key: 'viewingArchived', + label: t('manuscriptsPage.archivedManuscripts'), + onRemove: (event: MouseEvent): void => { + event.preventDefault() + onViewingArchivedChange?.(false) + }, + }, + ] + : []), + ] const selectedRows = dataSource.filter(row => selectedRowIds.includes(row.id)) const showSelectionActions = - selectable && (showArchiveActions || showDownloadAction) + (selectable && (showArchiveActions || showDownloadAction)) || + showViewArchivedToggle const hasSelection = selectedRows.length > 0 const allSelectedAreArchived = @@ -1087,40 +1112,53 @@ const ManuscriptsTable = ({ {modalContextHolder} {showSelectionActions && ( - - {t('manuscriptsTable.selectedCount', { - count: selectedRows.length, - })} - - - {showArchiveActions && ( - - )} - {showArchiveActions && ( - - )} - {showDownloadAction && ( - - )} - + {selectable && (showArchiveActions || showDownloadAction) && ( + <> + + {t('manuscriptsTable.selectedCount', { + count: selectedRows.length, + })} + + + {showArchiveActions && ( + + )} + {showArchiveActions && ( + + )} + {showDownloadAction && ( + + )} + + + )} + {showViewArchivedToggle && ( + + + + )} )} {filterChips.length > 0 && ( diff --git a/packages/client/stories/shared/ManuscriptsTable.stories.tsx b/packages/client/stories/shared/ManuscriptsTable.stories.tsx index c6e2a1572..743e4279d 100644 --- a/packages/client/stories/shared/ManuscriptsTable.stories.tsx +++ b/packages/client/stories/shared/ManuscriptsTable.stories.tsx @@ -1034,9 +1034,11 @@ export const WithSelectableRows = meta.story({ selectable: true, showArchiveActions: true, showDownloadAction: true, + showViewArchivedToggle: true, }, render: args => { const [dataSource, setDataSource] = useState(initialSelectableData) + const [viewingArchived, setViewingArchived] = useState(false) const [columnFilters, setColumnFilters] = useState< Record @@ -1069,7 +1071,9 @@ export const WithSelectableRows = meta.story({ } onFiltersChange={setColumnFilters} onUnarchiveSelected={ids => setArchived(ids, false)} + onViewingArchivedChange={setViewingArchived} totalCount={filtered.length} + viewingArchived={viewingArchived} /> ) }, From ef80f629b8abcd8e6fd490213513c8f61735a329 Mon Sep 17 00:00:00 2001 From: Yannis Barlas Date: Fri, 14 Aug 2026 12:15:34 +0300 Subject: [PATCH 14/51] fix(ui): fix new table not respecting changed theme --- packages/client/app/pages/Group.page.tsx | 15 +++++++++------ packages/client/app/theme/index.jsx | 6 +++--- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/client/app/pages/Group.page.tsx b/packages/client/app/pages/Group.page.tsx index 67a90b687..c7bffe28d 100644 --- a/packages/client/app/pages/Group.page.tsx +++ b/packages/client/app/pages/Group.page.tsx @@ -2,6 +2,7 @@ import { type ReactNode, useEffect, useMemo } from 'react' import { useParams, Outlet } from 'react-router-dom' import { useQuery } from '@apollo/client/react' import { ThemeProvider } from 'styled-components' +import { AntConfigProvider } from '@coko/client' import { GET_GROUPS } from '../queries' import { reloadTranslationsForGroup } from '../i18n' @@ -99,12 +100,14 @@ const GroupPage = (): ReactNode => { // TO DO - should yjs provider wrap the whole group? return ( - - - - - - + + + + + + + + ) } diff --git a/packages/client/app/theme/index.jsx b/packages/client/app/theme/index.jsx index e46f54c9b..c16773149 100644 --- a/packages/client/app/theme/index.jsx +++ b/packages/client/app/theme/index.jsx @@ -240,14 +240,14 @@ export const makeTheme = ( antComponents: { Table: { - headerBg: defaultBrandColor1, + headerBg: colorBrand1, headerColor: colorTextReverse, headerBorderRadius: borderRadius, cellPaddingBlock: '16px', - headerSortHoverBg: Color(defaultBrandColor1).darken(0.1).hex(), - headerSortActiveBg: Color(defaultBrandColor1).darken(0.1).hex(), + headerSortHoverBg: Color(colorBrand1).darken(0.1).hex(), + headerSortActiveBg: Color(colorBrand1).darken(0.1).hex(), headerIconColor: colorTextReverse, headerIconHoverColor: colorTextReverse, rowSelectedBg: colorPrimaryVeryLight, From 13d953445247d00a4ab83bbafa193d60402035f7 Mon Sep 17 00:00:00 2001 From: Yannis Barlas Date: Fri, 14 Aug 2026 13:05:09 +0300 Subject: [PATCH 15/51] feat(ui): handle two-tone manuscript statuses in new table --- .../components/sections/SubmissionsTable.jsx | 1 + .../client/app/ui/shared/ManuscriptStatus.tsx | 46 +++++++++++++++++-- .../client/app/ui/shared/ManuscriptsTable.tsx | 15 +++--- .../shared/ManuscriptStatus.stories.tsx | 22 +++++++++ 4 files changed, 72 insertions(+), 12 deletions(-) diff --git a/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx b/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx index af7195ea9..3df1e2e14 100644 --- a/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx +++ b/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx @@ -286,6 +286,7 @@ const SubmissionsTable = props => { row.key = manuscript.shortId row.id = manuscript.id + row.published = manuscript.published return row }, ) diff --git a/packages/client/app/ui/shared/ManuscriptStatus.tsx b/packages/client/app/ui/shared/ManuscriptStatus.tsx index b8ffe00d3..a7911701d 100644 --- a/packages/client/app/ui/shared/ManuscriptStatus.tsx +++ b/packages/client/app/ui/shared/ManuscriptStatus.tsx @@ -1,9 +1,11 @@ import { type ReactNode } from 'react' import { useTranslation } from 'react-i18next' +import styled from 'styled-components' import Badge from './Badge' +import { type BadgeVariant } from './_constants' -const variantMapper = { +const variantMapper: Record = { inProgress: 'warning', revise: 'warning', revising: 'warning', @@ -36,21 +38,57 @@ export const MANUSCRIPT_STATUSES = [ 'unpublished', ] as const +const STATUSES_FORCED_TO_PUBLISHED = ['evaluated'] + type ManuscriptStatusProps = { status: (typeof MANUSCRIPT_STATUSES)[number] + /** Timestamp the manuscript was published, if any. */ + published?: string | null small?: boolean } +const TwoToneWrapper = styled.span` + display: inline-flex; + + > span:first-child { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + > span:last-child { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } +` + const ManuscriptStatus = ({ status, + published, small, }: ManuscriptStatusProps): ReactNode => { const { t } = useTranslation() + const forceToPublished = STATUSES_FORCED_TO_PUBLISHED.includes(status) + const showPublishedHalf = + (!!published && status !== 'published') || forceToPublished + + if (!showPublishedHalf) { + return ( + + {t(`msStatus.${status}`)} + + ) + } + return ( - - {t(`msStatus.${status}`)} - + + + {t('msStatus.published')} + + + {t(`msStatus.${status}`)} + + ) } diff --git a/packages/client/app/ui/shared/ManuscriptsTable.tsx b/packages/client/app/ui/shared/ManuscriptsTable.tsx index b7ce0322d..2b62d6093 100644 --- a/packages/client/app/ui/shared/ManuscriptsTable.tsx +++ b/packages/client/app/ui/shared/ManuscriptsTable.tsx @@ -97,12 +97,11 @@ export type ManuscriptSearchSnippet = { /** * Other gaps identified against the legacy component-manuscripts-table cell renderers that are * NOT candidates for a new dataType (see above), since they aren't a "render this value" concern: - * - Value derivation: some legacy columns (reviewer's own status, last-reviewer-updated date, - * a status overridden by a second "published" field) already render fine via existing - * dataTypes, but need their value computed/aggregated from nested data (e.g. searching teams - * for the current user, or taking a max timestamp) before being handed to a column -- a data - * problem for the caller to solve, not a rendering gap here. See FilterableStatusBadge.jsx, - * ReviewerStatusBadge.jsx, LastReviewerUpdated.jsx. + * - Value derivation: some legacy columns (reviewer's own status, last-reviewer-updated date) + * already render fine via existing dataTypes, but need their value computed/aggregated from + * nested data (e.g. searching teams for the current user, or taking a max timestamp) before + * being handed to a column -- a data problem for the caller to solve, not a rendering gap + * here. See ReviewerStatusBadge.jsx, LastReviewerUpdated.jsx. * - Role-filtered member list: Editors.jsx lists team members whose role isn't author/reviewer; * generalizable in principle ("names of members matching a role filter") but currently * hardcodes which roles to exclude rather than taking a parameter. @@ -160,8 +159,8 @@ const renderDate = (value: any): ReactNode => { return convertTimestampToRelativeDateString(value) } -const renderStatus = (value: any): ReactNode => ( - +const renderStatus = (value: any, record: Record): ReactNode => ( + ) const renderReviewerStatus = (value: any): ReactNode => ( diff --git a/packages/client/stories/shared/ManuscriptStatus.stories.tsx b/packages/client/stories/shared/ManuscriptStatus.stories.tsx index 6155c5815..6ac90640f 100644 --- a/packages/client/stories/shared/ManuscriptStatus.stories.tsx +++ b/packages/client/stories/shared/ManuscriptStatus.stories.tsx @@ -35,6 +35,10 @@ export const EmbargoReleased = meta.story({ }, }) +/** + * Always forces a single 'published'-colored badge, regardless of whether + * `published` is set + */ export const Evaluated = meta.story({ args: { status: 'evaluated', @@ -94,3 +98,21 @@ export const Unpublished = meta.story({ status: 'unpublished', }, }) + +/** + * Two-tone badges: shown when a manuscript has a `published` timestamp but its + * current status is something else. + */ +export const PublishedThenUnpublished = meta.story({ + args: { + status: 'unpublished', + published: '2023-01-01T00:00:00.000Z', + }, +}) + +export const PublishedThenRevising = meta.story({ + args: { + status: 'revising', + published: '2023-01-01T00:00:00.000Z', + }, +}) From 1d9ccb1256914f510ad5b6930f6f02a250897785 Mon Sep 17 00:00:00 2001 From: Yannis Barlas Date: Fri, 14 Aug 2026 13:35:59 +0300 Subject: [PATCH 16/51] feat(ui): handle links in titles in new table --- .../src/components/sections/SubmissionsTable.jsx | 12 ++++++++++++ packages/client/app/ui/shared/ManuscriptsTable.tsx | 12 +++++++++++- .../stories/shared/ManuscriptsTable.stories.tsx | 6 ++++-- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx b/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx index 3df1e2e14..f43f317cc 100644 --- a/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx +++ b/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx @@ -13,6 +13,7 @@ import { URI_PAGENUM_PARAM, URI_SEARCH_PARAM, } from '../../../../../shared/urlParamUtils' +import { isValidDOI } from '../../../../../shared/doiFieldDefinition' import LegacyManuscriptsTable from '../../../../component-manuscripts-table/src/ManuscriptsTable' import buildColumnDefinitions from '../../../../component-manuscripts-table/src/util/buildColumnDefinitions' import { ConfigContext } from '../../../../config/src' @@ -44,6 +45,16 @@ const importSourceFor = manuscript => { return undefined } +const titleLinkFor = manuscript => { + const { $doi, $sourceUri } = manuscript.submission || {} + + if ($sourceUri) return $sourceUri + if ($doi && isValidDOI($doi)) + return `${$doi.includes('doi.org') ? '' : 'https://doi.org/'}${$doi}` + + return undefined +} + const LinkList = styled.div` align-items: flex-start; display: flex; @@ -258,6 +269,7 @@ const SubmissionsTable = props => { hasOverdueTasks: manuscript.hasOverdueTasksForUser, importSource: importSourceFor(manuscript), abstract: manuscript.submission?.$abstract, + link: titleLinkFor(manuscript), } return accumulator diff --git a/packages/client/app/ui/shared/ManuscriptsTable.tsx b/packages/client/app/ui/shared/ManuscriptsTable.tsx index 2b62d6093..78de8fa7b 100644 --- a/packages/client/app/ui/shared/ManuscriptsTable.tsx +++ b/packages/client/app/ui/shared/ManuscriptsTable.tsx @@ -512,6 +512,8 @@ type TitleCellValue = { hasOverdueTasks?: boolean importSource?: 'coar' | 'semanticScholar' abstract?: string + /** When set, the title text links out to this URL */ + link?: string } const TitleCell = ({ @@ -533,7 +535,15 @@ const TitleCell = ({ )} - {renderPlainOrRichText(value.title)} + + {value.link ? ( + + {renderPlainOrRichText(value.title)} + + ) : ( + renderPlainOrRichText(value.title) + )} + {showAbstract && ( Date: Fri, 14 Aug 2026 14:43:05 +0300 Subject: [PATCH 17/51] feat(ui): add column sorting to new table --- .../client/app/ui/shared/ManuscriptsTable.tsx | 81 +++++++++++++++---- 1 file changed, 64 insertions(+), 17 deletions(-) diff --git a/packages/client/app/ui/shared/ManuscriptsTable.tsx b/packages/client/app/ui/shared/ManuscriptsTable.tsx index 78de8fa7b..c0c4488bd 100644 --- a/packages/client/app/ui/shared/ManuscriptsTable.tsx +++ b/packages/client/app/ui/shared/ManuscriptsTable.tsx @@ -18,7 +18,7 @@ import { Tooltip, type TableColumnType, } from 'antd' -import type { FilterDropdownProps } from 'antd/es/table/interface' +import type { FilterDropdownProps, SorterResult } from 'antd/es/table/interface' import dayjs from 'dayjs' import DOMPurify from 'isomorphic-dompurify' @@ -87,6 +87,12 @@ export type ManuscriptsTableColumn = { * business-specific links) -- takes precedence over dataType. */ render?: (value: any, record: any) => ReactNode + sortable?: boolean +} + +export type ManuscriptsTableSortState = { + columnKey: string + order: 'ascend' | 'descend' } export type ManuscriptSearchSnippet = { @@ -124,6 +130,12 @@ type ManuscriptsTableProps = { */ columnFilters?: Record onFiltersChange?: (filters: Record) => void + /** + * Currently applied sort (controlled), for a single `sortable` column. + * `null`/`undefined` means unsorted. + */ + sortState?: ManuscriptsTableSortState | null + onSortChange?: (sortState: ManuscriptsTableSortState | null) => void /** * Compact/detailed view mode for 'reviewerStatusSummary' columns. * Defaults to 'detailed'. @@ -756,6 +768,7 @@ const resolveColumn = ( column: ManuscriptsTableColumn, context: { columnFilters?: Record + sortState?: ManuscriptsTableSortState | null reviewerStatusViewMode?: Record onReviewerStatusViewModeChange?: ( columnKey: string, @@ -768,7 +781,7 @@ const resolveColumn = ( ) => void }, ): TableColumnType> => { - let resolved: ManuscriptsTableColumn = column + let resolved: TableColumnType> = column if (!column.render) { switch (column.dataType) { @@ -832,7 +845,7 @@ const resolveColumn = ( } if (column.filterable && column.options) { - return { + resolved = { ...resolved, filters: column.options.map(({ value, label }) => ({ text: label ?? value, @@ -843,9 +856,9 @@ const resolveColumn = ( } if (column.filterable && column.dataType === 'date') { - return { + resolved = { ...resolved, - filterDropdown: (props: FilterDropdownProps) => ( + filterDropdown: (props: FilterDropdownProps): ReactNode => ( ), filteredValue: context.columnFilters?.[column.key] ?? null, @@ -853,7 +866,7 @@ const resolveColumn = ( } if (column.dataType === 'reviewerStatusSummary') { - return { + resolved = { ...resolved, title: ( , + sorter: + | SorterResult> + | SorterResult>[], ): void => { - if (filterableColumns.length === 0 || !onFiltersChange) return - - onFiltersChange( - Object.fromEntries( - filterableColumns.map(column => [ - column.key, - (filters[column.key] ?? []) as string[], - ]), - ), - ) + if (filterableColumns.length > 0 && onFiltersChange) { + onFiltersChange( + Object.fromEntries( + filterableColumns.map(column => [ + column.key, + (filters[column.key] ?? []) as string[], + ]), + ), + ) + } + + if (onSortChange) { + const activeSorter = Array.isArray(sorter) ? sorter[0] : sorter + + onSortChange( + activeSorter?.order && activeSorter.columnKey + ? { + columnKey: String(activeSorter.columnKey), + order: activeSorter.order, + } + : null, + ) + } } const resolvedColumns = columns.map(column => resolveColumn(column, { columnFilters, + sortState, reviewerStatusViewMode, onReviewerStatusViewModeChange, onOptionChange, From c5d9de85e723c26023d89d3e13bd74b52782e6e9 Mon Sep 17 00:00:00 2001 From: Yannis Barlas Date: Mon, 17 Aug 2026 11:38:03 +0300 Subject: [PATCH 18/51] feat(ui): sort by short id in new table --- .../components/sections/SubmissionsTable.jsx | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx b/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx index f43f317cc..99a16179a 100644 --- a/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx +++ b/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx @@ -12,6 +12,7 @@ import { extractSortData, URI_PAGENUM_PARAM, URI_SEARCH_PARAM, + URI_SORT_PARAM, } from '../../../../../shared/urlParamUtils' import { isValidDOI } from '../../../../../shared/doiFieldDefinition' import LegacyManuscriptsTable from '../../../../component-manuscripts-table/src/ManuscriptsTable' @@ -95,6 +96,23 @@ const SubmissionsTable = props => { const sortName = extractSortData(uriQueryParams).name const sortDirection = extractSortData(uriQueryParams).direction + // Shared with the legacy table's sort state (same 'sort' URI param), just + // translated to/from antd's { columnKey, order: 'ascend' | 'descend' }. + const sortState = sortName + ? { + columnKey: sortName, + order: sortDirection === 'ASC' ? 'ascend' : 'descend', + } + : null + + const handleSortChange = newSortState => + applyQueryParams({ + [URI_SORT_PARAM]: newSortState + ? `${newSortState.columnKey}_${newSortState.order === 'ascend' ? 'ASC' : 'DESC'}` + : null, + [URI_PAGENUM_PARAM]: 1, + }) + const page = uriQueryParams.get(URI_PAGENUM_PARAM) || 1 const limit = config?.manuscript?.paginationCount || 10 const { totalCount } = manuscriptsUserHasCurrentRoleIn @@ -225,6 +243,10 @@ const SubmissionsTable = props => { } } + if (column.key === 'shortId') { + return { ...column, sortable: true } + } + if ( ['created', 'updated'].includes(column.key) || fieldDefinitions[column.key]?.component === 'DatePicker' @@ -336,8 +358,10 @@ const SubmissionsTable = props => { applyQueryParams({ [URI_PAGENUM_PARAM]: newPage }) } onSearch={value => applyQueryParams({ [URI_SEARCH_PARAM]: value })} + onSortChange={handleSortChange} page={Number(page)} pageSize={limit} + sortState={sortState} totalCount={totalCount} /> From 195a5f4a89fa3396b17fa8234f7c36562944b1ed Mon Sep 17 00:00:00 2001 From: Yannis Barlas Date: Mon, 17 Aug 2026 11:54:43 +0300 Subject: [PATCH 19/51] feat(ui): sort dates in new table --- .../components/sections/SubmissionsTable.jsx | 23 +++++++++++++------ .../client/app/ui/shared/ManuscriptsTable.tsx | 1 + 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx b/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx index 99a16179a..c64309996 100644 --- a/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx +++ b/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx @@ -98,12 +98,21 @@ const SubmissionsTable = props => { // Shared with the legacy table's sort state (same 'sort' URI param), just // translated to/from antd's { columnKey, order: 'ascend' | 'descend' }. - const sortState = sortName - ? { - columnKey: sortName, - order: sortDirection === 'ASC' ? 'ascend' : 'descend', - } - : null + // With no explicit sort and no active search, the server's default order + // is COALESCE(p.created, m.created) DESC (see manuscriptUtils.js) -- show + // that on the 'created' header rather than looking unsorted. Once a + // search is active, results are actually ordered by search rank instead, + // so no column should claim to be sorted. + let sortState = null + + if (sortName) { + sortState = { + columnKey: sortName, + order: sortDirection === 'ASC' ? 'ascend' : 'descend', + } + } else if (!currentSearchQuery) { + sortState = { columnKey: 'created', order: 'descend' } + } const handleSortChange = newSortState => applyQueryParams({ @@ -251,7 +260,7 @@ const SubmissionsTable = props => { ['created', 'updated'].includes(column.key) || fieldDefinitions[column.key]?.component === 'DatePicker' ) { - return { ...column, dataType: 'date' } + return { ...column, dataType: 'date', sortable: true } } if (column.key === 'submission.$title') { diff --git a/packages/client/app/ui/shared/ManuscriptsTable.tsx b/packages/client/app/ui/shared/ManuscriptsTable.tsx index c0c4488bd..4d95fe2ca 100644 --- a/packages/client/app/ui/shared/ManuscriptsTable.tsx +++ b/packages/client/app/ui/shared/ManuscriptsTable.tsx @@ -892,6 +892,7 @@ const resolveColumn = ( resolved = { ...resolved, sorter: true, + sortDirections: ['descend', 'ascend'], sortOrder: context.sortState?.columnKey === column.key ? context.sortState.order From 2d009baeb41cbb0793ec11e6b3195a72bc161a01 Mon Sep 17 00:00:00 2001 From: Yannis Barlas Date: Mon, 17 Aug 2026 16:27:47 +0300 Subject: [PATCH 20/51] feat(ui): get sort and filter working on dates in new table on submissions table --- .../components/sections/SubmissionsTable.jsx | 55 ++++++++++++++++-- packages/client/app/shared/urlParamUtils.jsx | 2 +- .../client/app/ui/shared/ManuscriptsTable.tsx | 14 +++-- .../shared/ManuscriptsTable.stories.tsx | 58 +++++++++++++++++++ .../controllers/manuscript/manuscriptUtils.js | 47 +++++++++++++++ 5 files changed, 166 insertions(+), 10 deletions(-) diff --git a/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx b/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx index c64309996..054a6f04b 100644 --- a/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx +++ b/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx @@ -46,6 +46,19 @@ const importSourceFor = manuscript => { return undefined } +// The server's date-range filter value format is 'yyyyMMdd-yyyyMMdd' (see manuscriptUtils.js); +// ManuscriptsTable's date filter works with plain 'yyyy-MM-dd' boundaries instead, so convert +// between the two with simple string reformatting -- no timezone handling needed, since both +// sides already represent the same local calendar date. +const isoDateToCompact = isoDate => isoDate.replaceAll('-', '') + +const compactDateToIso = compactDate => + `${compactDate.slice(0, 4)}-${compactDate.slice(4, 6)}-${compactDate.slice(6, 8)}` + +const isDateColumnKey = (key, fieldDefinitions) => + ['created', 'updated'].includes(key) || + fieldDefinitions[key]?.component === 'DatePicker' + const titleLinkFor = manuscript => { const { $doi, $sourceUri } = manuscript.submission || {} @@ -122,6 +135,34 @@ const SubmissionsTable = props => { [URI_PAGENUM_PARAM]: 1, }) + // Only date columns are filterable so far, keyed by the same URL param name as their column + // key (shared with the legacy table's filter convention). + const columnFilters = {} + + uriQueryParams.forEach((value, key) => { + if (!isDateColumnKey(key, fieldDefinitions)) return + const [start, end] = value.split('-') + if (start && end) + columnFilters[key] = [compactDateToIso(start), compactDateToIso(end)] + }) + + const handleFiltersChange = newColumnFilters => + applyQueryParams({ + ...Object.fromEntries( + Object.entries(newColumnFilters).map(([key, values]) => { + const [start, end] = values ?? [] + + return [ + key, + start && end + ? `${isoDateToCompact(start)}-${isoDateToCompact(end)}` + : null, + ] + }), + ), + [URI_PAGENUM_PARAM]: 1, + }) + const page = uriQueryParams.get(URI_PAGENUM_PARAM) || 1 const limit = config?.manuscript?.paginationCount || 10 const { totalCount } = manuscriptsUserHasCurrentRoleIn @@ -256,11 +297,13 @@ const SubmissionsTable = props => { return { ...column, sortable: true } } - if ( - ['created', 'updated'].includes(column.key) || - fieldDefinitions[column.key]?.component === 'DatePicker' - ) { - return { ...column, dataType: 'date', sortable: true } + if (isDateColumnKey(column.key, fieldDefinitions)) { + // Server sorts submission.* fields as LOWER(text) (manuscriptUtils.js), which works + // correctly for DatePicker values -- they're persisted as ISO 8601 strings (see + // app/components/shared/DatePicker.jsx), and ISO 8601 sorts correctly lexicographically. + // Server also range-filters both created/updated and DatePicker submission fields the + // same way (see applyFilters). + return { ...column, dataType: 'date', sortable: true, filterable: true } } if (column.key === 'submission.$title') { @@ -361,8 +404,10 @@ const SubmissionsTable = props => { applyQueryParams({ [URI_PAGENUM_PARAM]: newPage }) } diff --git a/packages/client/app/shared/urlParamUtils.jsx b/packages/client/app/shared/urlParamUtils.jsx index 83c226921..3545c1e3b 100644 --- a/packages/client/app/shared/urlParamUtils.jsx +++ b/packages/client/app/shared/urlParamUtils.jsx @@ -37,7 +37,7 @@ export const useQueryParams = () => { const location = useLocation() const applyQueryParams = queryParams => { - const params = new URLSearchParams(location.search) + const params = new URLSearchParams(window.location.search) Object.entries(queryParams).forEach(([fieldName, fieldValue]) => { if (fieldValue) params.set(fieldName, fieldValue) else params.delete(fieldName) diff --git a/packages/client/app/ui/shared/ManuscriptsTable.tsx b/packages/client/app/ui/shared/ManuscriptsTable.tsx index 4d95fe2ca..e5c1875a2 100644 --- a/packages/client/app/ui/shared/ManuscriptsTable.tsx +++ b/packages/client/app/ui/shared/ManuscriptsTable.tsx @@ -927,13 +927,19 @@ type FilterChip = { onRemove: (event: MouseEvent) => void } +/** + * Sets the column's filter to an empty array rather than deleting the key + * outright: a consumer's onFiltersChange (e.g. SubmissionsTable.jsx) needs to + * see the key present-but-empty to know it should clear that column's URL + * param -- a missing key looks like "untouched". + */ const withoutColumnFilter = ( filters: Record, columnKey: string, -): Record => - Object.fromEntries( - Object.entries(filters).filter(([key]) => key !== columnKey), - ) +): Record => ({ + ...filters, + [columnKey]: [], +}) const buildFilterChips = ( columns: ManuscriptsTableColumn[], diff --git a/packages/client/stories/shared/ManuscriptsTable.stories.tsx b/packages/client/stories/shared/ManuscriptsTable.stories.tsx index b024fbec4..8f48c0e71 100644 --- a/packages/client/stories/shared/ManuscriptsTable.stories.tsx +++ b/packages/client/stories/shared/ManuscriptsTable.stories.tsx @@ -380,6 +380,64 @@ export const BuiltInDateRenderingWithFilter = meta.story({ }, }) +export const BuiltInDateRenderingWithSortAndFilter = meta.story({ + args: { + columns: [ + ...baseColumns, + { + title: 'Created', + dataIndex: 'created', + key: 'created', + dataType: 'date', + filterable: true, + sortable: true, + }, + ], + dataSource: allData, + page: 1, + pageSize: 10, + totalCount: allData.length, + onPageChange: () => {}, + onSearch: () => {}, + }, + render: args => { + const [columnFilters, setColumnFilters] = useState< + Record + >({}) + + const [sortState, setSortState] = useState<{ + columnKey: string + order: 'ascend' | 'descend' + } | null>(null) + + const [start, end] = columnFilters.created ?? [] + + const filtered = + start && end + ? allData.filter(row => row.created >= start && row.created <= end) + : allData + + const sorted = sortState + ? [...filtered].sort((a, b) => { + const direction = sortState.order === 'ascend' ? 1 : -1 + return a.created < b.created ? -direction : direction + }) + : filtered + + return ( + + ) + }, +}) + export const BuiltInStatusRendering = meta.story({ args: { columns: [ diff --git a/packages/server/controllers/manuscript/manuscriptUtils.js b/packages/server/controllers/manuscript/manuscriptUtils.js index 01bbaf51d..6e1c2ff7d 100644 --- a/packages/server/controllers/manuscript/manuscriptUtils.js +++ b/packages/server/controllers/manuscript/manuscriptUtils.js @@ -12,6 +12,7 @@ const { getStartOfDay, getEndOfDay, compactStringToDate, + dateToIso8601, } = require('../../utils/dateUtils') const URI_SEARCH_PARAM = 'search' @@ -213,6 +214,20 @@ const getSafelyNamedJsonbFieldInfo = (fieldName, submissionForm) => { } } +/** Like getSafelyNamedJsonbFieldInfo, but only returns a result for DatePicker fields -- + * these are stored as plain 'yyyy-MM-dd' strings (see client's DatePicker.jsx), so they can + * be range-filtered the same way as the 'created'/'updated' columns. */ +const getSafelyNamedJsonbDateFieldInfo = (fieldName, submissionForm) => { + const jsonbField = getSafelyNamedJsonbFieldInfo(fieldName, submissionForm) + if (!jsonbField) return null + + const field = submissionForm.structure.children.find( + f => f.name === fieldName, + ) + + return field.component === 'DatePicker' ? jsonbField : null +} + /** Check that the field exists and is not dangerously named (to avoid sql injection) */ const isValidNonJsonbField = (fieldName, submissionForm) => { if (!/^[a-zA-Z]\w*$/.test(fieldName)) { @@ -315,6 +330,38 @@ const applyFilters = ( return } + const jsonbDateField = getSafelyNamedJsonbDateFieldInfo( + filter.field, + submissionForm, + ) + + if (jsonbDateField) { + try { + const parts = filter.value.split('-') + + // Unlike created/updated (real timestamptz columns), the stored value has no time + // component, so compare plain 'yyyy-MM-dd' boundaries rather than start/end-of-day + // timestamps. + const dateFrom = dateToIso8601( + compactStringToDate(parts[0], timezoneOffsetMinutes), + ) + + const dateTo = dateToIso8601( + compactStringToDate(parts[1], timezoneOffsetMinutes), + ) + + addWhere(`m.submission->>? >= ?`, jsonbDateField.name, dateFrom) + addWhere(`m.submission->>? <= ?`, jsonbDateField.name, dateTo) + /* eslint-disable-next-line */ + } catch (error) { + logger.warn( + `Could not filter ${filter.field} by value '${filter.value}': could not parse as a date range.`, + ) + } + + return + } + if (!/^[\w :./,()\-<>=_]+$/.test(filter.value)) { logger.warn( `Ignoring filter "${filter.field}" with illegal value "${filter.value}"`, // To prevent code injection! From 13e8a1e69121db510d3169d6830e06edaa12c36e Mon Sep 17 00:00:00 2001 From: Yannis Barlas Date: Tue, 18 Aug 2026 12:42:03 +0300 Subject: [PATCH 21/51] feat(ui): make statuses filterable in submissions table --- .../components/sections/SubmissionsTable.jsx | 107 +++++++++++++----- packages/client/app/ui/base/Menu.tsx | 2 +- .../controllers/manuscript/manuscriptUtils.js | 15 ++- 3 files changed, 89 insertions(+), 35 deletions(-) diff --git a/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx b/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx index 054a6f04b..24d7b1e45 100644 --- a/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx +++ b/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx @@ -15,6 +15,7 @@ import { URI_SORT_PARAM, } from '../../../../../shared/urlParamUtils' import { isValidDOI } from '../../../../../shared/doiFieldDefinition' +import { MANUSCRIPT_STATUSES } from '../../../../../ui/shared/ManuscriptStatus' import LegacyManuscriptsTable from '../../../../component-manuscripts-table/src/ManuscriptsTable' import buildColumnDefinitions from '../../../../component-manuscripts-table/src/util/buildColumnDefinitions' import { ConfigContext } from '../../../../config/src' @@ -135,34 +136,6 @@ const SubmissionsTable = props => { [URI_PAGENUM_PARAM]: 1, }) - // Only date columns are filterable so far, keyed by the same URL param name as their column - // key (shared with the legacy table's filter convention). - const columnFilters = {} - - uriQueryParams.forEach((value, key) => { - if (!isDateColumnKey(key, fieldDefinitions)) return - const [start, end] = value.split('-') - if (start && end) - columnFilters[key] = [compactDateToIso(start), compactDateToIso(end)] - }) - - const handleFiltersChange = newColumnFilters => - applyQueryParams({ - ...Object.fromEntries( - Object.entries(newColumnFilters).map(([key, values]) => { - const [start, end] = values ?? [] - - return [ - key, - start && end - ? `${isoDateToCompact(start)}-${isoDateToCompact(end)}` - : null, - ] - }), - ), - [URI_PAGENUM_PARAM]: 1, - }) - const page = uriQueryParams.get(URI_PAGENUM_PARAM) || 1 const limit = config?.manuscript?.paginationCount || 10 const { totalCount } = manuscriptsUserHasCurrentRoleIn @@ -311,17 +284,39 @@ const SubmissionsTable = props => { } if (column.key === 'status') { - return { ...column, dataType: 'status' } + return { + ...column, + dataType: 'status', + filterable: true, + options: MANUSCRIPT_STATUSES.map(status => ({ + value: status, + label: t(`msStatus.${status}`), + })), + } } - if (column.key === 'submission.adaStatus') { - return { ...column, dataType: 'badge' } + if (column.key === 'submission.adaState') { + return { + ...column, + dataType: 'badge', + filterable: true, + options: [ + { value: 'draft', label: t('decisionPage.decisionTab.Draft') }, + { value: 'process', label: t('decisionPage.decisionTab.Process') }, + { + value: 'findable', + label: t('decisionPage.decisionTab.Findable'), + }, + { value: 'publish', label: t('decisionPage.decisionTab.Publish') }, + ], + } } if (fieldDefinitions[column.key]?.options) { return { ...column, dataType: 'options', + filterable: true, options: fieldDefinitions[column.key].options, } } @@ -329,6 +324,56 @@ const SubmissionsTable = props => { return column }) + // Keyed by the same URL param name as the column key (shared with the legacy table's filter + // convention). Date columns store a compact 'yyyyMMdd-yyyyMMdd' range (see manuscriptUtils.js); + // every other filterable column stores one or more selected values, comma-joined (the server + // matches a row if the field equals ANY of them -- see applyFilters). + const columnFilters = {} + + tableColumns + .filter(column => column.filterable) + .forEach(column => { + const value = uriQueryParams.get(column.key) + if (!value) return + + if (column.dataType === 'date') { + const [start, end] = value.split('-') + + if (start && end) + columnFilters[column.key] = [ + compactDateToIso(start), + compactDateToIso(end), + ] + + return + } + + columnFilters[column.key] = value.split(',') + }) + + const handleFiltersChange = newColumnFilters => + applyQueryParams({ + ...Object.fromEntries( + Object.entries(newColumnFilters).map(([key, values]) => { + const column = tableColumns.find(c => c.key === key) + + if (column?.dataType === 'date') { + const [start, end] = values ?? [] + + return [ + key, + start && end + ? `${isoDateToCompact(start)}-${isoDateToCompact(end)}` + : null, + ] + } + + return [key, values && values.length > 0 ? values.join(',') : null] + }), + ), + [URI_PAGENUM_PARAM]: 1, + }) + const dataSource = manuscriptsUserHasCurrentRoleIn.manuscripts.map( manuscriptObj => { const manuscript = { ...manuscriptObj } diff --git a/packages/client/app/ui/base/Menu.tsx b/packages/client/app/ui/base/Menu.tsx index 2a30d3d53..f02025ad8 100644 --- a/packages/client/app/ui/base/Menu.tsx +++ b/packages/client/app/ui/base/Menu.tsx @@ -65,7 +65,7 @@ const GroupSection = styled.div` const GroupLetter = styled.div<{ $menuCollapsed: boolean }>` height: ${grid(12)}; width: ${grid(12)}; - border-radius: ${th('borderRadiusLarge')}; + border-radius: ${th('borderRadius')}; background-color: ${th('colorBackground')}; color: ${th('colorPrimary')}; diff --git a/packages/server/controllers/manuscript/manuscriptUtils.js b/packages/server/controllers/manuscript/manuscriptUtils.js index 6e1c2ff7d..1912bcb01 100644 --- a/packages/server/controllers/manuscript/manuscriptUtils.js +++ b/packages/server/controllers/manuscript/manuscriptUtils.js @@ -374,16 +374,25 @@ const applyFilters = ( submissionForm, ) + const values = filter.value.split(',') + if (jsonbField) { const { name: jsonName, valuesAreKeyedObjects: isKeyed } = jsonbField if (isKeyed) { - addWhere(`(m.submission->?)::jsonb \\? ?`, jsonName, filter.value) + addWhere( + `(${values.map(() => `(m.submission->?)::jsonb \\? ?`).join(' OR ')})`, + ...values.flatMap(value => [jsonName, value]), + ) } else { - addWhere(`m.submission->>? = ?`, jsonName, filter.value) + addWhere( + `m.submission->>? IN (${values.map(() => '?').join(', ')})`, + jsonName, + ...values, + ) } } else if (filter.field === 'status') { - addWhere('m.status = ?', filter.value) + addWhere(`m.status IN (${values.map(() => '?').join(', ')})`, ...values) } else if (filter.field === 'archived') { // ignore: this is handled elsewhere } else { From 7dc7bca8fb73d41c31c4824123e2c27cea22e906 Mon Sep 17 00:00:00 2001 From: Yannis Barlas Date: Tue, 18 Aug 2026 12:50:16 +0300 Subject: [PATCH 22/51] feat(ui): sort title and rich text in new table --- .../src/components/sections/SubmissionsTable.jsx | 7 ++++++- packages/server/controllers/manuscript/manuscriptUtils.js | 5 ++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx b/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx index 24d7b1e45..754a28c9d 100644 --- a/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx +++ b/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx @@ -280,7 +280,12 @@ const SubmissionsTable = props => { } if (column.key === 'submission.$title') { - return { ...column, dataType: 'title', showAbstract: true } + return { + ...column, + dataType: 'title', + showAbstract: true, + sortable: true, + } } if (column.key === 'status') { diff --git a/packages/server/controllers/manuscript/manuscriptUtils.js b/packages/server/controllers/manuscript/manuscriptUtils.js index 1912bcb01..53edf8214 100644 --- a/packages/server/controllers/manuscript/manuscriptUtils.js +++ b/packages/server/controllers/manuscript/manuscriptUtils.js @@ -266,7 +266,10 @@ const applySortOrder = ({ field, isAscending }, submissionForm, addOrder) => { if (jsonbField) { const { name: jsonName } = jsonbField - addOrder(`LOWER(m.submission->>?)${sortDirection}`, jsonName) + addOrder( + `LOWER(${stripHtmlTags('m.submission->>?')})${sortDirection}`, + jsonName, + ) } else if (isValidNonJsonbField(field, submissionForm)) { let sortingField = '' if (field === 'created') sortingField = 'COALESCE(p.created, m.created)' From e4464b89a4ef86d25ebb1407529a8141844f51d7 Mon Sep 17 00:00:00 2001 From: Yannis Barlas Date: Tue, 18 Aug 2026 12:54:23 +0300 Subject: [PATCH 23/51] feat(ui): make free text sortable in new table --- .../src/components/sections/SubmissionsTable.jsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx b/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx index 754a28c9d..3161c2f5b 100644 --- a/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx +++ b/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx @@ -326,6 +326,14 @@ const SubmissionsTable = props => { } } + if ( + ['TextField', 'AbstractEditor'].includes( + fieldDefinitions[column.key]?.component, + ) + ) { + return { ...column, sortable: true } + } + return column }) From 3798289aed9bb06e92c4c7bfe1cbf8e4203bc543 Mon Sep 17 00:00:00 2001 From: Yannis Barlas Date: Tue, 18 Aug 2026 12:59:22 +0300 Subject: [PATCH 24/51] feat(ui): make manuscript versions count column non-sortable in new table --- .../src/components/sections/SubmissionsTable.jsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx b/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx index 3161c2f5b..9c65b6e5e 100644 --- a/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx +++ b/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx @@ -334,6 +334,10 @@ const SubmissionsTable = props => { return { ...column, sortable: true } } + if (column.key === 'manuscriptVersions') { + return { ...column, sortable: false } + } + return column }) From 4adb7c30cb200bb0ae2abd1cba06337702ffc33d Mon Sep 17 00:00:00 2001 From: Yannis Barlas Date: Tue, 18 Aug 2026 13:20:00 +0300 Subject: [PATCH 25/51] feat(ui): let search and sort co-exist on new table --- .../src/components/sections/SubmissionsTable.jsx | 5 ----- packages/server/controllers/manuscript/manuscriptUtils.js | 6 ++++-- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx b/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx index 9c65b6e5e..ea8275fae 100644 --- a/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx +++ b/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx @@ -112,11 +112,6 @@ const SubmissionsTable = props => { // Shared with the legacy table's sort state (same 'sort' URI param), just // translated to/from antd's { columnKey, order: 'ascend' | 'descend' }. - // With no explicit sort and no active search, the server's default order - // is COALESCE(p.created, m.created) DESC (see manuscriptUtils.js) -- show - // that on the 'created' header rather than looking unsorted. Once a - // search is active, results are actually ordered by search rank instead, - // so no column should claim to be sorted. let sortState = null if (sortName) { diff --git a/packages/server/controllers/manuscript/manuscriptUtils.js b/packages/server/controllers/manuscript/manuscriptUtils.js index 53edf8214..bf7fbb3f0 100644 --- a/packages/server/controllers/manuscript/manuscriptUtils.js +++ b/packages/server/controllers/manuscript/manuscriptUtils.js @@ -531,10 +531,12 @@ const buildQueryForManuscriptSearchFilterAndOrder = ( addWhere(`m.search_tsvector @@ to_tsquery('english', ?)`, searchQuery) - setOrderOnRank = 'ORDER BY rank DESC' + if (!sort) { + setOrderOnRank = 'ORDER BY rank DESC' + } } - if (!searchQuery && sort) { + if (sort) { applySortOrder(sort, submissionForm, addOrder) } else { // Give it some order to prevent it changing on refetch. From 6f46d4203afbe5e7fae2234dbe77f10f644b7005 Mon Sep 17 00:00:00 2001 From: Yannis Barlas Date: Wed, 19 Aug 2026 09:26:51 +0300 Subject: [PATCH 26/51] chore(*): backup --- .../components/DashboardSubmissionsPage.jsx | 60 +- .../components/sections/SubmissionsTable.jsx | 462 +------------- .../app/pages/hooks/useManuscriptsTable.tsx | 566 ++++++++++++++++++ 3 files changed, 579 insertions(+), 509 deletions(-) create mode 100644 packages/client/app/pages/hooks/useManuscriptsTable.tsx diff --git a/packages/client/app/components/component-dashboard/src/components/DashboardSubmissionsPage.jsx b/packages/client/app/components/component-dashboard/src/components/DashboardSubmissionsPage.jsx index 18fd4de7c..6b8e489b2 100644 --- a/packages/client/app/components/component-dashboard/src/components/DashboardSubmissionsPage.jsx +++ b/packages/client/app/components/component-dashboard/src/components/DashboardSubmissionsPage.jsx @@ -1,54 +1,13 @@ /* eslint-disable react-hooks/exhaustive-deps */ -import { useMutation, useQuery } from '@apollo/client/react' +import { useMutation } from '@apollo/client/react' import { useEffect, useContext } from 'react' -import { useLocation } from 'react-router-dom' import { ConfigContext } from '../../../config/src' -import { - extractFilters, - extractSortData, - URI_PAGENUM_PARAM, - useQueryParams, -} from '../../../../shared/urlParamUtils' -import { UPDATE_TAB, DASHBOARD } from '../../../../queries' +import { UPDATE_TAB } from '../../../../queries' import SubmissionsTable from './sections/SubmissionsTable' -import { CommsErrorBanner, Spinner } from '../../../shared' -import { useCurrentUser } from '../../../../pages/hooks/useCurrentUser' const DashboardSubmissionsPage = () => { - const location = useLocation() const config = useContext(ConfigContext) - const wantedRoles = ['author'] - - const currentUser = useCurrentUser() - - const applyQueryParams = useQueryParams() - - const uriQueryParams = new URLSearchParams(location.search) - const page = uriQueryParams.get(URI_PAGENUM_PARAM) || 1 - const sortName = extractSortData(uriQueryParams).name - const sortDirection = extractSortData(uriQueryParams).direction - const filters = extractFilters(uriQueryParams) - - const limit = config?.manuscript?.paginationCount || 10 - - const { loading, error, data, previousData } = useQuery(DASHBOARD, { - variables: { - wantedRoles, - sort: sortName - ? { field: sortName, isAscending: sortDirection === 'ASC' } - : null, - offset: (page - 1) * limit, - limit, - filters, - timezoneOffsetMinutes: new Date().getTimezoneOffset(), - groupId: config.groupId, - searchInAllVersions: false, - }, - fetchPolicy: 'network-only', - }) - - const currentData = data ?? previousData const [updateTab] = useMutation(UPDATE_TAB) @@ -60,25 +19,12 @@ const DashboardSubmissionsPage = () => { }) }, []) - if (loading && !currentData) return - if (error) return - const showSubmissions = config?.dashboard?.showSections?.includes('submission') if (!showSubmissions) return null - return ( - - ) + return } export default DashboardSubmissionsPage diff --git a/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx b/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx index ea8275fae..de749e280 100644 --- a/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx +++ b/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx @@ -1,438 +1,29 @@ -/* eslint-disable react/prop-types */ - -import { useMemo, useContext } from 'react' -import { useParams } from 'react-router-dom' import { useTranslation } from 'react-i18next' import styled from 'styled-components' -import get from 'lodash/get' import { grid } from '@coko/client' -import { authorColumns } from '../../../../../../config/journal/manuscripts' -import { - extractSortData, - URI_PAGENUM_PARAM, - URI_SEARCH_PARAM, - URI_SORT_PARAM, -} from '../../../../../shared/urlParamUtils' -import { isValidDOI } from '../../../../../shared/doiFieldDefinition' -import { MANUSCRIPT_STATUSES } from '../../../../../ui/shared/ManuscriptStatus' -import LegacyManuscriptsTable from '../../../../component-manuscripts-table/src/ManuscriptsTable' -import buildColumnDefinitions from '../../../../component-manuscripts-table/src/util/buildColumnDefinitions' -import { ConfigContext } from '../../../../config/src' +import useManuscriptsTable from '../../../../../pages/hooks/useManuscriptsTable' import { - Pagination, - PaginationContainerShadowed, SectionContent, SectionHeader, Title, + CommsErrorBanner, + Spinner, } from '../../../../shared' import ManuscriptsTable from '../../../../../ui/shared/ManuscriptsTable' -import Link from '../../../../../ui/shared/Link' - -const centeredColumns = [ - 'shortId', - 'adaState', - '$doi', - 'status', - 'manuscriptVersions', - 'reviewerStatusBadge', - 'statusCounts', -] - -const importSourceFor = manuscript => { - if (manuscript.importSourceServer === 'COAR') return 'coar' - if (manuscript.importSourceServer === 'semantic-scholar') - return 'semanticScholar' - - return undefined -} - -// The server's date-range filter value format is 'yyyyMMdd-yyyyMMdd' (see manuscriptUtils.js); -// ManuscriptsTable's date filter works with plain 'yyyy-MM-dd' boundaries instead, so convert -// between the two with simple string reformatting -- no timezone handling needed, since both -// sides already represent the same local calendar date. -const isoDateToCompact = isoDate => isoDate.replaceAll('-', '') - -const compactDateToIso = compactDate => - `${compactDate.slice(0, 4)}-${compactDate.slice(4, 6)}-${compactDate.slice(6, 8)}` - -const isDateColumnKey = (key, fieldDefinitions) => - ['created', 'updated'].includes(key) || - fieldDefinitions[key]?.component === 'DatePicker' - -const titleLinkFor = manuscript => { - const { $doi, $sourceUri } = manuscript.submission || {} - - if ($sourceUri) return $sourceUri - if ($doi && isValidDOI($doi)) - return `${$doi.includes('doi.org') ? '' : 'https://doi.org/'}${$doi}` - - return undefined -} - -const LinkList = styled.div` - align-items: flex-start; - display: flex; - flex-direction: column; -` const TableWrapper = styled.div` padding: ${grid(2)}; ` -const SubmissionsTable = props => { - const { - currentUser, - manuscriptsUserHasCurrentRoleIn, - submissionForm, - applyQueryParams, - uriQueryParams, - } = props - - const config = useContext(ConfigContext) - const { urlFrag } = config +const SubmissionsTable = () => { const { t } = useTranslation() - const { groupName } = useParams() - - const authorProofingEnabled = config.controlPanel?.authorProofingEnabled - - const fieldDefinitions = useMemo(() => { - const fields = submissionForm?.structure?.children ?? [] - const defs = {} - fields.forEach(field => { - // Incomplete fields in the formbuilder may not have a name specified. Ignore these - if (field.name) defs[field.name] = field - }) - return defs - }, [submissionForm]) - - const currentSearchQuery = uriQueryParams.get(URI_SEARCH_PARAM) - const sortName = extractSortData(uriQueryParams).name - const sortDirection = extractSortData(uriQueryParams).direction - - // Shared with the legacy table's sort state (same 'sort' URI param), just - // translated to/from antd's { columnKey, order: 'ascend' | 'descend' }. - let sortState = null - - if (sortName) { - sortState = { - columnKey: sortName, - order: sortDirection === 'ASC' ? 'ascend' : 'descend', - } - } else if (!currentSearchQuery) { - sortState = { columnKey: 'created', order: 'descend' } - } - - const handleSortChange = newSortState => - applyQueryParams({ - [URI_SORT_PARAM]: newSortState - ? `${newSortState.columnKey}_${newSortState.order === 'ascend' ? 'ASC' : 'DESC'}` - : null, - [URI_PAGENUM_PARAM]: 1, - }) - - const page = uriQueryParams.get(URI_PAGENUM_PARAM) || 1 - const limit = config?.manuscript?.paginationCount || 10 - const { totalCount } = manuscriptsUserHasCurrentRoleIn - - const specialComponentValues = { - urlFrag, - currentUser, - } - - const displayProps = { - uriQueryParams, - columnToSortOn: sortName, - sortDirection, - currentSearchQuery, - } - - const configColumns = (config.dashboard?.mySubmissions || []).map( - tc => tc.value, - ) - - const columnsProps = buildColumnDefinitions( - config, - authorColumns(configColumns), - fieldDefinitions, - specialComponentValues, - displayProps, - ) - - const columnKeys = - configColumns && configColumns.length > 0 - ? [...configColumns, 'actions'] - : [ - 'shortId', - 'submission.$title', - 'status', - 'created', - 'updated', - 'actions', - ] - - const specialColumnTitles = useMemo( - () => ({ - 'submission.$doi': t('formBuilder.fieldOpts.doi'), - 'submission.adaState': t('manuscriptsTable.adaState'), - actions: 'Actions', // not in translation! - author: t('manuscriptsTable.Author'), - authorProofingLink: 'Actions', // not in translation! - created: t('manuscriptsTable.Created'), - editor: t('manuscriptsTable.Editor'), - editorLinks: t('manuscriptsTable.Actions'), - lastUpdated: t('manuscriptsTable.lastReviewerStatusUpdate'), - manuscriptVersions: t('manuscriptsTable.Version'), - reviewerLinks: 'Action', // not in translation! - reviewerStatusBadge: t('manuscriptsTable.Your Status'), - // shortId: t('manuscriptsTable.Manuscript number'), - shortId: 'No.', // not in translation! - status: t('manuscriptsTable.Status'), - statusCounts: t('manuscriptsTable.Reviewer Status'), - submitter: t('manuscriptsTable.Author'), // alias of 'author' - titleAndAbstract: t('manuscriptsTable.Title'), - updated: t('manuscriptsTable.Updated'), - }), - [t], - ) - - const actionText = useMemo( - () => ({ - new: t('manuscriptsTable.actions.continueSubmission'), - submitted: t('manuscriptsTable.actions.View'), - revise: t('manuscriptsTable.actions.revise'), - revising: t('manuscriptsTable.actions.continueRevision'), - accepted: t('manuscriptsTable.actions.View'), - rejected: t('manuscriptsTable.actions.View'), - published: t('manuscriptsTable.actions.View'), - assigned: t('manuscriptsTable.actions.View'), - inProgress: t('manuscriptsTable.actions.View'), - completed: t('manuscriptsTable.actions.View'), - underEmbargo: t('manuscriptsTable.actions.View'), - embargoReleased: t('manuscriptsTable.actions.View'), - }), - [t], - ) - - const findColumnTitle = key => { - const formTitle = submissionForm.structure.children.find( - field => field.name === key, - )?.title + const { loading, error, ...tableProps } = useManuscriptsTable({ + variant: 'submitter', + }) - if (formTitle) return formTitle - if (specialColumnTitles[key]) return specialColumnTitles[key] - return key - } - - const tableColumns = columnKeys - .map(key => ({ - title: findColumnTitle(key), - dataIndex: key, - key: key, - align: centeredColumns.includes(key) ? 'center' : 'left', - })) - .map(column => { - if (column.key === 'actions') { - return { - ...column, - render: (_, record) => { - const { id, status, showAuthorProofing } = record - - return ( - - - {actionText[status]} - - - {showAuthorProofing && ( - - {(status === 'assigned' || status === 'inProgress') && - t( - 'dashboardPage.mySubmissions.Provide production feedback', - )} - - {status === 'completed' && - t('dashboardPage.mySubmissions.View production feedback')} - - )} - - ) - }, - } - } - - if (column.key === 'shortId') { - return { ...column, sortable: true } - } - - if (isDateColumnKey(column.key, fieldDefinitions)) { - // Server sorts submission.* fields as LOWER(text) (manuscriptUtils.js), which works - // correctly for DatePicker values -- they're persisted as ISO 8601 strings (see - // app/components/shared/DatePicker.jsx), and ISO 8601 sorts correctly lexicographically. - // Server also range-filters both created/updated and DatePicker submission fields the - // same way (see applyFilters). - return { ...column, dataType: 'date', sortable: true, filterable: true } - } - - if (column.key === 'submission.$title') { - return { - ...column, - dataType: 'title', - showAbstract: true, - sortable: true, - } - } - - if (column.key === 'status') { - return { - ...column, - dataType: 'status', - filterable: true, - options: MANUSCRIPT_STATUSES.map(status => ({ - value: status, - label: t(`msStatus.${status}`), - })), - } - } - - if (column.key === 'submission.adaState') { - return { - ...column, - dataType: 'badge', - filterable: true, - options: [ - { value: 'draft', label: t('decisionPage.decisionTab.Draft') }, - { value: 'process', label: t('decisionPage.decisionTab.Process') }, - { - value: 'findable', - label: t('decisionPage.decisionTab.Findable'), - }, - { value: 'publish', label: t('decisionPage.decisionTab.Publish') }, - ], - } - } - - if (fieldDefinitions[column.key]?.options) { - return { - ...column, - dataType: 'options', - filterable: true, - options: fieldDefinitions[column.key].options, - } - } - - if ( - ['TextField', 'AbstractEditor'].includes( - fieldDefinitions[column.key]?.component, - ) - ) { - return { ...column, sortable: true } - } - - if (column.key === 'manuscriptVersions') { - return { ...column, sortable: false } - } - - return column - }) - - // Keyed by the same URL param name as the column key (shared with the legacy table's filter - // convention). Date columns store a compact 'yyyyMMdd-yyyyMMdd' range (see manuscriptUtils.js); - // every other filterable column stores one or more selected values, comma-joined (the server - // matches a row if the field equals ANY of them -- see applyFilters). - const columnFilters = {} - - tableColumns - .filter(column => column.filterable) - .forEach(column => { - const value = uriQueryParams.get(column.key) - if (!value) return - - if (column.dataType === 'date') { - const [start, end] = value.split('-') - - if (start && end) - columnFilters[column.key] = [ - compactDateToIso(start), - compactDateToIso(end), - ] - - return - } - - columnFilters[column.key] = value.split(',') - }) - - const handleFiltersChange = newColumnFilters => - applyQueryParams({ - ...Object.fromEntries( - Object.entries(newColumnFilters).map(([key, values]) => { - const column = tableColumns.find(c => c.key === key) - - if (column?.dataType === 'date') { - const [start, end] = values ?? [] - - return [ - key, - start && end - ? `${isoDateToCompact(start)}-${isoDateToCompact(end)}` - : null, - ] - } - - return [key, values && values.length > 0 ? values.join(',') : null] - }), - ), - [URI_PAGENUM_PARAM]: 1, - }) - - const dataSource = manuscriptsUserHasCurrentRoleIn.manuscripts.map( - manuscriptObj => { - const manuscript = { ...manuscriptObj } - manuscript.submission = JSON.parse(manuscript.submission) - - const row = tableColumns.reduce((accumulator, current) => { - const property = current.dataIndex - - if (property === 'submission.$title') { - accumulator[property] = { - title: get(manuscript, property), - hasOverdueTasks: manuscript.hasOverdueTasksForUser, - importSource: importSourceFor(manuscript), - abstract: manuscript.submission?.$abstract, - link: titleLinkFor(manuscript), - } - - return accumulator - } - - accumulator[property] = get(manuscript, property) - return accumulator - }, {}) - - // #region author-proofing - const authorTeam = manuscript.teams.find(team => team.role === 'author') - - const sortedAuthors = authorTeam?.members - .slice() - .sort( - (a, b) => - Date.parse(new Date(b.created)) - Date.parse(new Date(a.created)), - ) - - row.showAuthorProofing = - authorProofingEnabled && - manuscript.authorFeedback.assignedAuthors?.length > 0 && - sortedAuthors[0]?.user?.id === currentUser.id && - ['assigned', 'inProgress', 'completed'].includes(manuscript.status) - // #endregion author-proofing - - row.key = manuscript.shortId - row.id = manuscript.id - row.published = manuscript.published - return row - }, - ) + if (loading) return + if (error) return return ( @@ -440,41 +31,8 @@ const SubmissionsTable = props => { {t('dashboardPage.My Submissions')} - - `${urlFrag}/versions/${manuscript.parentId || manuscript.id}/submit` - } - manuscripts={manuscriptsUserHasCurrentRoleIn.manuscripts} - sortDirection={sortDirection} - sortName={sortName} - /> - - applyQueryParams({ [URI_PAGENUM_PARAM]: newPage })} - totalCount={totalCount} - /> - - - applyQueryParams({ [URI_PAGENUM_PARAM]: newPage }) - } - onSearch={value => applyQueryParams({ [URI_SEARCH_PARAM]: value })} - onSortChange={handleSortChange} - page={Number(page)} - pageSize={limit} - sortState={sortState} - totalCount={totalCount} - /> + ) diff --git a/packages/client/app/pages/hooks/useManuscriptsTable.tsx b/packages/client/app/pages/hooks/useManuscriptsTable.tsx new file mode 100644 index 000000000..b3e9a9563 --- /dev/null +++ b/packages/client/app/pages/hooks/useManuscriptsTable.tsx @@ -0,0 +1,566 @@ +/* eslint-disable */ + +import { useMemo, useContext, type ReactNode } from 'react' +import { useParams, useLocation } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import { useQuery } from '@apollo/client/react' +import styled from 'styled-components' +import get from 'lodash/get' + +import { ConfigContext } from '../../components/config/src' +import { useCurrentUser } from './useCurrentUser' +import { + extractSortData, + extractFilters, + useQueryParams, + URI_PAGENUM_PARAM, + URI_SEARCH_PARAM, + URI_SORT_PARAM, + URI_REVIEWER_STATUS_PARAM, +} from '../../shared/urlParamUtils' +import { isValidDOI } from '../../shared/doiFieldDefinition' +import { MANUSCRIPT_STATUSES } from '../../ui/shared/ManuscriptStatus' +import { DASHBOARD } from '../../queries' +import Link from '../../ui/shared/Link' +import type { + ManuscriptsTableColumn, + ManuscriptsTableSortState, +} from '../../ui/shared/ManuscriptsTable' + +const LinkList = styled.div` + align-items: flex-start; + display: flex; + flex-direction: column; +` + +type Variant = 'submitter' | 'editor' | 'reviewer' + +type VariantConfig = { + roles: string[] + configColumnsKey: string + defaultColumnKeys: string[] +} + +const VARIANT_CONFIG: Record = { + submitter: { + roles: ['author'], + configColumnsKey: 'mySubmissions', + defaultColumnKeys: [ + 'shortId', + 'submission.$title', + 'status', + 'created', + 'updated', + 'actions', + ], + }, + editor: { + roles: ['seniorEditor', 'handlingEditor', 'editor'], + configColumnsKey: 'editingQueue', + defaultColumnKeys: [], + }, + reviewer: { + roles: [ + 'reviewer', + 'invited:reviewer', + 'accepted:reviewer', + 'inProgress:reviewer', + 'completed:reviewer', + 'collaborativeReviewer', + 'invited:collaborativeReviewer', + 'accepted:collaborativeReviewer', + 'inProgress:collaborativeReviewer', + 'completed:collaborativeReviewer', + ], + configColumnsKey: 'tableColumns', + defaultColumnKeys: [], + }, +} + +// Shared across all variants -- titles for columns that aren't real submission-form fields. +// findColumnTitle() only falls back to this when a column key doesn't match an actual field name. +const SPECIAL_COLUMN_TITLES = t => ({ + 'submission.$doi': t('formBuilder.fieldOpts.doi'), + 'submission.adaState': t('manuscriptsTable.adaState'), + actions: 'Actions', // not in translation! + author: t('manuscriptsTable.Author'), + authorProofingLink: 'Actions', // not in translation! + created: t('manuscriptsTable.Created'), + editor: t('manuscriptsTable.Editor'), + editorLinks: t('manuscriptsTable.Actions'), + lastUpdated: t('manuscriptsTable.lastReviewerStatusUpdate'), + manuscriptVersions: t('manuscriptsTable.Version'), + reviewerLinks: 'Action', // not in translation! + reviewerStatusBadge: t('manuscriptsTable.Your Status'), + shortId: 'No.', // not in translation! + status: t('manuscriptsTable.Status'), + statusCounts: t('manuscriptsTable.Reviewer Status'), + submitter: t('manuscriptsTable.Author'), // alias of 'author' + titleAndAbstract: t('manuscriptsTable.Title'), + updated: t('manuscriptsTable.Updated'), +}) + +const centeredColumns = [ + 'shortId', + 'adaState', + '$doi', + 'status', + 'manuscriptVersions', + 'reviewerStatusBadge', + 'statusCounts', +] + +const importSourceFor = manuscript => { + if (manuscript.importSourceServer === 'COAR') return 'coar' + if (manuscript.importSourceServer === 'semantic-scholar') + return 'semanticScholar' + + return undefined +} + +const titleLinkFor = manuscript => { + const { $doi, $sourceUri } = manuscript.submission || {} + + if ($sourceUri) return $sourceUri + if ($doi && isValidDOI($doi)) + return `${$doi.includes('doi.org') ? '' : 'https://doi.org/'}${$doi}` + + return undefined +} + +// The server's date-range filter value format is 'yyyyMMdd-yyyyMMdd' (see manuscriptUtils.js); +// ManuscriptsTable's date filter works with plain 'yyyy-MM-dd' boundaries instead, so convert +// between the two with simple string reformatting -- no timezone handling needed, since both +// sides already represent the same local calendar date. +const isoDateToCompact = isoDate => isoDate.replaceAll('-', '') + +const compactDateToIso = (compactDate: string): string => + `${compactDate.slice(0, 4)}-${compactDate.slice(4, 6)}-${compactDate.slice(6, 8)}` + +const isDateColumnKey = ( + key: string, + fieldDefinitions: Record, +): boolean => + ['created', 'updated'].includes(key) || + fieldDefinitions[key]?.component === 'DatePicker' + +/** Per-variant actions-column content. Only 'submitter' is implemented so far. */ +const renderActionsFor = ( + variant: Variant, + record: Record, + { + t, + groupName, + actionText, + }: { t: any; groupName?: string; actionText: Record }, +): ReactNode => { + if (variant === 'submitter') { + const { id, status, showAuthorProofing } = record + + return ( + + + {actionText[status]} + + + {showAuthorProofing && ( + + {(status === 'assigned' || status === 'inProgress') && + t('dashboardPage.mySubmissions.Provide production feedback')} + + {status === 'completed' && + t('dashboardPage.mySubmissions.View production feedback')} + + )} + + ) + } + + return null // TODO: editor/reviewer actions, once those tables are migrated +} + +/** Per-variant extra row data that isn't tied to any column. Only 'submitter' is implemented. */ +const enrichRowFor = ( + variant: Variant, + row: Record, + manuscript: Record, + { + authorProofingEnabled, + currentUser, + }: { authorProofingEnabled: boolean; currentUser: { id: string } }, +): void => { + if (variant === 'submitter') { + const authorTeam = manuscript.teams.find(team => team.role === 'author') + + const sortedAuthors = authorTeam?.members + .slice() + .sort( + (a: any, b: any) => + new Date(b.created).getTime() - new Date(a.created).getTime(), + ) + + row.showAuthorProofing = + authorProofingEnabled && + manuscript.authorFeedback.assignedAuthors?.length > 0 && + sortedAuthors[0]?.user?.id === currentUser.id && + ['assigned', 'inProgress', 'completed'].includes(manuscript.status) + } +} + +type UseManuscriptsTableResult = { + loading: boolean + error: unknown + columns: ManuscriptsTableColumn[] + dataSource: Record[] + page: number + pageSize: number + totalCount: number + onPageChange: (page: number) => void + onSearch: (value: string) => void + columnFilters: Record + onFiltersChange: (filters: Record) => void + sortState: ManuscriptsTableSortState | null + onSortChange: (sortState: ManuscriptsTableSortState | null) => void +} + +/** Fetches, and derives every prop needs, for one of the role-scoped + * dashboard tables (My Submissions / editing queue / reviews). */ +const useManuscriptsTable = ({ + variant, +}: { + variant: Variant +}): UseManuscriptsTableResult => { + const { roles, configColumnsKey, defaultColumnKeys } = VARIANT_CONFIG[variant] + + // Reviewer invitations are tied to a specific version, so a reviewer needs the older-version + // lookback to still see a manuscript they reviewed after it's been revised; authors/editors + // don't have that problem. Inferred from the role names rather than passed explicitly. + const searchInAllVersions = roles.some(role => + role.toLowerCase().includes('reviewer'), + ) + + const config: any = useContext(ConfigContext) + const { t } = useTranslation() + const { groupName } = useParams() + const currentUser = useCurrentUser() + const applyQueryParams = useQueryParams() + const location = useLocation() + const uriQueryParams = new URLSearchParams(location.search) + + const authorProofingEnabled = config.controlPanel?.authorProofingEnabled + + const currentSearchQuery = uriQueryParams.get(URI_SEARCH_PARAM) + const sortName = extractSortData(uriQueryParams).name + const sortDirection = extractSortData(uriQueryParams).direction + const filters = extractFilters(uriQueryParams) + const page = Number(uriQueryParams.get(URI_PAGENUM_PARAM)) || 1 + const pageSize = config?.manuscript?.paginationCount || 10 + + const { + data, + previousData, + loading: apolloLoading, + error, + } = useQuery(DASHBOARD, { + variables: { + reviewerStatus: uriQueryParams.get(URI_REVIEWER_STATUS_PARAM), + wantedRoles: roles, + sort: sortName + ? { field: sortName, isAscending: sortDirection === 'ASC' } + : null, + offset: (page - 1) * pageSize, + limit: pageSize, + filters, + timezoneOffsetMinutes: new Date().getTimezoneOffset(), + groupId: config.groupId, + searchInAllVersions, + }, + fetchPolicy: 'network-only', + }) + + const currentData: any = data ?? previousData + const loading = apolloLoading && !currentData + + const submissionForm = currentData?.formForPurposeAndCategory + const manuscripts = + currentData?.manuscriptsUserHasCurrentRoleIn?.manuscripts ?? [] + const totalCount = + currentData?.manuscriptsUserHasCurrentRoleIn?.totalCount ?? 0 + + const fieldDefinitions = useMemo(() => { + const fields = submissionForm?.structure?.children ?? [] + const defs = {} + fields.forEach(field => { + // Incomplete fields in the formbuilder may not have a name specified. Ignore these + if (field.name) defs[field.name] = field + }) + return defs + }, [submissionForm]) + + // Shared with the legacy table's sort state (same 'sort' URI param), just + // translated to/from antd's { columnKey, order: 'ascend' | 'descend' }. + // An explicit sort applies regardless of whether a search is active (see + // buildQueryForManuscriptSearchFilterAndOrder). The 'created' DESC default below is only a + // display fallback for the *unsorted* case, and only applies without an active search -- + // while searching with no explicit sort, the server orders by search rank instead, so no + // column should claim to be sorted. + let sortState: ManuscriptsTableSortState | null = null + + if (sortName) { + sortState = { + columnKey: sortName, + order: sortDirection === 'ASC' ? 'ascend' : 'descend', + } + } else if (!currentSearchQuery) { + sortState = { columnKey: 'created', order: 'descend' } + } + + const handleSortChange = newSortState => + applyQueryParams({ + [URI_SORT_PARAM]: newSortState + ? `${newSortState.columnKey}_${newSortState.order === 'ascend' ? 'ASC' : 'DESC'}` + : null, + [URI_PAGENUM_PARAM]: 1, + }) + + const specialColumnTitles = useMemo(() => SPECIAL_COLUMN_TITLES(t), [t]) + + const actionText = useMemo( + () => ({ + new: t('manuscriptsTable.actions.continueSubmission'), + submitted: t('manuscriptsTable.actions.View'), + revise: t('manuscriptsTable.actions.revise'), + revising: t('manuscriptsTable.actions.continueRevision'), + accepted: t('manuscriptsTable.actions.View'), + rejected: t('manuscriptsTable.actions.View'), + published: t('manuscriptsTable.actions.View'), + assigned: t('manuscriptsTable.actions.View'), + inProgress: t('manuscriptsTable.actions.View'), + completed: t('manuscriptsTable.actions.View'), + underEmbargo: t('manuscriptsTable.actions.View'), + embargoReleased: t('manuscriptsTable.actions.View'), + }), + [t], + ) + + const findColumnTitle = (key: string): string => { + const formTitle = submissionForm?.structure?.children.find( + field => field.name === key, + )?.title + + if (formTitle) return formTitle + if (specialColumnTitles[key]) return specialColumnTitles[key] + return key + } + + const configColumns = (config.dashboard?.[configColumnsKey] || []).map( + tc => tc.value, + ) + + const columnKeys = + configColumns.length > 0 ? [...configColumns, 'actions'] : defaultColumnKeys + + const tableColumns: ManuscriptsTableColumn[] = columnKeys + .map( + (key: string): ManuscriptsTableColumn => ({ + title: findColumnTitle(key), + dataIndex: key, + key, + align: centeredColumns.includes(key) ? 'center' : 'left', + }), + ) + .map((column: ManuscriptsTableColumn): ManuscriptsTableColumn => { + if (column.key === 'actions') { + return { + ...column, + render: (_: any, record: any) => + renderActionsFor(variant, record, { t, groupName, actionText }), + } + } + + if (column.key === 'shortId') { + return { ...column, sortable: true } + } + + if (isDateColumnKey(column.key, fieldDefinitions)) { + // Server sorts submission.* fields as LOWER(text) (manuscriptUtils.js), which works + // correctly for DatePicker values -- they're persisted as ISO 8601 strings (see + // app/components/shared/DatePicker.jsx), and ISO 8601 sorts correctly lexicographically. + // Server also range-filters both created/updated and DatePicker submission fields the + // same way (see applyFilters). + return { ...column, dataType: 'date', sortable: true, filterable: true } + } + + if (column.key === 'submission.$title') { + // Server strips HTML before sorting jsonb text fields (see applySortOrder), so this + // sorts on the visible title text rather than raw markup. + return { + ...column, + dataType: 'title', + showAbstract: true, + sortable: true, + } + } + + if (column.key === 'status') { + return { + ...column, + dataType: 'status', + filterable: true, + options: MANUSCRIPT_STATUSES.map(status => ({ + value: status, + label: t(`msStatus.${status}`), + })), + } + } + + if (column.key === 'submission.adaState') { + return { + ...column, + dataType: 'badge', + filterable: true, + options: [ + { value: 'draft', label: t('decisionPage.decisionTab.Draft') }, + { value: 'process', label: t('decisionPage.decisionTab.Process') }, + { + value: 'findable', + label: t('decisionPage.decisionTab.Findable'), + }, + { value: 'publish', label: t('decisionPage.decisionTab.Publish') }, + ], + } + } + + if (fieldDefinitions[column.key]?.options) { + return { + ...column, + dataType: 'options', + filterable: true, + options: fieldDefinitions[column.key].options, + } + } + + if ( + ['TextField', 'AbstractEditor'].includes( + fieldDefinitions[column.key]?.component, + ) + ) { + // Server strips HTML before sorting jsonb text fields (see applySortOrder), so this + // works correctly for AbstractEditor (rich text) fields too, not just plain TextFields. + return { ...column, sortable: true } + } + + if (column.key === 'manuscriptVersions') { + // Intentionally not sortable: it's a count (prior versions + current), derived from a + // GraphQL relation rather than a real column or jsonb field the server can order by. + return { ...column, sortable: false } + } + + return column + }) + + // Keyed by the same URL param name as the column key (shared with the legacy table's filter + // convention). Date columns store a compact 'yyyyMMdd-yyyyMMdd' range (see manuscriptUtils.js); + // every other filterable column stores one or more selected values, comma-joined (the server + // matches a row if the field equals ANY of them -- see applyFilters). + const columnFilters = {} + + tableColumns + .filter(column => column.filterable) + .forEach(column => { + const value = uriQueryParams.get(column.key) + if (!value) return + + if (column.dataType === 'date') { + const [start, end] = value.split('-') + + if (start && end) + columnFilters[column.key] = [ + compactDateToIso(start), + compactDateToIso(end), + ] + + return + } + + columnFilters[column.key] = value.split(',') + }) + + const handleFiltersChange = (newColumnFilters: Record) => + applyQueryParams({ + ...Object.fromEntries( + Object.entries(newColumnFilters).map(([key, values]) => { + const column = tableColumns.find(c => c.key === key) + + if (column?.dataType === 'date') { + const [start, end] = values ?? [] + + return [ + key, + start && end + ? `${isoDateToCompact(start)}-${isoDateToCompact(end)}` + : null, + ] + } + + return [key, values && values.length > 0 ? values.join(',') : null] + }), + ), + [URI_PAGENUM_PARAM]: 1, + }) + + const dataSource = manuscripts.map((manuscriptObj: any) => { + const manuscript = { ...manuscriptObj } + manuscript.submission = JSON.parse(manuscript.submission) + + const row = tableColumns.reduce>( + (accumulator, current) => { + const property = current.dataIndex + + if (property === 'submission.$title') { + accumulator[property] = { + title: get(manuscript, property), + hasOverdueTasks: manuscript.hasOverdueTasksForUser, + importSource: importSourceFor(manuscript), + abstract: manuscript.submission?.$abstract, + link: titleLinkFor(manuscript), + } + + return accumulator + } + + accumulator[property] = get(manuscript, property) + return accumulator + }, + {}, + ) + + row.key = manuscript.shortId + row.id = manuscript.id + row.published = manuscript.published + enrichRowFor(variant, row, manuscript, { + authorProofingEnabled, + currentUser, + }) + + return row + }) + + return { + loading, + error, + columns: tableColumns, + dataSource, + page: Number(page), + pageSize, + totalCount, + onPageChange: newPage => applyQueryParams({ [URI_PAGENUM_PARAM]: newPage }), + onSearch: value => applyQueryParams({ [URI_SEARCH_PARAM]: value }), + columnFilters, + onFiltersChange: handleFiltersChange, + sortState, + onSortChange: handleSortChange, + } +} + +export default useManuscriptsTable From b42f43366b67244586536f91e515d0b588135e87 Mon Sep 17 00:00:00 2001 From: yannis Date: Thu, 20 Aug 2026 15:48:56 +0300 Subject: [PATCH 27/51] feat(client): flesh out manuscripts table hook --- .../src/components/DashboardEditsPage.jsx | 4 +- .../src/components/DashboardReviewsPage.jsx | 4 +- .../components/sections/SubmissionsTable.jsx | 4 +- .../src/util/buildColumnDefinitions.js | 4 +- .../src/ManuscriptsPage.jsx | 12 +- packages/client/app/pages/hooks/useConfig.tsx | 8 + .../client/app/pages/hooks/useCurrentUser.tsx | 7 +- .../app/pages/hooks/useManuscriptsTable.tsx | 932 ++++++++++++------ packages/client/app/pages/hooks/useRole.tsx | 57 ++ .../client/app/queries/manuscript.queries.ts | 350 +++---- .../client/app/ui/shared/ManuscriptsTable.tsx | 21 +- packages/client/package.json | 2 +- .../shared/ManuscriptsTable.stories.tsx | 11 +- packages/client/yarn.lock | 16 +- 14 files changed, 893 insertions(+), 539 deletions(-) create mode 100644 packages/client/app/pages/hooks/useConfig.tsx create mode 100644 packages/client/app/pages/hooks/useRole.tsx diff --git a/packages/client/app/components/component-dashboard/src/components/DashboardEditsPage.jsx b/packages/client/app/components/component-dashboard/src/components/DashboardEditsPage.jsx index 8da161414..ca3b36eb0 100644 --- a/packages/client/app/components/component-dashboard/src/components/DashboardEditsPage.jsx +++ b/packages/client/app/components/component-dashboard/src/components/DashboardEditsPage.jsx @@ -15,7 +15,7 @@ import { import { UPDATE_TAB, REMOVE_TASK_ALERTS_FOR_CURRENT_USER, - DASHBOARD, + GET_MANUSCRIPTS_FOR_ROLE, UPDATE_MANUSCRIPT, } from '../../../../queries' import EditorTable from './sections/EditorTable' @@ -38,7 +38,7 @@ const DashboardEditsPage = () => { const limit = config?.manuscript?.paginationCount || 10 - const { data, loading, error } = useQuery(DASHBOARD, { + const { data, loading, error } = useQuery(GET_MANUSCRIPTS_FOR_ROLE, { variables: { wantedRoles, sort: sortName diff --git a/packages/client/app/components/component-dashboard/src/components/DashboardReviewsPage.jsx b/packages/client/app/components/component-dashboard/src/components/DashboardReviewsPage.jsx index 9cbebc809..3e19e1e1b 100644 --- a/packages/client/app/components/component-dashboard/src/components/DashboardReviewsPage.jsx +++ b/packages/client/app/components/component-dashboard/src/components/DashboardReviewsPage.jsx @@ -8,7 +8,7 @@ import { UPDATE_REVIEWER_STATUS, REVIEWER_RESPONSE, UPDATE_TAB, - DASHBOARD, + GET_MANUSCRIPTS_FOR_ROLE, UPDATE_MANUSCRIPT, } from '../../../../queries' import { useCurrentUser } from '../../../../pages/hooks/useCurrentUser' @@ -50,7 +50,7 @@ const DashboardReviewsPage = () => { const limit = config?.manuscript?.paginationCount || 10 - const { loading, error, data } = useQuery(DASHBOARD, { + const { loading, error, data } = useQuery(GET_MANUSCRIPTS_FOR_ROLE, { variables: { reviewerStatus: uriQueryParams.get(URI_REVIEWER_STATUS_PARAM), wantedRoles, diff --git a/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx b/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx index de749e280..a2462a091 100644 --- a/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx +++ b/packages/client/app/components/component-dashboard/src/components/sections/SubmissionsTable.jsx @@ -18,9 +18,7 @@ const TableWrapper = styled.div` const SubmissionsTable = () => { const { t } = useTranslation() - const { loading, error, ...tableProps } = useManuscriptsTable({ - variant: 'submitter', - }) + const { loading, error, ...tableProps } = useManuscriptsTable('submitter') if (loading) return if (error) return diff --git a/packages/client/app/components/component-manuscripts-table/src/util/buildColumnDefinitions.js b/packages/client/app/components/component-manuscripts-table/src/util/buildColumnDefinitions.js index f72e888de..a425269a8 100644 --- a/packages/client/app/components/component-manuscripts-table/src/util/buildColumnDefinitions.js +++ b/packages/client/app/components/component-manuscripts-table/src/util/buildColumnDefinitions.js @@ -8,7 +8,7 @@ const fieldCanBeSorted = field => { /** * buildColumnDefinition: Takes in a column key and information to build out a standardized object of properties * @param {string} columnName The column key - * @param {object} fieldDefinitions Field definitions returned from the GET_MANUSCRIPTS_AND_FORM query + * @param {object} fieldDefinitions Field definitions returned from the GET_ALL_MANUSCRIPTS query * @param {object} specialColumnProperties Special component definitions for columns * @param {object} customDisplayProps Props for display */ @@ -57,7 +57,7 @@ const buildColumnDefinition = ( /** * buildColumnDefinitions: Master function to build the Manuscripts table definition * @param {string[]} columnNames The columns we want to display as a part of the table - * @param {object} fieldDefinitions The graphQL structure of the fields returned from GET_MANUSCRIPTS_AND_FORM + * @param {object} fieldDefinitions The graphQL structure of the fields returned from GET_ALL_MANUSCRIPTS * @param {object} specialComponentValues values needed for specific components * @param {object} displayProps Props for display * @returns {object} the list of standardized column information diff --git a/packages/client/app/components/component-manuscripts/src/ManuscriptsPage.jsx b/packages/client/app/components/component-manuscripts/src/ManuscriptsPage.jsx index 5ae9fff09..44218053b 100644 --- a/packages/client/app/components/component-manuscripts/src/ManuscriptsPage.jsx +++ b/packages/client/app/components/component-manuscripts/src/ManuscriptsPage.jsx @@ -15,10 +15,9 @@ import { useLazyQuery, } from '@apollo/client/react' import fnv from 'fnv-plus' -import { saveAs } from 'file-saver' import { ConfigContext } from '../../config/src' import { - GET_MANUSCRIPTS_AND_FORM, + GET_ALL_MANUSCRIPTS, DELETE_MANUSCRIPT, IMPORT_MANUSCRIPTS, IMPORTED_MANUSCRIPTS, @@ -70,7 +69,7 @@ const ManuscriptsPage = () => { const archived = extractArchived(uriQueryParams) const limit = config?.manuscript?.paginationCount || 10 - const queryObject = useQuery(GET_MANUSCRIPTS_AND_FORM, { + const queryObject = useQuery(GET_ALL_MANUSCRIPTS, { variables: { sort: sortName ? { field: sortName, isAscending: sortDirection === 'ASC' } @@ -176,7 +175,12 @@ const ManuscriptsPage = () => { type: 'application/json', }) - saveAs(jsonBlob, fileName) + const url = URL.createObjectURL(jsonBlob) + const link = document.createElement('a') + link.href = url + link.download = fileName + link.click() + URL.revokeObjectURL(url) } const [doArchiveManuscripts] = useMutation(ARCHIVE_MANUSCRIPTS, { diff --git a/packages/client/app/pages/hooks/useConfig.tsx b/packages/client/app/pages/hooks/useConfig.tsx new file mode 100644 index 000000000..522049e3e --- /dev/null +++ b/packages/client/app/pages/hooks/useConfig.tsx @@ -0,0 +1,8 @@ +import { useContext } from 'react' + +import { ConfigContext } from '../../components/config/src' + +export const useConfig = (): any => { + const config = useContext(ConfigContext) + return config +} diff --git a/packages/client/app/pages/hooks/useCurrentUser.tsx b/packages/client/app/pages/hooks/useCurrentUser.tsx index 9cc0d030e..ce23764d9 100644 --- a/packages/client/app/pages/hooks/useCurrentUser.tsx +++ b/packages/client/app/pages/hooks/useCurrentUser.tsx @@ -1,6 +1,11 @@ import { createContext, useContext } from 'react' -type CurrentUser = { id: string /* ... */ } +type CurrentUser = { + id: string + groupRoles: string[] + globalRoles: string[] + /* ... */ +} export const CurrentUserContext = createContext(null) diff --git a/packages/client/app/pages/hooks/useManuscriptsTable.tsx b/packages/client/app/pages/hooks/useManuscriptsTable.tsx index b3e9a9563..556ac688b 100644 --- a/packages/client/app/pages/hooks/useManuscriptsTable.tsx +++ b/packages/client/app/pages/hooks/useManuscriptsTable.tsx @@ -1,64 +1,68 @@ -/* eslint-disable */ - -import { useMemo, useContext, type ReactNode } from 'react' -import { useParams, useLocation } from 'react-router-dom' +import { useMemo, useState, type ReactNode } from 'react' +import { useParams, useSearchParams } from 'react-router-dom' import { useTranslation } from 'react-i18next' -import { useQuery } from '@apollo/client/react' +import { useQuery, useMutation, useLazyQuery } from '@apollo/client/react' import styled from 'styled-components' import get from 'lodash/get' +import mapValues from 'lodash/mapValues' -import { ConfigContext } from '../../components/config/src' +import { useConfig } from './useConfig' import { useCurrentUser } from './useCurrentUser' import { - extractSortData, - extractFilters, - useQueryParams, - URI_PAGENUM_PARAM, - URI_SEARCH_PARAM, - URI_SORT_PARAM, - URI_REVIEWER_STATUS_PARAM, -} from '../../shared/urlParamUtils' -import { isValidDOI } from '../../shared/doiFieldDefinition' -import { MANUSCRIPT_STATUSES } from '../../ui/shared/ManuscriptStatus' -import { DASHBOARD } from '../../queries' + GET_MANUSCRIPTS_FOR_ROLE, + GET_ALL_MANUSCRIPTS, + GET_MANUSCRIPTS_DATA, + ARCHIVE_MANUSCRIPTS, + UNARCHIVE_MANUSCRIPTS, + UPDATE_MANUSCRIPT, +} from '../../queries' + import Link from '../../ui/shared/Link' +import { MANUSCRIPT_STATUSES } from '../../ui/shared/ManuscriptStatus' +import { + reviewerStatusValues, + reviewerStatusTranslationKeys, +} from '../../ui/shared/_constants' import type { ManuscriptsTableColumn, ManuscriptsTableSortState, } from '../../ui/shared/ManuscriptsTable' +// #region constants +const URI_PARAMS = { + SEARCH: 'search', + PAGENUM: 'pagenum', + SORT: 'sort', + ARCHIVED: 'archived', + REVIEWER_STATUS: 'reviewerStatusBadge', // 'your status' column on reviews dashboard tab +} + const LinkList = styled.div` align-items: flex-start; display: flex; flex-direction: column; ` -type Variant = 'submitter' | 'editor' | 'reviewer' +type Variant = 'submitter' | 'editor' | 'reviewer' | 'admin' type VariantConfig = { roles: string[] - configColumnsKey: string + configColumnsPath: string defaultColumnKeys: string[] } const VARIANT_CONFIG: Record = { submitter: { roles: ['author'], - configColumnsKey: 'mySubmissions', + configColumnsPath: 'dashboard.mySubmissions', defaultColumnKeys: [ 'shortId', 'submission.$title', 'status', 'created', 'updated', - 'actions', ], }, - editor: { - roles: ['seniorEditor', 'handlingEditor', 'editor'], - configColumnsKey: 'editingQueue', - defaultColumnKeys: [], - }, reviewer: { roles: [ 'reviewer', @@ -72,45 +76,97 @@ const VARIANT_CONFIG: Record = { 'inProgress:collaborativeReviewer', 'completed:collaborativeReviewer', ], - configColumnsKey: 'tableColumns', - defaultColumnKeys: [], + configColumnsPath: 'dashboard.tableColumns', + defaultColumnKeys: ['shortId', 'submission.$title', 'reviewerStatusBadge'], + }, + editor: { + roles: ['seniorEditor', 'handlingEditor', 'editor'], + configColumnsPath: 'dashboard.editingQueue', + defaultColumnKeys: [ + 'shortId', + 'submission.$title', + 'status', + 'manuscriptVersions', + 'statusCounts', + 'lastUpdated', + ], + }, + admin: { + roles: [], + configColumnsPath: 'manuscript.tableColumns', + defaultColumnKeys: [ + 'shortId', + 'titleAndAbstract', + 'created', + 'updated', + 'status', + 'submission.$customStatus', + 'author', + ], }, } -// Shared across all variants -- titles for columns that aren't real submission-form fields. -// findColumnTitle() only falls back to this when a column key doesn't match an actual field name. -const SPECIAL_COLUMN_TITLES = t => ({ - 'submission.$doi': t('formBuilder.fieldOpts.doi'), - 'submission.adaState': t('manuscriptsTable.adaState'), - actions: 'Actions', // not in translation! - author: t('manuscriptsTable.Author'), - authorProofingLink: 'Actions', // not in translation! - created: t('manuscriptsTable.Created'), - editor: t('manuscriptsTable.Editor'), - editorLinks: t('manuscriptsTable.Actions'), - lastUpdated: t('manuscriptsTable.lastReviewerStatusUpdate'), - manuscriptVersions: t('manuscriptsTable.Version'), - reviewerLinks: 'Action', // not in translation! - reviewerStatusBadge: t('manuscriptsTable.Your Status'), - shortId: 'No.', // not in translation! - status: t('manuscriptsTable.Status'), - statusCounts: t('manuscriptsTable.Reviewer Status'), - submitter: t('manuscriptsTable.Author'), // alias of 'author' - titleAndAbstract: t('manuscriptsTable.Title'), - updated: t('manuscriptsTable.Updated'), +const columnAlignments: Record = { + manuscriptVersions: 'center', + reviewerStatusBadge: 'center', + shortId: 'center', + status: 'center', + statusCounts: 'center', + 'submission.$doi': 'center', + 'submission.adaState': 'center', +} +// #endregion constants + +// #region helpers +const downloadBlob = (blob: Blob, fileName: string): void => { + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = fileName + link.click() + URL.revokeObjectURL(url) +} + +const REVIEWER_STATUS_VIEW_MODE_STORAGE_KEY = + 'manuscriptsTable.reviewerStatusViewMode' + +const readReviewerStatusViewMode = (): 'compact' | 'detailed' => + localStorage.getItem(REVIEWER_STATUS_VIEW_MODE_STORAGE_KEY) === 'compact' + ? 'compact' + : 'detailed' + +const isValidDOI = (doi: string): boolean => { + const doiRegex = + /^(https?:\/\/(dx\.)?doi\.org\/|doi:)?10.\d{4,9}\/[-._;():A-Z0-9]+$/i + return doiRegex.test(doi) +} + +const extractSortData = ( + params: URLSearchParams, +): { + name: string | undefined + direction: 'ascend' | 'descend' | undefined +} => ({ + name: params.get(URI_PARAMS.SORT)?.split('_')[0], + direction: params.get(URI_PARAMS.SORT)?.split('_')[1] as + | 'ascend' + | 'descend' + | undefined, }) -const centeredColumns = [ - 'shortId', - 'adaState', - '$doi', - 'status', - 'manuscriptVersions', - 'reviewerStatusBadge', - 'statusCounts', -] - -const importSourceFor = manuscript => { +const extractFilters = ( + params: URLSearchParams, +): { field: string; value: string | null }[] => + Array.from(params.keys()) + .filter(field => field !== URI_PARAMS.PAGENUM && field !== URI_PARAMS.SORT) + .map(field => ({ field, value: params.get(field) })) + +const extractArchived = (params: URLSearchParams): boolean => + params.has(URI_PARAMS.ARCHIVED) + +const importSourceFor = ( + manuscript: Record, +): 'coar' | 'semanticScholar' | undefined => { if (manuscript.importSourceServer === 'COAR') return 'coar' if (manuscript.importSourceServer === 'semantic-scholar') return 'semanticScholar' @@ -118,7 +174,7 @@ const importSourceFor = manuscript => { return undefined } -const titleLinkFor = manuscript => { +const titleLinkFor = (manuscript: Record): string | undefined => { const { $doi, $sourceUri } = manuscript.submission || {} if ($sourceUri) return $sourceUri @@ -128,224 +184,314 @@ const titleLinkFor = manuscript => { return undefined } -// The server's date-range filter value format is 'yyyyMMdd-yyyyMMdd' (see manuscriptUtils.js); -// ManuscriptsTable's date filter works with plain 'yyyy-MM-dd' boundaries instead, so convert -// between the two with simple string reformatting -- no timezone handling needed, since both -// sides already represent the same local calendar date. -const isoDateToCompact = isoDate => isoDate.replaceAll('-', '') +const findReviewerTeamMember = ( + version: Record, + userId: string, +): Record | undefined => + (version.teams ?? []) + .find((team: Record) => team.role === 'reviewer') + ?.members?.find((member: Record) => member.user.id === userId) + +const findCollaborativeReviewerTeamMember = ( + version: Record, + userId: string, +): Record | undefined => + (version.teams ?? []) + .find((team: Record) => team.role === 'collaborativeReviewer') + ?.members?.find((member: Record) => member.user.id === userId) + +const findReviewerStatus = ( + manuscript: Record, + userId: string, +): string => { + let memberIsCurrent = true + let member = + findReviewerTeamMember(manuscript, userId) ?? + findCollaborativeReviewerTeamMember(manuscript, userId) + + if (!member) { + memberIsCurrent = false + + for (const version of manuscript.manuscriptVersions ?? []) { + member = findReviewerTeamMember(version, userId) + if (member) break + } + } -const compactDateToIso = (compactDate: string): string => - `${compactDate.slice(0, 4)}-${compactDate.slice(4, 6)}-${compactDate.slice(6, 8)}` + let status = member?.status -const isDateColumnKey = ( - key: string, - fieldDefinitions: Record, -): boolean => - ['created', 'updated'].includes(key) || - fieldDefinitions[key]?.component === 'DatePicker' - -/** Per-variant actions-column content. Only 'submitter' is implemented so far. */ -const renderActionsFor = ( - variant: Variant, - record: Record, - { - t, - groupName, - actionText, - }: { t: any; groupName?: string; actionText: Record }, -): ReactNode => { - if (variant === 'submitter') { - const { id, status, showAuthorProofing } = record - - return ( - - - {actionText[status]} - - - {showAuthorProofing && ( - - {(status === 'assigned' || status === 'inProgress') && - t('dashboardPage.mySubmissions.Provide production feedback')} - - {status === 'completed' && - t('dashboardPage.mySubmissions.View production feedback')} - - )} - - ) - } + if ( + !status || + (!memberIsCurrent && !['completed', 'rejected'].includes(status)) + ) + status = 'closed' + + return status +} - return null // TODO: editor/reviewer actions, once those tables are migrated +const INVITATION_STATUS_MAPPING: Record = { + UNANSWERED: 'invited', + REJECTED: 'rejected', } -/** Per-variant extra row data that isn't tied to any column. Only 'submitter' is implemented. */ -const enrichRowFor = ( - variant: Variant, - row: Record, +const reviewerStatusEntriesFor = ( manuscript: Record, - { - authorProofingEnabled, - currentUser, - }: { authorProofingEnabled: boolean; currentUser: { id: string } }, -): void => { - if (variant === 'submitter') { - const authorTeam = manuscript.teams.find(team => team.role === 'author') - - const sortedAuthors = authorTeam?.members - .slice() - .sort( - (a: any, b: any) => - new Date(b.created).getTime() - new Date(a.created).getTime(), - ) +): { status: string; name: string }[] => { + const notAlreadyInvited = (reviewerMember: Record): boolean => { + if (reviewerMember.status !== 'invited') return true + + const foundInvitation = (manuscript.invitations ?? []).find( + (invitation: Record) => + invitation.toEmail === reviewerMember.user.email && + invitation.status === 'UNANSWERED' && + ['REVIEWER', 'COLLABORATIVE_REVIEWER'].includes( + invitation.invitedPersonType, + ), + ) - row.showAuthorProofing = - authorProofingEnabled && - manuscript.authorFeedback.assignedAuthors?.length > 0 && - sortedAuthors[0]?.user?.id === currentUser.id && - ['assigned', 'inProgress', 'completed'].includes(manuscript.status) + return !foundInvitation } + + const membersOfRole = (role: string): Record[] => + ( + (manuscript.teams ?? []).find( + (team: Record) => team.role === role, + )?.members ?? [] + ).filter(notAlreadyInvited) + + const reviewerMembers = membersOfRole('reviewer') + const collaborativeReviewerMembers = membersOfRole('collaborativeReviewer') + + const reviewerUserIds = [ + ...reviewerMembers, + ...collaborativeReviewerMembers, + ].map((member: Record) => member.user.id) + + const invitationEntries = (manuscript.invitations ?? []) + .filter( + (invitation: Record) => + invitation.status in INVITATION_STATUS_MAPPING && + ['REVIEWER', 'COLLABORATIVE_REVIEWER'].includes( + invitation.invitedPersonType, + ) && + !reviewerUserIds.includes(invitation.user?.id), + ) + .map((invitation: Record) => ({ + status: INVITATION_STATUS_MAPPING[invitation.status], + name: invitation.toEmail, + })) + + return [ + ...[...reviewerMembers, ...collaborativeReviewerMembers].map( + (member: Record) => ({ + status: member.status, + name: member.user.username, + }), + ), + ...invitationEntries, + ] } +/** + * Translate the table's 'yyyy-MM-dd' format to the server's date-range filter + * format 'yyyyMMdd'. And vice versa. + * No timezone handling needed, since both sides already represent the same + * local calendar date. + */ +const isoDateToCompact = (isoDate: string): string => + isoDate.replaceAll('-', '') + +const compactDateToIso = (compactDate: string): string => + `${compactDate.slice(0, 4)}-${compactDate.slice(4, 6)}-${compactDate.slice(6, 8)}` +// #endregion helpers + type UseManuscriptsTableResult = { - loading: boolean - error: unknown + columnFilters: Record columns: ManuscriptsTableColumn[] dataSource: Record[] - page: number - pageSize: number - totalCount: number + error: unknown + loading: boolean + onArchiveSelected: (ids: string[]) => void + onDownloadSelected: (ids: string[]) => void + onFiltersChange: (filters: Record) => void + onOptionChange: (columnKey: string, id: string, value: string | null) => void onPageChange: (page: number) => void + onReviewerStatusViewModeChange: (viewMode: 'compact' | 'detailed') => void onSearch: (value: string) => void - columnFilters: Record - onFiltersChange: (filters: Record) => void - sortState: ManuscriptsTableSortState | null onSortChange: (sortState: ManuscriptsTableSortState | null) => void + onUnarchiveSelected: (ids: string[]) => void + onViewingArchivedChange: (viewingArchived: boolean) => void + page: number + pageSize: number + reviewerStatusViewMode: 'compact' | 'detailed' + selectable: boolean + showArchiveActions: boolean + showDownloadAction: boolean + showViewArchivedToggle: boolean + sortState: ManuscriptsTableSortState | null + totalCount: number + viewingArchived: boolean } -/** Fetches, and derives every prop needs, for one of the role-scoped - * dashboard tables (My Submissions / editing queue / reviews). */ -const useManuscriptsTable = ({ - variant, -}: { - variant: Variant -}): UseManuscriptsTableResult => { - const { roles, configColumnsKey, defaultColumnKeys } = VARIANT_CONFIG[variant] - - // Reviewer invitations are tied to a specific version, so a reviewer needs the older-version - // lookback to still see a manuscript they reviewed after it's been revised; authors/editors - // don't have that problem. Inferred from the role names rather than passed explicitly. - const searchInAllVersions = roles.some(role => - role.toLowerCase().includes('reviewer'), - ) - - const config: any = useContext(ConfigContext) +/** Derives the props needs */ +const useManuscriptsTable = (variant: Variant): UseManuscriptsTableResult => { + const config: any = useConfig() const { t } = useTranslation() const { groupName } = useParams() const currentUser = useCurrentUser() - const applyQueryParams = useQueryParams() - const location = useLocation() - const uriQueryParams = new URLSearchParams(location.search) + const [searchParams, setSearchParams] = useSearchParams() - const authorProofingEnabled = config.controlPanel?.authorProofingEnabled + const [reviewerStatusViewMode, setReviewerStatusViewMode] = useState< + 'compact' | 'detailed' + >(readReviewerStatusViewMode) - const currentSearchQuery = uriQueryParams.get(URI_SEARCH_PARAM) - const sortName = extractSortData(uriQueryParams).name - const sortDirection = extractSortData(uriQueryParams).direction - const filters = extractFilters(uriQueryParams) - const page = Number(uriQueryParams.get(URI_PAGENUM_PARAM)) || 1 + // #region definitions + const { roles, configColumnsPath, defaultColumnKeys } = + VARIANT_CONFIG[variant] + const searchInAllVersions = variant === 'reviewer' + const authorProofingEnabled = config.controlPanel?.authorProofingEnabled + const currentSearchQuery = searchParams.get(URI_PARAMS.SEARCH) + const { name: sortName, direction: sortDirection } = + extractSortData(searchParams) + const filters = extractFilters(searchParams) + const page = Number(searchParams.get(URI_PARAMS.PAGENUM)) || 1 const pageSize = config?.manuscript?.paginationCount || 10 - const { - data, - previousData, - loading: apolloLoading, - error, - } = useQuery(DASHBOARD, { + const specialColumnTitles = { + 'submission.$doi': t('formBuilder.fieldOpts.doi'), + 'submission.adaState': t('manuscriptsTable.adaState'), + actions: t('manuscriptsTable.Actions'), + author: t('manuscriptsTable.Author'), + created: t('manuscriptsTable.Created'), + editor: t('manuscriptsTable.Editor'), + lastUpdated: t('manuscriptsTable.lastReviewerStatusUpdate'), + manuscriptVersions: t('manuscriptsTable.Version'), + reviewerLinks: 'Action', // not in translation! + reviewerStatusBadge: t('manuscriptsTable.Your Status'), + shortId: 'No.', // not in translation! + status: t('manuscriptsTable.Status'), + statusCounts: t('manuscriptsTable.Reviewer Status'), + submitter: t('manuscriptsTable.Author'), // alias of 'author' + titleAndAbstract: t('manuscriptsTable.Title'), + updated: t('manuscriptsTable.Updated'), + } + + const actionText = { + new: t('manuscriptsTable.actions.continueSubmission'), + submitted: t('manuscriptsTable.actions.View'), + revise: t('manuscriptsTable.actions.revise'), + revising: t('manuscriptsTable.actions.continueRevision'), + accepted: t('manuscriptsTable.actions.View'), + rejected: t('manuscriptsTable.actions.View'), + published: t('manuscriptsTable.actions.View'), + assigned: t('manuscriptsTable.actions.View'), + inProgress: t('manuscriptsTable.actions.View'), + completed: t('manuscriptsTable.actions.View'), + underEmbargo: t('manuscriptsTable.actions.View'), + embargoReleased: t('manuscriptsTable.actions.View'), + } + // #endregion definitions + + // #region query-data + const isArchived = extractArchived(searchParams) + + const sharedQueryVariables = { + sort: sortName + ? { field: sortName, isAscending: sortDirection === 'ascend' } + : null, + offset: (page - 1) * pageSize, + limit: pageSize, + filters, + timezoneOffsetMinutes: new Date().getTimezoneOffset(), + groupId: config.groupId, + } + + const roleQuery = useQuery(GET_MANUSCRIPTS_FOR_ROLE, { variables: { - reviewerStatus: uriQueryParams.get(URI_REVIEWER_STATUS_PARAM), + ...sharedQueryVariables, + reviewerStatus: searchParams.get(URI_PARAMS.REVIEWER_STATUS), wantedRoles: roles, - sort: sortName - ? { field: sortName, isAscending: sortDirection === 'ASC' } - : null, - offset: (page - 1) * pageSize, - limit: pageSize, - filters, - timezoneOffsetMinutes: new Date().getTimezoneOffset(), - groupId: config.groupId, searchInAllVersions, }, fetchPolicy: 'network-only', + skip: variant === 'admin', + }) + + const allQuery = useQuery(GET_ALL_MANUSCRIPTS, { + variables: { + ...sharedQueryVariables, + archived: isArchived, + }, + fetchPolicy: 'network-only', + skip: variant !== 'admin', }) + const { + data, + previousData, + loading: apolloLoading, + error, + refetch, + } = variant === 'admin' ? allQuery : roleQuery + + const [updateManuscript] = useMutation(UPDATE_MANUSCRIPT) + const [archiveManuscripts] = useMutation(ARCHIVE_MANUSCRIPTS) + const [unarchiveManuscripts] = useMutation(UNARCHIVE_MANUSCRIPTS) + const [getManuscriptsData] = useLazyQuery(GET_MANUSCRIPTS_DATA) + const currentData: any = data ?? previousData const loading = apolloLoading && !currentData const submissionForm = currentData?.formForPurposeAndCategory + const manuscripts = - currentData?.manuscriptsUserHasCurrentRoleIn?.manuscripts ?? [] + variant === 'admin' + ? (currentData?.paginatedManuscripts?.manuscripts ?? []) + : (currentData?.manuscriptsUserHasCurrentRoleIn?.manuscripts ?? []) + const totalCount = - currentData?.manuscriptsUserHasCurrentRoleIn?.totalCount ?? 0 + variant === 'admin' + ? (currentData?.paginatedManuscripts?.totalCount ?? 0) + : (currentData?.manuscriptsUserHasCurrentRoleIn?.totalCount ?? 0) const fieldDefinitions = useMemo(() => { const fields = submissionForm?.structure?.children ?? [] - const defs = {} - fields.forEach(field => { - // Incomplete fields in the formbuilder may not have a name specified. Ignore these + const defs: Record = {} + fields.forEach((field: Record): void => { + // Incomplete fields in the formbuilder may not have a name specified. Ignore these. if (field.name) defs[field.name] = field }) return defs }, [submissionForm]) - // Shared with the legacy table's sort state (same 'sort' URI param), just - // translated to/from antd's { columnKey, order: 'ascend' | 'descend' }. - // An explicit sort applies regardless of whether a search is active (see - // buildQueryForManuscriptSearchFilterAndOrder). The 'created' DESC default below is only a - // display fallback for the *unsorted* case, and only applies without an active search -- - // while searching with no explicit sort, the server orders by search rank instead, so no - // column should claim to be sorted. let sortState: ManuscriptsTableSortState | null = null - if (sortName) { - sortState = { - columnKey: sortName, - order: sortDirection === 'ASC' ? 'ascend' : 'descend', - } + sortState = { columnKey: sortName, order: sortDirection ?? 'descend' } } else if (!currentSearchQuery) { sortState = { columnKey: 'created', order: 'descend' } } + // #endregion query-data - const handleSortChange = newSortState => - applyQueryParams({ - [URI_SORT_PARAM]: newSortState - ? `${newSortState.columnKey}_${newSortState.order === 'ascend' ? 'ASC' : 'DESC'}` - : null, - [URI_PAGENUM_PARAM]: 1, - }) + // #region table-columns + const rawConfigColumns = get(config, configColumnsPath) - const specialColumnTitles = useMemo(() => SPECIAL_COLUMN_TITLES(t), [t]) - - const actionText = useMemo( - () => ({ - new: t('manuscriptsTable.actions.continueSubmission'), - submitted: t('manuscriptsTable.actions.View'), - revise: t('manuscriptsTable.actions.revise'), - revising: t('manuscriptsTable.actions.continueRevision'), - accepted: t('manuscriptsTable.actions.View'), - rejected: t('manuscriptsTable.actions.View'), - published: t('manuscriptsTable.actions.View'), - assigned: t('manuscriptsTable.actions.View'), - inProgress: t('manuscriptsTable.actions.View'), - completed: t('manuscriptsTable.actions.View'), - underEmbargo: t('manuscriptsTable.actions.View'), - embargoReleased: t('manuscriptsTable.actions.View'), - }), - [t], - ) + const configColumns = Array.isArray(rawConfigColumns) + ? rawConfigColumns.map( + (column: { value: string; label: string }): string => column.value, + ) + : (rawConfigColumns || '') + .split(',') + .map((columnName: string) => columnName.trim()) + .filter(Boolean) + + const columnKeys = [ + ...(configColumns.length > 0 ? configColumns : defaultColumnKeys), + 'actions', + ] const findColumnTitle = (key: string): string => { const formTitle = submissionForm?.structure?.children.find( - field => field.name === key, + (field: Record): boolean => field.name === key, )?.title if (formTitle) return formTitle @@ -353,28 +499,67 @@ const useManuscriptsTable = ({ return key } - const configColumns = (config.dashboard?.[configColumnsKey] || []).map( - tc => tc.value, - ) - - const columnKeys = - configColumns.length > 0 ? [...configColumns, 'actions'] : defaultColumnKeys - const tableColumns: ManuscriptsTableColumn[] = columnKeys + // build column definitions as expected by the table ui .map( (key: string): ManuscriptsTableColumn => ({ title: findColumnTitle(key), dataIndex: key, key, - align: centeredColumns.includes(key) ? 'center' : 'left', + align: columnAlignments[key] ?? 'left', }), ) + // handle all extra properties needed for special cases .map((column: ManuscriptsTableColumn): ManuscriptsTableColumn => { if (column.key === 'actions') { return { ...column, - render: (_: any, record: any) => - renderActionsFor(variant, record, { t, groupName, actionText }), + render: (_: any, record: any): ReactNode => { + if (variant === 'submitter') { + const { id, status, showAuthorProofing } = record + + return ( + + + {actionText[status]} + + + {showAuthorProofing && ( + + {(status === 'assigned' || status === 'inProgress') && + t( + 'dashboardPage.mySubmissions.Provide production feedback', + )} + + {status === 'completed' && + t( + 'dashboardPage.mySubmissions.View production feedback', + )} + + )} + + ) + } + + if (variant === 'editor') { + const { id, parentId } = record + + return ( + + + {t('manuscriptsTable.Control')} + + + {t('manuscriptsTable.Production')} + + + ) + } + + return null // TODO: reviewer actions, once that table is migrated + }, } } @@ -382,26 +567,45 @@ const useManuscriptsTable = ({ return { ...column, sortable: true } } - if (isDateColumnKey(column.key, fieldDefinitions)) { - // Server sorts submission.* fields as LOWER(text) (manuscriptUtils.js), which works - // correctly for DatePicker values -- they're persisted as ISO 8601 strings (see - // app/components/shared/DatePicker.jsx), and ISO 8601 sorts correctly lexicographically. - // Server also range-filters both created/updated and DatePicker submission fields the - // same way (see applyFilters). + if ( + ['created', 'updated'].includes(column.key) || + fieldDefinitions[column.key]?.component === 'DatePicker' + ) { return { ...column, dataType: 'date', sortable: true, filterable: true } } - if (column.key === 'submission.$title') { - // Server strips HTML before sorting jsonb text fields (see applySortOrder), so this - // sorts on the visible title text rather than raw markup. + if ( + column.key === 'submission.$title' || + column.key === 'titleAndAbstract' + ) { return { ...column, dataType: 'title', - showAbstract: true, + showAbstract: column.key === 'titleAndAbstract', sortable: true, } } + if (column.key === 'reviewerStatusBadge') { + return { + ...column, + dataType: 'reviewerStatus', + filterable: true, + options: reviewerStatusValues.map(status => ({ + value: status, + label: t(reviewerStatusTranslationKeys[status]), + })), + } + } + + if (column.key === 'statusCounts') { + return { ...column, dataType: 'reviewerStatusSummary' } + } + + if (column.key === 'author' || column.key === 'submitter') { + return { ...column, dataType: 'person' } + } + if (column.key === 'status') { return { ...column, @@ -431,6 +635,16 @@ const useManuscriptsTable = ({ } } + if (column.key === 'submission.$customStatus') { + return { + ...column, + dataType: 'options', + editable: Boolean(config.manuscript?.labelColumn), + filterable: true, + options: fieldDefinitions[column.key]?.options, + } + } + if (fieldDefinitions[column.key]?.options) { return { ...column, @@ -440,25 +654,28 @@ const useManuscriptsTable = ({ } } - if ( - ['TextField', 'AbstractEditor'].includes( - fieldDefinitions[column.key]?.component, - ) - ) { - // Server strips HTML before sorting jsonb text fields (see applySortOrder), so this - // works correctly for AbstractEditor (rich text) fields too, not just plain TextFields. + if (fieldDefinitions[column.key]?.component === 'AbstractEditor') { + return { ...column, dataType: 'richText', sortable: true } + } + + if (fieldDefinitions[column.key]?.component === 'TextField') { return { ...column, sortable: true } } if (column.key === 'manuscriptVersions') { - // Intentionally not sortable: it's a count (prior versions + current), derived from a - // GraphQL relation rather than a real column or jsonb field the server can order by. + /** + * Intentionally not sortable: it's a count (prior versions + current), + * derived from a GraphQL relation rather than a real column or jsonb + * field the server can sort on. + */ return { ...column, sortable: false } } return column }) + // #endregion table-columns + // #region column-filters // Keyed by the same URL param name as the column key (shared with the legacy table's filter // convention). Date columns store a compact 'yyyyMMdd-yyyyMMdd' range (see manuscriptUtils.js); // every other filterable column stores one or more selected values, comma-joined (the server @@ -468,7 +685,7 @@ const useManuscriptsTable = ({ tableColumns .filter(column => column.filterable) .forEach(column => { - const value = uriQueryParams.get(column.key) + const value = searchParams.get(column.key) if (!value) return if (column.dataType === 'date') { @@ -485,30 +702,9 @@ const useManuscriptsTable = ({ columnFilters[column.key] = value.split(',') }) + // #endregion column-filters - const handleFiltersChange = (newColumnFilters: Record) => - applyQueryParams({ - ...Object.fromEntries( - Object.entries(newColumnFilters).map(([key, values]) => { - const column = tableColumns.find(c => c.key === key) - - if (column?.dataType === 'date') { - const [start, end] = values ?? [] - - return [ - key, - start && end - ? `${isoDateToCompact(start)}-${isoDateToCompact(end)}` - : null, - ] - } - - return [key, values && values.length > 0 ? values.join(',') : null] - }), - ), - [URI_PAGENUM_PARAM]: 1, - }) - + // #region row-data const dataSource = manuscripts.map((manuscriptObj: any) => { const manuscript = { ...manuscriptObj } manuscript.submission = JSON.parse(manuscript.submission) @@ -529,6 +725,16 @@ const useManuscriptsTable = ({ return accumulator } + if (property === 'author' || property === 'submitter') { + accumulator[property] = manuscript.submitter && { + displayName: manuscript.submitter.username, + profilePicture: manuscript.submitter.profilePicture, + orcid: manuscript.submitter.defaultIdentity?.identifier, + } + + return accumulator + } + accumulator[property] = get(manuscript, property) return accumulator }, @@ -537,29 +743,179 @@ const useManuscriptsTable = ({ row.key = manuscript.shortId row.id = manuscript.id + row.parentId = manuscript.parentId row.published = manuscript.published - enrichRowFor(variant, row, manuscript, { - authorProofingEnabled, - currentUser, - }) + + if (variant === 'admin') { + row.archived = isArchived + } + + if (variant === 'submitter') { + const authorTeam = manuscript.teams.find(team => team.role === 'author') + + const sortedAuthors = authorTeam?.members + .slice() + .sort( + (a: any, b: any) => + new Date(b.created).getTime() - new Date(a.created).getTime(), + ) + + row.showAuthorProofing = + authorProofingEnabled && + manuscript.authorFeedback.assignedAuthors?.length > 0 && + sortedAuthors[0]?.user?.id === currentUser.id && + ['assigned', 'inProgress', 'completed'].includes(manuscript.status) + } + + if (variant === 'reviewer') { + row.reviewerStatusBadge = findReviewerStatus(manuscript, currentUser.id) + } + + if (variant === 'editor') { + row.statusCounts = reviewerStatusEntriesFor(manuscript) + } return row }) + // #endregion row-data + + // #region handlers + const applyQueryParams = ( + queryParams: Record, + ): void => { + const params = new URLSearchParams(searchParams) + + Object.entries(queryParams).forEach(([fieldName, fieldValue]) => { + if (fieldValue) params.set(fieldName, String(fieldValue)) + else params.delete(fieldName) + }) + + setSearchParams(params) + } + + const handleSortChange = ( + newSortState: ManuscriptsTableSortState | null, + ): void => { + applyQueryParams({ + [URI_PARAMS.SORT]: newSortState + ? `${newSortState.columnKey}_${newSortState.order}` + : null, + [URI_PARAMS.PAGENUM]: 1, + }) + } + + const handleFiltersChange = ( + newColumnFilters: Record, + ): void => { + applyQueryParams({ + ...mapValues(newColumnFilters, (values, key) => { + const column = tableColumns.find(c => c.key === key) + + if (column?.dataType === 'date') { + const [start, end] = values ?? [] + + return start && end + ? `${isoDateToCompact(start)}-${isoDateToCompact(end)}` + : null + } + + return values && values.length > 0 ? values.join(',') : null + }), + [URI_PARAMS.PAGENUM]: 1, + }) + } + + const handlePageChange = (newPage: number): void => { + applyQueryParams({ [URI_PARAMS.PAGENUM]: newPage }) + } + + const handleSearch = (value: string): void => { + applyQueryParams({ [URI_PARAMS.SEARCH]: value }) + } + + const handleOptionChange = ( + columnKey: string, + id: string, + value: string | null, + ): void => { + if (columnKey !== 'submission.$customStatus') return + + updateManuscript({ + variables: { + id, + input: JSON.stringify({ submission: { $customStatus: value } }), + }, + }) + } + + const handleArchiveSelected = async (ids: string[]): Promise => { + await archiveManuscripts({ variables: { ids } }) + refetch() + } + + const handleUnarchiveSelected = async (ids: string[]): Promise => { + await unarchiveManuscripts({ variables: { ids } }) + refetch() + } + + const handleDownloadSelected = async (ids: string[]): Promise => { + const { data: exportData } = await getManuscriptsData({ + variables: { selectedManuscripts: ids }, + }) + + // @ts-ignore + const cleanedData = (exportData?.getManuscriptsData ?? []).map( + ({ __typename, ...rest }: Record) => rest, + ) + + const jsonBlob = new Blob([JSON.stringify(cleanedData)], { + type: 'application/json', + }) + + downloadBlob(jsonBlob, 'exportedData.json') + } + + const handleViewingArchivedChange = (viewingArchived: boolean): void => { + applyQueryParams({ + [URI_PARAMS.ARCHIVED]: viewingArchived ? 'true' : null, + [URI_PARAMS.PAGENUM]: 1, + }) + } + + const handleReviewerStatusViewModeChange = ( + viewMode: 'compact' | 'detailed', + ): void => { + setReviewerStatusViewMode(viewMode) + localStorage.setItem(REVIEWER_STATUS_VIEW_MODE_STORAGE_KEY, viewMode) + } + // #endregion handlers return { - loading, - error, + columnFilters, columns: tableColumns, dataSource, + error, + loading, + onArchiveSelected: handleArchiveSelected, + onDownloadSelected: handleDownloadSelected, + onFiltersChange: handleFiltersChange, + onOptionChange: handleOptionChange, + onPageChange: handlePageChange, + onReviewerStatusViewModeChange: handleReviewerStatusViewModeChange, + onSearch: handleSearch, + onSortChange: handleSortChange, + onUnarchiveSelected: handleUnarchiveSelected, + onViewingArchivedChange: handleViewingArchivedChange, page: Number(page), pageSize, - totalCount, - onPageChange: newPage => applyQueryParams({ [URI_PAGENUM_PARAM]: newPage }), - onSearch: value => applyQueryParams({ [URI_SEARCH_PARAM]: value }), - columnFilters, - onFiltersChange: handleFiltersChange, + reviewerStatusViewMode, + selectable: variant === 'admin', + showArchiveActions: variant === 'admin', + showDownloadAction: variant === 'admin', + showViewArchivedToggle: variant === 'admin', sortState, - onSortChange: handleSortChange, + totalCount, + viewingArchived: isArchived, } } diff --git a/packages/client/app/pages/hooks/useRole.tsx b/packages/client/app/pages/hooks/useRole.tsx new file mode 100644 index 000000000..e43e635eb --- /dev/null +++ b/packages/client/app/pages/hooks/useRole.tsx @@ -0,0 +1,57 @@ +import { useCurrentUser } from './useCurrentUser' + +type GroupRoleFlags = { + isAdmin: boolean + isGroupAdmin: boolean + isGroupManager: boolean +} + +export const useGroupRole = (): GroupRoleFlags => { + const currentUser = useCurrentUser() + + return { + // Alternatively, we could split admin to useGlobalRole + isAdmin: currentUser.globalRoles.includes('admin'), + isGroupAdmin: currentUser.groupRoles.includes('groupAdmin'), + isGroupManager: currentUser.groupRoles.includes('groupManager'), + } +} + +type ManuscriptRoleFlags = { + isAuthor: boolean + isReviewer: boolean + isCollaborativeReviewer: boolean + isEditor: boolean + isSeniorEditor: boolean + isHandlingEditor: boolean +} + +export const useManuscriptRole = ( + manuscript: Record, +): ManuscriptRoleFlags => { + const currentUser = useCurrentUser() + + if (!manuscript.teams) { + console.error(` + useManuscriptRole: manuscript.teams is missing -- did the query fetch it? + All roles will return false as a result. + `) + } + + const roles = (manuscript.teams ?? []) + .filter((team: Record) => + team.members.some( + (member: Record) => member.user.id === currentUser.id, + ), + ) + .map((team: Record) => team.role) + + return { + isAuthor: roles.includes('author'), + isReviewer: roles.includes('reviewer'), + isCollaborativeReviewer: roles.includes('collaborativeReviewer'), + isEditor: roles.includes('editor'), + isSeniorEditor: roles.includes('seniorEditor'), + isHandlingEditor: roles.includes('handlingEditor'), + } +} diff --git a/packages/client/app/queries/manuscript.queries.ts b/packages/client/app/queries/manuscript.queries.ts index fdac3462f..99836e6cf 100644 --- a/packages/client/app/queries/manuscript.queries.ts +++ b/packages/client/app/queries/manuscript.queries.ts @@ -20,8 +20,111 @@ export const DELETE_MANUSCRIPT = gql` } ` -export const GET_MANUSCRIPTS_AND_FORM = gql` - query Manuscripts( +const MANUSCRIPT_FIELDS = gql` + fragment ManuscriptFields on Manuscript { + id + shortId + submitter { + id + username + isOnline + profilePicture + defaultIdentity { + id + identifier + name + } + } + teams { + id + role + displayName + members { + id + user { + id + username + email + defaultIdentity { + identifier + } + } + status + updated + } + } + status + authorFeedback { + assignedAuthors { + authorName + assignedOnDate + } + } + meta { + manuscriptId + history { + type + date + } + } + submission + created + updated + firstVersionCreated + published + hasOverdueTasksForUser + invitations { + id + status + toEmail + invitedPersonType + user { + id + } + } + rolesFound + importSourceServer + } +` + +const FORM_FIELDS = gql` + fragment FormFields on Form { + structure { + children { + id + component + name + title + shortDescription + isReadOnly + validate { + id + label + value + labelColor + defaultValue + } + validateValue { + minChars + maxChars + minSize + } + doiValidation + doiUniqueSuffixValidation + options { + id + label + labelColor + defaultValue + value + } + } + } + } +` + +export const GET_ALL_MANUSCRIPTS = gql` + query AllManuscripts( $sort: ManuscriptsSort $filters: [ManuscriptsFilter!]! $offset: Int @@ -41,123 +144,27 @@ export const GET_MANUSCRIPTS_AND_FORM = gql` ) { totalCount manuscripts { - id - shortId - meta { - manuscriptId - } - submission - created - updated - firstVersionCreated - status - published - teams { - id - role - members { - id - user { - id - username - } - } - } - importSourceServer manuscriptVersions { - id - shortId - meta { - manuscriptId - } - submission - created - updated - status - published - teams { - id - role - members { - id - user { - defaultIdentity { - identifier - } - id - username - } - } - } - submitter { - username - isOnline - defaultIdentity { - id - identifier - name - } - id - profilePicture - } - importSourceServer - } - submitter { - username - isOnline - defaultIdentity { - id - identifier - name - } - id - profilePicture + ...ManuscriptFields + parentId } + ...ManuscriptFields searchSnippets { field html } } } - formForPurposeAndCategory( purpose: "submit" category: "submission" groupId: $groupId ) { - structure { - children { - id - component - name - title - shortDescription - isReadOnly - validate { - id - label - value - labelColor - defaultValue - } - validateValue { - minChars - maxChars - minSize - } - doiValidation - doiUniqueSuffixValidation - options { - id - label - labelColor - defaultValue - value - } - } - } + ...FormFields } } + ${MANUSCRIPT_FIELDS} + ${FORM_FIELDS} ` export const IMPORT_MANUSCRIPTS = gql` @@ -1012,93 +1019,18 @@ export const SET_SHOULD_PUBLISH_FIELD = gql` } ` -const manuscriptFragment = ` - id - shortId - teams { - id - role - displayName - members { - id - user { - id - username - email - } - status - updated - } - } - status - authorFeedback { - assignedAuthors { - authorName - assignedOnDate - } - } - meta { - manuscriptId - history { - type - date - } - } - submission - created - updated - firstVersionCreated - published - hasOverdueTasksForUser - invitations { - id - status - toEmail - invitedPersonType - user { - id - } - } - rolesFound -` - -const formForPurposeAndCategoryFragment = ` - formForPurposeAndCategory(purpose: "submit", category: "submission", groupId: $groupId) { - structure { - children { - id - component - name - title - shortDescription - isReadOnly - validate { - id - label - value - labelColor - defaultValue - } - validateValue { - minChars - maxChars - minSize - } - doiValidation - options { - id - label - labelColor - defaultValue - value - } - } - } - } -` - -export const DASHBOARD = gql` - query Dashboard($reviewerStatus: String, $wantedRoles: [String]!, $sort: ManuscriptsSort, $filters: [ManuscriptsFilter!]!, $offset: Int, $limit: Int, $timezoneOffsetMinutes: Int, $groupId: ID!, $searchInAllVersions: Boolean!) { +export const GET_MANUSCRIPTS_FOR_ROLE = gql` + query ManuscriptsForRole( + $reviewerStatus: String + $wantedRoles: [String]! + $sort: ManuscriptsSort + $filters: [ManuscriptsFilter!]! + $offset: Int + $limit: Int + $timezoneOffsetMinutes: Int + $groupId: ID! + $searchInAllVersions: Boolean! + ) { manuscriptsUserHasCurrentRoleIn( reviewerStatus: $reviewerStatus wantedRoles: $wantedRoles @@ -1108,23 +1040,31 @@ export const DASHBOARD = gql` limit: $limit timezoneOffsetMinutes: $timezoneOffsetMinutes groupId: $groupId - searchInAllVersions: $searchInAllVersions) - { - totalCount - manuscripts { - manuscriptVersions { - ${manuscriptFragment} - parentId - } - ${manuscriptFragment} - searchSnippets { - field - html - } + searchInAllVersions: $searchInAllVersions + ) { + totalCount + manuscripts { + manuscriptVersions { + ...ManuscriptFields + parentId + } + ...ManuscriptFields + searchSnippets { + field + html } } - ${formForPurposeAndCategoryFragment} + } + formForPurposeAndCategory( + purpose: "submit" + category: "submission" + groupId: $groupId + ) { + ...FormFields + } } + ${MANUSCRIPT_FIELDS} + ${FORM_FIELDS} ` export const FRONTPAGE = gql` diff --git a/packages/client/app/ui/shared/ManuscriptsTable.tsx b/packages/client/app/ui/shared/ManuscriptsTable.tsx index e5c1875a2..f6ac2175d 100644 --- a/packages/client/app/ui/shared/ManuscriptsTable.tsx +++ b/packages/client/app/ui/shared/ManuscriptsTable.tsx @@ -140,11 +140,8 @@ type ManuscriptsTableProps = { * Compact/detailed view mode for 'reviewerStatusSummary' columns. * Defaults to 'detailed'. */ - reviewerStatusViewMode?: Record - onReviewerStatusViewModeChange?: ( - columnKey: string, - viewMode: 'compact' | 'detailed', - ) => void + reviewerStatusViewMode?: 'compact' | 'detailed' + onReviewerStatusViewModeChange?: (viewMode: 'compact' | 'detailed') => void /** * Fired when a value is picked or cleared in an `editable` 'options' column. `id` is the * changed manuscript's `record.id`; `value` is `null` when cleared. @@ -769,11 +766,8 @@ const resolveColumn = ( context: { columnFilters?: Record sortState?: ManuscriptsTableSortState | null - reviewerStatusViewMode?: Record - onReviewerStatusViewModeChange?: ( - columnKey: string, - viewMode: 'compact' | 'detailed', - ) => void + reviewerStatusViewMode?: 'compact' | 'detailed' + onReviewerStatusViewModeChange?: (viewMode: 'compact' | 'detailed') => void onOptionChange?: ( columnKey: string, id: string, @@ -800,8 +794,7 @@ const resolveColumn = ( render: (value: any): ReactNode => ( @@ -871,12 +864,10 @@ const resolveColumn = ( title: ( context.onReviewerStatusViewModeChange?.( - column.key, isCompact ? 'compact' : 'detailed', ) } diff --git a/packages/client/package.json b/packages/client/package.json index b58bda36d..ab9c0c8ea 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -39,7 +39,6 @@ "cheerio": "1.0.0-rc.10", "color": "^3.0.0", "dayjs": "^1.11.21", - "file-saver": "^2.0.5", "file-type": "^20.4.1", "fnv-plus": "^1.3.1", "formik": "2.2.6", @@ -99,6 +98,7 @@ "@faker-js/faker": "^10.5.0", "@storybook/addon-docs": "^10.4.6", "@storybook/react-vite": "^10.4.6", + "@types/lodash": "^4.17.25", "@types/react": "^19.2.14", "storybook": "^10.4.6" }, diff --git a/packages/client/stories/shared/ManuscriptsTable.stories.tsx b/packages/client/stories/shared/ManuscriptsTable.stories.tsx index 8f48c0e71..e024ebb6a 100644 --- a/packages/client/stories/shared/ManuscriptsTable.stories.tsx +++ b/packages/client/stories/shared/ManuscriptsTable.stories.tsx @@ -648,18 +648,13 @@ export const BuiltInReviewerStatusSummaryRendering = meta.story({ }, render: args => { const [reviewerStatusViewMode, setReviewerStatusViewMode] = useState< - Record - >({}) + 'compact' | 'detailed' + >('detailed') return ( - setReviewerStatusViewMode(previous => ({ - ...previous, - [columnKey]: viewMode, - })) - } + onReviewerStatusViewModeChange={setReviewerStatusViewMode} reviewerStatusViewMode={reviewerStatusViewMode} /> ) diff --git a/packages/client/yarn.lock b/packages/client/yarn.lock index 0d3a97b93..b3a14514e 100644 --- a/packages/client/yarn.lock +++ b/packages/client/yarn.lock @@ -2890,6 +2890,13 @@ __metadata: languageName: node linkType: hard +"@types/lodash@npm:^4.17.25": + version: 4.17.25 + resolution: "@types/lodash@npm:4.17.25" + checksum: 10c0/ca2049f16bf527e04d6e27702e3912a484faaff587de77a5c58e0d243b9292a655e1d7f7e5bdf2ffdf49a76da10673097f8dd4901990679058d47ccd99051877 + languageName: node + linkType: hard + "@types/mdx@npm:^2.0.0": version: 2.0.14 resolution: "@types/mdx@npm:2.0.14" @@ -4653,13 +4660,6 @@ __metadata: languageName: node linkType: hard -"file-saver@npm:^2.0.5": - version: 2.0.5 - resolution: "file-saver@npm:2.0.5" - checksum: 10c0/0a361f683786c34b2574aea53744cb70d0a6feb0fa5e3af00f2fcb6c9d40d3049cc1470e38c6c75df24219f247f6fb3076f86943958f580e62ee2ffe897af8b1 - languageName: node - linkType: hard - "file-selector@npm:^0.1.12": version: 0.1.19 resolution: "file-selector@npm:0.1.19" @@ -5398,13 +5398,13 @@ __metadata: "@rjsf/core": "npm:4.2.3" "@storybook/addon-docs": "npm:^10.4.6" "@storybook/react-vite": "npm:^10.4.6" + "@types/lodash": "npm:^4.17.25" "@types/react": "npm:^19.2.14" "@uiw/react-codemirror": "npm:^4.21.20" antd: "npm:^6.3.7" cheerio: "npm:1.0.0-rc.10" color: "npm:^3.0.0" dayjs: "npm:^1.11.21" - file-saver: "npm:^2.0.5" file-type: "npm:^20.4.1" fnv-plus: "npm:^1.3.1" formik: "npm:2.2.6" From b04f8651364f6339d4b4ac4a8b994f77633bdace Mon Sep 17 00:00:00 2001 From: yannis Date: Fri, 21 Aug 2026 17:39:32 +0300 Subject: [PATCH 28/51] feat(ui): search ux --- .../src/components/DashboardLayout.jsx | 18 --- .../components/sections/SubmissionsTable.jsx | 2 +- .../src/SearchControl.jsx | 4 +- packages/client/app/i18n/en/translation.js | 11 +- packages/client/app/i18n/es-la/translation.js | 11 +- packages/client/app/i18n/fr/translation.js | 11 +- packages/client/app/i18n/ru/translation.js | 11 +- .../app/pages/hooks/useManuscriptsTable.tsx | 60 ++++---- .../client/app/ui/shared/ManuscriptsTable.tsx | 130 +++++++++++++++++- 9 files changed, 201 insertions(+), 57 deletions(-) diff --git a/packages/client/app/components/component-dashboard/src/components/DashboardLayout.jsx b/packages/client/app/components/component-dashboard/src/components/DashboardLayout.jsx index 8cd886172..7ea7838ab 100644 --- a/packages/client/app/components/component-dashboard/src/components/DashboardLayout.jsx +++ b/packages/client/app/components/component-dashboard/src/components/DashboardLayout.jsx @@ -9,13 +9,7 @@ import { useTranslation } from 'react-i18next' import { Button } from '../../../pubsweet' import { HiddenTabsContainer, Tab, TabContainer } from '../../../shared' import Page from '../../../../ui/shared/Page' -import SearchControl from '../../../component-manuscripts/src/SearchControl' import { ControlsContainer } from '../../../component-manuscripts/src/style' -import { - URI_PAGENUM_PARAM, - URI_SEARCH_PARAM, - useQueryParams, -} from '../../../../shared/urlParamUtils' import { FlexRow } from '../../../../globals' import { ConfigContext } from '../../../config/src' @@ -43,9 +37,6 @@ const DashboardLayout = ({ children }) => { const navigate = useNavigate() const location = useLocation() const { groupName } = useParams() - const applyQueryParams = useQueryParams() - const uriQueryParams = new URLSearchParams(location.search) - const currentSearchQuery = uriQueryParams.get(URI_SEARCH_PARAM) const dashboardPages = [] const { t } = useTranslation() @@ -85,15 +76,6 @@ const DashboardLayout = ({ children }) => { ))} - - applyQueryParams({ - [URI_SEARCH_PARAM]: newQuery, - [URI_PAGENUM_PARAM]: 1, - }) - } - currentSearchQuery={currentSearchQuery} - />
) From 23479dd164782ed092f7447d864f45657459f370 Mon Sep 17 00:00:00 2001 From: yannis Date: Mon, 24 Aug 2026 11:16:51 +0300 Subject: [PATCH 29/51] feat(client): add bundle analyzer --- .gitignore | 1 + packages/client/package.json | 3 +- packages/client/yarn.lock | 219 ++++++++++++++++++++++++++++++++++- 3 files changed, 216 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 19c8ba147..d3233ecbb 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,4 @@ packages/server/scripts/local !**/.yarn/versions .claude +stats.html diff --git a/packages/client/package.json b/packages/client/package.json index ab9c0c8ea..7bd34e250 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -12,6 +12,7 @@ "node": ">=24.0.0" }, "scripts": { + "analyze": "NODE_ENV=development yarn coko-client-build && rm -rf ./_build", "typecheck": "node_modules/.bin/tsc --noEmit -p tsconfig.json" }, "resolutions": { @@ -27,7 +28,7 @@ "@codemirror/lang-css": "^6.2.1", "@codemirror/lang-html": "^6.4.6", "@codemirror/lang-json": "^6.0.1", - "@coko/client": "^2.5.0", + "@coko/client": "^2.6.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", diff --git a/packages/client/yarn.lock b/packages/client/yarn.lock index b3a14514e..4458b6511 100644 --- a/packages/client/yarn.lock +++ b/packages/client/yarn.lock @@ -599,9 +599,9 @@ __metadata: languageName: node linkType: hard -"@coko/client@npm:^2.5.0": - version: 2.5.0 - resolution: "@coko/client@npm:2.5.0" +"@coko/client@npm:^2.6.0": + version: 2.6.0 + resolution: "@coko/client@npm:2.6.0" dependencies: "@ant-design/icons": "npm:^6.2.2" "@dnd-kit/core": "npm:^6.3.1" @@ -619,6 +619,7 @@ __metadata: react: "npm:^19.2.5" react-dom: "npm:^19.2.5" react-router: "npm:^7.14.2" + rollup-plugin-visualizer: "npm:^7.1.1" rxjs: "npm:^7.8.2" styled-components: "npm:^6.4.1" styled-normalize: "npm:^8.1.1" @@ -633,7 +634,7 @@ __metadata: bin: coko-client-build: ./scripts/coko-client-build.mjs coko-client-dev: ./scripts/coko-client-dev.mjs - checksum: 10c0/57cb9c4393f0c623cf04955759171ee7c122de25ba6c743778baacb7c9db168573d47dd7573ec935b60bceb96026c560d72e1527b192a5c8b016d5efce29ee36 + checksum: 10c0/fe97988d11e0ffa2296a546afaa18da08a3299c70dbc7b9a39f50502ec38a25230b759c938e7b046afe1e2bbb038e30d50685dfbf81bb5ab58fe02b0d9d394c5 languageName: node linkType: hard @@ -3257,6 +3258,13 @@ __metadata: languageName: node linkType: hard +"ansi-regex@npm:^6.2.2": + version: 6.3.0 + resolution: "ansi-regex@npm:6.3.0" + checksum: 10c0/bc047786d0531f1f22d1e22e9863e8554488a399f1ee5270b753efa6de938a788d27456f2b256770f11fdd8b1e847483bc1b55ddc2b3ffe82ec45c875292c651 + languageName: node + linkType: hard + "ansi-styles@npm:^3.2.1": version: 3.2.1 resolution: "ansi-styles@npm:3.2.1" @@ -3282,6 +3290,13 @@ __metadata: languageName: node linkType: hard +"ansi-styles@npm:^6.2.1": + version: 6.2.3 + resolution: "ansi-styles@npm:6.2.3" + checksum: 10c0/23b8a4ce14e18fb854693b95351e286b771d23d8844057ed2e7d083cd3e708376c3323707ec6a24365f7d7eda3ca00327fe04092e29e551499ec4c8b7bfac868 + languageName: node + linkType: hard + "antd@npm:^6.3.7": version: 6.3.7 resolution: "antd@npm:6.3.7" @@ -3629,6 +3644,17 @@ __metadata: languageName: node linkType: hard +"cliui@npm:^9.0.1": + version: 9.0.1 + resolution: "cliui@npm:9.0.1" + dependencies: + string-width: "npm:^7.2.0" + strip-ansi: "npm:^7.1.0" + wrap-ansi: "npm:^9.0.0" + checksum: 10c0/13441832e9efe7c7a76bd2b8e683555c478d461a9f249dc5db9b17fe8d4b47fa9277b503914b90bd00e4a151abb6b9b02b2288972ffe2e5e3ca40bcb1c2330d3 + languageName: node + linkType: hard + "clsx@npm:^1.1.1": version: 1.2.1 resolution: "clsx@npm:1.2.1" @@ -4164,6 +4190,16 @@ __metadata: languageName: node linkType: hard +"default-browser@npm:^5.4.0": + version: 5.5.1 + resolution: "default-browser@npm:5.5.1" + dependencies: + bundle-name: "npm:^4.1.0" + default-browser-id: "npm:^5.0.0" + checksum: 10c0/feb5e7a6a6f2fcbc7a6c5c78e071a4f7a3ab136e6244b9637e58fe6170f6e1254ae099cbe2ebc2b2823c387ed50fda176ce31d386f4f2ebbd43d8fb1cb6aacf7 + languageName: node + linkType: hard + "deferred-leveldown@npm:~5.3.0": version: 5.3.0 resolution: "deferred-leveldown@npm:5.3.0" @@ -4310,6 +4346,13 @@ __metadata: languageName: node linkType: hard +"emoji-regex@npm:^10.3.0": + version: 10.6.0 + resolution: "emoji-regex@npm:10.6.0" + checksum: 10c0/1e4aa097bb007301c3b4b1913879ae27327fdc48e93eeefefe3b87e495eb33c5af155300be951b4349ff6ac084f4403dc9eff970acba7c1c572d89396a9a32d7 + languageName: node + linkType: hard + "emoji-regex@npm:^8.0.0": version: 8.0.0 resolution: "emoji-regex@npm:8.0.0" @@ -4508,7 +4551,7 @@ __metadata: languageName: node linkType: hard -"escalade@npm:^3.2.0": +"escalade@npm:^3.1.1, escalade@npm:^3.2.0": version: 3.2.0 resolution: "escalade@npm:3.2.0" checksum: 10c0/ced4dd3a78e15897ed3be74e635110bbf3b08877b0a41be50dcb325ee0e0b5f65fc2d50e9845194d7c4633f327e2e1c6cce00a71b617c5673df0374201d67f65 @@ -4782,6 +4825,20 @@ __metadata: languageName: node linkType: hard +"get-caller-file@npm:^2.0.5": + version: 2.0.5 + resolution: "get-caller-file@npm:2.0.5" + checksum: 10c0/c6c7b60271931fa752aeb92f2b47e355eac1af3a2673f47c9589e8f8a41adc74d45551c1bc57b5e66a80609f10ffb72b6f575e4370d61cc3f7f3aaff01757cde + languageName: node + linkType: hard + +"get-east-asian-width@npm:^1.0.0, get-east-asian-width@npm:^1.5.0": + version: 1.6.0 + resolution: "get-east-asian-width@npm:1.6.0" + checksum: 10c0/7e72e9550fd49ca5b246f9af6bb2afc129c96412845ff6556b3274fd44817a381702ca17028efe9866b261a3d44254cbf21e6c90cf05b4b61675630af776d431 + languageName: node + linkType: hard + "get-user-locale@npm:^1.2.0": version: 1.5.1 resolution: "get-user-locale@npm:1.5.1" @@ -5162,6 +5219,13 @@ __metadata: languageName: node linkType: hard +"is-in-ssh@npm:^1.0.0": + version: 1.0.0 + resolution: "is-in-ssh@npm:1.0.0" + checksum: 10c0/fbb4c25d85c543df09997fbe7aeca410ae0c839c5825bba2d4c672df765e9ce0e7558e781b371c0a21d6ef9bbac39b31875617a68eaaea5504438d07db9a2ffa + languageName: node + linkType: hard + "is-inside-container@npm:^1.0.0": version: 1.0.0 resolution: "is-inside-container@npm:1.0.0" @@ -5388,7 +5452,7 @@ __metadata: "@codemirror/lang-css": "npm:^6.2.1" "@codemirror/lang-html": "npm:^6.4.6" "@codemirror/lang-json": "npm:^6.0.1" - "@coko/client": "npm:^2.5.0" + "@coko/client": "npm:^2.6.0" "@dnd-kit/core": "npm:^6.3.1" "@dnd-kit/sortable": "npm:^10.0.0" "@dnd-kit/utilities": "npm:^3.2.2" @@ -6163,6 +6227,20 @@ __metadata: languageName: node linkType: hard +"open@npm:^11.0.0": + version: 11.0.1 + resolution: "open@npm:11.0.1" + dependencies: + default-browser: "npm:^5.4.0" + define-lazy-prop: "npm:^3.0.0" + is-in-ssh: "npm:^1.0.0" + is-inside-container: "npm:^1.0.0" + powershell-utils: "npm:^0.2.0" + wsl-utils: "npm:^1.0.0" + checksum: 10c0/fd650ac10806a06bfe197af0ef143cd2a3127294721524972f04377fb1144da5e32a2d0507497e214385981ec5b6b4b43210e22d7ef22bc45c390771d80658a5 + languageName: node + linkType: hard + "optimism@npm:^0.18.0": version: 0.18.0 resolution: "optimism@npm:0.18.0" @@ -6452,6 +6530,13 @@ __metadata: languageName: node linkType: hard +"picomatch@npm:^4.0.5": + version: 4.0.5 + resolution: "picomatch@npm:4.0.5" + checksum: 10c0/947bc6b6e1ff1e6c5aaf95b107a0839d12802f4f7b867663f67d47accba939ca1cb582cf99dfc30438efa1c4648ac5990967e783e8929c36b03e8440704ef1bd + languageName: node + linkType: hard + "postcss-value-parser@npm:^4.0.2": version: 4.2.0 resolution: "postcss-value-parser@npm:4.2.0" @@ -6470,6 +6555,20 @@ __metadata: languageName: node linkType: hard +"powershell-utils@npm:^0.1.0": + version: 0.1.0 + resolution: "powershell-utils@npm:0.1.0" + checksum: 10c0/a64713cf3583259c9ed6be211c06b4b19e8608bcb0f7f6287ffac0a95b8c7582b6b662eea0e201fd659492c8e9f9c5fd0bfc4579645c5add9c1a600075621c95 + languageName: node + linkType: hard + +"powershell-utils@npm:^0.2.0": + version: 0.2.0 + resolution: "powershell-utils@npm:0.2.0" + checksum: 10c0/0fa649e933800f33276262646cfb0d2ffcdfdb6a68ff80f5ab66e33c3197d3edf022e774502d5e458aee683702994e6d201fda85958c3cb7ccc413e0746b4373 + languageName: node + linkType: hard + "proc-log@npm:^5.0.0": version: 5.0.0 resolution: "proc-log@npm:5.0.0" @@ -7548,6 +7647,28 @@ __metadata: languageName: node linkType: hard +"rollup-plugin-visualizer@npm:^7.1.1": + version: 7.1.1 + resolution: "rollup-plugin-visualizer@npm:7.1.1" + dependencies: + open: "npm:^11.0.0" + picomatch: "npm:^4.0.5" + source-map: "npm:^0.8.0" + yargs: "npm:^18.1.0" + peerDependencies: + rolldown: 1.x || ^1.0.0-beta || ^1.0.0-rc + rollup: 2.x || 3.x || 4.x + peerDependenciesMeta: + rolldown: + optional: true + rollup: + optional: true + bin: + rollup-plugin-visualizer: dist/bin/cli.js + checksum: 10c0/ac740fb4e2e06b9ff7ac42af8c9af12d9d07ac1e45eaf0c1c3f5b23548cc07e8bdf66690a5c3d0229e0e145387566f160517db882fa89b621275b974f575abc6 + languageName: node + linkType: hard + "rope-sequence@npm:^1.3.0": version: 1.3.4 resolution: "rope-sequence@npm:1.3.4" @@ -7740,6 +7861,13 @@ __metadata: languageName: node linkType: hard +"source-map@npm:^0.8.0": + version: 0.8.0 + resolution: "source-map@npm:0.8.0" + checksum: 10c0/fd2a5b81a1bf40f6155592f19c44abb46fc75b0f0f9473dd40c665b6f3866516a26218dad3d76b58b8b3e44560f3ab2191a837e1ce6631987e51a2db2e1cac56 + languageName: node + linkType: hard + "source-map@npm:~0.6.1": version: 0.6.1 resolution: "source-map@npm:0.6.1" @@ -7851,6 +7979,27 @@ __metadata: languageName: node linkType: hard +"string-width@npm:^7.0.0, string-width@npm:^7.2.0": + version: 7.2.0 + resolution: "string-width@npm:7.2.0" + dependencies: + emoji-regex: "npm:^10.3.0" + get-east-asian-width: "npm:^1.0.0" + strip-ansi: "npm:^7.1.0" + checksum: 10c0/eb0430dd43f3199c7a46dcbf7a0b34539c76fe3aa62763d0b0655acdcbdf360b3f66f3d58ca25ba0205f42ea3491fa00f09426d3b7d3040e506878fc7664c9b9 + languageName: node + linkType: hard + +"string-width@npm:^8.2.1": + version: 8.2.2 + resolution: "string-width@npm:8.2.2" + dependencies: + get-east-asian-width: "npm:^1.5.0" + strip-ansi: "npm:^7.1.2" + checksum: 10c0/895624534e4e2f229e880bbc30aa77bdeb758e1a0edce203e0a17b8b4f08b8d3c1156516efc50a35e3efd0052d938a73797692111250ac9f52ee384710a01714 + languageName: node + linkType: hard + "string_decoder@npm:^1.1.1": version: 1.3.0 resolution: "string_decoder@npm:1.3.0" @@ -7878,6 +8027,15 @@ __metadata: languageName: node linkType: hard +"strip-ansi@npm:^7.1.0, strip-ansi@npm:^7.1.2": + version: 7.2.0 + resolution: "strip-ansi@npm:7.2.0" + dependencies: + ansi-regex: "npm:^6.2.2" + checksum: 10c0/544d13b7582f8254811ea97db202f519e189e59d35740c46095897e254e4f1aa9fe1524a83ad6bc5ad67d4dd6c0281d2e0219ed62b880a6238a16a17d375f221 + languageName: node + linkType: hard + "strip-bom@npm:^3.0.0": version: 3.0.0 resolution: "strip-bom@npm:3.0.0" @@ -8716,6 +8874,17 @@ __metadata: languageName: node linkType: hard +"wrap-ansi@npm:^9.0.0": + version: 9.0.2 + resolution: "wrap-ansi@npm:9.0.2" + dependencies: + ansi-styles: "npm:^6.2.1" + string-width: "npm:^7.0.0" + strip-ansi: "npm:^7.1.0" + checksum: 10c0/3305839b9a0d6fb930cb63a52f34d3936013d8b0682ff3ec133c9826512620f213800ffa19ea22904876d5b7e9a3c1f40682f03597d986a4ca881fa7b033688c + languageName: node + linkType: hard + "ws@npm:^6.2.1": version: 6.2.3 resolution: "ws@npm:6.2.3" @@ -8779,6 +8948,16 @@ __metadata: languageName: node linkType: hard +"wsl-utils@npm:^1.0.0": + version: 1.0.0 + resolution: "wsl-utils@npm:1.0.0" + dependencies: + is-wsl: "npm:^3.1.0" + powershell-utils: "npm:^0.1.0" + checksum: 10c0/95f01898b531c240eae84cf7518f9628dd3487f5ebe523f36654347de4d3e35ecc4402acbdf3516632c908c1befca22ae915cfd27ac10f770554ebc89a771133 + languageName: node + linkType: hard + "xml-name-validator@npm:^4.0.0": version: 4.0.0 resolution: "xml-name-validator@npm:4.0.0" @@ -8904,6 +9083,13 @@ __metadata: languageName: node linkType: hard +"y18n@npm:^5.0.5": + version: 5.0.8 + resolution: "y18n@npm:5.0.8" + checksum: 10c0/4df2842c36e468590c3691c894bc9cdbac41f520566e76e24f59401ba7d8b4811eb1e34524d57e54bc6d864bcb66baab7ffd9ca42bf1eda596618f9162b91249 + languageName: node + linkType: hard + "yallist@npm:^3.0.2": version: 3.1.1 resolution: "yallist@npm:3.1.1" @@ -8932,6 +9118,27 @@ __metadata: languageName: node linkType: hard +"yargs-parser@npm:^22.0.0": + version: 22.0.0 + resolution: "yargs-parser@npm:22.0.0" + checksum: 10c0/cb7ef81759c4271cb1d96b9351dbbc9a9ce35d3e1122d2b739bf6c432603824fa02c67cc12dcef6ea80283379d63495686e8f41cc7b06c6576e792aba4d33e1c + languageName: node + linkType: hard + +"yargs@npm:^18.1.0": + version: 18.1.0 + resolution: "yargs@npm:18.1.0" + dependencies: + cliui: "npm:^9.0.1" + escalade: "npm:^3.1.1" + get-caller-file: "npm:^2.0.5" + string-width: "npm:^8.2.1" + y18n: "npm:^5.0.5" + yargs-parser: "npm:^22.0.0" + checksum: 10c0/86052bbed90a9aaaaf1eabe004f47c7420e1b7f8d314170cfb0ad4721f518ec2fb7ba5a6daa20708b1595aba257f1554b2d5b3f5606356f86aa9dae18de0bec5 + languageName: node + linkType: hard + "yjs@npm:13.5.38": version: 13.5.38 resolution: "yjs@npm:13.5.38" From baa3bfcb053080da1f179273685ba80d51da35c5 Mon Sep 17 00:00:00 2001 From: yannis Date: Mon, 24 Aug 2026 12:26:14 +0300 Subject: [PATCH 30/51] feat(client): replace reviewer table --- .../src/components/DashboardReviewsPage.jsx | 113 +-------------- .../src/components/sections/ReviewerTable.jsx | 122 +++-------------- packages/client/app/i18n/en/translation.js | 2 + packages/client/app/i18n/es-la/translation.js | 2 + packages/client/app/i18n/fr/translation.js | 2 + packages/client/app/i18n/ru/translation.js | 2 + .../app/pages/hooks/useManuscriptsTable.tsx | 129 +++++++++++++++++- .../client/app/ui/shared/ManuscriptsTable.tsx | 3 + .../manuscript/manuscript.controllers.js | 3 +- 9 files changed, 164 insertions(+), 214 deletions(-) diff --git a/packages/client/app/components/component-dashboard/src/components/DashboardReviewsPage.jsx b/packages/client/app/components/component-dashboard/src/components/DashboardReviewsPage.jsx index 3e19e1e1b..bf3724599 100644 --- a/packages/client/app/components/component-dashboard/src/components/DashboardReviewsPage.jsx +++ b/packages/client/app/components/component-dashboard/src/components/DashboardReviewsPage.jsx @@ -1,104 +1,15 @@ /* eslint-disable react-hooks/exhaustive-deps */ -import { useMutation, useQuery } from '@apollo/client/react' +import { useMutation } from '@apollo/client/react' import { useEffect, useContext } from 'react' -import { useLocation } from 'react-router-dom' import { ConfigContext } from '../../../config/src' -import { - UPDATE_REVIEWER_STATUS, - REVIEWER_RESPONSE, - UPDATE_TAB, - GET_MANUSCRIPTS_FOR_ROLE, - UPDATE_MANUSCRIPT, -} from '../../../../queries' -import { useCurrentUser } from '../../../../pages/hooks/useCurrentUser' -import { - extractFilters, - extractSortData, - URI_PAGENUM_PARAM, - URI_REVIEWER_STATUS_PARAM, - useQueryParams, -} from '../../../../shared/urlParamUtils' +import { UPDATE_TAB } from '../../../../queries' import ReviewerTable from './sections/ReviewerTable' -import { CommsErrorBanner, Spinner } from '../../../shared' const DashboardReviewsPage = () => { - const location = useLocation() const config = useContext(ConfigContext) - const currentUser = useCurrentUser() - - const wantedRoles = [ - 'reviewer', - 'invited:reviewer', - 'accepted:reviewer', - 'inProgress:reviewer', - 'completed:reviewer', - 'collaborativeReviewer', - 'invited:collaborativeReviewer', - 'accepted:collaborativeReviewer', - 'inProgress:collaborativeReviewer', - 'completed:collaborativeReviewer', - ] - - const applyQueryParams = useQueryParams() - - const uriQueryParams = new URLSearchParams(location.search) - const page = uriQueryParams.get(URI_PAGENUM_PARAM) || 1 - const sortName = extractSortData(uriQueryParams).name - const sortDirection = extractSortData(uriQueryParams).direction - const filters = extractFilters(uriQueryParams) - - const limit = config?.manuscript?.paginationCount || 10 - - const { loading, error, data } = useQuery(GET_MANUSCRIPTS_FOR_ROLE, { - variables: { - reviewerStatus: uriQueryParams.get(URI_REVIEWER_STATUS_PARAM), - wantedRoles, - sort: sortName - ? { field: sortName, isAscending: sortDirection === 'ASC' } - : null, - offset: (page - 1) * limit, - limit, - filters, - timezoneOffsetMinutes: new Date().getTimezoneOffset(), - groupId: config.groupId, - searchInAllVersions: true, - }, - fetchPolicy: 'network-only', - }) - - const [update] = useMutation(UPDATE_MANUSCRIPT) - const [doUpdateManuscript] = useMutation(UPDATE_MANUSCRIPT) - - const setReadyToEvaluateLabels = id => { - update({ - variables: { - id, - input: JSON.stringify({ - submission: { - $customStatus: 'readyToEvaluate', - }, - }), - }, - }) - } - - const unsetCustomStatus = id => { - update({ - variables: { - id, - input: JSON.stringify({ - submission: { - $customStatus: null, - }, - }), - }, - }) - } const [updateTab] = useMutation(UPDATE_TAB) - const [reviewerRespond] = useMutation(REVIEWER_RESPONSE) - const [updateReviewerStatus] = useMutation(UPDATE_REVIEWER_STATUS) useEffect(() => { updateTab({ @@ -108,23 +19,11 @@ const DashboardReviewsPage = () => { }) }, []) - if (loading) return - if (error) return + const showReviews = config?.dashboard?.showSections?.includes('review') + + if (!showReviews) return null - return config?.dashboard?.showSections?.includes('review') ? ( - - ) : null + return } export default DashboardReviewsPage diff --git a/packages/client/app/components/component-dashboard/src/components/sections/ReviewerTable.jsx b/packages/client/app/components/component-dashboard/src/components/sections/ReviewerTable.jsx index 054b540b0..44584f380 100644 --- a/packages/client/app/components/component-dashboard/src/components/sections/ReviewerTable.jsx +++ b/packages/client/app/components/component-dashboard/src/components/sections/ReviewerTable.jsx @@ -1,121 +1,37 @@ -/* eslint-disable react/prop-types */ - -import { useMemo, useContext } from 'react' import { useTranslation } from 'react-i18next' -import ManuscriptsTable from '../../../../component-manuscripts-table/src/ManuscriptsTable' -import buildColumnDefinitions from '../../../../component-manuscripts-table/src/util/buildColumnDefinitions' +import styled from 'styled-components' +import { grid } from '@coko/client' + +import useManuscriptsTable from '../../../../../pages/hooks/useManuscriptsTable' import { - Pagination, - PaginationContainerShadowed, SectionContent, SectionHeader, Title, + CommsErrorBanner, + Spinner, } from '../../../../shared' -import { reviewerColumns } from '../../../../../../config/journal/manuscripts' -import { - extractSortData, - URI_PAGENUM_PARAM, - URI_SEARCH_PARAM, -} from '../../../../../shared/urlParamUtils' -import { ConfigContext } from '../../../../config/src' - -const ReviewerTable = ({ - currentUser, - doUpdateManuscript, - manuscriptsUserHasCurrentRoleIn, - reviewerRespond, - submissionForm, - unsetCustomStatus, - setReadyToEvaluateLabels, - updateReviewerStatus, - uriQueryParams, - applyQueryParams, -}) => { - const config = useContext(ConfigContext) - const { urlFrag } = config - - const fieldDefinitions = useMemo(() => { - const fields = submissionForm?.structure?.children ?? [] - const defs = {} - fields.forEach(field => { - // Incomplete fields in the formbuilder may not have a name specified. Ignore these - if (field.name) defs[field.name] = field - }) - return defs - }, [submissionForm]) - - const currentSearchQuery = uriQueryParams.get(URI_SEARCH_PARAM) - const sortName = extractSortData(uriQueryParams).name - const sortDirection = extractSortData(uriQueryParams).direction - - const page = uriQueryParams.get(URI_PAGENUM_PARAM) || 1 - const limit = config?.manuscript?.paginationCount || 10 - const { totalCount } = manuscriptsUserHasCurrentRoleIn - - const getMainActionLink = manuscript => { - const reviewerStatus = manuscript.teams - ?.find(team => team.role === 'reviewer') - ?.members?.find(member => member.user.id === currentUser.id)?.status +import ManuscriptsTable from '../../../../../ui/shared/ManuscriptsTable' - return reviewerStatus === 'invited' || reviewerStatus === 'rejected' - ? `${urlFrag}/versions/${manuscript.id}/reviewPreview` - : `${urlFrag}/versions/${manuscript.parentId || manuscript.id}/review` - } - - const setReadyToEvaluateLabel = id => { - return setReadyToEvaluateLabels(id) - } - - const specialComponentValues = { - urlFrag, - currentUser, - reviewerRespond, - updateReviewerStatus, - setReadyToEvaluateLabel, - unsetCustomStatus, - getMainActionLink, - } - - const displayProps = { - uriQueryParams, - columnToSortOn: sortName, - sortDirection, - currentSearchQuery, - } - - const rColumn = (config.dashboard?.tableColumns || []).map(tc => tc.value) - - const columnsProps = buildColumnDefinitions( - config, - reviewerColumns(rColumn), - fieldDefinitions, - specialComponentValues, - displayProps, - doUpdateManuscript, - ) +const TableWrapper = styled.div` + padding: ${grid(3)} ${grid(2)}; +` +const ReviewerTable = () => { const { t } = useTranslation() + const { loading, error, ...tableProps } = useManuscriptsTable('reviewer') + + if (loading) return + if (error) return return ( {t('dashboardPage.To Review')} - - applyQueryParams({ [URI_PAGENUM_PARAM]: newPage })} - totalCount={totalCount} - /> + + + + ) } diff --git a/packages/client/app/i18n/en/translation.js b/packages/client/app/i18n/en/translation.js index de3dd33be..72d64c5c3 100644 --- a/packages/client/app/i18n/en/translation.js +++ b/packages/client/app/i18n/en/translation.js @@ -208,6 +208,8 @@ const en = { selectedCount: '{{count}} selected', confirmArchive: 'Archive {{count}} manuscripts?', confirmUnarchive: 'Unarchive {{count}} manuscripts?', + confirmReviewAccept: 'Accept this review invitation?', + confirmReviewReject: 'Decline this review invitation?', detailedView: 'Detailed view', reviewAccept: 'Accept', reviewReject: 'Decline', diff --git a/packages/client/app/i18n/es-la/translation.js b/packages/client/app/i18n/es-la/translation.js index 9784c6032..feb96a72c 100644 --- a/packages/client/app/i18n/es-la/translation.js +++ b/packages/client/app/i18n/es-la/translation.js @@ -208,6 +208,8 @@ const esLa = { selectedCount: '{{count}} seleccionados', confirmArchive: '¿Archivar {{count}} manuscritos?', confirmUnarchive: '¿Desarchivar {{count}} manuscritos?', + confirmReviewAccept: '¿Aceptar esta invitación de revisión?', + confirmReviewReject: '¿Rechazar esta invitación de revisión?', detailedView: 'Vista detallada', reviewAccept: 'Aceptar', reviewReject: 'Rechazar', diff --git a/packages/client/app/i18n/fr/translation.js b/packages/client/app/i18n/fr/translation.js index a23bcbe7d..8cc237df5 100644 --- a/packages/client/app/i18n/fr/translation.js +++ b/packages/client/app/i18n/fr/translation.js @@ -208,6 +208,8 @@ const fr = { selectedCount: '{{count}} sélectionné(s)', confirmArchive: 'Archiver {{count}} manuscrits ?', confirmUnarchive: 'Désarchiver {{count}} manuscrits ?', + confirmReviewAccept: 'Accepter cette invitation à évaluer ?', + confirmReviewReject: 'Refuser cette invitation à évaluer ?', detailedView: 'Vue détaillée', reviewAccept: 'Accepter', reviewReject: 'Rejeter', diff --git a/packages/client/app/i18n/ru/translation.js b/packages/client/app/i18n/ru/translation.js index e035a4c10..c26c3d9fa 100644 --- a/packages/client/app/i18n/ru/translation.js +++ b/packages/client/app/i18n/ru/translation.js @@ -208,6 +208,8 @@ const ru = { selectedCount: 'Выбрано: {{count}}', confirmArchive: 'Архивировать {{count}} рукописей?', confirmUnarchive: 'Разархивировать {{count}} рукописей?', + confirmReviewAccept: 'Принять приглашение на рецензирование?', + confirmReviewReject: 'Отклонить приглашение на рецензирование?', detailedView: 'Подробный вид', pagination_few: `Показано {{lastResult}} из {{totalCount}} результатов`, pagination_many: `Показано {{lastResult}} из {{totalCount}} результатов`, diff --git a/packages/client/app/pages/hooks/useManuscriptsTable.tsx b/packages/client/app/pages/hooks/useManuscriptsTable.tsx index f5f75fec3..e49d8e154 100644 --- a/packages/client/app/pages/hooks/useManuscriptsTable.tsx +++ b/packages/client/app/pages/hooks/useManuscriptsTable.tsx @@ -3,10 +3,11 @@ */ import { useMemo, useState, type ReactNode } from 'react' -import { useParams, useSearchParams } from 'react-router-dom' +import { useParams, useSearchParams, useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { useQuery, useMutation, useLazyQuery } from '@apollo/client/react' import styled from 'styled-components' +import { grid, th, Modal } from '@coko/client' import get from 'lodash/get' import mapValues from 'lodash/mapValues' @@ -19,6 +20,8 @@ import { ARCHIVE_MANUSCRIPTS, UNARCHIVE_MANUSCRIPTS, UPDATE_MANUSCRIPT, + REVIEWER_RESPONSE, + UPDATE_REVIEWER_STATUS, } from '../../queries' import Link from '../../ui/shared/Link' @@ -47,6 +50,18 @@ const LinkList = styled.div` flex-direction: column; ` +const ActionRow = styled.div` + align-items: center; + display: flex; + + > div { + border-right: 2px solid ${th('colorPrimary')}; + padding-right: ${grid(2)}; + margin-right: ${grid(2)}; + height: ${grid(6)}; + } +` + type Variant = 'submitter' | 'editor' | 'reviewer' | 'admin' type VariantConfig = { @@ -327,6 +342,7 @@ type UseManuscriptsTableResult = { onViewingArchivedChange: (viewingArchived: boolean) => void page: number pageSize: number + reviewerModalContextHolder: ReactNode reviewerStatusViewMode: 'compact' | 'detailed' searchQuery: string selectable: boolean @@ -344,7 +360,9 @@ const useManuscriptsTable = (variant: Variant): UseManuscriptsTableResult => { const { t } = useTranslation() const { groupName } = useParams() const currentUser = useCurrentUser() + const navigate = useNavigate() const [searchParams, setSearchParams] = useSearchParams() + const [reviewerModal, reviewerModalContextHolder] = Modal.useModal() const [reviewerStatusViewMode, setReviewerStatusViewMode] = useState< 'compact' | 'detailed' @@ -443,6 +461,8 @@ const useManuscriptsTable = (variant: Variant): UseManuscriptsTableResult => { const [archiveManuscripts] = useMutation(ARCHIVE_MANUSCRIPTS) const [unarchiveManuscripts] = useMutation(UNARCHIVE_MANUSCRIPTS) const [getManuscriptsData] = useLazyQuery(GET_MANUSCRIPTS_DATA) + const [reviewerRespond] = useMutation(REVIEWER_RESPONSE) + const [updateReviewerStatus] = useMutation(UPDATE_REVIEWER_STATUS) const currentData: any = data ?? previousData const loading = apolloLoading && !currentData @@ -489,8 +509,15 @@ const useManuscriptsTable = (variant: Variant): UseManuscriptsTableResult => { .map((columnName: string) => columnName.trim()) .filter(Boolean) + const baseColumnKeys = + configColumns.length > 0 ? configColumns : defaultColumnKeys + const columnKeys = [ - ...(configColumns.length > 0 ? configColumns : defaultColumnKeys), + ...baseColumnKeys, + ...(variant === 'reviewer' && + !baseColumnKeys.includes('reviewerStatusBadge') + ? ['reviewerStatusBadge'] + : []), 'actions', ] @@ -561,7 +588,95 @@ const useManuscriptsTable = (variant: Variant): UseManuscriptsTableResult => { ) } - return null // TODO: reviewer actions, once that table is migrated + if (variant === 'reviewer') { + const { id, parentId, reviewerStatusBadge, reviewerTeamId } = + record + + const reviewLinkText: Record = { + completed: t('common.View'), + accepted: t('manuscriptsTable.reviewDo'), + inProgress: t('manuscriptsTable.reviewContinue'), + closed: t('common.View'), + } + + if ( + ['accepted', 'completed', 'inProgress', 'closed'].includes( + reviewerStatusBadge, + ) + ) { + const reviewLink = `/${groupName}/versions/${parentId || id}/review` + + return ( + => { + event.preventDefault() + + if (reviewerStatusBadge === 'accepted') { + await updateReviewerStatus({ + variables: { manuscriptId: id, status: 'inProgress' }, + }) + } + + navigate(reviewLink) + }} + to={reviewLink} + > + {reviewLinkText[reviewerStatusBadge]} + + ) + } + + if (reviewerStatusBadge === 'invited') { + const respond = (action: 'accepted' | 'rejected'): void => { + reviewerModal.confirm({ + content: t( + action === 'accepted' + ? 'manuscriptsTable.confirmReviewAccept' + : 'manuscriptsTable.confirmReviewReject', + ), + okText: t('common.OK'), + cancelText: t('common.Cancel'), + onOk: () => { + reviewerRespond({ + variables: { + currentUserId: currentUser.id, + action, + teamId: reviewerTeamId, + }, + }) + }, + }) + } + + return ( + + { + event.preventDefault() + respond('accepted') + }} + to="#" + > + {t('manuscriptsTable.reviewAccept')} + +
+ { + event.preventDefault() + respond('rejected') + }} + to="#" + > + {t('manuscriptsTable.reviewReject')} + +
+ ) + } + + return null + } + + return null }, } } @@ -778,6 +893,13 @@ const useManuscriptsTable = (variant: Variant): UseManuscriptsTableResult => { if (variant === 'reviewer') { row.reviewerStatusBadge = findReviewerStatus(manuscript, currentUser.id) + row.reviewerTeamId = manuscript.teams?.find( + (team: Record) => + ['reviewer', 'collaborativeReviewer'].includes(team.role) && + team.members?.some( + (member: Record) => member.user.id === currentUser.id, + ), + )?.id } if (variant === 'editor') { @@ -917,6 +1039,7 @@ const useManuscriptsTable = (variant: Variant): UseManuscriptsTableResult => { onViewingArchivedChange: handleViewingArchivedChange, page: Number(page), pageSize, + reviewerModalContextHolder, reviewerStatusViewMode, searchQuery: currentSearchQuery ?? '', selectable: variant === 'admin', diff --git a/packages/client/app/ui/shared/ManuscriptsTable.tsx b/packages/client/app/ui/shared/ManuscriptsTable.tsx index 38f456bb0..cfe08e91f 100644 --- a/packages/client/app/ui/shared/ManuscriptsTable.tsx +++ b/packages/client/app/ui/shared/ManuscriptsTable.tsx @@ -170,6 +170,7 @@ type ManuscriptsTableProps = { showViewArchivedToggle?: boolean viewingArchived?: boolean onViewingArchivedChange?: (viewingArchived: boolean) => void + reviewerModalContextHolder?: ReactNode } const renderDate = (value: any): ReactNode => { @@ -1061,6 +1062,7 @@ const ManuscriptsTable = ({ showViewArchivedToggle = false, viewingArchived = false, onViewingArchivedChange, + reviewerModalContextHolder, }: ManuscriptsTableProps): ReactNode => { const { t } = useTranslation() const [modal, modalContextHolder] = Modal.useModal() @@ -1247,6 +1249,7 @@ const ManuscriptsTable = ({ return ( <> {modalContextHolder} + {reviewerModalContextHolder} {showSelectionActions && ( {selectable && (showArchiveActions || showDownloadAction) && ( diff --git a/packages/server/controllers/manuscript/manuscript.controllers.js b/packages/server/controllers/manuscript/manuscript.controllers.js index a242733f0..fe3406798 100644 --- a/packages/server/controllers/manuscript/manuscript.controllers.js +++ b/packages/server/controllers/manuscript/manuscript.controllers.js @@ -973,7 +973,8 @@ const manuscriptsUserHasCurrentRoleIn = async ( t.members.some( member => member.userId === userId && - (!reviewerStatus || member.status === reviewerStatus), + (!reviewerStatus || + reviewerStatus.split(',').includes(member.status)), ) ) rolesFound.add(t.role) From b14a89b7efe059e672fd7027e167c4f5a4e4555e Mon Sep 17 00:00:00 2001 From: yannis Date: Mon, 24 Aug 2026 12:57:34 +0300 Subject: [PATCH 31/51] feat(client): replace editor table with new ui --- .../src/components/DashboardEditsPage.jsx | 93 +-------------- .../src/components/sections/EditorTable.jsx | 106 ++++-------------- packages/client/app/i18n/en/translation.js | 2 + packages/client/app/i18n/es-la/translation.js | 2 + packages/client/app/i18n/fr/translation.js | 2 + packages/client/app/i18n/ru/translation.js | 2 + .../app/pages/hooks/useManuscriptsTable.tsx | 41 +++++-- packages/client/app/ui/base/Icons.tsx | 1 + .../client/app/ui/shared/ManuscriptsTable.tsx | 47 +++++++- 9 files changed, 109 insertions(+), 187 deletions(-) diff --git a/packages/client/app/components/component-dashboard/src/components/DashboardEditsPage.jsx b/packages/client/app/components/component-dashboard/src/components/DashboardEditsPage.jsx index ca3b36eb0..2b540acb2 100644 --- a/packages/client/app/components/component-dashboard/src/components/DashboardEditsPage.jsx +++ b/packages/client/app/components/component-dashboard/src/components/DashboardEditsPage.jsx @@ -1,90 +1,17 @@ /* eslint-disable react-hooks/exhaustive-deps */ -import { useMutation, useQuery } from '@apollo/client/react' -import { useEffect, useContext } from 'react' -import { useLocation } from 'react-router-dom' +import { useMutation } from '@apollo/client/react' +import { useEffect } from 'react' -import { ConfigContext } from '../../../config/src' -import { useCurrentUser } from '../../../../pages/hooks/useCurrentUser' -import { - extractFilters, - extractSortData, - URI_PAGENUM_PARAM, - useQueryParams, -} from '../../../../shared/urlParamUtils' import { UPDATE_TAB, REMOVE_TASK_ALERTS_FOR_CURRENT_USER, - GET_MANUSCRIPTS_FOR_ROLE, - UPDATE_MANUSCRIPT, } from '../../../../queries' import EditorTable from './sections/EditorTable' -import { CommsErrorBanner, Spinner } from '../../../shared' const DashboardEditsPage = () => { - const location = useLocation() - const config = useContext(ConfigContext) - const wantedRoles = ['seniorEditor', 'handlingEditor', 'editor'] - - const currentUser = useCurrentUser() - - const applyQueryParams = useQueryParams() - - const uriQueryParams = new URLSearchParams(location.search) - const page = uriQueryParams.get(URI_PAGENUM_PARAM) || 1 - const sortName = extractSortData(uriQueryParams).name - const sortDirection = extractSortData(uriQueryParams).direction - const filters = extractFilters(uriQueryParams) - - const limit = config?.manuscript?.paginationCount || 10 - - const { data, loading, error } = useQuery(GET_MANUSCRIPTS_FOR_ROLE, { - variables: { - wantedRoles, - sort: sortName - ? { field: sortName, isAscending: sortDirection === 'ASC' } - : null, - offset: (page - 1) * limit, - limit, - filters, - timezoneOffsetMinutes: new Date().getTimezoneOffset(), - groupId: config.groupId, - searchInAllVersions: false, - }, - fetchPolicy: 'network-only', - }) - const [updateTab] = useMutation(UPDATE_TAB) - const [update] = useMutation(UPDATE_MANUSCRIPT) - const [doUpdateManuscript] = useMutation(UPDATE_MANUSCRIPT) - - const setReadyToEvaluateLabels = id => { - update({ - variables: { - id, - input: JSON.stringify({ - submission: { - $customStatus: 'readyToEvaluate', - }, - }), - }, - }) - } - - const unsetCustomStatus = id => { - update({ - variables: { - id, - input: JSON.stringify({ - submission: { - $customStatus: null, - }, - }), - }, - }) - } - const [removeTaskAlertsForCurrentUser] = useMutation( REMOVE_TASK_ALERTS_FOR_CURRENT_USER, ) @@ -98,21 +25,7 @@ const DashboardEditsPage = () => { removeTaskAlertsForCurrentUser() }, []) - if (loading) return - if (error) return - - return ( - - ) + return } export default DashboardEditsPage diff --git a/packages/client/app/components/component-dashboard/src/components/sections/EditorTable.jsx b/packages/client/app/components/component-dashboard/src/components/sections/EditorTable.jsx index 8cb4b6cce..e70ae5a65 100644 --- a/packages/client/app/components/component-dashboard/src/components/sections/EditorTable.jsx +++ b/packages/client/app/components/component-dashboard/src/components/sections/EditorTable.jsx @@ -1,105 +1,37 @@ -/* eslint-disable react/prop-types */ - -import { useMemo, useContext } from 'react' import { useTranslation } from 'react-i18next' -import ManuscriptsTable from '../../../../component-manuscripts-table/src/ManuscriptsTable' -import buildColumnDefinitions from '../../../../component-manuscripts-table/src/util/buildColumnDefinitions' +import styled from 'styled-components' +import { grid } from '@coko/client' + +import useManuscriptsTable from '../../../../../pages/hooks/useManuscriptsTable' import { - Pagination, - PaginationContainerShadowed, SectionContent, SectionHeader, Title, + CommsErrorBanner, + Spinner, } from '../../../../shared' -import { editorColumns } from '../../../../../../config/journal/manuscripts' -import { - extractSortData, - URI_PAGENUM_PARAM, - URI_SEARCH_PARAM, -} from '../../../../../shared/urlParamUtils' -import { ConfigContext } from '../../../../config/src' - -const EditorTable = ({ - currentUser, - doUpdateManuscript, - manuscriptsUserHasCurrentRoleIn, - submissionForm, - unsetCustomStatus, - setReadyToEvaluateLabels, - uriQueryParams, - applyQueryParams, -}) => { - const config = useContext(ConfigContext) - const { urlFrag } = config - - const fieldDefinitions = useMemo(() => { - const fields = submissionForm?.structure?.children ?? [] - const defs = {} - fields.forEach(field => { - // Incomplete fields in the formbuilder may not have a name specified. Ignore these - if (field.name) defs[field.name] = field - }) - return defs - }, [submissionForm]) - - const currentSearchQuery = uriQueryParams.get(URI_SEARCH_PARAM) - const sortName = extractSortData(uriQueryParams).name - const sortDirection = extractSortData(uriQueryParams).direction - - const page = uriQueryParams.get(URI_PAGENUM_PARAM) || 1 - const limit = config?.manuscript?.paginationCount || 10 - const { totalCount } = manuscriptsUserHasCurrentRoleIn +import ManuscriptsTable from '../../../../../ui/shared/ManuscriptsTable' - const setReadyToEvaluateLabel = id => { - return setReadyToEvaluateLabels(id) - } - - const specialComponentValues = { - urlFrag, - currentUser, - setReadyToEvaluateLabel, - unsetCustomStatus, - } - - const displayProps = { - uriQueryParams, - columnToSortOn: sortName, - sortDirection, - currentSearchQuery, - } - - const rColumn = (config.dashboard?.editingQueue || []).map(tc => tc.value) - - const columnsProps = buildColumnDefinitions( - config, - editorColumns(rColumn), - fieldDefinitions, - specialComponentValues, - displayProps, - doUpdateManuscript, - ) +const TableWrapper = styled.div` + padding: ${grid(3)} ${grid(2)}; +` +const EditorTable = () => { const { t } = useTranslation() + const { loading, error, ...tableProps } = useManuscriptsTable('editor') + + if (loading) return + if (error) return return ( {t("dashboardPage.Manuscripts I'm editor of")} - - applyQueryParams({ [URI_PAGENUM_PARAM]: newPage })} - totalCount={totalCount} - /> + + + + ) } diff --git a/packages/client/app/i18n/en/translation.js b/packages/client/app/i18n/en/translation.js index 72d64c5c3..22d839426 100644 --- a/packages/client/app/i18n/en/translation.js +++ b/packages/client/app/i18n/en/translation.js @@ -211,6 +211,8 @@ const en = { confirmReviewAccept: 'Accept this review invitation?', confirmReviewReject: 'Decline this review invitation?', detailedView: 'Detailed view', + reviewerStatusColumnTip: + 'Each block represents a reviewer and the color represents their status. Hover over the blocks for details.', reviewAccept: 'Accept', reviewReject: 'Decline', reviewDo: 'Do Review', diff --git a/packages/client/app/i18n/es-la/translation.js b/packages/client/app/i18n/es-la/translation.js index feb96a72c..34a04fe83 100644 --- a/packages/client/app/i18n/es-la/translation.js +++ b/packages/client/app/i18n/es-la/translation.js @@ -211,6 +211,8 @@ const esLa = { confirmReviewAccept: '¿Aceptar esta invitación de revisión?', confirmReviewReject: '¿Rechazar esta invitación de revisión?', detailedView: 'Vista detallada', + reviewerStatusColumnTip: + 'Cada bloque representa a un revisor y el color representa su estado. Pasa el cursor sobre los bloques para ver más detalles.', reviewAccept: 'Aceptar', reviewReject: 'Rechazar', reviewDo: 'Hacer Revisión', diff --git a/packages/client/app/i18n/fr/translation.js b/packages/client/app/i18n/fr/translation.js index 8cc237df5..ea8351b67 100644 --- a/packages/client/app/i18n/fr/translation.js +++ b/packages/client/app/i18n/fr/translation.js @@ -211,6 +211,8 @@ const fr = { confirmReviewAccept: 'Accepter cette invitation à évaluer ?', confirmReviewReject: 'Refuser cette invitation à évaluer ?', detailedView: 'Vue détaillée', + reviewerStatusColumnTip: + 'Chaque bloc représente un évaluateur et la couleur représente son statut. Survolez les blocs pour plus de détails.', reviewAccept: 'Accepter', reviewReject: 'Rejeter', reviewDo: 'Faire la révision', diff --git a/packages/client/app/i18n/ru/translation.js b/packages/client/app/i18n/ru/translation.js index c26c3d9fa..7ced8c4e2 100644 --- a/packages/client/app/i18n/ru/translation.js +++ b/packages/client/app/i18n/ru/translation.js @@ -211,6 +211,8 @@ const ru = { confirmReviewAccept: 'Принять приглашение на рецензирование?', confirmReviewReject: 'Отклонить приглашение на рецензирование?', detailedView: 'Подробный вид', + reviewerStatusColumnTip: + 'Каждый блок представляет рецензента, а цвет — его статус. Наведите курсор на блок, чтобы увидеть подробности.', pagination_few: `Показано {{lastResult}} из {{totalCount}} результатов`, pagination_many: `Показано {{lastResult}} из {{totalCount}} результатов`, reviewAccept: 'Принять', diff --git a/packages/client/app/pages/hooks/useManuscriptsTable.tsx b/packages/client/app/pages/hooks/useManuscriptsTable.tsx index e49d8e154..5335e275a 100644 --- a/packages/client/app/pages/hooks/useManuscriptsTable.tsx +++ b/packages/client/app/pages/hooks/useManuscriptsTable.tsx @@ -68,6 +68,7 @@ type VariantConfig = { roles: string[] configColumnsPath: string defaultColumnKeys: string[] + forcedColumnKeys: string[] } const VARIANT_CONFIG: Record = { @@ -81,6 +82,7 @@ const VARIANT_CONFIG: Record = { 'created', 'updated', ], + forcedColumnKeys: [], }, reviewer: { roles: [ @@ -97,6 +99,7 @@ const VARIANT_CONFIG: Record = { ], configColumnsPath: 'dashboard.tableColumns', defaultColumnKeys: ['shortId', 'submission.$title', 'reviewerStatusBadge'], + forcedColumnKeys: ['reviewerStatusBadge'], }, editor: { roles: ['seniorEditor', 'handlingEditor', 'editor'], @@ -109,6 +112,7 @@ const VARIANT_CONFIG: Record = { 'statusCounts', 'lastUpdated', ], + forcedColumnKeys: ['statusCounts', 'lastUpdated'], }, admin: { roles: [], @@ -122,6 +126,7 @@ const VARIANT_CONFIG: Record = { 'submission.$customStatus', 'author', ], + forcedColumnKeys: [], }, } @@ -130,7 +135,6 @@ const columnAlignments: Record = { reviewerStatusBadge: 'center', shortId: 'center', status: 'center', - statusCounts: 'center', 'submission.$doi': 'center', 'submission.adaState': 'center', } @@ -311,6 +315,22 @@ const reviewerStatusEntriesFor = ( ] } +const lastReviewerUpdateFor = ( + manuscript: Record, +): string | undefined => { + const updatedTimes = ( + (manuscript.teams ?? []).find( + (team: Record) => team.role === 'reviewer', + )?.members ?? [] + ).map((member: Record) => member.updated) + + if (updatedTimes.length === 0) return undefined + + return updatedTimes.reduce((max: string, current: string) => + new Date(current) > new Date(max) ? current : max, + ) +} + /** * Translate the table's 'yyyy-MM-dd' format to the server's date-range filter * format 'yyyyMMdd'. And vice versa. @@ -369,7 +389,7 @@ const useManuscriptsTable = (variant: Variant): UseManuscriptsTableResult => { >(readReviewerStatusViewMode) // #region definitions - const { roles, configColumnsPath, defaultColumnKeys } = + const { roles, configColumnsPath, defaultColumnKeys, forcedColumnKeys } = VARIANT_CONFIG[variant] const searchInAllVersions = variant === 'reviewer' const authorProofingEnabled = config.controlPanel?.authorProofingEnabled @@ -514,10 +534,7 @@ const useManuscriptsTable = (variant: Variant): UseManuscriptsTableResult => { const columnKeys = [ ...baseColumnKeys, - ...(variant === 'reviewer' && - !baseColumnKeys.includes('reviewerStatusBadge') - ? ['reviewerStatusBadge'] - : []), + ...forcedColumnKeys.filter(key => !baseColumnKeys.includes(key)), 'actions', ] @@ -692,6 +709,10 @@ const useManuscriptsTable = (variant: Variant): UseManuscriptsTableResult => { return { ...column, dataType: 'date', sortable: true, filterable: true } } + if (column.key === 'lastUpdated') { + return { ...column, dataType: 'date' } + } + if ( column.key === 'submission.$title' || column.key === 'titleAndAbstract' @@ -717,7 +738,11 @@ const useManuscriptsTable = (variant: Variant): UseManuscriptsTableResult => { } if (column.key === 'statusCounts') { - return { ...column, dataType: 'reviewerStatusSummary' } + return { + ...column, + dataType: 'reviewerStatusSummary', + helpTooltip: t('manuscriptsTable.reviewerStatusColumnTip'), + } } if (column.key === 'author' || column.key === 'submitter') { @@ -869,6 +894,7 @@ const useManuscriptsTable = (variant: Variant): UseManuscriptsTableResult => { row.published = manuscript.published row.status = manuscript.status row.searchSnippets = manuscript.searchSnippets + row.manuscriptVersions = (manuscript.manuscriptVersions?.length ?? 0) + 1 if (variant === 'admin') { row.archived = isArchived @@ -904,6 +930,7 @@ const useManuscriptsTable = (variant: Variant): UseManuscriptsTableResult => { if (variant === 'editor') { row.statusCounts = reviewerStatusEntriesFor(manuscript) + row.lastUpdated = lastReviewerUpdateFor(manuscript) } return row diff --git a/packages/client/app/ui/base/Icons.tsx b/packages/client/app/ui/base/Icons.tsx index 97387d50f..7055bfbf3 100644 --- a/packages/client/app/ui/base/Icons.tsx +++ b/packages/client/app/ui/base/Icons.tsx @@ -10,6 +10,7 @@ export { FormOutlined as Form, HomeOutlined as Home, InfoCircleOutlined as Info, + QuestionCircleOutlined as Help, ReadOutlined as Book, TableOutlined as Table, UnorderedListOutlined as Tasks, diff --git a/packages/client/app/ui/shared/ManuscriptsTable.tsx b/packages/client/app/ui/shared/ManuscriptsTable.tsx index cfe08e91f..2fdf146ac 100644 --- a/packages/client/app/ui/shared/ManuscriptsTable.tsx +++ b/packages/client/app/ui/shared/ManuscriptsTable.tsx @@ -36,6 +36,7 @@ import Avatar from './Avatar' import { Close, Coar, + Help, Info, SemanticScholar, Table as TableIcon, @@ -96,6 +97,11 @@ export type ManuscriptsTableColumn = { */ render?: (value: any, record: any) => ReactNode sortable?: boolean + /** + * Shows a question-mark icon next to the column header, with this text in a + * click-to-opne tooltip. + */ + helpTooltip?: string } export type ManuscriptsTableSortState = { @@ -705,6 +711,29 @@ const ReviewerStatusColumnHeaderWrapper = styled.div` gap: ${grid(2)}; ` +const ColumnTitleWithTooltipWrapper = styled.span` + align-items: center; + display: inline-flex; + gap: ${grid(2)}; +` + +const ColumnTitleWithTooltip = ({ + title, + tooltip, +}: { + title: ReactNode + tooltip: string +}): ReactNode => ( + + {title} + + + + + + +) + const RadioGroup = styled(RadioGroupInput)` /* stylelint-disable declaration-no-important */ .ant-radio-button-wrapper { @@ -734,7 +763,7 @@ const ReviewerStatusColumnHeader = ({ isCompact, onChange, }: { - title: string + title: ReactNode isCompact: boolean onChange: (isCompact: boolean) => void }): ReactNode => { @@ -742,7 +771,7 @@ const ReviewerStatusColumnHeader = ({ return ( - {title} + {title} onChange(value === 'compact')} @@ -868,6 +897,18 @@ const resolveColumn = ( } } + if (column.helpTooltip) { + resolved = { + ...resolved, + title: ( + + ), + } + } + if (column.dataType === 'reviewerStatusSummary') { resolved = { ...resolved, @@ -881,7 +922,7 @@ const resolveColumn = ( isCompact ? 'compact' : 'detailed', ) } - title={column.title} + title={resolved.title} /> ), } From e7a381785d4dbbd78b0bf8e91f0d49e82236a8f5 Mon Sep 17 00:00:00 2001 From: yannis Date: Tue, 25 Aug 2026 11:04:43 +0300 Subject: [PATCH 32/51] feat(client): use new manuscripts table for admin manuscripts --- .../component-manuscripts/src/Manuscripts.jsx | 468 +++--------------- .../src/ManuscriptsPage.jsx | 230 +-------- packages/client/app/i18n/en/translation.js | 1 + packages/client/app/i18n/es-la/translation.js | 1 + packages/client/app/i18n/fr/translation.js | 1 + packages/client/app/i18n/ru/translation.js | 1 + .../app/pages/hooks/useManuscriptsTable.tsx | 112 ++++- .../client/app/ui/shared/ManuscriptsTable.tsx | 6 +- 8 files changed, 182 insertions(+), 638 deletions(-) diff --git a/packages/client/app/components/component-manuscripts/src/Manuscripts.jsx b/packages/client/app/components/component-manuscripts/src/Manuscripts.jsx index 3519b212a..738ea1163 100644 --- a/packages/client/app/components/component-manuscripts/src/Manuscripts.jsx +++ b/packages/client/app/components/component-manuscripts/src/Manuscripts.jsx @@ -1,45 +1,28 @@ /* eslint-disable react/prop-types */ -/* eslint-disable no-shadow */ import { useContext, useState } from 'react' -import { useNavigate } from 'react-router-dom' +import { useNavigate, useParams } from 'react-router-dom' import styled from 'styled-components' -import { Trans, useTranslation } from 'react-i18next' +import { useTranslation } from 'react-i18next' import { ToastContainer } from 'react-toastify' import 'react-toastify/dist/ReactToastify.css' import { grid } from '@coko/client' import Page from '../../../ui/shared/Page' +import ManuscriptsTable from '../../../ui/shared/ManuscriptsTable' +import useManuscriptsTable from '../../../pages/hooks/useManuscriptsTable' -import { Checkbox, Dropdown } from '../../pubsweet' - -import { validateManuscriptSubmission } from '../../../shared/manuscriptUtils' -import { - URI_PAGENUM_PARAM, - URI_SEARCH_PARAM, -} from '../../../shared/urlParamUtils' import MessageContainer from '../../component-chat/src/MessageContainer' -import ManuscriptsTable from '../../component-manuscripts-table/src/ManuscriptsTable' -import buildColumnDefinitions from '../../component-manuscripts-table/src/util/buildColumnDefinitions' -import { ConfirmationModal } from '../../component-modal/src/ConfirmationModal' import { ActionButton, Columns, CommsErrorBanner, Container, - Pagination, - PaginationContainerShadowed, RoundIconButton, ScrollableContent, Spinner, } from '../../shared' -import SearchControl from './SearchControl' -import { - ControlsContainer, - SelectAllField, - SelectedManuscriptsNumber, - ViewArchivedAction, -} from './style' +import { ControlsContainer } from './style' import { ConfigContext } from '../../config/src' const OuterContainer = styled(Container)` @@ -61,12 +44,11 @@ const FlexRow = styled.div` display: flex; gap: ${grid(2)}; justify-content: flex-end; + margin-bottom: ${grid(2)}; ` -const FlexRowWithSmallGapAbove = styled(FlexRow)` - align-items: end; - justify-content: flex-start; - margin-bottom: ${grid(2)}; +const TableWrapper = styled.div` + padding: ${grid(3)} ${grid(2)}; ` const RoundIconButtonWrapper = styled(RoundIconButton).attrs({ @@ -75,249 +57,32 @@ const RoundIconButtonWrapper = styled(RoundIconButton).attrs({ position: sticky; ` -const DropdownContainer = styled.div.attrs({ - 'data-testid': 'manuscripts-dropdown-container', -})` - width: 100px; - - button { - background: ${props => - props.disabled - ? props.theme.color.gray90 - : props.theme.color.brand1.base}; - color: ${props => - props.disabled ? props.theme.color.gray60 : props.theme.color.white}; - line-height: calc(8px * 2); - min-width: calc(8px * 10); - padding: 5px 4px; - - span { - float: none; - font-size: 14px; - } - } - - ul { - width: 90px; - - li { - font-size: 14px; - } - } - - svg { - height: calc(3 * 6px); - margin-top: -5px; - stroke: ${props => - props.disabled ? props.theme.color.gray60 : props.theme.color.white}; - width: calc(3 * 6px); - } -` - -const Manuscripts = props => { - const { - applyQueryParams, - validateDoi, - validateSuffix, - setReadyToEvaluateLabels, - deleteManuscriptMutations, - hideManuscriptsChat, - importManuscripts, - isImporting, - publishManuscript, - unsetCustomStatus, - queryObject, - sortDirection, - sortName, - page, - urlFrag, - chatRoomId, - configuredColumnNames, - shouldAllowBulkImport, - archived, - archiveManuscripts, - unarchiveManuscripts, - uriQueryParams, - currentUser, - chatProps, - groupManagerDiscussionChannel, - channels, - doUpdateManuscript, - exportManuscriptsToJson, - chatExpand, - } = props - +const Manuscripts = ({ + hideManuscriptsChat, + importManuscripts, + isImporting, + shouldAllowBulkImport, + currentUser, + chatProps, + groupManagerDiscussionChannel, + channels, + chatRoomId, + chatExpand, +}) => { const navigate = useNavigate() + const { groupName } = useParams() const { t } = useTranslation() - const config = useContext(ConfigContext) const channelData = chatProps?.channelsData?.find( channel => channel?.channelId === groupManagerDiscussionChannel?.id, ) - const [isOpenBulkArchiveModal, setIsOpenBulkArchiveModal] = useState(false) - - const [isOpenBulkUnarchiveModal, setIsOpenBulkUnarchiveModal] = - useState(false) - - const [selectedNewManuscripts, setSelectedNewManuscripts] = useState([]) - const [isAdminChatOpen, setIsAdminChatOpen] = useState( currentUser.chatExpanded, ) - const toggleNewManuscriptCheck = id => { - setSelectedNewManuscripts(s => { - const result = selectedNewManuscripts.includes(id) - ? s.filter(manuscriptId => manuscriptId !== id) - : [...s, id] - - return result - }) - } - - const toggleAllNewManuscriptsCheck = () => { - const newManuscriptsFromCurrentPageIds = manuscripts.map( - manuscript => manuscript.id, - ) - - const isEveryNewManuscriptIsSelectedFromCurrentPage = manuscripts.every( - manuscript => selectedNewManuscripts.includes(manuscript.id), - ) - - setSelectedNewManuscripts(currentSelectedManuscripts => { - const result = isEveryNewManuscriptIsSelectedFromCurrentPage - ? currentSelectedManuscripts.filter(selectedManuscript => { - if (newManuscriptsFromCurrentPageIds.includes(selectedManuscript)) - return false - return true - }) - : [ - ...new Set([ - ...currentSelectedManuscripts, - ...manuscripts.map(manuscript => manuscript.id), - ]), - ] - - return result - }) - } - - const limit = config?.manuscript?.paginationCount - - const { loading, error, data } = queryObject - - const deleteManuscript = id => deleteManuscriptMutations(id) - - const tryPublishManuscript = async manuscript => { - let result = null - - const hasInvalidFields = await validateManuscriptSubmission( - manuscript.submission, - data.formForPurposeAndCategory?.structure, - validateDoi, - validateSuffix, - ) - - if (hasInvalidFields.filter(Boolean).length) { - result = [ - { - stepLabel: 'publishing', - errorMessage: t('manuscriptsPage.manuscriptInvalid'), - }, - ] - } else { - result = (await publishManuscript(manuscript.id)).data.publishManuscript - } - - return result - } - - if (loading) return - if (error) return - - const manuscripts = data.paginatedManuscripts.manuscripts.map(m => { - return { - ...m, - submission: JSON.parse(m.submission), - manuscriptVersions: m.manuscriptVersions?.map(v => ({ - ...v, - submission: JSON.parse(v.submission), - })), - } - }) - - const fieldDefinitions = {} - const fields = data.formForPurposeAndCategory?.structure?.children ?? [] - fields.forEach(field => { - if (field.name) fieldDefinitions[field.name] = field // Incomplete fields in the formbuilder may not have a name specified. Ignore these - }) - - const { totalCount } = data.paginatedManuscripts - - const setReadyToEvaluateLabel = id => { - if (selectedNewManuscripts.includes(id)) { - toggleNewManuscriptCheck(id) - } - - return setReadyToEvaluateLabels(id) - } - - // eslint-disable-next-line no-unused-vars - const bulkSetLabelReadyToEvaluate = (selectedNewManuscripts, manuscripts) => { - manuscripts - .filter(manuscript => !selectedNewManuscripts.includes(manuscript.id)) - .forEach(manuscript => { - setReadyToEvaluateLabel(manuscript.id) - }) - } - - const doBulkArchive = () => { - archiveManuscripts(selectedNewManuscripts) - setSelectedNewManuscripts([]) - } - - const doBulkUnarchive = () => { - unarchiveManuscripts(selectedNewManuscripts) - setSelectedNewManuscripts([]) - } - - const currentSearchQuery = uriQueryParams.get(URI_SEARCH_PARAM) - - // Props for instantiating special components - const specialComponentValues = { - deleteManuscript, - tryPublishManuscript, - selectedNewManuscripts, - toggleNewManuscriptCheck, - setReadyToEvaluateLabel, - unsetCustomStatus, - urlFrag, - } - - // Props for filtering / sorting - const displayProps = { - uriQueryParams, - columnToSortOn: sortName, - sortDirection, - currentSearchQuery, - } - - const adjustedColumnNames = [...configuredColumnNames] - adjustedColumnNames.push('actions') - adjustedColumnNames.splice(0, 0, 'rowItemCheckbox') - - // Source of truth for columns - const columnsProps = buildColumnDefinitions( - config, - adjustedColumnNames, - fieldDefinitions, - specialComponentValues, - displayProps, - doUpdateManuscript, - archived, - ) + const { loading, error, ...tableProps } = useManuscriptsTable('admin') const hideChat = async () => { try { @@ -331,88 +96,18 @@ const Manuscripts = props => { }) await Promise.all(dataRefetchPromises) - } catch (error) { - console.error('Error hiding chat:', error) + } catch (hideChatError) { + console.error('Error hiding chat:', hideChatError) } } - const topRightControls = ( - - {config?.manuscript?.newSubmission && !archived && ( - navigate(`${urlFrag}/newSubmission`)} - primary - > - {t('dashboardPage.New submission')} - - )} - {shouldAllowBulkImport && !archived && ( - - {isImporting - ? t('manuscriptsPage.Refreshing') - : t('manuscriptsPage.Refresh')} - - )} - - ) - - const searchAndChatControls = ( - - - applyQueryParams({ - [URI_SEARCH_PARAM]: newQuery, - [URI_PAGENUM_PARAM]: 1, - }) - } - currentSearchQuery={currentSearchQuery} - /> - {!isAdminChatOpen && !hideManuscriptsChat && ( - { - setIsAdminChatOpen(true) - chatExpand({ variables: { state: true } }) - }} - title={t('chat.Show group manager discussion')} - unreadMessagesCount={channelData?.unreadMessagesCount} - /> - )} - - ) - - const toggleArchived = () => { - applyQueryParams({ archived: !archived }) - } - - const actionDropDownOptions = [ - archived - ? { - id: 1, - onClick: () => setIsOpenBulkUnarchiveModal(true), - title: t('manuscriptsPage.Unarchive'), - } - : { - id: 1, - onClick: () => setIsOpenBulkArchiveModal(true), - title: t('manuscriptsPage.Archive'), - }, - { - id: 2, - onClick: () => { - exportManuscriptsToJson(selectedNewManuscripts) - }, - title: t('manuscriptsPage.exportAsJson'), - }, - ] + if (loading) return + if (error) return return ( { /> - {topRightControls} - - - - - selectedNewManuscripts.includes(manuscript.id), + + + {config?.manuscript?.newSubmission && + !tableProps.viewingArchived && ( + navigate(`/${groupName}/newSubmission`)} + primary + > + {t('dashboardPage.New submission')} + )} - label={t('manuscriptsPage.Select All')} - onChange={toggleAllNewManuscriptsCheck} - /> - - + {isImporting + ? t('manuscriptsPage.Refreshing') + : t('manuscriptsPage.Refresh')} + + )} + {!isAdminChatOpen && !hideManuscriptsChat && ( + { + setIsAdminChatOpen(true) + chatExpand({ variables: { state: true } }) + }} + title={t('chat.Show group manager discussion')} + unreadMessagesCount={channelData?.unreadMessagesCount} /> - - - - {t('manuscriptsPage.takeAction')} - - - - - {t( - archived - ? 'manuscriptsPage.viewUnarchived' - : 'manuscriptsPage.viewArchived', )} - - {searchAndChatControls} - - -
- - - - - applyQueryParams({ [URI_PAGENUM_PARAM]: newPage }) - } - totalCount={totalCount} - /> -
+ + + + + + + +
- {/* Group Manager Discussion, Video Chat, Hide Chat, Chat component */} {isAdminChatOpen && !hideManuscriptsChat && ( { /> )}
- setIsOpenBulkArchiveModal(false)} - confirmationAction={doBulkArchive} - isOpen={isOpenBulkArchiveModal} - message={t('manuscriptsPage.confirmArchive')} - /> - setIsOpenBulkUnarchiveModal(false)} - confirmationAction={doBulkUnarchive} - isOpen={isOpenBulkUnarchiveModal} - message={t('manuscriptsPage.confirmUnarchive')} - />
) diff --git a/packages/client/app/components/component-manuscripts/src/ManuscriptsPage.jsx b/packages/client/app/components/component-manuscripts/src/ManuscriptsPage.jsx index 44218053b..514b15719 100644 --- a/packages/client/app/components/component-manuscripts/src/ManuscriptsPage.jsx +++ b/packages/client/app/components/component-manuscripts/src/ManuscriptsPage.jsx @@ -1,88 +1,28 @@ -/* eslint-disable react-hooks/immutability */ - -/* eslint-disable no-shadow */ - -import { useState, useContext, useRef, useEffect } from 'react' -import { useLocation } from 'react-router-dom' +import { useState, useContext } from 'react' import { toast } from 'react-toastify' import 'react-toastify/dist/ReactToastify.css' import { useTranslation } from 'react-i18next' -import { - useQuery, - useMutation, - useSubscription, - useApolloClient, - useLazyQuery, -} from '@apollo/client/react' +import { useQuery, useMutation, useSubscription } from '@apollo/client/react' import fnv from 'fnv-plus' import { ConfigContext } from '../../config/src' import { - GET_ALL_MANUSCRIPTS, - DELETE_MANUSCRIPT, IMPORT_MANUSCRIPTS, IMPORTED_MANUSCRIPTS, GET_SYSTEM_WIDE_DISCUSSION_CHANNEL, - ARCHIVE_MANUSCRIPTS, - GET_MANUSCRIPTS_DATA, - UNARCHIVE_MANUSCRIPTS, - PUBLISH_MANUSCRIPT, EXPAND_CHAT, - UPDATE_MANUSCRIPT, } from '../../../queries' import Manuscripts from './Manuscripts' -import { - extractFilters, - extractSortData, - extractArchived, - URI_PAGENUM_PARAM, - useQueryParams, -} from '../../../shared/urlParamUtils' -import { validateDoi, validateSuffix } from '../../../shared/commsUtils' import useChat from '../../../hooks/useChat' import { useCurrentUser } from '../../../pages/hooks/useCurrentUser' const ManuscriptsPage = () => { - const location = useLocation() const { t } = useTranslation() const currentUser = useCurrentUser() const config = useContext(ConfigContext) - const { urlFrag } = config const chatRoomId = fnv.hash(config.clientUrl).hex() - const [doUpdateManuscript] = useMutation(UPDATE_MANUSCRIPT) - - /** Returns an array of column names, e.g. - * ['shortId', 'created', 'titleAndAbstract', 'submission.topic', 'status'] */ - const configuredColumnNames = (config?.manuscript?.tableColumns || '') - .split(',') - .map(columnName => columnName.trim()) - const [isImporting, setIsImporting] = useState(false) - const applyQueryParams = useQueryParams() - - const uriQueryParams = new URLSearchParams(location.search) - const page = uriQueryParams.get(URI_PAGENUM_PARAM) || 1 - const sortName = extractSortData(uriQueryParams).name - const sortDirection = extractSortData(uriQueryParams).direction - const filters = extractFilters(uriQueryParams) - const archived = extractArchived(uriQueryParams) - const limit = config?.manuscript?.paginationCount || 10 - - const queryObject = useQuery(GET_ALL_MANUSCRIPTS, { - variables: { - sort: sortName - ? { field: sortName, isAscending: sortDirection === 'ASC' } - : null, - offset: (page - 1) * limit, - limit, - filters, - archived, - timezoneOffsetMinutes: new Date().getTimezoneOffset(), - groupId: config.groupId, - }, - fetchPolicy: 'network-only', - }) // GET_SYSTEM_WIDE_DISCUSSION_ID const systemWideDiscussionChannel = useQuery( @@ -101,7 +41,6 @@ const ManuscriptsPage = () => { } = data setIsImporting(false) - applyQueryParams({ [URI_PAGENUM_PARAM]: 1 }) toast.success( manuscriptsImportStatus && 'Manuscripts successfully imported', @@ -122,154 +61,8 @@ const ManuscriptsPage = () => { }) } - // Used Lazy Query, as it's fetching data upon a particular trigger - const [getManuscriptsData, { loading, error, data, refetch }] = - useLazyQuery(GET_MANUSCRIPTS_DATA) - - const selectedNewManuscriptsRef = useRef([]) - - // Once export action is selected, the following block would trigger query to fetch data from the backend. - const exportManuscriptsToJson = async selectedNewManuscripts => { - try { - selectedNewManuscriptsRef.current = selectedNewManuscripts - - if (selectedNewManuscripts) { - if (refetch) { - const refetchData = await refetch({ - selectedManuscripts: selectedNewManuscripts, - }) - - const extractedData = refetchData.data.getManuscriptsData.map( - /* eslint-disable-next-line no-unused-vars */ - ({ __typename, ...rest }) => rest, - ) - - downloadJSON(extractedData, 'exportedData.json') - } else { - // If refetch is not available, execute the query again - getManuscriptsData({ - variables: { selectedManuscripts: selectedNewManuscripts }, - }) - } - } - } catch (error) { - console.error('Error exporting manuscripts:', error) - } - } - - // To respond to the JSON data fetched when export is selected as action - useEffect(() => { - if (!loading && !error && data) { - const extractedData = data.getManuscriptsData.map( - /* eslint-disable-next-line no-unused-vars */ - ({ __typename, ...rest }) => rest, - ) - - downloadJSON(extractedData, 'exportedData.json') - } - }, [loading, error, data]) - - // Function to download the JSON data - const downloadJSON = (data, fileName) => { - const jsonBlob = new Blob([JSON.stringify(data)], { - type: 'application/json', - }) - - const url = URL.createObjectURL(jsonBlob) - const link = document.createElement('a') - link.href = url - link.download = fileName - link.click() - URL.revokeObjectURL(url) - } - - const [doArchiveManuscripts] = useMutation(ARCHIVE_MANUSCRIPTS, { - update(cache, { data: { ids } }) { - const cacheIds = cache.identify({ - __typename: 'Manuscript', - id: ids, - }) - - cache.evict({ cacheIds }) - }, - }) - - const [doUnarchiveManuscripts] = useMutation(UNARCHIVE_MANUSCRIPTS, { - update(cache, { data: { ids } }) { - const cacheIds = cache.identify({ - __typename: 'Manuscript', - id: ids, - }) - - cache.evict({ cacheIds }) - }, - }) - - const [deleteManuscriptMutation] = useMutation(DELETE_MANUSCRIPT, { - update(cache, { data: { id } }) { - const cacheId = cache.identify({ - __typename: 'Manuscript', - id, - }) - - cache.evict({ cacheId }) - }, - }) - - const deleteManuscriptMutations = id => { - deleteManuscriptMutation({ variables: { id } }) - } - - const setReadyToEvaluateLabels = id => { - update({ - variables: { - id, - input: JSON.stringify({ - submission: { - $customStatus: 'readyToEvaluate', - }, - }), - }, - }) - } - - const unsetCustomStatus = id => { - update({ - variables: { - id, - input: JSON.stringify({ - submission: { - $customStatus: null, - }, - }), - }, - }) - } - - const archiveManuscripts = selectedNewManuscript => { - doArchiveManuscripts({ - variables: { ids: selectedNewManuscript }, - }) - } - - const unarchiveManuscripts = selectedNewManuscript => { - doUnarchiveManuscripts({ - variables: { ids: selectedNewManuscript }, - }) - } - - const [update] = useMutation(UPDATE_MANUSCRIPT) const [chatExpand] = useMutation(EXPAND_CHAT) - const [doPublishManuscript] = useMutation(PUBLISH_MANUSCRIPT) - const client = useApolloClient() - - const publishManuscript = async manuscriptId => { - return doPublishManuscript({ - variables: { id: manuscriptId }, - }) - } - const shouldAllowBulkImport = config?.manuscript?.manualImport const groupManagerDiscussionChannel = @@ -294,35 +87,16 @@ const ManuscriptsPage = () => { return ( ) } diff --git a/packages/client/app/i18n/en/translation.js b/packages/client/app/i18n/en/translation.js index 22d839426..6a2577b8e 100644 --- a/packages/client/app/i18n/en/translation.js +++ b/packages/client/app/i18n/en/translation.js @@ -210,6 +210,7 @@ const en = { confirmUnarchive: 'Unarchive {{count}} manuscripts?', confirmReviewAccept: 'Accept this review invitation?', confirmReviewReject: 'Decline this review invitation?', + confirmPublish: 'Publish this manuscript?', detailedView: 'Detailed view', reviewerStatusColumnTip: 'Each block represents a reviewer and the color represents their status. Hover over the blocks for details.', diff --git a/packages/client/app/i18n/es-la/translation.js b/packages/client/app/i18n/es-la/translation.js index 34a04fe83..441630b2c 100644 --- a/packages/client/app/i18n/es-la/translation.js +++ b/packages/client/app/i18n/es-la/translation.js @@ -210,6 +210,7 @@ const esLa = { confirmUnarchive: '¿Desarchivar {{count}} manuscritos?', confirmReviewAccept: '¿Aceptar esta invitación de revisión?', confirmReviewReject: '¿Rechazar esta invitación de revisión?', + confirmPublish: '¿Publicar este manuscrito?', detailedView: 'Vista detallada', reviewerStatusColumnTip: 'Cada bloque representa a un revisor y el color representa su estado. Pasa el cursor sobre los bloques para ver más detalles.', diff --git a/packages/client/app/i18n/fr/translation.js b/packages/client/app/i18n/fr/translation.js index ea8351b67..92477e1c7 100644 --- a/packages/client/app/i18n/fr/translation.js +++ b/packages/client/app/i18n/fr/translation.js @@ -210,6 +210,7 @@ const fr = { confirmUnarchive: 'Désarchiver {{count}} manuscrits ?', confirmReviewAccept: 'Accepter cette invitation à évaluer ?', confirmReviewReject: 'Refuser cette invitation à évaluer ?', + confirmPublish: 'Publier ce manuscrit ?', detailedView: 'Vue détaillée', reviewerStatusColumnTip: 'Chaque bloc représente un évaluateur et la couleur représente son statut. Survolez les blocs pour plus de détails.', diff --git a/packages/client/app/i18n/ru/translation.js b/packages/client/app/i18n/ru/translation.js index 7ced8c4e2..0504be1b9 100644 --- a/packages/client/app/i18n/ru/translation.js +++ b/packages/client/app/i18n/ru/translation.js @@ -210,6 +210,7 @@ const ru = { confirmUnarchive: 'Разархивировать {{count}} рукописей?', confirmReviewAccept: 'Принять приглашение на рецензирование?', confirmReviewReject: 'Отклонить приглашение на рецензирование?', + confirmPublish: 'Опубликовать эту рукопись?', detailedView: 'Подробный вид', reviewerStatusColumnTip: 'Каждый блок представляет рецензента, а цвет — его статус. Наведите курсор на блок, чтобы увидеть подробности.', diff --git a/packages/client/app/pages/hooks/useManuscriptsTable.tsx b/packages/client/app/pages/hooks/useManuscriptsTable.tsx index 5335e275a..637b769c4 100644 --- a/packages/client/app/pages/hooks/useManuscriptsTable.tsx +++ b/packages/client/app/pages/hooks/useManuscriptsTable.tsx @@ -5,7 +5,12 @@ import { useMemo, useState, type ReactNode } from 'react' import { useParams, useSearchParams, useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' -import { useQuery, useMutation, useLazyQuery } from '@apollo/client/react' +import { + useQuery, + useMutation, + useLazyQuery, + useApolloClient, +} from '@apollo/client/react' import styled from 'styled-components' import { grid, th, Modal } from '@coko/client' import get from 'lodash/get' @@ -22,6 +27,7 @@ import { UPDATE_MANUSCRIPT, REVIEWER_RESPONSE, UPDATE_REVIEWER_STATUS, + PUBLISH_MANUSCRIPT, } from '../../queries' import Link from '../../ui/shared/Link' @@ -34,6 +40,10 @@ import type { ManuscriptsTableColumn, ManuscriptsTableSortState, } from '../../ui/shared/ManuscriptsTable' +import { articleStatuses } from '../../globals' +import { validateManuscriptSubmission } from '../../shared/manuscriptUtils' +import { validateDoi, validateSuffix } from '../../shared/commsUtils' +import PublishingResponse from '../../components/component-review/src/components/publishing/PublishingResponse' // #region constants const URI_PARAMS = { @@ -345,6 +355,7 @@ const compactDateToIso = (compactDate: string): string => // #endregion helpers type UseManuscriptsTableResult = { + actionModalContextHolder: ReactNode columnFilters: Record columns: ManuscriptsTableColumn[] dataSource: Record[] @@ -362,7 +373,6 @@ type UseManuscriptsTableResult = { onViewingArchivedChange: (viewingArchived: boolean) => void page: number pageSize: number - reviewerModalContextHolder: ReactNode reviewerStatusViewMode: 'compact' | 'detailed' searchQuery: string selectable: boolean @@ -382,7 +392,7 @@ const useManuscriptsTable = (variant: Variant): UseManuscriptsTableResult => { const currentUser = useCurrentUser() const navigate = useNavigate() const [searchParams, setSearchParams] = useSearchParams() - const [reviewerModal, reviewerModalContextHolder] = Modal.useModal() + const [actionModal, actionModalContextHolder] = Modal.useModal() const [reviewerStatusViewMode, setReviewerStatusViewMode] = useState< 'compact' | 'detailed' @@ -483,6 +493,8 @@ const useManuscriptsTable = (variant: Variant): UseManuscriptsTableResult => { const [getManuscriptsData] = useLazyQuery(GET_MANUSCRIPTS_DATA) const [reviewerRespond] = useMutation(REVIEWER_RESPONSE) const [updateReviewerStatus] = useMutation(UPDATE_REVIEWER_STATUS) + const [publishManuscript] = useMutation(PUBLISH_MANUSCRIPT) + const apolloClient = useApolloClient() const currentData: any = data ?? previousData const loading = apolloLoading && !currentData @@ -645,7 +657,7 @@ const useManuscriptsTable = (variant: Variant): UseManuscriptsTableResult => { if (reviewerStatusBadge === 'invited') { const respond = (action: 'accepted' | 'rejected'): void => { - reviewerModal.confirm({ + actionModal.confirm({ content: t( action === 'accepted' ? 'manuscriptsTable.confirmReviewAccept' @@ -693,6 +705,63 @@ const useManuscriptsTable = (variant: Variant): UseManuscriptsTableResult => { return null } + if (variant === 'admin') { + const { id, status, submission, archived: rowArchived } = record + const instanceName = config?.instanceName + + const showEvaluation = + !rowArchived && + ['preprint1', 'preprint2'].includes(instanceName) && + Object.values(articleStatuses).includes(status) + + const showControl = + !rowArchived && ['journal', 'prc'].includes(instanceName) + + const showPublish = + !rowArchived && + ['preprint1', 'preprint2'].includes(instanceName) && + status === articleStatuses.evaluated + + return ( + + {showEvaluation && ( + + {t('manuscriptsTable.actions.Evaluation')} + + )} + {showControl && ( + + {t('manuscriptsTable.actions.Control')} + + )} + + {t('manuscriptsTable.actions.View')} + + {!rowArchived && ( + + {t('manuscriptsTable.actions.Production')} + + )} + {showPublish && ( + { + event.preventDefault() + actionModal.confirm({ + content: t('manuscriptsTable.confirmPublish'), + okText: t('common.OK'), + cancelText: t('common.Cancel'), + onOk: () => handlePublish(id, submission), + }) + }} + to="#" + > + {t('manuscriptsTable.actions.Publish')} + + )} + + ) + } + return null }, } @@ -898,6 +967,7 @@ const useManuscriptsTable = (variant: Variant): UseManuscriptsTableResult => { if (variant === 'admin') { row.archived = isArchived + row.submission = manuscript.submission } if (variant === 'submitter') { @@ -1016,6 +1086,38 @@ const useManuscriptsTable = (variant: Variant): UseManuscriptsTableResult => { refetch() } + const handlePublish = async ( + manuscriptId: string, + submission: Record, + ): Promise => { + const invalidFields = await validateManuscriptSubmission( + submission, + submissionForm?.structure, + validateDoi(apolloClient), + validateSuffix(apolloClient, config.groupId), + ) + + if (invalidFields.filter(Boolean).length > 0) { + actionModal.error({ content: t('manuscriptsPage.manuscriptInvalid') }) + return + } + + const { data: publishData } = await publishManuscript({ + variables: { id: manuscriptId }, + }) + + const response = publishData?.publishManuscript + + if (response?.steps?.some((step: Record) => !step.succeeded)) { + actionModal.error({ + content: , + title: t('manuscriptsTable.actions.Publishing error'), + }) + } + + refetch() + } + const handleDownloadSelected = async (ids: string[]): Promise => { const { data: exportData } = await getManuscriptsData({ variables: { selectedManuscripts: ids }, @@ -1066,7 +1168,7 @@ const useManuscriptsTable = (variant: Variant): UseManuscriptsTableResult => { onViewingArchivedChange: handleViewingArchivedChange, page: Number(page), pageSize, - reviewerModalContextHolder, + actionModalContextHolder, reviewerStatusViewMode, searchQuery: currentSearchQuery ?? '', selectable: variant === 'admin', diff --git a/packages/client/app/ui/shared/ManuscriptsTable.tsx b/packages/client/app/ui/shared/ManuscriptsTable.tsx index 2fdf146ac..02f8c8b1e 100644 --- a/packages/client/app/ui/shared/ManuscriptsTable.tsx +++ b/packages/client/app/ui/shared/ManuscriptsTable.tsx @@ -176,7 +176,7 @@ type ManuscriptsTableProps = { showViewArchivedToggle?: boolean viewingArchived?: boolean onViewingArchivedChange?: (viewingArchived: boolean) => void - reviewerModalContextHolder?: ReactNode + actionModalContextHolder?: ReactNode } const renderDate = (value: any): ReactNode => { @@ -1103,7 +1103,7 @@ const ManuscriptsTable = ({ showViewArchivedToggle = false, viewingArchived = false, onViewingArchivedChange, - reviewerModalContextHolder, + actionModalContextHolder, }: ManuscriptsTableProps): ReactNode => { const { t } = useTranslation() const [modal, modalContextHolder] = Modal.useModal() @@ -1290,7 +1290,7 @@ const ManuscriptsTable = ({ return ( <> {modalContextHolder} - {reviewerModalContextHolder} + {actionModalContextHolder} {showSelectionActions && ( {selectable && (showArchiveActions || showDownloadAction) && ( From 2d3c95f40c8a67f5b9a8c1cc6414285a69ae7014 Mon Sep 17 00:00:00 2001 From: yannis Date: Tue, 25 Aug 2026 11:21:35 +0300 Subject: [PATCH 33/51] feat(client): clean up download json manuscripts output --- .../app/pages/hooks/useManuscriptsTable.tsx | 44 +++++++++++++------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/packages/client/app/pages/hooks/useManuscriptsTable.tsx b/packages/client/app/pages/hooks/useManuscriptsTable.tsx index 637b769c4..39c7e050e 100644 --- a/packages/client/app/pages/hooks/useManuscriptsTable.tsx +++ b/packages/client/app/pages/hooks/useManuscriptsTable.tsx @@ -151,15 +151,6 @@ const columnAlignments: Record = { // #endregion constants // #region helpers -const downloadBlob = (blob: Blob, fileName: string): void => { - const url = URL.createObjectURL(blob) - const link = document.createElement('a') - link.href = url - link.download = fileName - link.click() - URL.revokeObjectURL(url) -} - const REVIEWER_STATUS_VIEW_MODE_STORAGE_KEY = 'manuscriptsTable.reviewerStatusViewMode' @@ -1119,20 +1110,45 @@ const useManuscriptsTable = (variant: Variant): UseManuscriptsTableResult => { } const handleDownloadSelected = async (ids: string[]): Promise => { + const stringifiedJsonKeys = ['submission', 'jsonData'] + + const sanitizeExportValue = (value: any): any => { + if (Array.isArray(value)) return value.map(sanitizeExportValue) + + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value) + .filter(([key]) => key !== '__typename') + .map(([key, val]) => [ + key, + stringifiedJsonKeys.includes(key) && typeof val === 'string' + ? JSON.parse(val || '{}') + : sanitizeExportValue(val), + ]), + ) + } + + return value + } + const { data: exportData } = await getManuscriptsData({ variables: { selectedManuscripts: ids }, }) - // @ts-ignore - const cleanedData = (exportData?.getManuscriptsData ?? []).map( - ({ __typename, ...rest }: Record) => rest, + const cleanedData = sanitizeExportValue( + exportData?.getManuscriptsData ?? [], ) - const jsonBlob = new Blob([JSON.stringify(cleanedData)], { + const jsonBlob = new Blob([JSON.stringify(cleanedData, null, 2)], { type: 'application/json', }) - downloadBlob(jsonBlob, 'exportedData.json') + const url = URL.createObjectURL(jsonBlob) + const link = document.createElement('a') + link.href = url + link.download = 'exportedData.json' + link.click() + URL.revokeObjectURL(url) } const handleViewingArchivedChange = (viewingArchived: boolean): void => { From d1018264689cb7d5bbc4a0c9fe0062bc32627752 Mon Sep 17 00:00:00 2001 From: yannis Date: Tue, 25 Aug 2026 11:31:01 +0300 Subject: [PATCH 34/51] fix(client): make sure all special column titles are used and in translation files --- packages/client/app/i18n/en/translation.js | 1 + packages/client/app/i18n/es-la/translation.js | 1 + packages/client/app/i18n/fr/translation.js | 1 + packages/client/app/i18n/ru/translation.js | 1 + packages/client/app/pages/hooks/useManuscriptsTable.tsx | 3 +-- 5 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/client/app/i18n/en/translation.js b/packages/client/app/i18n/en/translation.js index 6a2577b8e..83df480c6 100644 --- a/packages/client/app/i18n/en/translation.js +++ b/packages/client/app/i18n/en/translation.js @@ -183,6 +183,7 @@ const en = { 'Press / anywhere on the page to jump to search.', ], 'Manuscript number': 'Manuscript number', + 'No.': 'No.', Created: 'Created', Updated: 'Updated', lastReviewerStatusUpdate: 'Last Reviewer Status Update', diff --git a/packages/client/app/i18n/es-la/translation.js b/packages/client/app/i18n/es-la/translation.js index 441630b2c..c2339b64f 100644 --- a/packages/client/app/i18n/es-la/translation.js +++ b/packages/client/app/i18n/es-la/translation.js @@ -183,6 +183,7 @@ const esLa = { 'Presione / en cualquier parte de la página para ir a la búsqueda.', ], 'Manuscript number': 'Número de Manuscrito', + 'No.': 'N.º', Created: 'Creado', Updated: 'Actualizado', lastReviewerStatusUpdate: 'Actualización del estado del último revisor', diff --git a/packages/client/app/i18n/fr/translation.js b/packages/client/app/i18n/fr/translation.js index 92477e1c7..22c249215 100644 --- a/packages/client/app/i18n/fr/translation.js +++ b/packages/client/app/i18n/fr/translation.js @@ -183,6 +183,7 @@ const fr = { "Appuyez sur / n'importe où sur la page pour accéder à la recherche.", ], 'Manuscript number': 'Numéro de manuscrit', + 'No.': 'N°', Created: 'Créé', Updated: 'Mis à jour', lastReviewerStatusUpdate: 'Dernière mise à jour du statut du réviseur', diff --git a/packages/client/app/i18n/ru/translation.js b/packages/client/app/i18n/ru/translation.js index 0504be1b9..fc62d9ef8 100644 --- a/packages/client/app/i18n/ru/translation.js +++ b/packages/client/app/i18n/ru/translation.js @@ -183,6 +183,7 @@ const ru = { 'Нажмите / в любом месте страницы, чтобы перейти к поиску.', ], 'Manuscript number': 'Номер статьи', + 'No.': '№', Created: 'Создано', Updated: 'Обновлено', lastReviewerStatusUpdate: 'Последнее обновление статуса рецензента', diff --git a/packages/client/app/pages/hooks/useManuscriptsTable.tsx b/packages/client/app/pages/hooks/useManuscriptsTable.tsx index 39c7e050e..4bdc662fa 100644 --- a/packages/client/app/pages/hooks/useManuscriptsTable.tsx +++ b/packages/client/app/pages/hooks/useManuscriptsTable.tsx @@ -410,9 +410,8 @@ const useManuscriptsTable = (variant: Variant): UseManuscriptsTableResult => { editor: t('manuscriptsTable.Editor'), lastUpdated: t('manuscriptsTable.lastReviewerStatusUpdate'), manuscriptVersions: t('manuscriptsTable.Version'), - reviewerLinks: 'Action', // not in translation! reviewerStatusBadge: t('manuscriptsTable.Your Status'), - shortId: 'No.', // not in translation! + shortId: t('manuscriptsTable.No.'), status: t('manuscriptsTable.Status'), statusCounts: t('manuscriptsTable.Reviewer Status'), submitter: t('manuscriptsTable.Author'), // alias of 'author' From 591cc799f6b0e5cf490c4338e908bb519a094efd Mon Sep 17 00:00:00 2001 From: yannis Date: Tue, 25 Aug 2026 11:41:35 +0300 Subject: [PATCH 35/51] fix(client): fix editor special column in manuscripts table --- .../client/app/pages/hooks/useManuscriptsTable.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/client/app/pages/hooks/useManuscriptsTable.tsx b/packages/client/app/pages/hooks/useManuscriptsTable.tsx index 4bdc662fa..cca08553d 100644 --- a/packages/client/app/pages/hooks/useManuscriptsTable.tsx +++ b/packages/client/app/pages/hooks/useManuscriptsTable.tsx @@ -332,6 +332,18 @@ const lastReviewerUpdateFor = ( ) } +const editorNamesFor = (manuscript: Record): string => + [ + ...new Set( + (manuscript.teams ?? []) + .filter((team: Record) => + VARIANT_CONFIG.editor.roles.includes(team.role), + ) + .map((team: Record) => team.members?.[0]?.user?.username) + .filter(Boolean), + ), + ].join(', ') + /** * Translate the table's 'yyyy-MM-dd' format to the server's date-range filter * format 'yyyyMMdd'. And vice versa. @@ -954,6 +966,7 @@ const useManuscriptsTable = (variant: Variant): UseManuscriptsTableResult => { row.status = manuscript.status row.searchSnippets = manuscript.searchSnippets row.manuscriptVersions = (manuscript.manuscriptVersions?.length ?? 0) + 1 + row.editor = editorNamesFor(manuscript) if (variant === 'admin') { row.archived = isArchived From 22622aef798407ba8f7d7b7ae2b6cac7c52ddf37 Mon Sep 17 00:00:00 2001 From: yannis Date: Tue, 25 Aug 2026 11:49:10 +0300 Subject: [PATCH 36/51] fix(client): change list of statuses based on instance name --- .../app/pages/hooks/useManuscriptsTable.tsx | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/client/app/pages/hooks/useManuscriptsTable.tsx b/packages/client/app/pages/hooks/useManuscriptsTable.tsx index cca08553d..d858f57ab 100644 --- a/packages/client/app/pages/hooks/useManuscriptsTable.tsx +++ b/packages/client/app/pages/hooks/useManuscriptsTable.tsx @@ -31,7 +31,6 @@ import { } from '../../queries' import Link from '../../ui/shared/Link' -import { MANUSCRIPT_STATUSES } from '../../ui/shared/ManuscriptStatus' import { reviewerStatusValues, reviewerStatusTranslationKeys, @@ -148,6 +147,35 @@ const columnAlignments: Record = { 'submission.$doi': 'center', 'submission.adaState': 'center', } + +const JOURNAL_STATUS_OPTIONS = [ + 'new', + 'submitted', + 'accepted', + 'rejected', + 'revise', + 'revising', + 'published', + 'unpublished', + 'assigned', + 'inProgress', + 'completed', + 'underEmbargo', + 'embargoReleased', +] + +const PREPRINT_STATUS_OPTIONS = [ + 'new', + 'submitted', + 'evaluated', + 'published', + 'unpublished', + 'assigned', + 'inProgress', + 'completed', + 'underEmbargo', + 'embargoReleased', +] // #endregion constants // #region helpers @@ -821,11 +849,15 @@ const useManuscriptsTable = (variant: Variant): UseManuscriptsTableResult => { } if (column.key === 'status') { + const statusOptions = ['journal', 'prc'].includes(config?.instanceName) + ? JOURNAL_STATUS_OPTIONS + : PREPRINT_STATUS_OPTIONS + return { ...column, dataType: 'status', filterable: true, - options: MANUSCRIPT_STATUSES.map(status => ({ + options: statusOptions.map(status => ({ value: status, label: t(`msStatus.${status}`), })), From d6b418ff3eef3921d5087b87b8ca17e4ad821544 Mon Sep 17 00:00:00 2001 From: yannis Date: Tue, 25 Aug 2026 11:57:35 +0300 Subject: [PATCH 37/51] fix(client): use created date of first version to display in table --- packages/client/app/pages/hooks/useManuscriptsTable.tsx | 5 +++++ packages/client/app/ui/shared/Page.tsx | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/client/app/pages/hooks/useManuscriptsTable.tsx b/packages/client/app/pages/hooks/useManuscriptsTable.tsx index d858f57ab..c89a17753 100644 --- a/packages/client/app/pages/hooks/useManuscriptsTable.tsx +++ b/packages/client/app/pages/hooks/useManuscriptsTable.tsx @@ -985,6 +985,11 @@ const useManuscriptsTable = (variant: Variant): UseManuscriptsTableResult => { return accumulator } + if (property === 'created') { + accumulator[property] = manuscript.firstVersionCreated + return accumulator + } + accumulator[property] = get(manuscript, property) return accumulator }, diff --git a/packages/client/app/ui/shared/Page.tsx b/packages/client/app/ui/shared/Page.tsx index eb366ad01..c36cc1f97 100644 --- a/packages/client/app/ui/shared/Page.tsx +++ b/packages/client/app/ui/shared/Page.tsx @@ -19,7 +19,7 @@ const Header = styled(H1)` /* stylelint-disable declaration-no-important */ margin-top: 0; margin-bottom: ${grid(6)}; - padding-bottom: ${grid(2)}; + padding-bottom: ${grid(1)}; border-bottom: 2px solid ${th('colorPrimary')}; color: ${th('colorPrimary')} !important; From bdc38629a9c95d8cb21d93f1dcd80b13b12f1200 Mon Sep 17 00:00:00 2001 From: yannis Date: Tue, 25 Aug 2026 12:14:28 +0300 Subject: [PATCH 38/51] fix(ui): fix table horizontal scrolling when squeezed --- packages/client/app/ui/shared/ManuscriptsTable.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/client/app/ui/shared/ManuscriptsTable.tsx b/packages/client/app/ui/shared/ManuscriptsTable.tsx index 02f8c8b1e..148fc82a4 100644 --- a/packages/client/app/ui/shared/ManuscriptsTable.tsx +++ b/packages/client/app/ui/shared/ManuscriptsTable.tsx @@ -1425,6 +1425,7 @@ const ManuscriptsTable = ({ onChange: onPageChange, }} rowSelection={rowSelection} + scroll={{ x: resolvedColumns.length * 150 }} /> ) From 0b906ae626416d847d7d2d8a126c291f062ce667 Mon Sep 17 00:00:00 2001 From: yannis Date: Tue, 25 Aug 2026 13:54:33 +0300 Subject: [PATCH 39/51] refactor(ui): organize the manuscripts table file --- .../app/pages/hooks/useManuscriptsTable.tsx | 2 + .../client/app/ui/shared/ManuscriptsTable.tsx | 1370 +++++++++-------- packages/client/app/ui/shared/Table.tsx | 2 +- 3 files changed, 715 insertions(+), 659 deletions(-) diff --git a/packages/client/app/pages/hooks/useManuscriptsTable.tsx b/packages/client/app/pages/hooks/useManuscriptsTable.tsx index c89a17753..333494a49 100644 --- a/packages/client/app/pages/hooks/useManuscriptsTable.tsx +++ b/packages/client/app/pages/hooks/useManuscriptsTable.tsx @@ -2,6 +2,7 @@ * Collapse regions to make this file more managable to read through. */ +// #region import import { useMemo, useState, type ReactNode } from 'react' import { useParams, useSearchParams, useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' @@ -43,6 +44,7 @@ import { articleStatuses } from '../../globals' import { validateManuscriptSubmission } from '../../shared/manuscriptUtils' import { validateDoi, validateSuffix } from '../../shared/commsUtils' import PublishingResponse from '../../components/component-review/src/components/publishing/PublishingResponse' +// #endregion import // #region constants const URI_PARAMS = { diff --git a/packages/client/app/ui/shared/ManuscriptsTable.tsx b/packages/client/app/ui/shared/ManuscriptsTable.tsx index 148fc82a4..1c1d95042 100644 --- a/packages/client/app/ui/shared/ManuscriptsTable.tsx +++ b/packages/client/app/ui/shared/ManuscriptsTable.tsx @@ -1,3 +1,11 @@ +/** + * Collapse regions to make this file more managable to read through. + * + * A good starting point to find details for a specific column is to go to the + * column-setup region and see what other region that leads you to. + */ + +// #region import import { type ReactNode, type MouseEvent, @@ -52,428 +60,134 @@ import { badgeDefaultColorToken, } from './_constants' import { convertTimestampToRelativeDateString } from '../../shared/dateUtils' +// #endregion import + +// #region helpers +const RICH_TEXT_PREFIX_REGEX = /^\s*/ + +const renderPlainOrRichText = (value: any): ReactNode => { + if (!value || !RICH_TEXT_PREFIX_REGEX.test(value)) return value || null + return ( + + ) +} +// #endregion helpers +// #region options type ManuscriptsTableColumnOption = { value: string label?: string labelColor?: string } -export type ManuscriptsTableColumn = { - title: string - dataIndex: string - key: string - align?: 'left' | 'center' | 'right' - /** - * Opts into one of this component's built-in renderers for a common kind of - * value. Ignored if `render` is also supplied. - */ - dataType?: - | 'date' - | 'status' - | 'reviewerStatus' - | 'reviewerStatusSummary' - | 'badge' - | 'options' - | 'person' - | 'title' - | 'richText' - options?: ManuscriptsTableColumnOption[] - /** Applies to 'options', 'status', 'reviewerStatus' and 'date' datatypes */ - filterable?: boolean - /** - * Applies to the 'title' datatype -- shows an info icon that opens a tooltip - * with the manuscript's abstract on click. - */ - showAbstract?: boolean - /** - * Applies to the 'options' datatype, single-value case only. - * Renders a select dropdown instead of a static badge. - */ - editable?: boolean - /** - * Escape hatch for anything else (e.g. an actions column with - * business-specific links) -- takes precedence over dataType. - */ - render?: (value: any, record: any) => ReactNode - sortable?: boolean - /** - * Shows a question-mark icon next to the column header, with this text in a - * click-to-opne tooltip. - */ - helpTooltip?: string -} - -export type ManuscriptsTableSortState = { - columnKey: string - order: 'ascend' | 'descend' -} +const renderSingleOption = ( + value: string, + options: ManuscriptsTableColumnOption[], +): ReactNode => { + const option = options.find(o => o.value === value) -export type ManuscriptSearchSnippet = { - field: string - html: string + return ( + + {option?.label ?? value} + + ) } -/** - * Other gaps identified against the legacy component-manuscripts-table cell renderers that are - * NOT candidates for a new dataType (see above), since they aren't a "render this value" concern: - * - Value derivation: some legacy columns (reviewer's own status, last-reviewer-updated date) - * already render fine via existing dataTypes, but need their value computed/aggregated from - * nested data (e.g. searching teams for the current user, or taking a max timestamp) before - * being handed to a column -- a data problem for the caller to solve, not a rendering gap - * here. See ReviewerStatusBadge.jsx, LastReviewerUpdated.jsx. - * - Role-filtered member list: Editors.jsx lists team members whose role isn't author/reviewer; - * generalizable in principle ("names of members matching a role filter") but currently - * hardcodes which roles to exclude rather than taking a parameter. - */ - -type ManuscriptsTableProps = { - columns: ManuscriptsTableColumn[] - dataSource: Record[] - page: number - pageSize: number - totalCount: number - onPageChange: (page: number) => void - onSearch: (value: string) => void - searchQuery?: string - bordered?: boolean - loading?: boolean - /** - * Currently selected filter values, keyed by column key (controlled), e.g. - * { status: ['accepted', 'rejected'] }. Only relevant for columns with - * `filterable: true`. - */ - columnFilters?: Record - onFiltersChange?: (filters: Record) => void - /** - * Currently applied sort (controlled), for a single `sortable` column. - * `null`/`undefined` means unsorted. - */ - sortState?: ManuscriptsTableSortState | null - onSortChange?: (sortState: ManuscriptsTableSortState | null) => void - /** - * Compact/detailed view mode for 'reviewerStatusSummary' columns. - * Defaults to 'detailed'. - */ - reviewerStatusViewMode?: 'compact' | 'detailed' - onReviewerStatusViewModeChange?: (viewMode: 'compact' | 'detailed') => void - /** - * Fired when a value is picked or cleared in an `editable` 'options' column. `id` is the - * changed manuscript's `record.id`; `value` is `null` when cleared. - */ - onOptionChange?: (columnKey: string, id: string, value: string | null) => void - /** - * Enables row selection checkboxes. - */ - selectable?: boolean - /** Only visible is selectable is true. */ - showArchiveActions?: boolean - /** Only visible is selectable is true. */ - showDownloadAction?: boolean - onArchiveSelected?: (ids: string[]) => void - onUnarchiveSelected?: (ids: string[]) => void - onDownloadSelected?: (ids: string[]) => void - showViewArchivedToggle?: boolean - viewingArchived?: boolean - onViewingArchivedChange?: (viewingArchived: boolean) => void - actionModalContextHolder?: ReactNode -} +const OptionsListWrapper = styled.div` + display: flex; + flex-wrap: wrap; + gap: ${grid(1)}; +` -const renderDate = (value: any): ReactNode => { +const renderOptions = ( + value: any, + options: ManuscriptsTableColumnOption[] = [], +): ReactNode => { if (!value) return null - return convertTimestampToRelativeDateString(value) -} -const renderStatus = (value: any, record: Record): ReactNode => ( - -) + if (Array.isArray(value)) { + if (value.length === 0) return null -const renderReviewerStatus = (value: any): ReactNode => ( - -) + return ( + + {value.map(v => ( + {renderSingleOption(v, options)} + ))} + + ) + } -const ReviewerStatusGridWrapper = styled.div` - display: inline-flex; - flex-wrap: wrap; - gap: ${grid(1)}; - max-width: calc(${grid(4)} * 5 + ${grid(1)} * 4); -` + return renderSingleOption(value, options) +} -const ReviewerStatusSquare = styled.span<{ $colorToken: string }>` - display: inline-block; - width: ${grid(4)}; - height: ${grid(4)}; - border-radius: ${th('borderRadius')}; - background-color: ${(props: { theme: any; $colorToken: string }): string => - props.theme[props.$colorToken]}; +const EditableOptionSelect = styled(Select)` + .ant-select { + padding-block: ${grid(1)}; + } ` -const ReviewerStatusTooltipList = styled.div<{ $columns: number }>` - display: grid; - grid-template-columns: ${(props: { $columns: number }): string => - `repeat(${props.$columns}, minmax(0, 1fr))`}; - gap: ${grid(4)}; - padding: ${grid(2)}; -` +/** + * Single-value only - not meant for the array/multi-select case. + */ +const renderEditableOption = ( + value: any, + record: Record, + column: ManuscriptsTableColumn, + onOptionChange: + | ((columnKey: string, id: string, value: string | null) => void) + | undefined, +): ReactNode => { + const options = column.options ?? [] + const id = record.id -const ReviewerStatusTooltipRow = styled.div` + return ( + + renderSingleOption(selectedValue as string, options) + } + onChange={(newValue: string | undefined): void => + onOptionChange?.(column.key, id, newValue ?? null) + } + onClick={(event: MouseEvent): void => event.stopPropagation()} + optionRender={option => + renderSingleOption(option.value as string, options) + } + options={options.map(option => ({ + value: option.value, + label: option.label ?? option.value, + }))} + size="small" + value={value || undefined} + /> + ) +} +// #endregion options + +// #region person +const PersonWrapper = styled.div` display: flex; align-items: center; gap: ${grid(2)}; - padding-bottom: ${grid(1)}; - margin-bottom: ${grid(1)}; - border-bottom: 1px solid ${th('colorBorder')}; - font-size: ${th('fontSizeBaseSmall')}; -` - -const ReviewerStatusTooltipSwatch = styled.span<{ $colorToken: string }>` - flex-shrink: 0; - width: ${grid(3)}; - height: ${grid(3)}; - border-radius: ${th('borderRadius')}; - background-color: ${(props: { theme: any; $colorToken: string }): string => - props.theme[props.$colorToken]}; ` -const ReviewerStatusTooltipGroup = styled.div` +const PersonInfo = styled.div` display: flex; flex-direction: column; - gap: ${grid(1)}; ` -const ReviewerStatusTooltipMembers = styled.div` - display: flex; - flex-direction: column; +const PersonIdentifier = styled.span` + color: ${th('colorTextPlaceholder')}; font-size: ${th('fontSizeBaseSmaller')}; ` -const ReviewerStatusTooltipCompactList = styled.div` - display: flex; - flex-direction: column; - gap: ${grid(1)}; - padding: ${grid(2)}; - font-size: ${th('fontSizeBaseSmall')}; -` - -const ReviewerStatusTooltipCompactRow = styled.div` - display: flex; - align-items: center; - gap: ${grid(2)}; -` - -const resolveReviewerStatusColorToken = ( - status: ReviewerStatusValue, -): string => { - const variant = reviewerStatusVariants[status] - return variant ? badgeVariantColorTokens[variant] : badgeDefaultColorToken -} - -type ReviewerStatusEntry = { - status: ReviewerStatusValue - name: string -} - -const ReviewerStatusSummary = ({ - value, - isCompact, -}: { - value: any - isCompact: boolean -}): ReactNode => { - const { t } = useTranslation() - - if (!Array.isArray(value) || value.length === 0) return null - - const reviewers = value as ReviewerStatusEntry[] - - const groups: Partial> = {} - - reviewers.forEach(reviewer => { - groups[reviewer.status] = [...(groups[reviewer.status] ?? []), reviewer] - }) - - const groupEntries = Object.entries(groups) as [ - ReviewerStatusValue, - ReviewerStatusEntry[], - ][] - - const content = isCompact ? ( - - {groupEntries.map(([status, members]) => ( - - - - {members.length}{' '} - {t(reviewerStatusTranslationKeys[status])} - - - ))} - - ) : ( - - {groupEntries.map(([status, members]) => ( - - - - - {members.length}{' '} - {t(reviewerStatusTranslationKeys[status])} - - - - {members.map(member => ( - {member.name} - ))} - - - ))} - - ) - - return ( - - - - {reviewers.map(({ status, name }) => ( - - ))} - - - - ) -} - -const renderBadge = (value: any): ReactNode => {value} - -const RICH_TEXT_PREFIX_REGEX = /^\s*/ - -const renderPlainOrRichText = (value: any): ReactNode => { - if (!value || !RICH_TEXT_PREFIX_REGEX.test(value)) return value || null - return ( - - ) -} - -const stripHtml = (html: string): string => - DOMPurify.sanitize(html, { ALLOWED_TAGS: [] }) - -const renderRichText = (value: any): ReactNode => renderPlainOrRichText(value) - -const renderSingleOption = ( - value: string, - options: ManuscriptsTableColumnOption[], -): ReactNode => { - const option = options.find(o => o.value === value) - - return ( - - {option?.label ?? value} - - ) -} - -const OptionsListWrapper = styled.div` - display: flex; - flex-wrap: wrap; - gap: ${grid(1)}; -` - -const renderOptions = ( - value: any, - options: ManuscriptsTableColumnOption[] = [], -): ReactNode => { - if (!value) return null - - if (Array.isArray(value)) { - if (value.length === 0) return null - - return ( - - {value.map(v => ( - {renderSingleOption(v, options)} - ))} - - ) - } - - return renderSingleOption(value, options) -} - -const EditableOptionSelect = styled(Select)` - .ant-select { - padding-block: ${grid(1)}; - } -` - -/** - * Single-value only - not meant for the array/multi-select case. - */ -const renderEditableOption = ( - value: any, - record: Record, - column: ManuscriptsTableColumn, - onOptionChange: - | ((columnKey: string, id: string, value: string | null) => void) - | undefined, -): ReactNode => { - const options = column.options ?? [] - const id = record.id - - return ( - - renderSingleOption(selectedValue as string, options) - } - onChange={(newValue: string | undefined): void => - onOptionChange?.(column.key, id, newValue ?? null) - } - onClick={(event: MouseEvent): void => event.stopPropagation()} - optionRender={option => - renderSingleOption(option.value as string, options) - } - options={options.map(option => ({ - value: option.value, - label: option.label ?? option.value, - }))} - size="small" - value={value || undefined} - /> - ) -} - -const PersonWrapper = styled.div` - display: flex; - align-items: center; - gap: ${grid(2)}; -` - -const PersonInfo = styled.div` - display: flex; - flex-direction: column; -` - -const PersonIdentifier = styled.span` - color: ${th('colorTextPlaceholder')}; - font-size: ${th('fontSizeBaseSmaller')}; -` - -const renderPerson = (value: any): ReactNode => { - if (!value) return null +const renderPerson = (value: any): ReactNode => { + if (!value) return null return ( @@ -487,7 +201,9 @@ const renderPerson = (value: any): ReactNode => { ) } +// #endregion person +// #region title const TitleWrapper = styled.div` display: flex; flex-direction: column; @@ -532,6 +248,17 @@ const truncateAbstract = (abstract: string): string => { return `${words.slice(0, ABSTRACT_WORD_LIMIT).join(' ')}...` } +const stripHtml = (html: string): string => + DOMPurify.sanitize(html, { ALLOWED_TAGS: [] }) + +const TITLE_CHARACTER_LIMIT = 60 + +const truncateTitle = (title: string): string => { + const plainTitle = stripHtml(title) + if (plainTitle.length <= TITLE_CHARACTER_LIMIT) return plainTitle + return `${plainTitle.slice(0, TITLE_CHARACTER_LIMIT).trimEnd()}...` +} + type TitleCellValue = { title: string hasOverdueTasks?: boolean @@ -563,10 +290,10 @@ const TitleCell = ({ {value.link ? ( - {renderPlainOrRichText(value.title)} + {renderPlainOrRichText(truncateTitle(value.title))} ) : ( - renderPlainOrRichText(value.title) + renderPlainOrRichText(truncateTitle(value.title)) )} @@ -606,7 +333,9 @@ const TitleCell = ({ ) } +// #endregion title +// #region dates const { RangePicker } = DatePicker const DateRangeFilterWrapper = styled.div` @@ -661,6 +390,13 @@ const DateRangeFilterDropdown = ({ ) } +const renderDate = (value: any): ReactNode => { + if (!value) return null + return convertTimestampToRelativeDateString(value) +} +// #endregion dates + +// #region search-snippets const SnippetsWrapper = styled.div` display: flex; flex-direction: column; @@ -687,6 +423,11 @@ const renderHighlightedSnippet = (html: string): ReactNode => { return parts.map((part, index) => {part}) } +type ManuscriptSearchSnippet = { + field: string + html: string +} + const SearchSnippets = ({ snippets, }: { @@ -703,14 +444,9 @@ const SearchSnippets = ({ const hasSearchSnippets = (row: Record): boolean => Array.isArray(row.searchSnippets) && row.searchSnippets.length > 0 +// #endregion search-snippets -const ReviewerStatusColumnHeaderWrapper = styled.div` - display: flex; - align-items: center; - justify-content: space-between; - gap: ${grid(2)}; -` - +// #region column-header-help const ColumnTitleWithTooltipWrapper = styled.span` align-items: center; display: inline-flex; @@ -733,8 +469,19 @@ const ColumnTitleWithTooltip = ({ ) +// #endregion column-header-help + +// #region reviewer-grid +const ReviewerStatusColumnHeaderWrapper = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: ${grid(2)}; +` const RadioGroup = styled(RadioGroupInput)` + display: flex; + /* stylelint-disable declaration-no-important */ .ant-radio-button-wrapper { background-color: ${th('colorPrimary')} !important; @@ -749,55 +496,353 @@ const RadioGroup = styled(RadioGroupInput)` &:hover { border: 1px solid ${th('colorTextReverse')}; } - } + } + + .ant-radio-button-wrapper-checked { + background-color: ${th('colorTextReverse')} !important; + color: ${th('colorPrimary')} !important; + border-color: ${th('colorTextReverse')} !important; + } +` + +const ReviewerStatusColumnHeader = ({ + title, + isCompact, + onChange, +}: { + title: ReactNode + isCompact: boolean + onChange: (isCompact: boolean) => void +}): ReactNode => { + const { t } = useTranslation() + + return ( + + {title} + onChange(value === 'compact')} + optionType="button" + size="small" + value={isCompact ? 'compact' : 'detailed'} + > + + + + + + + + + + + + + ) +} + +const ReviewerStatusGridWrapper = styled.div` + display: inline-flex; + flex-wrap: wrap; + gap: ${grid(1)}; + max-width: calc(${grid(4)} * 5 + ${grid(1)} * 4); +` + +const ReviewerStatusSquare = styled.span<{ $colorToken: string }>` + display: inline-block; + width: ${grid(4)}; + height: ${grid(4)}; + border-radius: ${th('borderRadius')}; + background-color: ${(props: { theme: any; $colorToken: string }): string => + props.theme[props.$colorToken]}; +` + +const ReviewerStatusTooltipList = styled.div<{ $columns: number }>` + display: grid; + grid-template-columns: ${(props: { $columns: number }): string => + `repeat(${props.$columns}, minmax(0, 1fr))`}; + gap: ${grid(4)}; + padding: ${grid(2)}; +` + +const ReviewerStatusTooltipRow = styled.div` + display: flex; + align-items: center; + gap: ${grid(2)}; + padding-bottom: ${grid(1)}; + margin-bottom: ${grid(1)}; + border-bottom: 1px solid ${th('colorBorder')}; + font-size: ${th('fontSizeBaseSmall')}; +` + +const ReviewerStatusTooltipSwatch = styled.span<{ $colorToken: string }>` + flex-shrink: 0; + width: ${grid(3)}; + height: ${grid(3)}; + border-radius: ${th('borderRadius')}; + background-color: ${(props: { theme: any; $colorToken: string }): string => + props.theme[props.$colorToken]}; +` + +const ReviewerStatusTooltipGroup = styled.div` + display: flex; + flex-direction: column; + gap: ${grid(1)}; +` + +const ReviewerStatusTooltipMembers = styled.div` + display: flex; + flex-direction: column; + font-size: ${th('fontSizeBaseSmaller')}; +` + +const ReviewerStatusTooltipCompactList = styled.div` + display: flex; + flex-direction: column; + gap: ${grid(1)}; + padding: ${grid(2)}; + font-size: ${th('fontSizeBaseSmall')}; +` + +const ReviewerStatusTooltipCompactRow = styled.div` + display: flex; + align-items: center; + gap: ${grid(2)}; +` + +const resolveReviewerStatusColorToken = ( + status: ReviewerStatusValue, +): string => { + const variant = reviewerStatusVariants[status] + return variant ? badgeVariantColorTokens[variant] : badgeDefaultColorToken +} + +type ReviewerStatusEntry = { + status: ReviewerStatusValue + name: string +} + +const ReviewerStatusSummary = ({ + value, + isCompact, +}: { + value: any + isCompact: boolean +}): ReactNode => { + const { t } = useTranslation() + + if (!Array.isArray(value) || value.length === 0) return null + + const reviewers = value as ReviewerStatusEntry[] + + const groups: Partial> = {} + + reviewers.forEach(reviewer => { + groups[reviewer.status] = [...(groups[reviewer.status] ?? []), reviewer] + }) + + const groupEntries = Object.entries(groups) as [ + ReviewerStatusValue, + ReviewerStatusEntry[], + ][] + + const content = isCompact ? ( + + {groupEntries.map(([status, members]) => ( + + + + {members.length}{' '} + {t(reviewerStatusTranslationKeys[status])} + + + ))} + + ) : ( + + {groupEntries.map(([status, members]) => ( + + + + + {members.length}{' '} + {t(reviewerStatusTranslationKeys[status])} + + + + {members.map(member => ( + {member.name} + ))} + + + ))} + + ) + + return ( + + + + {reviewers.map(({ status, name }) => ( + + ))} + + + + ) +} +// #endregion reviewer-grid + +// #region filter-chips +const FilterChipsWrapper = styled.div` + display: flex; + flex-wrap: wrap; + gap: ${grid(1)}; + margin-bottom: ${grid(2)}; +` + +const FilterChipCloseButton = styled.button` + display: inline-flex; + margin-left: ${grid(2)}; + background: none; + color: inherit; + cursor: pointer; +` + +const formatChipDate = (value: string): string => + dayjs(value).format('MMM D, YYYY') + +type FilterChip = { + key: string + label: string + onRemove: (event: MouseEvent) => void +} + +/** + * Sets the column's filter to an empty array rather than deleting the key + * outright: a consumer's onFiltersChange (e.g. SubmissionsTable.jsx) needs to + * see the key present-but-empty to know it should clear that column's URL + * param -- a missing key looks like "untouched". + */ +const withoutColumnFilter = ( + filters: Record, + columnKey: string, +): Record => ({ + ...filters, + [columnKey]: [], +}) + +const buildFilterChips = ( + columns: ManuscriptsTableColumn[], + columnFilters: Record, + onFiltersChange: (filters: Record) => void, +): FilterChip[] => { + const chips: FilterChip[] = [] + + Object.entries(columnFilters).forEach(([columnKey, values]) => { + if (!values || values.length === 0) return + + const column = columns.find(c => c.key === columnKey) + if (!column) return + + if (column.dataType === 'date') { + const [start, end] = values + if (!start || !end) return + + chips.push({ + key: columnKey, + label: `${column.title}: ${formatChipDate(start)} – ${formatChipDate(end)}`, + onRemove: event => { + event.preventDefault() + onFiltersChange(withoutColumnFilter(columnFilters, columnKey)) + }, + }) + return + } - .ant-radio-button-wrapper-checked { - background-color: ${th('colorTextReverse')} !important; - color: ${th('colorPrimary')} !important; - border-color: ${th('colorTextReverse')} !important; - } -` + values.forEach(value => { + const label = column.options?.find(o => o.value === value)?.label ?? value -const ReviewerStatusColumnHeader = ({ - title, - isCompact, - onChange, -}: { - title: ReactNode - isCompact: boolean - onChange: (isCompact: boolean) => void -}): ReactNode => { - const { t } = useTranslation() + chips.push({ + key: `${columnKey}:${value}`, + label: `${column.title}: ${label}`, + onRemove: event => { + event.preventDefault() + const remaining = values.filter(v => v !== value) - return ( - - {title} - onChange(value === 'compact')} - optionType="button" - size="small" - value={isCompact ? 'compact' : 'detailed'} - > - - - - - - - - - - - - - ) + onFiltersChange( + remaining.length > 0 + ? { ...columnFilters, [columnKey]: remaining } + : withoutColumnFilter(columnFilters, columnKey), + ) + }, + }) + }) + }) + + return chips +} +// #endregion filter-chips + +// #region column-setup +export type ManuscriptsTableColumn = { + align?: 'left' | 'center' | 'right' + dataIndex: string + /** + * Opts into one of this component's built-in renderers for a common kind of + * value. Ignored if `render` is also supplied. + */ + dataType?: + | 'date' + | 'status' + | 'reviewerStatus' + | 'reviewerStatusSummary' + | 'badge' + | 'options' + | 'person' + | 'title' + | 'richText' + /** + * Applies to the 'options' datatype, single-value case only. + * Renders a select dropdown instead of a static badge. + */ + editable?: boolean + /** Applies to 'options', 'status', 'reviewerStatus' and 'date' datatypes */ + filterable?: boolean + /** + * Shows a question-mark icon next to the column header, with this text in a + * click-to-opne tooltip. + */ + helpTooltip?: string + key: string + title: string + options?: ManuscriptsTableColumnOption[] + /** + * Escape hatch for anything else (e.g. an actions column with + * business-specific links) -- takes precedence over dataType. + */ + render?: (value: any, record: any) => ReactNode + /** + * Applies to the 'title' datatype -- shows an info icon that opens a tooltip + * with the manuscript's abstract on click. + */ + showAbstract?: boolean + sortable?: boolean } const resolveColumn = ( @@ -816,16 +861,42 @@ const resolveColumn = ( ): TableColumnType> => { let resolved: TableColumnType> = column + // maps datatypes to their render functions (unless a custom render is provided) if (!column.render) { switch (column.dataType) { + case 'badge': + resolved = { + ...column, + render: (value: any): ReactNode => {value}, + } + break case 'date': resolved = { ...column, render: renderDate } break - case 'status': - resolved = { ...column, render: renderStatus } + case 'options': + resolved = { + ...column, + render: (value: any, record: Record): ReactNode => + column.editable + ? renderEditableOption( + value, + record, + column, + context.onOptionChange, + ) + : renderOptions(value, column.options), + } + break + case 'person': + resolved = { ...column, render: renderPerson } break case 'reviewerStatus': - resolved = { ...column, render: renderReviewerStatus } + resolved = { + ...column, + render: (value: any): ReactNode => ( + + ), + } break case 'reviewerStatusSummary': resolved = { @@ -840,35 +911,30 @@ const resolveColumn = ( ), } break - case 'badge': - resolved = { ...column, render: renderBadge } - break case 'richText': - resolved = { ...column, render: renderRichText } - break - case 'person': - resolved = { ...column, render: renderPerson } + resolved = { + ...column, + render: (value: any): ReactNode => renderPlainOrRichText(value), + } break - case 'title': + case 'status': resolved = { ...column, - render: (value: any): ReactNode => ( - + render: (value: any, record: Record): ReactNode => ( + ), } break - case 'options': + case 'title': resolved = { ...column, - render: (value: any, record: Record): ReactNode => - column.editable - ? renderEditableOption( - value, - record, - column, - context.onOptionChange, - ) - : renderOptions(value, column.options), + render: (value: any): ReactNode => ( + + ), } break default: @@ -918,122 +984,35 @@ const resolveColumn = ( (context.reviewerStatusViewMode ?? 'detailed') === 'compact' } onChange={isCompact => - context.onReviewerStatusViewModeChange?.( - isCompact ? 'compact' : 'detailed', - ) - } - title={resolved.title} - /> - ), - } - } - - // Ignored (forced off) for 'render' escape-hatch columns -- they have no - // real dataIndex value semantics for the server to sort on. - if (column.sortable && !column.render) { - resolved = { - ...resolved, - sorter: true, - sortDirections: ['descend', 'ascend'], - sortOrder: - context.sortState?.columnKey === column.key - ? context.sortState.order - : null, - } - } - - return resolved -} - -const FilterChipsWrapper = styled.div` - display: flex; - flex-wrap: wrap; - gap: ${grid(1)}; - margin-bottom: ${grid(2)}; -` - -const FilterChipCloseButton = styled.button` - display: inline-flex; - margin-left: ${grid(2)}; - background: none; - color: inherit; - cursor: pointer; -` - -const formatChipDate = (value: string): string => - dayjs(value).format('MMM D, YYYY') - -type FilterChip = { - key: string - label: string - onRemove: (event: MouseEvent) => void -} - -/** - * Sets the column's filter to an empty array rather than deleting the key - * outright: a consumer's onFiltersChange (e.g. SubmissionsTable.jsx) needs to - * see the key present-but-empty to know it should clear that column's URL - * param -- a missing key looks like "untouched". - */ -const withoutColumnFilter = ( - filters: Record, - columnKey: string, -): Record => ({ - ...filters, - [columnKey]: [], -}) - -const buildFilterChips = ( - columns: ManuscriptsTableColumn[], - columnFilters: Record, - onFiltersChange: (filters: Record) => void, -): FilterChip[] => { - const chips: FilterChip[] = [] - - Object.entries(columnFilters).forEach(([columnKey, values]) => { - if (!values || values.length === 0) return - - const column = columns.find(c => c.key === columnKey) - if (!column) return - - if (column.dataType === 'date') { - const [start, end] = values - if (!start || !end) return - - chips.push({ - key: columnKey, - label: `${column.title}: ${formatChipDate(start)} – ${formatChipDate(end)}`, - onRemove: event => { - event.preventDefault() - onFiltersChange(withoutColumnFilter(columnFilters, columnKey)) - }, - }) - return + context.onReviewerStatusViewModeChange?.( + isCompact ? 'compact' : 'detailed', + ) + } + title={resolved.title} + /> + ), } + } - values.forEach(value => { - const label = column.options?.find(o => o.value === value)?.label ?? value - - chips.push({ - key: `${columnKey}:${value}`, - label: `${column.title}: ${label}`, - onRemove: event => { - event.preventDefault() - const remaining = values.filter(v => v !== value) - - onFiltersChange( - remaining.length > 0 - ? { ...columnFilters, [columnKey]: remaining } - : withoutColumnFilter(columnFilters, columnKey), - ) - }, - }) - }) - }) + // Ignored (forced off) for 'render' escape-hatch columns -- they have no + // real dataIndex value semantics for the server to sort on. + if (column.sortable && !column.render) { + resolved = { + ...resolved, + sorter: true, + sortDirections: ['descend', 'ascend'], + sortOrder: + context.sortState?.columnKey === column.key + ? context.sortState.order + : null, + } + } - return chips + return resolved } +// #endregion column-setup +// #region table-styles const SearchBarWrapper = styled.div` display: flex; align-items: center; @@ -1076,40 +1055,99 @@ const SelectionCount = styled.span` font-size: ${th('fontSizeBaseSmall')}; color: ${th('colorTextPlaceholder')}; ` +// #endregion table-styles + +// #region table-render +export type ManuscriptsTableSortState = { + columnKey: string + order: 'ascend' | 'descend' +} + +type ManuscriptsTableProps = { + actionModalContextHolder?: ReactNode + columns: ManuscriptsTableColumn[] + /** + * Currently selected filter values, keyed by column key (controlled), e.g. + * { status: ['accepted', 'rejected'] }. Only relevant for columns with + * `filterable: true`. + */ + columnFilters?: Record + dataSource: Record[] + loading?: boolean + onArchiveSelected?: (ids: string[]) => void + onDownloadSelected?: (ids: string[]) => void + onFiltersChange?: (filters: Record) => void + /** + * Fired when a value is picked or cleared in an `editable` 'options' column. `id` is the + * changed manuscript's `record.id`; `value` is `null` when cleared. + */ + onOptionChange?: (columnKey: string, id: string, value: string | null) => void + onPageChange: (page: number) => void + onReviewerStatusViewModeChange?: (viewMode: 'compact' | 'detailed') => void + onSearch: (value: string) => void + onSortChange?: (sortState: ManuscriptsTableSortState | null) => void + onUnarchiveSelected?: (ids: string[]) => void + onViewingArchivedChange?: (viewingArchived: boolean) => void + page: number + pageSize: number + /** + * Compact/detailed view mode for 'reviewerStatusSummary' columns. + * Defaults to 'detailed'. + */ + reviewerStatusViewMode?: 'compact' | 'detailed' + searchQuery?: string + /** + * Enables row selection checkboxes. + */ + selectable?: boolean + /** Only visible is selectable is true. */ + showArchiveActions?: boolean + /** Only visible is selectable is true. */ + showDownloadAction?: boolean + showViewArchivedToggle?: boolean + /** + * Currently applied sort (controlled), for a single `sortable` column. + * `null`/`undefined` means unsorted. + */ + sortState?: ManuscriptsTableSortState | null + totalCount: number + viewingArchived?: boolean +} const ManuscriptsTable = ({ + actionModalContextHolder, + columnFilters, columns, dataSource, - page, - pageSize, - totalCount, - onPageChange, - onSearch, - searchQuery = '', loading = false, - columnFilters, + onArchiveSelected, + onDownloadSelected, onFiltersChange, - sortState, + onOptionChange, + onPageChange, + onReviewerStatusViewModeChange, + onSearch, onSortChange, + onUnarchiveSelected, + onViewingArchivedChange, + page, + pageSize, reviewerStatusViewMode, - onReviewerStatusViewModeChange, - onOptionChange, + searchQuery = '', selectable = false, showArchiveActions = false, showDownloadAction = false, - onArchiveSelected, - onUnarchiveSelected, - onDownloadSelected, showViewArchivedToggle = false, + sortState, + totalCount, viewingArchived = false, - onViewingArchivedChange, - actionModalContextHolder, }: ManuscriptsTableProps): ReactNode => { const { t } = useTranslation() const [modal, modalContextHolder] = Modal.useModal() const [selectedRowIds, setSelectedRowIds] = useState([]) const searchBarRef = useRef(null) + // effect handles keyboard shortcuts useEffect(() => { const handleKeyDown = (event: KeyboardEvent): void => { const { activeElement } = document @@ -1143,68 +1181,6 @@ const ManuscriptsTable = ({ return (): void => document.removeEventListener('keydown', handleKeyDown) }, []) - const rowsWithSnippets = dataSource.filter(hasSearchSnippets) - - const expandable = rowsWithSnippets.length > 0 && { - expandedRowKeys: rowsWithSnippets.map(row => row.key), - expandedRowRender: (record: Record): ReactNode => ( - - ), - rowExpandable: hasSearchSnippets, - showExpandColumn: false, - } - - const filterableColumns = columns.filter(column => - Boolean( - column.filterable && (column.options || column.dataType === 'date'), - ), - ) - - // Reports which values are selected, for every filterable column, and - // (separately) the current sort, for whichever sortable column the user - // last clicked. Never sorts `dataSource` itself -- the caller re-fetches. - const handleTableChange = ( - _pagination: unknown, - filters: Record, - sorter: - | SorterResult> - | SorterResult>[], - ): void => { - if (filterableColumns.length > 0 && onFiltersChange) { - onFiltersChange( - Object.fromEntries( - filterableColumns.map(column => [ - column.key, - (filters[column.key] ?? []) as string[], - ]), - ), - ) - } - - if (onSortChange) { - const activeSorter = Array.isArray(sorter) ? sorter[0] : sorter - - onSortChange( - activeSorter?.order && activeSorter.columnKey - ? { - columnKey: String(activeSorter.columnKey), - order: activeSorter.order, - } - : null, - ) - } - } - - const resolvedColumns = columns.map(column => - resolveColumn(column, { - columnFilters, - sortState, - reviewerStatusViewMode, - onReviewerStatusViewModeChange, - onOptionChange, - }), - ) - const filterChips = [ ...(searchQuery ? [ @@ -1235,6 +1211,20 @@ const ManuscriptsTable = ({ : []), ] + // #region search-snippets + const rowsWithSnippets = dataSource.filter(hasSearchSnippets) + + const expandable = rowsWithSnippets.length > 0 && { + expandedRowKeys: rowsWithSnippets.map(row => row.key), + expandedRowRender: (record: Record): ReactNode => ( + + ), + rowExpandable: hasSearchSnippets, + showExpandColumn: false, + } + // #endregion search-snippets + + // #region row-selection const selectedRows = dataSource.filter(row => selectedRowIds.includes(row.id)) const showSelectionActions = @@ -1254,6 +1244,51 @@ const ManuscriptsTable = ({ setSelectedRowIds(rows.map(row => row.id)), } : undefined + // #endregion row-selection + + // #region handlers + const filterableColumns = columns.filter(column => + Boolean( + column.filterable && (column.options || column.dataType === 'date'), + ), + ) + + /** + * Reports which values are selected, for every filterable column, and + * (separately) the current sort, for whichever sortable column the user last + * clicked. Never sorts `dataSource` itself -- the caller re-fetches. + */ + const handleTableChange = ( + _pagination: unknown, + filters: Record, + sorter: + | SorterResult> + | SorterResult>[], + ): void => { + if (filterableColumns.length > 0 && onFiltersChange) { + onFiltersChange( + Object.fromEntries( + filterableColumns.map(column => [ + column.key, + (filters[column.key] ?? []) as string[], + ]), + ), + ) + } + + if (onSortChange) { + const activeSorter = Array.isArray(sorter) ? sorter[0] : sorter + + onSortChange( + activeSorter?.order && activeSorter.columnKey + ? { + columnKey: String(activeSorter.columnKey), + order: activeSorter.order, + } + : null, + ) + } + } const handleArchiveClick = (): void => { modal.confirm({ @@ -1286,11 +1321,23 @@ const ManuscriptsTable = ({ const handleDownloadClick = (): void => { onDownloadSelected?.(selectedRows.map(row => row.id)) } + // #endregion handlers + + const resolvedColumns = columns.map(column => + resolveColumn(column, { + columnFilters, + sortState, + reviewerStatusViewMode, + onReviewerStatusViewModeChange, + onOptionChange, + }), + ) return ( <> {modalContextHolder} {actionModalContextHolder} + {showSelectionActions && ( {selectable && (showArchiveActions || showDownloadAction) && ( @@ -1300,6 +1347,7 @@ const ManuscriptsTable = ({ count: selectedRows.length, })} + {showArchiveActions && (
) } +// #endregion table-render export default ManuscriptsTable diff --git a/packages/client/app/ui/shared/Table.tsx b/packages/client/app/ui/shared/Table.tsx index 3a9c30c35..d6fbbd3ed 100644 --- a/packages/client/app/ui/shared/Table.tsx +++ b/packages/client/app/ui/shared/Table.tsx @@ -7,7 +7,7 @@ const Table = styled(OriginalTable)` .ant-table-wrapper { border: 1px solid ${th('colorPrimary')}; border-radius: ${th('borderRadius')}; - overflow: hidden; + overflow: auto hidden; } .ant-table-content { From 16191e65a0c12bf6a46c164e1060ee286851e89d Mon Sep 17 00:00:00 2001 From: yannis Date: Tue, 25 Aug 2026 14:21:00 +0300 Subject: [PATCH 40/51] chore(client): drop dead code and unused dependencies --- .../component-dashboard/src/style.jsx | 1 + .../component-dashboard/src/utils.js | 19 -- .../src/FilterSortHeader.jsx | 215 ------------- .../src/ManuscriptRow.jsx | 138 --------- .../src/ManuscriptsTable.jsx | 85 ------ .../src/cell-components/Actions.jsx | 98 ------ .../cell-components/AuthorProofingLink.jsx | 135 --------- .../src/cell-components/DefaultField.jsx | 53 ---- .../src/cell-components/EditorItemLinks.jsx | 36 --- .../src/cell-components/Editors.jsx | 15 - .../cell-components/FilterableStatusBadge.jsx | 19 -- .../src/cell-components/LabelDropdown.jsx | 204 ------------- .../cell-components/LabelsOrSelectButton.jsx | 49 --- .../cell-components/LastReviewerUpdated.jsx | 28 -- .../src/cell-components/OverdueTooltip.jsx | 27 -- .../src/cell-components/ReviewStatusDonut.jsx | 191 ------------ .../src/cell-components/ReviewerItemLinks.jsx | 115 ------- .../cell-components/ReviewerStatusBadge.jsx | 12 - .../src/cell-components/RowItemCheckbox.jsx | 27 -- .../src/cell-components/StatusBadge.jsx | 17 -- .../src/cell-components/SubmitChevron.jsx | 21 -- .../src/cell-components/Submitter.jsx | 23 -- .../TitleWithAbstractAsTooltip.jsx | 99 ------ .../src/cell-components/index.js | 16 - .../src/cell-components/reviewStatusUtils.js | 41 --- .../component-manuscripts-table/src/style.css | 7 - .../component-manuscripts-table/src/style.jsx | 153 ---------- .../src/util/buildColumnDefinitions.js | 92 ------ .../src/util/specialColumnProps.js | 282 ------------------ .../src/components/DeclinedReviewer.jsx | 2 +- .../src/components/ReviewersDeclined.jsx | 2 +- .../component-review/src/components/style.jsx | 10 +- .../client/app/shared/manuscriptUtils.jsx | 98 ------ packages/client/app/shared/urlParamUtils.jsx | 5 - packages/client/config/journal/manuscripts.js | 54 ---- packages/client/package.json | 1 - packages/client/yarn.lock | 11 - 37 files changed, 12 insertions(+), 2389 deletions(-) delete mode 100644 packages/client/app/components/component-dashboard/src/utils.js delete mode 100644 packages/client/app/components/component-manuscripts-table/src/FilterSortHeader.jsx delete mode 100644 packages/client/app/components/component-manuscripts-table/src/ManuscriptRow.jsx delete mode 100644 packages/client/app/components/component-manuscripts-table/src/ManuscriptsTable.jsx delete mode 100644 packages/client/app/components/component-manuscripts-table/src/cell-components/Actions.jsx delete mode 100644 packages/client/app/components/component-manuscripts-table/src/cell-components/AuthorProofingLink.jsx delete mode 100644 packages/client/app/components/component-manuscripts-table/src/cell-components/DefaultField.jsx delete mode 100644 packages/client/app/components/component-manuscripts-table/src/cell-components/EditorItemLinks.jsx delete mode 100644 packages/client/app/components/component-manuscripts-table/src/cell-components/Editors.jsx delete mode 100644 packages/client/app/components/component-manuscripts-table/src/cell-components/FilterableStatusBadge.jsx delete mode 100644 packages/client/app/components/component-manuscripts-table/src/cell-components/LabelDropdown.jsx delete mode 100644 packages/client/app/components/component-manuscripts-table/src/cell-components/LabelsOrSelectButton.jsx delete mode 100644 packages/client/app/components/component-manuscripts-table/src/cell-components/LastReviewerUpdated.jsx delete mode 100644 packages/client/app/components/component-manuscripts-table/src/cell-components/OverdueTooltip.jsx delete mode 100644 packages/client/app/components/component-manuscripts-table/src/cell-components/ReviewStatusDonut.jsx delete mode 100644 packages/client/app/components/component-manuscripts-table/src/cell-components/ReviewerItemLinks.jsx delete mode 100644 packages/client/app/components/component-manuscripts-table/src/cell-components/ReviewerStatusBadge.jsx delete mode 100644 packages/client/app/components/component-manuscripts-table/src/cell-components/RowItemCheckbox.jsx delete mode 100644 packages/client/app/components/component-manuscripts-table/src/cell-components/StatusBadge.jsx delete mode 100644 packages/client/app/components/component-manuscripts-table/src/cell-components/SubmitChevron.jsx delete mode 100644 packages/client/app/components/component-manuscripts-table/src/cell-components/Submitter.jsx delete mode 100644 packages/client/app/components/component-manuscripts-table/src/cell-components/TitleWithAbstractAsTooltip.jsx delete mode 100644 packages/client/app/components/component-manuscripts-table/src/cell-components/index.js delete mode 100644 packages/client/app/components/component-manuscripts-table/src/cell-components/reviewStatusUtils.js delete mode 100644 packages/client/app/components/component-manuscripts-table/src/style.css delete mode 100644 packages/client/app/components/component-manuscripts-table/src/style.jsx delete mode 100644 packages/client/app/components/component-manuscripts-table/src/util/buildColumnDefinitions.js delete mode 100644 packages/client/app/components/component-manuscripts-table/src/util/specialColumnProps.js delete mode 100644 packages/client/config/journal/manuscripts.js diff --git a/packages/client/app/components/component-dashboard/src/style.jsx b/packages/client/app/components/component-dashboard/src/style.jsx index 583f6c01b..6044b6fcd 100644 --- a/packages/client/app/components/component-dashboard/src/style.jsx +++ b/packages/client/app/components/component-dashboard/src/style.jsx @@ -83,6 +83,7 @@ export const Placeholder = styled.div.attrs({ padding: 4em; place-items: center; ` + export const Centered = styled.div` text-align: center; ` diff --git a/packages/client/app/components/component-dashboard/src/utils.js b/packages/client/app/components/component-dashboard/src/utils.js deleted file mode 100644 index 5dad87a9a..000000000 --- a/packages/client/app/components/component-dashboard/src/utils.js +++ /dev/null @@ -1,19 +0,0 @@ -export const getLatestVersion = manuscript => { - if ( - !manuscript || - !manuscript.manuscriptVersions || - manuscript.manuscriptVersions.length <= 0 - ) - return manuscript - - return manuscript.manuscriptVersions[0] -} - -export const getNumVersions = manuscript => { - return (manuscript.manuscriptVersions?.length ?? 0) + 1 -} - -export const getRoles = (m, userId) => - m.teams - .filter(t => t.members.some(member => member.user.id === userId)) - .map(t => t.role) diff --git a/packages/client/app/components/component-manuscripts-table/src/FilterSortHeader.jsx b/packages/client/app/components/component-manuscripts-table/src/FilterSortHeader.jsx deleted file mode 100644 index 4a7a001c0..000000000 --- a/packages/client/app/components/component-manuscripts-table/src/FilterSortHeader.jsx +++ /dev/null @@ -1,215 +0,0 @@ -/* eslint-disable react/prop-types */ - -import styled from 'styled-components' -import Popup from 'reactjs-popup' -import { Calendar as FeatherCalendar } from 'react-feather' -import { th, grid } from '@coko/client' -import { useTranslation } from 'react-i18next' -import { Cell, HeadingCell } from './style' -import { Select, DateRangeCalendar, SortUp, SortDown } from '../../shared' -import { - dateToCompactStringLocal, - compactStringToDateLocal, -} from '../../../shared/dateUtils' - -/* eslint-disable-next-line no-unused-vars */ -const CalendarIcon = styled(({ isActive, ...props }) => ( - -))` - height: ${grid(4)}; - stroke: ${props => - props.isActive ? props.theme.color.brand1.base : props.theme.color.gray60}; - width: ${grid(4)}; - - &:hover { - stroke: ${th('color.brand1.base')}; - } -` - -const IconButton = styled.button` - background: none; - border: none; - display: flex; - padding: 0 2px; -` - -const getDateRangeFromFilterString = filterString => { - if (!filterString) return [] - const parts = filterString.split('-') - if (parts.length !== 2) return [] - - try { - const startDate = compactStringToDateLocal(parts[0]) - const endDate = compactStringToDateLocal(parts[1]) - return [startDate, endDate] - /* eslint-disable-next-line no-unused-vars */ - } catch (e) { - console.error(`Could not parse date range '${filterString}'`) - return [] - } -} - -const FilterSortHeader = ({ - columnInfo, - sortName, - sortDirection, - setSort, - setFilter, - name, -}) => { - const { t } = useTranslation() - - if (columnInfo.canFilterByDateRange) { - const changeSort = () => { - const priorSortName = sortName - let newSortDirection - - if (priorSortName !== columnInfo.name) { - newSortDirection = columnInfo.defaultSortDirection - } else if (sortDirection === 'ASC') { - newSortDirection = 'DESC' - } else if (sortDirection === 'DESC') { - newSortDirection = 'ASC' - } - - setSort(columnInfo.name, newSortDirection) - } - - const filterByDateRange = range => { - if (range?.length === 2) { - setFilter( - columnInfo.name, - `${dateToCompactStringLocal(range[0])}-${dateToCompactStringLocal( - range[1], - )}`, - ) - } else setFilter(columnInfo.name, null) - } - - return ( - - { - '\u200B' /* zero-width space so the layout engine understands where the baseline is */ - } - ( - - - - )} - > - {close => ( - { - filterByDateRange(range) - close() - }} - value={getDateRangeFromFilterString(columnInfo.filterValue)} - /> - )} - - - {columnInfo.title} - {columnInfo.sortDirection === 'ASC' && } - {columnInfo.sortDirection === 'DESC' && } - - ) - } - - if (columnInfo.filterOptions && columnInfo.filterOptions.length > 1) { - const options = [ - { - label: {t('manuscriptsTable.all')}, - value: '', - }, - ].concat(columnInfo.filterOptions) - - return ( - -