AdvancedTable.vue 17 KB

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