AdvancedTable.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590
  1. <template>
  2. <div>
  3. <div>
  4. <button
  5. v-for="column in hidableSortedColumns"
  6. :key="column.name"
  7. class="button"
  8. @click="toggleColumnVisibility(column)"
  9. >
  10. {{
  11. `${
  12. this.shownColumns.indexOf(column.name) !== -1
  13. ? "Hide"
  14. : "Show"
  15. } ${column.name} column`
  16. }}
  17. </button>
  18. </div>
  19. <div class="table-outer-container">
  20. <div class="table-container">
  21. <table class="table">
  22. <thead>
  23. <draggable
  24. item-key="name"
  25. v-model="orderedColumns"
  26. v-bind="columnDragOptions"
  27. tag="tr"
  28. draggable=".item-draggable"
  29. >
  30. <template #item="{ element: column }">
  31. <th
  32. :class="{
  33. sortable: column.sortable,
  34. 'item-draggable': column.draggable
  35. }"
  36. :style="{
  37. minWidth: column.minWidth,
  38. width: column.width,
  39. maxWidth: column.maxWidth
  40. }"
  41. v-if="
  42. shownColumns.indexOf(column.name) !== -1
  43. "
  44. >
  45. <span @click="changeSort(column)">
  46. {{ column.displayName }}
  47. <span
  48. v-if="
  49. column.sortable &&
  50. sort[column.sortProperty]
  51. "
  52. >({{
  53. sort[column.sortProperty]
  54. }})</span
  55. >
  56. </span>
  57. <tippy
  58. v-if="column.filterable"
  59. :touch="true"
  60. :interactive="true"
  61. placement="bottom"
  62. theme="search"
  63. ref="search"
  64. trigger="click"
  65. >
  66. <i
  67. class="
  68. material-icons
  69. action-dropdown-icon
  70. "
  71. :content="`Filter by ${column.displayName}`"
  72. v-tippy
  73. @click.prevent="true"
  74. >search</i
  75. >
  76. <template #content>
  77. <div
  78. class="
  79. control
  80. is-grouped
  81. input-with-button
  82. "
  83. >
  84. <p class="control is-expanded">
  85. <input
  86. class="input"
  87. type="text"
  88. :placeholder="`Filter by ${column.displayName}`"
  89. :value="
  90. column.filterProperty !==
  91. null
  92. ? filter[
  93. column
  94. .filterProperty
  95. ]
  96. : ''
  97. "
  98. @keyup.enter="
  99. changeFilter(
  100. column,
  101. $event
  102. )
  103. "
  104. />
  105. </p>
  106. <p class="control">
  107. <a class="button is-info">
  108. <i
  109. class="
  110. material-icons
  111. icon-with-button
  112. "
  113. >search</i
  114. >
  115. </a>
  116. </p>
  117. </div>
  118. </template>
  119. </tippy>
  120. </th>
  121. </template>
  122. </draggable>
  123. </thead>
  124. <tbody>
  125. <tr
  126. v-for="(item, itemIndex) in data"
  127. :key="item._id"
  128. :class="{
  129. selected: item.selected,
  130. highlighted: item.highlighted
  131. }"
  132. @click="clickItem(itemIndex, $event)"
  133. >
  134. <td
  135. v-for="column in sortedFilteredColumns"
  136. :key="`${item._id}-${column.name}`"
  137. >
  138. <slot
  139. :name="`column-${column.name}`"
  140. :item="item"
  141. v-if="
  142. column.properties.every(
  143. property =>
  144. item[property] !== undefined
  145. )
  146. "
  147. ></slot>
  148. </td>
  149. </tr>
  150. </tbody>
  151. </table>
  152. </div>
  153. <div class="table-footer">
  154. <div>
  155. <button
  156. v-if="page > 1"
  157. class="button is-primary material-icons"
  158. @click="changePage(1)"
  159. content="First Page"
  160. v-tippy
  161. >
  162. skip_previous
  163. </button>
  164. <button
  165. v-if="page > 1"
  166. class="button is-primary material-icons"
  167. @click="changePage(page - 1)"
  168. content="Previous Page"
  169. v-tippy
  170. >
  171. fast_rewind
  172. </button>
  173. <p>Page {{ page }} / {{ lastPage }}</p>
  174. <button
  175. v-if="page < lastPage"
  176. class="button is-primary material-icons"
  177. @click="changePage(page + 1)"
  178. content="Next Page"
  179. v-tippy
  180. >
  181. fast_forward
  182. </button>
  183. <button
  184. v-if="page < lastPage"
  185. class="button is-primary material-icons"
  186. @click="changePage(lastPage)"
  187. content="Last Page"
  188. v-tippy
  189. >
  190. skip_next
  191. </button>
  192. </div>
  193. <div>
  194. <div class="control">
  195. <label class="label">Items per page</label>
  196. <p class="control select">
  197. <select
  198. v-model.number="pageSize"
  199. @change="getData()"
  200. >
  201. <option value="10">10</option>
  202. <option value="25">25</option>
  203. <option value="50">50</option>
  204. <option value="100">100</option>
  205. <option value="250">250</option>
  206. <option value="500">500</option>
  207. <option value="1000">1000</option>
  208. </select>
  209. </p>
  210. </div>
  211. </div>
  212. </div>
  213. </div>
  214. </div>
  215. </template>
  216. <script>
  217. import { mapGetters } from "vuex";
  218. import draggable from "vuedraggable";
  219. import Toast from "toasters";
  220. import ws from "@/ws";
  221. export default {
  222. components: {
  223. draggable
  224. },
  225. props: {
  226. /*
  227. Column properties:
  228. name: Unique lowercase name
  229. displayName: Nice name for the column header
  230. properties: The properties this column needs to show data
  231. sortable: Boolean for whether the order of a particular column can be changed
  232. sortProperty: The property the backend will sort on if this column gets sorted, e.g. title
  233. filterable: Boolean for whether or not a column can use a filter
  234. filterProperty: The property the backend will filter on, e.g. title
  235. hidable: Boolean for whether a column can be hidden
  236. defaultVisibility: Default visibility for a column, either "shown" or "hidden"
  237. draggable: Boolean for whether a column can be dragged/reordered
  238. minWidth: Minimum width of column, e.g. 50px
  239. width: Width of column, e.g. 100px
  240. maxWidth: Maximum width of column, e.g. 150px
  241. */
  242. columns: { type: Array, default: null },
  243. dataAction: { type: String, default: null }
  244. },
  245. data() {
  246. return {
  247. page: 1,
  248. pageSize: 10,
  249. data: [],
  250. count: 0, // TODO Rename
  251. sort: {},
  252. filter: {},
  253. orderedColumns: [],
  254. shownColumns: [],
  255. columnDragOptions() {
  256. return {
  257. animation: 200,
  258. group: "columns",
  259. disabled: false,
  260. ghostClass: "draggable-list-ghost",
  261. filter: ".ignore-elements",
  262. fallbackTolerance: 50
  263. };
  264. }
  265. };
  266. },
  267. computed: {
  268. properties() {
  269. return Array.from(
  270. new Set(
  271. this.sortedFilteredColumns.flatMap(
  272. column => column.properties
  273. )
  274. )
  275. );
  276. },
  277. lastPage() {
  278. return Math.ceil(this.count / this.pageSize);
  279. },
  280. sortedFilteredColumns() {
  281. return this.orderedColumns.filter(
  282. column => this.shownColumns.indexOf(column.name) !== -1
  283. );
  284. },
  285. hidableSortedColumns() {
  286. return this.orderedColumns.filter(column => column.hidable);
  287. },
  288. lastSelectedItemIndex() {
  289. return this.data.findIndex(item => item.highlighted);
  290. },
  291. ...mapGetters({
  292. socket: "websockets/getSocket"
  293. })
  294. },
  295. watch: {
  296. pageSize(pageSize) {
  297. localStorage.setItem("adminPageSize", pageSize);
  298. }
  299. },
  300. mounted() {
  301. const columns = [
  302. {
  303. name: "select",
  304. displayName: "",
  305. properties: [],
  306. sortable: false,
  307. filterable: false,
  308. hidable: false,
  309. draggable: false
  310. },
  311. ...this.columns
  312. ];
  313. this.orderedColumns = columns;
  314. // A column will be shown if the defaultVisibility is set to shown, OR if the defaultVisibility is not set to shown and hidable is false
  315. this.shownColumns = columns
  316. .filter(column => column.defaultVisibility !== "hidden")
  317. .map(column => column.name);
  318. let pageSize = parseFloat(localStorage.getItem("adminPageSize"));
  319. pageSize =
  320. typeof pageSize === "number" && !Number.isNaN(pageSize)
  321. ? pageSize
  322. : 10;
  323. localStorage.setItem("adminPageSize", pageSize);
  324. this.pageSize = pageSize;
  325. ws.onConnect(this.init);
  326. },
  327. methods: {
  328. init() {
  329. this.getData();
  330. },
  331. getData() {
  332. this.socket.dispatch(
  333. this.dataAction,
  334. this.page,
  335. this.pageSize,
  336. this.properties,
  337. this.sort,
  338. this.filter,
  339. res => {
  340. console.log(111, res);
  341. new Toast(res.message);
  342. if (res.status === "success") {
  343. const { data, count } = res.data;
  344. this.data = data;
  345. this.count = count;
  346. }
  347. }
  348. );
  349. },
  350. changePage(page) {
  351. if (page < 1) return;
  352. if (page > this.lastPage) return;
  353. if (page === this.page) return;
  354. this.page = page;
  355. this.getData();
  356. },
  357. changeSort(column) {
  358. if (column.sortable) {
  359. const { sortProperty } = column;
  360. if (this.sort[sortProperty] === undefined)
  361. this.sort[sortProperty] = "ascending";
  362. else if (this.sort[sortProperty] === "ascending")
  363. this.sort[sortProperty] = "descending";
  364. else if (this.sort[sortProperty] === "descending")
  365. delete this.sort[sortProperty];
  366. this.getData();
  367. }
  368. },
  369. changeFilter(column, event) {
  370. if (column.filterable) {
  371. const { value } = event.target;
  372. const { filterProperty } = column;
  373. if (this.filter[filterProperty] !== undefined && value === "") {
  374. delete this.filter[filterProperty];
  375. } else if (this.filter[filterProperty] !== value) {
  376. this.filter[filterProperty] = value;
  377. } else return;
  378. this.getData();
  379. }
  380. },
  381. toggleColumnVisibility(column) {
  382. if (this.shownColumns.indexOf(column.name) !== -1) {
  383. this.shownColumns.splice(
  384. this.shownColumns.indexOf(column.name),
  385. 1
  386. );
  387. } else {
  388. this.shownColumns.push(column.name);
  389. }
  390. this.getData();
  391. },
  392. clickItem(itemIndex, event) {
  393. const { shiftKey, ctrlKey } = event;
  394. // Shift was pressed, so attempt to select all items between the clicked item and last clicked item
  395. if (shiftKey) {
  396. // If there is a last clicked item
  397. if (this.lastSelectedItemIndex >= 0) {
  398. // Clicked item is lower than last item, so select upwards until it reaches the last selected item
  399. if (itemIndex > this.lastSelectedItemIndex) {
  400. for (
  401. let itemIndexUp = itemIndex;
  402. itemIndexUp > this.lastSelectedItemIndex;
  403. itemIndexUp -= 1
  404. ) {
  405. this.data[itemIndexUp].selected = true;
  406. }
  407. }
  408. // Clicked item is higher than last item, so select downwards until it reaches the last selected item
  409. else if (itemIndex < this.lastSelectedItemIndex) {
  410. for (
  411. let itemIndexDown = itemIndex;
  412. itemIndexDown < this.lastSelectedItemIndex;
  413. itemIndexDown += 1
  414. ) {
  415. this.data[itemIndexDown].selected = true;
  416. }
  417. }
  418. }
  419. }
  420. // Ctrl was pressed, so toggle selected on the clicked item
  421. else if (ctrlKey) {
  422. this.data[itemIndex].selected = !this.data[itemIndex].selected;
  423. }
  424. // Neither ctrl nor shift were pressed, so unselect all items and set the clicked item to selected
  425. else {
  426. this.data = this.data.map(item => ({
  427. ...item,
  428. selected: false
  429. }));
  430. this.data[itemIndex].selected = true;
  431. }
  432. // Set the last clicked item to no longer be highlighted, if it exists
  433. if (this.lastSelectedItemIndex >= 0)
  434. this.data[this.lastSelectedItemIndex].highlighted = false;
  435. // Set the clicked item to be highlighted
  436. this.data[itemIndex].highlighted = true;
  437. }
  438. }
  439. };
  440. </script>
  441. <style lang="scss" scoped>
  442. .night-mode .table-outer-container {
  443. .table-container .table {
  444. &,
  445. thead th {
  446. background-color: var(--dark-grey-3);
  447. color: var(--light-grey-2);
  448. }
  449. tr {
  450. &:nth-child(even) {
  451. background-color: var(--dark-grey-2);
  452. }
  453. &:hover,
  454. &:focus,
  455. &.highlighted {
  456. background-color: var(--dark-grey);
  457. }
  458. }
  459. }
  460. .table-footer {
  461. background-color: var(--dark-grey-3);
  462. color: var(--light-grey-2);
  463. }
  464. }
  465. .table-outer-container {
  466. border-radius: 5px;
  467. box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1), 0 0 0 1px rgba(10, 10, 10, 0.1);
  468. margin: 10px 0;
  469. overflow: hidden;
  470. .table-container {
  471. overflow-x: auto;
  472. table {
  473. border-collapse: separate;
  474. thead {
  475. tr {
  476. th {
  477. &.sortable {
  478. cursor: pointer;
  479. }
  480. span {
  481. & > .material-icons {
  482. font-size: 22px;
  483. position: relative;
  484. top: 6px;
  485. cursor: pointer;
  486. }
  487. &:first-child {
  488. white-space: nowrap;
  489. line-height: 32px;
  490. }
  491. }
  492. }
  493. }
  494. }
  495. tbody {
  496. tr {
  497. &.selected td:first-child {
  498. border-left: 5px solid var(--primary-color);
  499. padding-left: 0;
  500. }
  501. &.highlighted {
  502. background-color: var(--light-grey);
  503. td:first-child {
  504. border-left: 5px solid var(--red);
  505. padding-left: 0;
  506. }
  507. }
  508. &.selected.highlighted td:first-child {
  509. border-left: 5px solid var(--green);
  510. padding-left: 0;
  511. }
  512. }
  513. }
  514. th,
  515. td {
  516. border: 1px solid var(--light-grey-2);
  517. border-width: 0 1px 1px 0;
  518. &:first-child,
  519. &:last-child {
  520. border-width: 0 0 1px;
  521. }
  522. }
  523. }
  524. table thead tr th:first-child,
  525. table tbody tr td:first-child {
  526. position: sticky;
  527. left: 0;
  528. z-index: 2;
  529. padding: 0;
  530. padding-left: 5px;
  531. }
  532. }
  533. .table-footer {
  534. display: flex;
  535. flex-direction: row;
  536. flex-wrap: wrap;
  537. justify-content: space-between;
  538. line-height: 36px;
  539. background-color: var(--white);
  540. & > div:first-child,
  541. div .control {
  542. display: flex;
  543. flex-direction: row;
  544. margin-bottom: 0 !important;
  545. button {
  546. margin: 5px;
  547. font-size: 20px;
  548. }
  549. p,
  550. label {
  551. margin: 5px;
  552. font-size: 14px;
  553. font-weight: 600;
  554. }
  555. &.select::after {
  556. top: 18px;
  557. }
  558. }
  559. }
  560. }
  561. </style>