AdvancedTable.vue 17 KB

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