diff --git a/daiquiri/core/assets/js/api/BaseApi.js b/daiquiri/core/assets/js/api/BaseApi.js index b8b61021..65a05ccd 100644 --- a/daiquiri/core/assets/js/api/BaseApi.js +++ b/daiquiri/core/assets/js/api/BaseApi.js @@ -15,7 +15,7 @@ function ValidationError(errors) { this.errors = errors } -class BaseApi { +export default class BaseApi { static get(url) { return fetch(baseUrl + url).catch(error => { @@ -29,6 +29,18 @@ class BaseApi { }) } + static getText(url) { + return fetch(baseUrl + url).catch(error => { + throw new ApiError(error.message) + }).then(response => { + if (response.ok) { + return response.text() + } else { + throw new ApiError(response.statusText, response.status) + } + }) + } + static post(url, data) { return fetch(baseUrl + url, { method: 'POST', @@ -120,5 +132,3 @@ class BaseApi { } } - -export default BaseApi diff --git a/daiquiri/core/assets/js/api/CoreApi.js b/daiquiri/core/assets/js/api/CoreApi.js new file mode 100644 index 00000000..485a8f43 --- /dev/null +++ b/daiquiri/core/assets/js/api/CoreApi.js @@ -0,0 +1,20 @@ +import BaseApi from './BaseApi' + +import { encodeParams } from 'daiquiri/core/assets/js/utils/api' + +export default class CoreApi extends BaseApi { + + static fetchDataLinks(dataLinkId) { + const params = { + 'ID': dataLinkId, + 'RESPONSEFORMAT': 'application/json' + } + + return this.get(`/datalink/links?${encodeParams(params)}`).then(response => response.links) + } + + static fetchNote(url) { + return this.getText(url) + } + +} diff --git a/daiquiri/core/assets/js/components/table/Table.js b/daiquiri/core/assets/js/components/table/Table.js index 5fbe0b27..128d0f7f 100644 --- a/daiquiri/core/assets/js/components/table/Table.js +++ b/daiquiri/core/assets/js/components/table/Table.js @@ -1,14 +1,95 @@ -import React from 'react' +import React, { useState, useEffect } from 'react' import PropTypes from 'prop-types' import { isEmpty } from 'lodash' +import { useModal } from 'daiquiri/core/assets/js/hooks/modal' + +import { getFileUrl, isDataLinkColumn, isImageColumn, isNoteColumn, isModalColumn } from '../../utils/table.js' + import TableFooter from './TableFooter' import TableHeader from './TableHeader' +import TableModal from './TableModal' import TablePane from './TablePane' const Table = ({ columns, rows, pageSizes, params, setParams }) => { const show = !(isEmpty(columns) || isEmpty(rows)) - const pageCount = rows.count / params.page_size + const pageCount = Math.ceil(rows.count / params.page_size) + + const [modalRef, showModal, hideModal] = useModal() + const [modalValues, setModalValues] = useState({}) + const [active, setActive] = useState({}) + + useEffect(() => { + if (modalRef.current && modalRef.current.classList.contains('show') && modalValues.page == params.page) { + // update the modal if (a) it is shown and (b) if we are not currently changing pages + updateModal(active) + } + }, [active]) + + useEffect(() => { + if (modalRef.current && modalRef.current.classList.contains('show')) { + // always update the modal if the rows change + updateModal(active) + } + }, [rows]) + + const updateModal = ({ rowIndex, columnIndex }) => { + const column = columns[columnIndex] + const value = rows.results[rowIndex][columnIndex] + + if (isModalColumn(column)) { + setModalValues({ + title: value, + dataLinkId: isDataLinkColumn(column) ? value : null, + noteUrl: isNoteColumn(column) ? getFileUrl(column, value) : null, + imageSrc: isImageColumn(column) ? getFileUrl(column, value) : null, + page: params.page, + up: (rowIndex > 0 || params.page > 1), + down: (rowIndex < params.page_size - 1 || params.page < pageCount), + right: columns.filter((c, i) => i > columnIndex).some(isModalColumn), + left: columns.filter((c, i) => i < columnIndex).some(isModalColumn), + }) + } + } + + const handleClick = (rowIndex, columnIndex) => { + setActive({ rowIndex, columnIndex }) + + console.log(isModalColumn(columns[columnIndex])) + + if (isModalColumn(columns[columnIndex])) { + updateModal({ rowIndex, columnIndex }) + showModal() + } + } + + const handleNavigation = (direction) => { + if (direction == 'up') { + if (active.rowIndex > 0) { + setActive({ ...active, rowIndex: active.rowIndex - 1 }) + } else if (params.page > 1) { + setActive({ ...active, rowIndex: params.page_size - 1 }) + setParams({ ...params, page: params.page - 1 }) + } + } else if (direction == 'down') { + if (active.rowIndex < params.page_size - 1) { + setActive({ ...active, rowIndex: active.rowIndex + 1 }) + } else if (params.page < pageCount) { + setActive({ ...active, rowIndex: 0 }) + setParams({ ...params, page: params.page + 1 }) + } + } else if (direction == 'right') { + const columnIndex = columns.findIndex((c, i) => isModalColumn(c) && i > active.columnIndex) + if (columnIndex > 0) { + setActive({ ...active, columnIndex}) + } + } else if (direction == 'left') { + const columnIndex = columns.findIndex((c, i) => isModalColumn(c) && i < active.columnIndex) + if (columnIndex > 0) { + setActive({ ...active, columnIndex}) + } + } + } return show && (
@@ -21,7 +102,9 @@ const Table = ({ columns, rows, pageSizes, params, setParams }) => { columns={columns} rows={rows} params={params} + active={active} setParams={setParams} + onClick={handleClick} /> { params={params} setParams={setParams} /> +
) } diff --git a/daiquiri/core/assets/js/components/table/TableBody.js b/daiquiri/core/assets/js/components/table/TableBody.js index 432b1272..c74d5125 100644 --- a/daiquiri/core/assets/js/components/table/TableBody.js +++ b/daiquiri/core/assets/js/components/table/TableBody.js @@ -4,7 +4,7 @@ import { isEmpty } from 'lodash' import TableCell from './TableCell' -const TableBody = ({ columns, rows }) => { +const TableBody = ({ columns, rows, active, onClick }) => { return ( { @@ -15,11 +15,17 @@ const TableBody = ({ columns, rows }) => { ) : rows.results.map((row, rowIndex) => ( - + { columns.map((column, columnIndex) => ( - + )) } @@ -32,7 +38,9 @@ const TableBody = ({ columns, rows }) => { TableBody.propTypes = { columns: PropTypes.array.isRequired, - rows: PropTypes.object.isRequired + rows: PropTypes.object.isRequired, + active: PropTypes.object.isRequired, + onClick: PropTypes.func.isRequired } export default TableBody diff --git a/daiquiri/core/assets/js/components/table/TableCell.js b/daiquiri/core/assets/js/components/table/TableCell.js index b81821f0..d72df458 100644 --- a/daiquiri/core/assets/js/components/table/TableCell.js +++ b/daiquiri/core/assets/js/components/table/TableCell.js @@ -1,17 +1,59 @@ import React from 'react' import PropTypes from 'prop-types' -const TableCell = ({ column, value }) => { +import { getBasename, getFileUrl, getLinkUrl, getReferenceUrl, + isModalColumn, isFileColumn, isLinkColumn } from '../../utils/table.js' + +const TableCell = ({ column, value, rowIndex, columnIndex, onClick }) => { + + const handleClick = (event) => { + event.preventDefault() + event.stopPropagation() + onClick(rowIndex, columnIndex) + } + + const renderCell = () => { + if (column.ucd && column.ucd.includes('meta.ref')) { + if (isModalColumn(column)) { + // render the modal + return ( + {value} + ) + } else if (isFileColumn(column)) { + // render a file link + return ( + {getBasename(value)} + ) + } else if (isLinkColumn(column)) { + // render a regular link + return ( + {value} + ) + } else { + // render a link to the resolver + return ( + {value} + ) + } + } else { + // this is not a reference, just render the value + return value + } + } + return ( -
- {value} +
+ {renderCell()}
) } TableCell.propTypes = { - column: PropTypes.number.isRequired, - value: PropTypes.array.isRequired + column: PropTypes.object.isRequired, + value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + rowIndex: PropTypes.number.isRequired, + columnIndex: PropTypes.number.isRequired, + onClick: PropTypes.func.isRequired } export default TableCell diff --git a/daiquiri/core/assets/js/components/table/TableModal.js b/daiquiri/core/assets/js/components/table/TableModal.js new file mode 100644 index 00000000..2009df7c --- /dev/null +++ b/daiquiri/core/assets/js/components/table/TableModal.js @@ -0,0 +1,62 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import { useDataLinksQuery, useNoteQuery } from '../../hooks/queries' + +import TableModalNavigation from './TableModalNavigation' + +const TableModal = ({ modalRef, modalValues, onNavigation, onClose }) => { + + const { data: dataLinks } = useDataLinksQuery(modalValues.dataLinkId) + const { data: note } = useNoteQuery(modalValues.noteUrl) + + return ( +
+
+ { + modalValues && ( +
+
+
{modalValues.title}
+ +
+
+ + { + dataLinks && ( + + ) + } + { + note &&
{note}
+ } + { + modalValues.imageSrc && ( + {modalValues.title} + ) + } +
+
+ ) + } +
+
+ ) +} + +TableModal.propTypes = { + modalRef: PropTypes.object, + modalValues: PropTypes.object, + onNavigation: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired +} + +export default TableModal diff --git a/daiquiri/core/assets/js/components/table/TableModalNavigation.js b/daiquiri/core/assets/js/components/table/TableModalNavigation.js new file mode 100644 index 00000000..5a2a268a --- /dev/null +++ b/daiquiri/core/assets/js/components/table/TableModalNavigation.js @@ -0,0 +1,62 @@ +import React from 'react' +import PropTypes from 'prop-types' + +const TableModalNavigation = ({ values, onClick }) => { + return ( +
+
+ { + values.up && ( +
+ +
+ ) + } + { + values.down && ( +
+ +
+ ) + } + { + values.left && ( +
+ +
+ ) + } + { + values.right && ( +
+ +
+ ) + } +
+
+ ) +} + +TableModalNavigation.propTypes = { + values: PropTypes.object, + onClick: PropTypes.func.isRequired +} + +export default TableModalNavigation diff --git a/daiquiri/core/assets/js/components/table/TablePane.js b/daiquiri/core/assets/js/components/table/TablePane.js index 0886cf43..590a0ffc 100644 --- a/daiquiri/core/assets/js/components/table/TablePane.js +++ b/daiquiri/core/assets/js/components/table/TablePane.js @@ -4,13 +4,22 @@ import PropTypes from 'prop-types' import TableHead from './TableHead' import TableBody from './TableBody' -const TablePane = ({ columns, rows, params, setParams }) => { +const TablePane = ({ columns, rows, params, active, setParams, onClick }) => { return (
- - + +
) @@ -20,7 +29,9 @@ TablePane.propTypes = { columns: PropTypes.array.isRequired, rows: PropTypes.object.isRequired, params: PropTypes.object.isRequired, - setParams: PropTypes.func.isRequired + active: PropTypes.object.isRequired, + setParams: PropTypes.func.isRequired, + onClick: PropTypes.func.isRequired } export default TablePane diff --git a/daiquiri/core/assets/js/hooks/modal.js b/daiquiri/core/assets/js/hooks/modal.js index 107d2e2c..ab234681 100644 --- a/daiquiri/core/assets/js/hooks/modal.js +++ b/daiquiri/core/assets/js/hooks/modal.js @@ -1,5 +1,6 @@ import { useRef } from 'react' import { Modal } from 'bootstrap' +import { isNil } from 'lodash' export const useModal = () => { const ref = useRef() @@ -11,7 +12,9 @@ export const useModal = () => { const hideModal = () => { const modal = Modal.getInstance(ref.current) - modal.hide() + if (!isNil(modal)) { + modal.hide() + } } return [ref, showModal, hideModal] diff --git a/daiquiri/core/assets/js/hooks/queries.js b/daiquiri/core/assets/js/hooks/queries.js new file mode 100644 index 00000000..0c8ffb0b --- /dev/null +++ b/daiquiri/core/assets/js/hooks/queries.js @@ -0,0 +1,22 @@ +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { isEmpty } from 'lodash' + +import CoreApi from '../api/CoreApi' + +export const useDataLinksQuery = (dataLinkId) => { + return useQuery({ + queryKey: ['dataLinks', dataLinkId], + queryFn: () => CoreApi.fetchDataLinks(dataLinkId), + placeholderData: keepPreviousData, + enabled: !isEmpty(dataLinkId) + }) +} + +export const useNoteQuery = (url) => { + return useQuery({ + queryKey: ['note', url], + queryFn: () => CoreApi.fetchNote(url), + placeholderData: keepPreviousData, + enabled: !isEmpty(url) + }) +} diff --git a/daiquiri/core/assets/js/utils/table.js b/daiquiri/core/assets/js/utils/table.js new file mode 100644 index 00000000..7b44c292 --- /dev/null +++ b/daiquiri/core/assets/js/utils/table.js @@ -0,0 +1,23 @@ +import { baseUrl } from './meta' + +export const getBasename = (string) => string.replace(/^.*[\\/]/, '') + +export const getFileUrl = (column, value) => `${baseUrl}/files/${value}` + +export const getLinkUrl = (column, value) => value + +export const getReferenceUrl = (column, value) => `${baseUrl}/serve/references/${column.name}/${value}` + +export const isRefColumn = (column) => column.ucd && column.ucd.includes('meta.ref') + +export const isLinkColumn = (column) => column.ucd && column.ucd.includes('meta.ref.url') + +export const isDataLinkColumn = (column) => column.ucd && column.ucd.includes('meta.ref.id') + +export const isImageColumn = (column) => column.ucd && column.ucd.includes('meta.image') + +export const isNoteColumn = (column) => column.ucd && column.ucd.includes('meta.note') + +export const isFileColumn = (column) => column.ucd && column.ucd.includes('meta.file') + +export const isModalColumn = (column) => isDataLinkColumn(column) || isImageColumn(column) || isNoteColumn(column) diff --git a/daiquiri/core/assets/scss/table.scss b/daiquiri/core/assets/scss/table.scss index 6cc8d3cf..b08c45f2 100644 --- a/daiquiri/core/assets/scss/table.scss +++ b/daiquiri/core/assets/scss/table.scss @@ -31,11 +31,6 @@ $handle-width: 0.5rem; border-right: none; } } - tr { - &.selected { - background-color: var(--dq-table-active-bg); - } - } td { .dq-table-cell { padding: $handle-width 0.5rem; @@ -86,4 +81,31 @@ $handle-width: 0.5rem; } } } + + .dq-table-modal { + .modal-body { + min-height: 6rem; + } + + .dq-table-modal-navigation { + width: 4rem; + height: 4rem; + + background-color: var(--bs-secondary-bg); + border-radius: 50%; + + button { + opacity: 0.5; + + [class^="material-symbols"] { + font-size: 1.6rem; + font-weight: bold; + } + + &:hover { + opacity: 1; + } + } + } + } } diff --git a/daiquiri/datalink/viewsets.py b/daiquiri/datalink/viewsets.py index 52f7e5c0..ef485cc1 100644 --- a/daiquiri/datalink/viewsets.py +++ b/daiquiri/datalink/viewsets.py @@ -8,11 +8,12 @@ from .constants import DATALINK_FIELDS, DATALINK_CONTENT_TYPE from .adapter import DatalinkAdapter -from .models import Datalink + class SyncDatalinkJobViewSet(viewsets.GenericViewSet): - '''Generate the datalink VOTable - ''' + ''' + Generate the datalink VOTable + ''' def list(self, request): return self.perform_sync_job(request, request.GET) @@ -21,7 +22,6 @@ def create(self, request): return self.perform_sync_job(request, request.POST) def perform_sync_job(self, request, data): - if 'ID' in data: identifiers = data.getlist('ID') else: @@ -38,7 +38,7 @@ def perform_sync_job(self, request, data): { 'href': row[1], 'text': row[4] - } for row in rows + } for row in rows if not row[3] ] }) else: