-
- {loading ?
: null}
-
-
-
-
+
+
);
};
diff --git a/src/frontend/components/dashboard/lib/react/Menu/MenuItem.test.tsx b/src/frontend/components/dashboard/lib/react/Menu/MenuItem.test.tsx
new file mode 100644
index 000000000..831ec5644
--- /dev/null
+++ b/src/frontend/components/dashboard/lib/react/Menu/MenuItem.test.tsx
@@ -0,0 +1,49 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import '@testing-library/dom';
+import { describe, it, expect } from '@jest/globals';
+
+import MenuItem from './MenuItem';
+import { DashboardProps } from '../types';
+
+describe('MenuItem', () => {
+ it('Renders a MenuItem with H1 set', () => {
+ const DashboardProps: DashboardProps = {
+ name: 'Dashboard 1',
+ url: 'http://localhost:3000/dashboard',
+ download_url: 'http://localhost:3000/dashboard/download',
+ }
+
+ render(
);
+
+ expect(screen.getByText('Dashboard 1').parentElement).toBeInstanceOf(HTMLHeadingElement);
+ });
+
+ it('Renders a MenuItem without H1 set', () => {
+ const DashboardProps: DashboardProps = {
+ name: 'Dashboard 1',
+ url: 'http://localhost:3000/dashboard',
+ download_url: 'http://localhost:3000/dashboard/download',
+ }
+
+ render(
);
+
+ expect(screen.getByText('Dashboard 1').parentElement).toBeInstanceOf(HTMLAnchorElement);
+ });
+
+ it('Renders a MenuItem with different currentDashboard', () => {
+ const DashboardProps: DashboardProps[] = [{
+ name: 'Dashboard 1',
+ url: 'http://localhost:3000/dashboard',
+ download_url: 'http://localhost:3000/dashboard/download',
+ }, {
+ name: 'Dashboard 2',
+ url: 'http://localhost:3000/dashboard2',
+ download_url: 'http://localhost:3000/dashboard2/download',
+ }]
+
+ render(
);
+
+ expect(screen.getByText('Dashboard 1').parentElement).toBeInstanceOf(HTMLAnchorElement);
+ });
+});
\ No newline at end of file
diff --git a/src/frontend/components/dashboard/lib/react/Menu/MenuItem.tsx b/src/frontend/components/dashboard/lib/react/Menu/MenuItem.tsx
new file mode 100644
index 000000000..edb13f277
--- /dev/null
+++ b/src/frontend/components/dashboard/lib/react/Menu/MenuItem.tsx
@@ -0,0 +1,23 @@
+import React from "react"
+import { DashboardProps } from "../types"
+import { Nav } from "react-bootstrap"
+
+const MenuItem = ({ dashboard, currentDashboard, includeH1 }: { dashboard: DashboardProps, currentDashboard: DashboardProps, includeH1: boolean }) => {
+ if (dashboard.name === currentDashboard.name) {
+ if (includeH1) {
+ return
+ {dashboard.name}
+
+ } else {
+ return
+ {dashboard.name}
+
+ }
+ } else {
+ return
+ {dashboard.name}
+
+ }
+}
+
+export default MenuItem;
\ No newline at end of file
diff --git a/src/frontend/components/dashboard/lib/react/Widget.tsx b/src/frontend/components/dashboard/lib/react/Widget.tsx
deleted file mode 100644
index de1e5dda2..000000000
--- a/src/frontend/components/dashboard/lib/react/Widget.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import React from "react";
-import { initializeRegisteredComponents } from 'component'
-
-export default class Widget extends React.Component
{
- private ref;
-
- constructor(props) {
- super(props);
-
- this.ref = React.createRef();
- }
-
- shouldComponentUpdate = (nextProps) => {
- return nextProps.widget.html !== this.props.widget.html;
- }
-
- componentDidUpdate = () => {
- this.initializeLinkspace();
- }
-
- initializeLinkspace = () => {
- if (!this.ref) {
- return;
- }
- initializeRegisteredComponents(this.ref.current)
- }
-
- render() {
- return (
-
-
-
- {this.props.readOnly ? null :
- edit widget
- drag widget
- }
-
-
- );
- }
-}
diff --git a/src/frontend/components/dashboard/lib/react/Widget/Widget.test.tsx b/src/frontend/components/dashboard/lib/react/Widget/Widget.test.tsx
new file mode 100644
index 000000000..7577cd510
--- /dev/null
+++ b/src/frontend/components/dashboard/lib/react/Widget/Widget.test.tsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import '@testing-library/dom';
+import { describe, it, expect, jest } from '@jest/globals';
+
+import Widget from './Widget';
+import { WidgetProps } from '../types';
+
+describe('Widget', () => {
+ const WidgetProps: WidgetProps = {
+ config: {
+ h: 1,
+ i: "0",
+ w: 1,
+ x: 0,
+ y: 0
+ },
+ html: "Test
",
+ };
+
+ it('Renders a Widget with HTML set', () => {
+ render();
+
+ expect(screen.getByText('Test')).toBeInstanceOf(HTMLDivElement);
+ expect(screen.getByTestId('edit')).toBeInstanceOf(HTMLAnchorElement);
+ expect(screen.getByTestId('drag')).toBeInstanceOf(HTMLSpanElement);
+ });
+
+ it('Renders a Readonly Widget', ()=>{
+ render();
+
+ expect(screen.queryByTestId('edit')).toBeNull();
+ expect(screen.queryByTestId('drag')).toBeNull();
+ });
+
+ it('Calls onEditClick when edit button is clicked', ()=>{
+ const onEditClick = jest.fn();
+ render();
+
+ screen.getByTestId('edit').click();
+ expect(onEditClick).toHaveBeenCalled();
+ });
+});
diff --git a/src/frontend/components/dashboard/lib/react/Widget/Widget.tsx b/src/frontend/components/dashboard/lib/react/Widget/Widget.tsx
new file mode 100644
index 000000000..a2aaddb2a
--- /dev/null
+++ b/src/frontend/components/dashboard/lib/react/Widget/Widget.tsx
@@ -0,0 +1,23 @@
+import React, { createRef, useEffect } from "react";
+import { initializeRegisteredComponents } from 'component'
+
+export default function Widget({html, readOnly, onEditClick}) {
+ const ref = createRef();
+
+ useEffect(()=>{
+ if(!ref.current) return;
+ initializeRegisteredComponents(ref.current);
+ },[html]);
+
+ return (
+ <>
+
+
+ {!readOnly && (<>
+
edit widget
+
drag widget
+ >)}
+
+ >
+ );
+}
diff --git a/src/frontend/components/dashboard/lib/react/api.tsx b/src/frontend/components/dashboard/lib/react/api/index.ts
similarity index 59%
rename from src/frontend/components/dashboard/lib/react/api.tsx
rename to src/frontend/components/dashboard/lib/react/api/index.ts
index c171d5c19..6625496c3 100644
--- a/src/frontend/components/dashboard/lib/react/api.tsx
+++ b/src/frontend/components/dashboard/lib/react/api/index.ts
@@ -1,16 +1,20 @@
+import { Layout } from "react-grid-layout";
+
+type RequestMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
+
export default class ApiClient {
- private baseUrl;
- private headers;
- private isDev;
+ private baseUrl: string;
+ private headers: { [key: string]: string };
+ private isDev: boolean;
constructor(baseUrl = "") {
this.baseUrl = baseUrl;
this.headers = {};
// @ts-expect-error "isDev is not valid"
- this.isDev = window.siteConfig && window.siteConfig.isDev
+ this.isDev = window.siteConfig && window.siteConfig.isDev;
}
- async _fetch(route, method, body) {
+ async _fetch(route: string, method: RequestMethod, body?: T) {
if (!route) throw new Error("Route is undefined");
let csrfParam = "";
@@ -24,7 +28,7 @@ export default class ApiClient {
const fullRoute = `${this.baseUrl}${route}${csrfParam}`;
- const opts : any = {
+ const opts: any = {
method,
headers: Object.assign(this.headers),
credentials: 'same-origin', // Needed for older versions of Firefox, otherwise cookies not sent
@@ -35,41 +39,41 @@ export default class ApiClient {
return fetch(fullRoute, opts);
}
- GET(route) { return this._fetch(route, "GET", null); }
+ GET(route: string) { return this._fetch(route, "GET"); }
- POST(route, body) { return this._fetch(route, "POST", body); }
+ POST(route: string, body: T) { return this._fetch(route, "POST", body); }
- PUT(route, body) { return this._fetch(route, "PUT", body); }
+ PUT(route: string, body: T) { return this._fetch(route, "PUT", body); }
- PATCH(route, body) { return this._fetch(route, "PATCH", body); }
+ PATCH(route: string, body: T) { return this._fetch(route, "PATCH", body); }
- DELETE(route) { return this._fetch(route, "DELETE", null); }
+ DELETE(route: string) { return this._fetch(route, "DELETE"); }
- saveLayout = (id, layout) => {
+ saveLayout = (id: string, layout: Layout[]) => {
if (!this.isDev) {
const strippedLayout = layout.map(widget => ({ ...widget, moved: undefined }));
return this.PUT(`/dashboard/${id}`, strippedLayout);
}
}
- createWidget = async type => {
+ createWidget = async (type: string) => {
const response = this.isDev ? await this.GET(`/widget/create.json?type=${type}`) : await this.POST(`/widget?type=${type}`, null)
return await response.json()
}
- getWidgetHtml = async id => {
+ getWidgetHtml = async (id: string) => {
const html = this.isDev ? await this.GET(`/widget/${id}/create`) : await this.GET(`/widget/${id}`)
return html.text();
}
- deleteWidget = id => !this.isDev && this.DELETE(`/widget/${id}`)
+ deleteWidget = (id: string) => !this.isDev && this.DELETE(`/widget/${id}`)
- getEditForm = async id => {
+ getEditForm = async (id: string) => {
const response = await this.GET(`/widget/${id}/edit`);
return response.json();
}
- saveWidget = async (url, params) => {
+ saveWidget = async (url: string, params: any) => {
const result = this.isDev ? await this.GET(`/widget/update.json`) : await this.PUT(`${url}`, params);
return await result.json();
}
diff --git a/src/frontend/components/dashboard/lib/react/app.tsx b/src/frontend/components/dashboard/lib/react/app.tsx
deleted file mode 100644
index 3a4e6dc0c..000000000
--- a/src/frontend/components/dashboard/lib/react/app.tsx
+++ /dev/null
@@ -1,349 +0,0 @@
-import React from "react";
-import serialize from "form-serialize";
-
-import Modal from "react-modal";
-import RGL, { WidthProvider } from "react-grid-layout";
-
-import Header from "./Header";
-import Widget from './Widget';
-import Footer from "./Footer";
-import { sidebarObservable } from '../../../sidebar/lib/sidebarObservable';
-
-declare global {
- interface Window {
- Linkspace : any,
- // @ts-expect-error "Typings clash with JSTree"
- siteConfig: any
- }
-}
-
-const ReactGridLayout = WidthProvider(RGL);
-
-const modalStyle = {
- content: {
- minWidth: "350px",
- maxWidth: "80vw",
- maxHeight: "90vh",
- top: "50%",
- left: "50%",
- right: "auto",
- bottom: "auto",
- marginRight: "-50%",
- transform: "translate(-50%, -50%)",
- msTransform: "translate(-50%, -50%)",
- padding: 0
- },
- overlay: {
- zIndex: 1030,
- background: "rgba(0, 0, 0, .15)"
- }
-};
-
-class App extends React.Component {
- private formRef;
-
- constructor(props) {
- super(props);
- Modal.setAppElement("#ld-app");
-
- const layout = props.widgets.map(widget => widget.config);
- this.formRef = React.createRef();
- sidebarObservable.addSubscriber(this);
-
- this.state = {
- widgets: props.widgets,
- layout,
- editModalOpen: false,
- activeItem: 0,
- editHtml: "",
- editError: null,
- loading: false,
- loadingEditHtml: true,
- };
- }
-
- componentDidMount = () => {
- this.initializeGlobeComponents();
- }
-
- componentDidUpdate = (prevProps, prevState) => {
- window.requestAnimationFrame(this.overWriteSubmitEventListener);
-
- if (this.state.editModalOpen && prevState.loadingEditHtml && !this.state.loadingEditHtml && this.formRef) {
- this.initializeSummernoteComponent();
- }
-
- if (!this.state.editModalOpen && !prevState.loadingEditHtml && !this.state.loadingEditHtml) {
- this.initializeGlobeComponents();
- }
- }
-
- initializeSummernoteComponent = () => {
- const summernoteEl = this.formRef.current.querySelector('.summernote');
- if (summernoteEl) {
- import(/* WebpackChunkName: "summernote" */ "../../../summernote/lib/component")
- .then(({ default: SummerNoteComponent }) => {
- new SummerNoteComponent(summernoteEl)
- });
- }
- }
-
- initializeGlobeComponents = () => {
- const arrGlobe = document.querySelectorAll(".globe");
- import('../../../globe/lib/component').then(({default: GlobeComponent}) => {
- arrGlobe.forEach((globe) => {
- new GlobeComponent(globe)
- });
- });
- }
-
- updateWidgetHtml = async (id) => {
- const newHtml = await this.props.api.getWidgetHtml(id);
- const newWidgets = this.state.widgets.map(widget => {
- if (widget.config.i === id) {
- return {
- ...widget,
- html: newHtml,
- };
- }
- return widget;
- });
- this.setState({ widgets: newWidgets });
- }
-
- fetchEditForm = async (id) => {
- const editFormHtml = await this.props.api.getEditForm(id);
- if (editFormHtml.is_error) {
- this.setState({ loadingEditHtml: false, editError: editFormHtml.message });
- return;
- }
- this.setState({ loadingEditHtml: false, editError: false, editHtml: editFormHtml.content });
- }
-
- onEditClick = id => (event) => {
- event.preventDefault();
- this.showEditForm(id);
- }
-
- showEditForm = (id) => {
- this.setState({ editModalOpen: true, loadingEditHtml: true, activeItem: id });
- this.fetchEditForm(id);
- }
-
- closeModal = () => {
- this.setState({ editModalOpen: false });
- }
-
- deleteActiveWidget = () => {
- // eslint-disable-next-line no-alert
- if (!window.confirm("Deleting a widget is permanent! Are you sure?"))
- return
-
- this.setState({
- widgets: this.state.widgets.filter(item => item.config.i !== this.state.activeItem),
- editModalOpen: false,
- });
- this.props.api.deleteWidget(this.state.activeItem);
- }
-
- saveActiveWidget = async (event) => {
- event.preventDefault();
- const formEl = this.formRef.current.querySelector("form");
- if (!formEl) {
- // eslint-disable-next-line no-console
- console.error("No form element was found!");
- return;
- }
-
- const form = serialize(formEl, { hash: true });
- const result = await this.props.api.saveWidget(formEl.getAttribute("action"), form);
- if (result.is_error) {
- this.setState({ editError: result.message });
- return;
- }
- this.updateWidgetHtml(this.state.activeItem);
- this.closeModal();
- }
-
- isGridConflict = (x, y, w, h) => {
- const ulc = { x, y };
- const drc = { x: x + w, y: y + h };
- return this.state.layout.some((widget) => {
- if (ulc.x >= (widget.x + widget.w) || widget.x >= drc.x) {
- return false;
- }
- if (ulc.y >= (widget.y + widget.h) || widget.y >= drc.y) {
- return false;
- }
- return true;
- });
- }
-
- firstAvailableSpot = (w, h) => {
- let x = 0;
- let y = 0;
- while (this.isGridConflict(x, y, w, h)) {
- if ((x + w) < this.props.gridConfig.cols) {
- x += 1;
- } else {
- x = 0;
- y += 1;
- }
- if (y > 200) break;
- }
- return { x, y };
- }
-
- // eslint-disable-next-line no-unused-vars
- addWidget = async (type) => {
- this.setState({loading: true});
- const result = await this.props.api.createWidget(type)
- if (result.error) {
- this.setState({loading: false});
- alert(result.message);
- return;
- }
- const id = result.message;
- const { x, y } = this.firstAvailableSpot(1, 1);
- const widgetLayout = {
- i: id,
- x,
- y,
- w: 1,
- h: 1,
- };
- const newLayout = this.state.layout.concat(widgetLayout);
- this.setState({
- widgets: this.state.widgets.concat({
- config: widgetLayout,
- html: "Loading...",
- }),
- layout: newLayout,
- loading: false,
- }, () => this.updateWidgetHtml(id));
- this.props.api.saveLayout(this.props.dashboardId, newLayout);
- this.showEditForm(id);
- }
-
- generateDOM = () => (
- this.state.widgets.map(widget => (
-
-
-
- ))
- )
-
- onLayoutChange = (layout) => {
- if (this.shouldSaveLayout(this.state.layout, layout)) {
- this.props.api.saveLayout(this.props.dashboardId, layout);
- }
- this.setState({ layout });
- }
-
- shouldSaveLayout = (prevLayout, newLayout) => {
- if (prevLayout.length !== newLayout.length) {
- return true;
- }
- for (let i = 0; i < prevLayout.length; i += 1) {
- const entriesNew = Object.entries(newLayout[i]);
- const isDifferent = entriesNew.some((keypair) => {
- const [key, value] = keypair;
- if (key === "moved" || key === "static") return false;
- if (value !== prevLayout[i][key]) return true;
- return false;
- });
- if (isDifferent) return true;
- }
- return false;
- }
-
- renderModal = () => (
-
-
-
-
Edit widget
-
-
-
-
- {this.state.editError
- ?
{this.state.editError}
: null}
- {this.state.loadingEditHtml
- ?
Loading... :
}
-
-
-
-
-
-
-
-
-
-
- )
-
- overWriteSubmitEventListener = () => {
- const formContainer = document.getElementById("ld-form-container");
- if (!formContainer)
- return
-
- const form = formContainer.querySelector("form");
- if (!form)
- return
-
- form.addEventListener("submit", this.saveActiveWidget);
- const submitButton = document.createElement("input");
- submitButton.setAttribute("type", "submit");
- submitButton.setAttribute("style", "visibility: hidden");
- form.appendChild(submitButton);
- }
-
- handleSideBarChange = () => {
- window.dispatchEvent(new Event('resize'));
- }
-
- render() {
- return (
-
- {this.props.hideMenu ? null :
}
- {this.renderModal()}
-
-
- {this.generateDOM()}
-
-
- {this.props.hideMenu ? null :
}
-
- );
- }
-}
-
-export default App;
diff --git a/src/frontend/components/dashboard/lib/react/footer.scss b/src/frontend/components/dashboard/lib/react/footer.scss
index 94d2f6928..9ee7e68af 100644
--- a/src/frontend/components/dashboard/lib/react/footer.scss
+++ b/src/frontend/components/dashboard/lib/react/footer.scss
@@ -6,6 +6,9 @@
bottom: $padding-large-vertical;
flex-direction: column;
justify-content: end;
+ .dropdown:not(:last-of-type) {
+ margin-right: $padding-base-horizontal
+ }
}
@include media-breakpoint-up(md) {
diff --git a/src/frontend/components/dashboard/lib/react/header.scss b/src/frontend/components/dashboard/lib/react/header.scss
index 3d2d6858a..5338ac38c 100644
--- a/src/frontend/components/dashboard/lib/react/header.scss
+++ b/src/frontend/components/dashboard/lib/react/header.scss
@@ -1,3 +1,12 @@
+.nav-pills {
+ --bs-heading-color: #{$white};
+ --bs-nav-pills-link-active-bg: #{$brand-secundary};
+ h1 {
+ font-size: 1rem;
+ margin: 0;
+ }
+}
+
.ld-header-container {
text-align: right;
diff --git a/src/frontend/components/dashboard/lib/react/types/index.ts b/src/frontend/components/dashboard/lib/react/types/index.ts
new file mode 100644
index 000000000..2e310e8e1
--- /dev/null
+++ b/src/frontend/components/dashboard/lib/react/types/index.ts
@@ -0,0 +1,54 @@
+import ReactGridLayout, { Layout } from "react-grid-layout";
+import ApiClient from "../api";
+import { RefObject } from "react";
+
+export type HeaderProps = {
+ hMargin: number;
+ dashboards: DashboardProps[];
+ currentDashboard: DashboardProps;
+ includeH1: boolean;
+}
+
+export type DashboardProps = {
+ name: string;
+ url: string;
+ download_url?: string;
+}
+
+export type FooterProps = {
+ addWidget: (type: string) => void;
+ widgetTypes: string[];
+ currentDashboard: DashboardProps;
+ readOnly: boolean;
+ noDownload: boolean;
+}
+
+export type WidgetProps = {
+ html: string;
+ config: Layout;
+}
+
+export type AppProps = {
+ hideMenu: boolean;
+ gridConfig: ReactGridLayout.ReactGridLayoutProps;
+ dashboards: DashboardProps[];
+ currentDashboard: DashboardProps;
+ includeH1: boolean;
+ readOnly: boolean;
+ widgetTypes: string[];
+ noDownload: boolean;
+ widgets: WidgetProps[];
+ dashboardId: string;
+ api: ApiClient;
+}
+
+export type AppModalProps = {
+ closeModal: ()=>void,
+ formRef: RefObject,
+ deleteActiveWidget: ()=>void,
+ saveActiveWidget: ()=>void,
+ editModalOpen: boolean,
+ editError: string|null,
+ loadingEditHtml: boolean,
+ editHtml: string|null
+}
diff --git a/src/frontend/components/data-table/_data-table-row.scss b/src/frontend/components/data-table/_data-table-row.scss
deleted file mode 100644
index 209d441c5..000000000
--- a/src/frontend/components/data-table/_data-table-row.scss
+++ /dev/null
@@ -1,178 +0,0 @@
-.data-table .data-table-row--child {
- position: relative;
- padding-left: 2rem;
-
- &::after {
- @extend %icon-font;
-
- content: "\E806";
- position: absolute;
- top: 50%;
- left: $padding-base-horizontal;
- margin-right: 0.5rem;
- transform: translateY(-50%);
- font-size: 0.9em;
- }
-}
-
-/* stylelint-disable selector-no-qualifying-type */
-
-/* Necessary to overrule default styling */
-table.dataTable {
- // Replace the collapse control with an arrow
- &.dtr-column > tbody > tr > td.dtr-control::before,
- &.dtr-column > tbody > tr > th.dtr-control::before {
- @extend %icon-font;
-
- content: "\E805";
- // position: absolute; // This is causing the arrow to be misplaced
- top: 1.3rem;
- right: $padding-small-horizontal;
- left: auto;
- margin: 0 0.5rem 0 0;
- transform: rotate(90deg);
- border: 0;
- background-color: $transparent;
- box-shadow: none;
- color: $gray-extra-dark;
- font-size: 0.9em;
- transition: all 0.2s ease-in-out;
- }
-
- &.dtr-column > tbody > tr.parent > td.dtr-control::before,
- &.dtr-column > tbody > tr.parent > th.dtr-control::before {
- content: "\E805";
- transform: rotate(-90deg);
- background-color: $transparent;
- }
-
- & > tbody > tr.child ul.dtr-details > li {
- border-bottom: none;
- }
-
- & > tbody > tr.child span.dtr-title {
- min-width: auto;
- }
-
- &.dtr-column > tbody > tr > td.dtr-control.data-table-row--child,
- &.dtr-column > tbody > tr > th.dtr-control.data-table-row--child {
- padding-left: 4rem;
-
- &::after {
- left: 2rem;
- }
- }
-
- td {
- line-height: 1.125rem; // 18px
- }
-
- td.reorder {
- text-align: right;
- cursor: move;
-
- span {
- @include visually-hidden;
- }
-
- &::before {
- @extend %icon-font;
-
- content: "\E829";
- }
- }
-}
-
-.table-striped {
- border-bottom: 1px solid $gray;
-
- /* bottom border is too much for tables that are within the record view page */
- li & {
- border-bottom: none;
- }
-
- tbody tr:nth-of-type(odd) {
- background-color: $transparent;
- }
-
- tbody tr.odd,
- tbody tr.odd + tr.child {
- background-color: $gray-light;
- }
-
- tbody tr > td {
- line-height: 17px; // Makes each row 50px high
- }
-
- /* stylelint-disable declaration-no-important */
-
- /* Necessary to overrule default styling */
- tbody tr.odd + tr.child:hover {
- background-color: $gray-light !important;
- }
-
- &.table-lines {
- border-bottom: none;
- }
-}
-
-.table-hover {
- tbody tr.odd:hover {
- background-color: rgba($brand-secundary, 0.2);
- cursor: pointer;
- }
-
- tbody td .link {
- display: block;
- margin: -$padding-base-vertical;
- padding: $padding-base-vertical $padding-base-horizontal;
- transition: none;
- border-bottom: none;
- color: $gray-extra-dark;
-
- &:hover,
- &:active,
- &:focus {
- border-bottom: none;
- color: $gray-extra-dark;
- }
- }
-
- tbody td.child .dtr-data .link {
- margin: 0;
- padding: 0;
- }
-
- tbody tr:hover {
- cursor: pointer;
- }
-
- tbody tr.tr--focus,
- tbody tr.odd.tr--focus {
- background-color: rgba($brand-secundary, 0.2);
- }
-}
-
-/* Necessary to overrule default styling */
-.dataTables_scroll {
- .dataTables_scrollHead .dataTables_scrollHeadInner {
- width: 100% !important;
-
- .data-table {
- width: 100% !important;
-
- &.table-striped {
- border-bottom: none;
- }
-
- tr th {
- width: 100% !important;
- }
- }
- }
-
- // First row of fixed height, scrollable tables should still have a top border to ensure the same height of each row
- .dataTables_scrollBody > table > tbody tr:first-child td {
- border-top: 1px solid rgba(0, 0, 0, 0.05);
- }
-}
diff --git a/src/frontend/components/data-table/_data-table-toggle.scss b/src/frontend/components/data-table/_data-table-toggle.scss
deleted file mode 100644
index acda1ff47..000000000
--- a/src/frontend/components/data-table/_data-table-toggle.scss
+++ /dev/null
@@ -1,14 +0,0 @@
-.dataTables_scrollBody:has(.table-toggle) {
- @include media-breakpoint-down(md) {
- /* stylelint-disable-next-line declaration-no-important */
- height: auto !important;
- }
-}
-
-.table-toggle tr[data-field-is-toggled="false"] {
- display: none;
-}
-
-.table-toggle tbody tr:nth-child(odd of [data-field-is-toggled="true"]) {
- background-color: $gray-light;
-}
diff --git a/src/frontend/components/data-table/_data-table.scss b/src/frontend/components/data-table/_data-table.scss
index dab6a9882..80099cca2 100644
--- a/src/frontend/components/data-table/_data-table.scss
+++ b/src/frontend/components/data-table/_data-table.scss
@@ -1,383 +1,182 @@
-/* stylelint-disable selector-no-qualifying-type */
+@import "~datatables.net-bs5/css/dataTables.bootstrap5.css",
+ "~datatables.net-responsive-bs5/css/responsive.bootstrap5.css",
+ "~datatables.net-rowreorder-bs5/css/rowReorder.bootstrap5.css";
.data-table {
- border-spacing: 0;
- font-size: $font-size-sm;
- &.table-thead-hidden thead {
- @include visually-hidden;
- }
-
- thead {
- background-color: $white;
- z-index: 1;
- }
-
- thead th {
- border-bottom: 1px solid $gray;
- text-transform: uppercase;
- vertical-align: top;
+ tr {
- &[class*="sorting_asc"],
- &[class*="sorting_desc"] {
- color: $brand-secundary;
+ td,
+ th {
+ padding: 0.5rem;
+ }
}
- &.data-table__header--invisible span,
- &.dt__header--inivisible span {
- @include visually-hidden;
+ th {
+ margin: 0;
+ padding: 1rem;
+
+ .data-table__header-wrapper {
+ display: flex;
+ align-items: center;
+
+ .data-table__sort {
+ cursor: pointer;
+ }
+
+ .dt-column-order {
+ display: none;
+ }
+
+ .dropdown-toggle {
+ opacity: 0;
+ border: none;
+ margin: 0;
+ padding: 0;
+ transition: all 0.3s ease;
+
+ &::after {
+ display: none;
+ }
+ }
+
+ .data-table__sort {
+ display: flex;
+ flex-direction: row;
+ }
+
+ &:hover {
+ .data-table__sort {
+ cursor: pointer;
+ }
+
+ .dropdown-toggle {
+ opacity: 1;
+ border-bottom: none;
+ }
+ }
+ }
}
- }
-
- tfoot {
- background-color: $table-hover-bg;
- font-weight: bold;
- }
-
- &.table-lines th,
- &.table-lines td {
- border-top: 0;
- border-bottom: 1px solid $gray;
- }
-
- .autosize {
- max-height: 30px;
- }
-}
-
-/* Necessary to overrule default styling */
-table.dataTable thead .sorting,
-table.dataTable thead .sorting_disabled {
-
- /* stylelint-disable declaration-no-important */
- // Remove the sorting arrows from the sorting element, important needed to overrule default styling
- &::before,
- &::after {
- content: normal !important;
- }
-}
-
-// Styling of the generated dataTable
-.dataTables_wrapper {
- margin-bottom: $padding-small-vertical;
- font-size: $font-size-sm;
-
- &:last-child {
- margin-bottom: 0;
- }
-
- .row {
- width: 100%;
- }
-
- .row--header,
- .row--main {
- margin-bottom: $padding-base-vertical;
- }
-}
-
-.row--fiv-header {
- margin-top: 1.9rem;
-}
-
-.dataTables_toggle_full_width {
-
- .btn-toggle,
- .btn-toggle-off {
- padding-top: 7px;
- }
-}
-
-.data-table__container--scrollable {
- overflow: auto;
- thead {
- position: sticky;
- top: 0;
- }
-}
-
-.dataTables_info_wrapper {
- display: none;
-}
-
-.dataTables_length_wrapper {
- margin-top: $padding-large-vertical;
-}
-
-.dataTables_length {
- .form-control {
- @include form-control;
- }
-}
-
-.dataTables_filter {
- label {
- @include input-search;
-
- display: flex;
- justify-content: flex-start;
- }
-
- .form-control {
- @include form-control;
- }
-}
-
-.data-table__sort {
- display: flex;
- align-items: flex-start;
- order: 2;
- padding: 0;
- transition: 0.2s all ease-in;
- border: 0;
- border-bottom: 1px solid $transparent;
- background-color: $transparent;
- color: $gray-extra-dark;
- font-weight: bold;
- text-align: left;
- text-transform: uppercase;
-
- .btn-sort {
- margin-top: 0.1rem;
- margin-left: 0.25rem;
- opacity: 0;
-
- &:hover {
- border-bottom: none;
+ .data-table__sort {
+ &::after {
+ color: transparent;
+ @extend %icon-font;
+ content: quote("\E805");
+ padding-left: 0.5rem;
+ transition: all 0.3s ease;
+ }
}
- }
- &:hover,
- &:active,
- &:focus,
- .dt-ordering-asc &,
- .dt-ordering-desc & {
- color: $brand-secundary;
-
- .btn-sort {
- opacity: 1;
+ .dt-ordering-desc {
+ .data-table__sort {
+ &::after {
+ color: $brand-secundary;
+ transform: rotate(90deg);
+ }
+ }
}
- }
-
- .data-table__header--invisible & {
- display: none;
- }
-}
-
-.data-table__search {
- margin: 0 0.1rem 0 -1rem;
-
- .dropdown-toggle {
- margin-top: 0.1rem;
- transition: 0.2s opacity ease-in;
- opacity: 0;
- &:hover,
- &:active,
- &:focus {
- opacity: 1;
+ .dt-ordering-asc {
+ .data-table__sort {
+ &::after {
+ color: $brand-secundary;
+ transform: rotate(-90deg);
+ }
+ }
}
-
- &::after {
- content: normal;
- }
- }
-
- &.show .dropdown-toggle {
- opacity: 1;
- }
-
- label {
- @include input-search;
- }
-
- .input .form-control {
- width: auto;
- }
-
- .data-table__header--invisible & {
- display: none;
- }
}
-.col {
- .dt-search {
- input[type="search"] { // The styling has to be _very_ specific, if I leave out the `.col` field, it won't work.
- width: 98%; // I'm not sure where the bug this solves came from - I think it may be because of the DT upgrade (and just wasn't spotted) - if I use 100% it causes the other elements to be pushed down (I'm not keen on using percentiles, either).
- }
- }
+div.dt-processing>div:last-child>div {
+ background-color: $brand-secundary;
+ background: $brand-secundary;
}
-.dataTables_scrollHead .table--bordered,
-.dt-scroll-head .table--bordered {
- box-sizing: border-box;
- border: 1px solid $gray;
- border-bottom: 0;
- border-top-left-radius: $table-border-radius;
- border-top-right-radius: $table-border-radius;
-
- thead {
- // Why is this not in the variables file?? Or even more important, why is this a variable?
- $table-bordered-head-color: #585858;
-
- color: $table-bordered-head-color;
- }
+.dt-layout-row {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 1rem;
}
-.dataTables_scrollBody:has(.table--bordered),
-.dt-scroll-body:has(.table--bordered) {
- border: 1px solid $gray;
- border-top: 0;
- border-bottom-left-radius: $table-border-radius;
- border-bottom-right-radius: $table-border-radius;
-
- .table-striped {
- border-bottom: 0;
- }
+.dt-start {
+ // This is so it doesn't clash when the toggle is visible for fullscreen
+ width: 62%;
}
-.dataTables_scrollFoot .table-striped.table--bordered,
-.dt-scroll-foot .table-striped.table--bordered {
- border: 0;
+@include media-breakpoint-up(xl) {
+ div.dt-start {
+ width: 75%;
+ }
}
-.data-table__header-wrapper {
- display: flex;
- position: relative;
- align-items: flex-start;
-
- &.filter .data-table__search .dropdown-toggle.btn-search {
- opacity: 1;
- }
-
- &:hover,
- &:active,
- &:focus {
- .data-table__search .dropdown-toggle {
- opacity: 1;
+.dt-end {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-evenly;
+ align-items: center;
+ .form-check {
+ margin-left: 1rem;
}
- }
}
-// Pagination
-.dataTables_paginate .pagination {
- justify-content: center;
+div.dt-container div.dt-search input {
+ margin: 0;
+ width: 100%;
}
.page-item {
- .page-link {
- transition: 0.2s all ease;
- }
-
- &.active .page-link,
- .page-link:hover {
- border-color: $brand-secundary;
- background-color: $brand-secundary;
- color: $white;
- }
-}
-
-/* Necessary to overrule default styling */
-div.dataTables_wrapper div.dataTables_length {
- text-align: left;
-
- label {
- justify-content: flex-start;
-
- .form-control {
- margin-left: $padding-small-horizontal;
+ .page-link {
+ transition: 0.2s all ease;
}
- }
-}
-/* Necessary to overrule default styling */
-div.dataTables_wrapper div.dataTables_filter input.form-control {
- width: 100%;
- margin-left: 0;
+ &.active .page-link,
+ .page-link:hover {
+ border-color: $brand-secundary;
+ background-color: $brand-secundary;
+ color: $white;
+ }
}
-// Table fullscreen mode
-:fullscreen {
- body {
- padding: 0;
- background-color: $white;
- }
-
- .main {
- max-width: none;
- }
-
- .sidebar,
- .table-header,
- .content-block__navigation,
- .content-block__head {
- display: none;
- }
-
- .content-block__main {
- padding-top: 0;
- }
-
- .dataTables_wrapper {
- padding-top: $padding-large-vertical;
- }
-
- .data-table {
- margin-top: 0 !important; //Necessary to overrule external styling
- }
+th.data-table__header--invisible span {
+ @include visually-hidden;
}
-@include media-breakpoint-up(lg) {
- .dataTables_wrapper .row--main {
- margin-bottom: $padding-large-vertical;
- }
-
- .dataTables_length_wrapper {
- margin-top: 0;
- }
-
- .dataTables_length label {
- justify-content: flex-end;
- }
-
- .dataTables_info_wrapper {
- display: block;
- text-align: right;
- }
-
- .dataTables_paginate .pagination {
- justify-content: flex-start;
- }
+.dt-layout-table {
+ .dt-layout-cell {
+ width: 100%;
+ }
}
-div.dataTables_wrapper div.dataTables_processing,
-div.td-container div.dt-processing {
- top: 10rem;
+div.dt-length label {
+ padding-right: 0.5rem;
+ font-weight: 600;
}
-table.table-purge {
-
- thead tr th,
- tbody tr td {
- text-align: center !important; // For some reason, this doesn't override unless I do !important
- }
- tbody tr td {
- border: 1px solid $gray;
- border-top: 0;
- }
- thead tr th {
- background-color: $brand-secundary;
- color: $white;
- }
-}
+.table-modal {
+ overflow: auto;
+ position: fixed;
+ z-index: 1020;
+ /* Make sure this modal is above the nav bar */
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ padding: 1rem;
+ background: white;
+
+ .row {
+ margin-right: 0;
+ margin-left: 0;
+ width: 100%;
+ }
-button.btn-remove,
-button.btn-add {
- margin: $padding-base-vertical 0;
+ .data-table__header--invisible {
+ display: none;
+ }
}
-.dt-column-order {
- display: none;
- visibility: collapse;
+td:not(.dtr-control) {
+ cursor: pointer;
}
diff --git a/src/frontend/components/data-table/_table-modal.scss b/src/frontend/components/data-table/_table-modal.scss
deleted file mode 100644
index 0c122e6f6..000000000
--- a/src/frontend/components/data-table/_table-modal.scss
+++ /dev/null
@@ -1,16 +0,0 @@
-.table-modal {
- position: fixed;
- z-index: 1020;
- /* Make sure this modal is above the nav bar */
- top: 0;
- right: 0;
- bottom: 0;
- left: 0;
- padding: 1rem;
- background: white;
-
- .row {
- margin-right: 0;
- margin-left: 0;
- }
-}
\ No newline at end of file
diff --git a/src/frontend/components/data-table/lib/DataTablesPlugins.ts b/src/frontend/components/data-table/lib/DataTablesPlugins.ts
new file mode 100644
index 000000000..57fb4adfb
--- /dev/null
+++ b/src/frontend/components/data-table/lib/DataTablesPlugins.ts
@@ -0,0 +1,27 @@
+import DataTable from "datatables.net-bs5";
+
+/**
+ * Create a toggle button
+ * @param id The id of the toggle button
+ * @param label The label to use for the toggle button
+ * @param onToggle The function to call when the toggle button is toggled
+ * @returns A jQuery object representing the toggle button
+ */
+function createToggleButton(id:string, label:string, checked: boolean, onToggle:(ev:JQuery.Event)=>void) {
+ const element = $(`
+ `);
+
+ element.find(`#${id}`).on('change', (ev) => onToggle(ev));
+
+ return element;
+}
+
+// I feel using the "proper" toggle from bootstrap is better than the custom one and adding extra "fluff" to the datatables code in my opinion
+DataTable.feature.register('fullscreen', function (settings, opts) {
+ return createToggleButton('fullscreen-button', 'Fullscreen', opts.checked, opts.onToggle);
+});
\ No newline at end of file
diff --git a/src/frontend/components/data-table/lib/component.js b/src/frontend/components/data-table/lib/component.js
index 1bd3abf77..08becafbd 100644
--- a/src/frontend/components/data-table/lib/component.js
+++ b/src/frontend/components/data-table/lib/component.js
@@ -1,33 +1,33 @@
-/* eslint-disable @typescript-eslint/no-this-alias */
-
import { Component, initializeRegisteredComponents } from 'component'
-import 'datatables.net-bs4'
-import 'datatables.net-buttons-bs4'
-import 'datatables.net-responsive-bs4'
-import 'datatables.net-rowreorder-bs4'
+import 'datatables.net-bs5'
+import 'datatables.net-buttons-bs5'
+import 'datatables.net-responsive-bs5'
+import 'datatables.net-rowreorder-bs5'
+import './DataTablesPlugins'
import { setupDisclosureWidgets, onDisclosureClick } from 'components/more-less/lib/disclosure-widgets'
import { moreLess } from 'components/more-less/lib/more-less'
import { bindToggleTableClickHandlers } from './toggle-table'
+import { createElement } from "util/domutils";
const MORE_LESS_TRESHOLD = 50
-//TODO: It is worth noting that there are significant changes between DataTables.net v1 and v2 (hence the major version increase)
-// We are currently using v2 in this component, but with various deprecated features in use that may need to be updated in the future
class DataTableComponent extends Component {
- constructor(element) {
+ constructor(element) {
super(element)
this.el = $(this.element)
this.hasCheckboxes = this.el.hasClass('table-selectable')
this.hasClearState = this.el.hasClass('table-clear-state')
this.forceButtons = this.el.hasClass('table-force-buttons')
+ this.fullTable = this.el.hasClass('dt-table-full')
this.searchParams = new URLSearchParams(window.location.search)
this.base_url = this.el.data('href') ? this.el.data('href') : undefined
this.isFullScreen = false
+
this.initTable()
}
initTable() {
- if(this.hasClearState) {
+ if (this.hasClearState) {
this.clearTableStateForPage()
const url = new URL(window.location.href)
@@ -39,20 +39,24 @@ class DataTableComponent extends Component {
}
const conf = this.getConf()
- const {columns} = conf
+ const { columns } = conf
this.columns = columns
this.el.DataTable(conf)
+
+ $(window).on('resize', () => {
+ this.el.DataTable().responsive.recalc()
+ });
+
this.initializingTable = true
- $('.dt-column-order').remove() //datatables.net adds it's own ordering class - we remove it because it's easier than rewriting basically everywhere we use datatables
if (this.hasCheckboxes) {
this.addSelectAllCheckbox()
}
if (this.el.hasClass('table-account-requests')) {
- this.modal = $.find('#userModal')
+ this.modal = $('#userModal')
this.initClickableTable()
- this.el.on('draw.dt', ()=> {
+ this.el.on('draw.dt', () => {
this.initClickableTable()
})
}
@@ -60,7 +64,7 @@ class DataTableComponent extends Component {
bindToggleTableClickHandlers(this.el)
// Bind events to disclosure buttons and record-popup links on opening of child row
- $(this.el).on('childRow.dt', (e, show, row) => {
+ $(this.el).on('childRow.dt', (_e, _show, row) => {
const $childRow = $(row.child())
const recordPopupElements = $childRow.find('.record-popup')
@@ -68,7 +72,7 @@ class DataTableComponent extends Component {
if (recordPopupElements) {
import(/* webpackChunkName: "record-popup" */ 'components/record-popup/lib/component').then(({ default: RecordPopupComponent }) => {
- recordPopupElements.each((i, el) => {
+ recordPopupElements.each((_i, el) => {
new RecordPopupComponent(el)
});
});
@@ -78,7 +82,7 @@ class DataTableComponent extends Component {
clearTableStateForPage() {
for (let i = 0; i < localStorage.length; i++) {
- const storageKey = localStorage.key( i )
+ const storageKey = localStorage.key(i)
if (!storageKey.startsWith("DataTables")) {
continue;
@@ -90,7 +94,7 @@ class DataTableComponent extends Component {
continue;
}
- if(window.location.href.indexOf('/' + keySegments.slice(1).join('/')) !== -1) {
+ if (window.location.href.indexOf('/' + keySegments.slice(1).join('/')) !== -1) {
localStorage.removeItem(storageKey)
}
}
@@ -102,9 +106,15 @@ class DataTableComponent extends Component {
links.off('click')
links.off('focus')
links.off('blur')
- links.on('click', (ev) => { this.handleClick(ev) })
- links.on('focus', (ev) => { this.toggleFocus(ev, true) })
- links.on('blur', (ev) => { this.toggleFocus(ev, false) })
+ links.on('click', (ev) => {
+ this.handleClick(ev)
+ })
+ links.on('focus', (ev) => {
+ this.toggleFocus(ev, true)
+ })
+ links.on('blur', (ev) => {
+ this.toggleFocus(ev, false)
+ })
}
toggleFocus(ev, hasFocus) {
@@ -134,7 +144,7 @@ class DataTableComponent extends Component {
btnReject.val(id)
}
- fields.each((i, field) => {
+ fields.each((_i, field) => {
const fieldName = $(field).attr('name')
const fieldValue = $(row).find(`td[data-${fieldName}]`).data(fieldName)
@@ -157,10 +167,10 @@ class DataTableComponent extends Component {
getCheckboxElement(id, label) {
return (
`` +
- `` +
- `` +
+ `` +
+ `` +
'
'
- )
+ )
}
addSelectAllCheckbox() {
@@ -179,10 +189,10 @@ class DataTableComponent extends Component {
})
// Check if the 'select all' checkbox is checked and all checkboxes need to be checked
- $selectAllElm.find('input').on( 'click', (ev) => {
+ $selectAllElm.find('input').on('click', (ev) => {
const checkbox = $(ev.target)
- if ($(checkbox).is( ':checked' )) {
+ if ($(checkbox).is(':checked')) {
this.checkAllCheckboxes($checkBoxes, true)
} else {
this.checkAllCheckboxes($checkBoxes, false)
@@ -192,7 +202,7 @@ class DataTableComponent extends Component {
checkAllCheckboxes($checkBoxes, bCheckAll) {
if (bCheckAll) {
- $checkBoxes.prop( 'checked', true )
+ $checkBoxes.prop('checked', true)
} else {
$checkBoxes.prop('checked', false)
}
@@ -201,7 +211,7 @@ class DataTableComponent extends Component {
checkSelectAll($checkBoxes, $selectAllCheckBox) {
let bSelectAll = true
- $checkBoxes.each((i, checkBox) => {
+ $checkBoxes.each((_i, checkBox) => {
if (!checkBox.checked) {
$selectAllCheckBox.prop('checked', false)
bSelectAll = false
@@ -216,19 +226,16 @@ class DataTableComponent extends Component {
addSortButton(dataTable, column, headerContent) {
const $header = $(column.header())
const $button = $(`
-
`
@@ -291,11 +298,11 @@ class DataTableComponent extends Component {
$searchInput.appendTo($('.input', $searchElement))
if (col.typeahead_use_id) {
$searchInput.after(`