From 19c88299d0775bc01f706031b195659065945d7c Mon Sep 17 00:00:00 2001 From: "Dusan Mijatovic (PC2020)" Date: Fri, 17 Jan 2025 16:53:35 +0100 Subject: [PATCH] refactor: move rsd remote name functionality to admin section instead using env variable --- .env.example | 5 - database/025-rsd-info.sql | 28 ++- deployment/docker-compose.yml | 5 +- docker-compose.yml | 5 +- frontend/components/admin/AdminNav.tsx | 11 +- .../components/admin/pages/add/addConfig.ts | 4 +- .../components/admin/rsd-info/AddRsdInfo.tsx | 167 ++++++++++++++++++ .../admin/rsd-info/RsdInfoTable.tsx | 92 ++++++++++ .../components/admin/rsd-info/apiRsdInfo.ts | 153 ++++++++++++++++ frontend/components/admin/rsd-info/config.tsx | 115 ++++++++++++ frontend/components/admin/rsd-info/index.tsx | 21 +++ .../components/admin/rsd-info/useRsdInfo.tsx | 97 ++++++++++ frontend/components/table/EditableCell.tsx | 79 ++++++--- frontend/components/table/EditableTable.tsx | 14 +- frontend/components/table/TableBody.tsx | 27 ++- frontend/components/table/TableHeader.tsx | 51 ++++-- frontend/pages/admin/rsd-info.tsx | 75 ++++++++ 17 files changed, 863 insertions(+), 86 deletions(-) create mode 100644 frontend/components/admin/rsd-info/AddRsdInfo.tsx create mode 100644 frontend/components/admin/rsd-info/RsdInfoTable.tsx create mode 100644 frontend/components/admin/rsd-info/apiRsdInfo.ts create mode 100644 frontend/components/admin/rsd-info/config.tsx create mode 100644 frontend/components/admin/rsd-info/index.tsx create mode 100644 frontend/components/admin/rsd-info/useRsdInfo.tsx create mode 100644 frontend/pages/admin/rsd-info.tsx diff --git a/.env.example b/.env.example index 47a671386..4150f4862 100644 --- a/.env.example +++ b/.env.example @@ -28,11 +28,6 @@ # automatically name the containers. COMPOSE_PROJECT_NAME="rsd" -# RSD REMOTE NAME -# identify this instance as remote by this name -# it is used as source label in RPC aggregated_software_overview -RSD_REMOTE_NAME=Local RSD - # ---- PUBLIC ENV VARIABLES ------------- # postgresql diff --git a/database/025-rsd-info.sql b/database/025-rsd-info.sql index e64608499..035c20043 100644 --- a/database/025-rsd-info.sql +++ b/database/025-rsd-info.sql @@ -1,14 +1,16 @@ --- SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center) --- SPDX-FileCopyrightText: 2024 Netherlands eScience Center +-- SPDX-FileCopyrightText: 2024 - 2025 Dusan Mijatovic (Netherlands eScience Center) +-- SPDX-FileCopyrightText: 2024 - 2025 Netherlands eScience Center -- -- SPDX-License-Identifier: Apache-2.0 -- RSD info table -- used to obtain RSD name to use for remotes --- it should provide basic info about rsd instance (eg. endpoints) +-- it should provide basic info about rsd instance +-- manually insert remote_name property CREATE TABLE rsd_info ( key VARCHAR(100) PRIMARY KEY, - value VARCHAR(250), + value VARCHAR(250) NOT NULL, + public BOOLEAN DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL ); @@ -37,22 +39,18 @@ $$; CREATE TRIGGER sanitise_update_rsd_info BEFORE UPDATE ON rsd_info FOR EACH ROW EXECUTE PROCEDURE sanitise_update_rsd_info(); --- Insert remote_name key extracted from env variable RSD_REMOTE_NAME, default value is 'Local RSD' --- AND basic endpoints info -INSERT INTO rsd_info VALUES - ('remote_name', COALESCE(current_setting('rsd.remote_name',true),'Local RSD')), - ('postgrest_api','/api/v1'), - ('images_api','/images'), - ('swagger','/swagger'), - ('codemeta','/metadata/codemeta') -; +-- EXAMPLE OF PUBLIC PROPERTIES TO INSERT IN THE rsd_info table +-- REMOTE NAME IS used to identify your instance to other RSD instances +INSERT INTO rsd_info VALUES ('remote_name','Not defined',TRUE); -- RLS -- rsd info table ALTER TABLE rsd_info ENABLE ROW LEVEL SECURITY; --- anyone can read (SELECT) + +-- anyone can read (SELECT) public keys CREATE POLICY anyone_can_read ON rsd_info FOR SELECT TO rsd_web_anon, rsd_user - USING (TRUE); + USING (public = TRUE); + -- rsd_admin has all rights CREATE POLICY admin_all_rights ON rsd_info TO rsd_admin USING (TRUE) diff --git a/deployment/docker-compose.yml b/deployment/docker-compose.yml index 0482fcd3d..164ba58e2 100644 --- a/deployment/docker-compose.yml +++ b/deployment/docker-compose.yml @@ -5,7 +5,7 @@ # SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all) # SPDX-FileCopyrightText: 2022 Helmholtz Centre for Environmental Research (UFZ) # SPDX-FileCopyrightText: 2022 dv4all -# SPDX-FileCopyrightText: 2023 - 2024 Dusan Mijatovic (Netherlands eScience Center) +# SPDX-FileCopyrightText: 2023 - 2025 Dusan Mijatovic (Netherlands eScience Center) # # SPDX-License-Identifier: Apache-2.0 @@ -24,9 +24,6 @@ services: - POSTGRES_USER - POSTGRES_PASSWORD - POSTGRES_AUTHENTICATOR_PASSWORD - - RSD_REMOTE_NAME - # insert remote_name into postgres settings, this value is used in initial SQL scripts - command: postgres -D /var/lib/postgresql/data -c 'rsd.remote_name=${RSD_REMOTE_NAME}' volumes: # persist data in named docker volume # to remove use: docker compose down --volumes diff --git a/docker-compose.yml b/docker-compose.yml index 1fc333534..713d5f27b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ # SPDX-FileCopyrightText: 2022 - 2024 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences # SPDX-FileCopyrightText: 2022 Helmholtz Centre for Environmental Research (UFZ) # SPDX-FileCopyrightText: 2022 Matthias Rüster (GFZ) -# SPDX-FileCopyrightText: 2023 - 2024 Dusan Mijatovic (Netherlands eScience Center) +# SPDX-FileCopyrightText: 2023 - 2025 Dusan Mijatovic (Netherlands eScience Center) # SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all) # # SPDX-License-Identifier: Apache-2.0 @@ -26,9 +26,6 @@ services: - POSTGRES_USER - POSTGRES_PASSWORD - POSTGRES_AUTHENTICATOR_PASSWORD - - RSD_REMOTE_NAME - # insert remote_name into postgres settings, this value is used in initial SQL scripts - command: postgres -D /var/lib/postgresql/data -c 'rsd.remote_name=${RSD_REMOTE_NAME}' volumes: # persist data in named docker volume # to remove use: docker compose down --volumes diff --git a/frontend/components/admin/AdminNav.tsx b/frontend/components/admin/AdminNav.tsx index 5e950ca0a..1dee303e2 100644 --- a/frontend/components/admin/AdminNav.tsx +++ b/frontend/components/admin/AdminNav.tsx @@ -1,5 +1,5 @@ -// SPDX-FileCopyrightText: 2023 - 2024 Dusan Mijatovic (Netherlands eScience Center) -// SPDX-FileCopyrightText: 2023 - 2024 Netherlands eScience Center +// SPDX-FileCopyrightText: 2023 - 2025 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2023 - 2025 Netherlands eScience Center // SPDX-FileCopyrightText: 2023 Christian Meeßen (GFZ) // SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) // SPDX-FileCopyrightText: 2023 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences @@ -31,6 +31,7 @@ import CategoryIcon from '@mui/icons-material/Category' import TerminalIcon from '@mui/icons-material/Terminal' import ListAltIcon from '@mui/icons-material/ListAlt' import HubIcon from '@mui/icons-material/Hub' +import CookieIcon from '@mui/icons-material/Cookie' import {editMenuItemButtonSx} from '~/config/menuItems' @@ -107,6 +108,12 @@ export const adminPages = { icon: , path: '/admin/mentions', }, + rsd_info:{ + title: 'Rsd info', + subtitle: '', + icon: , + path: '/admin/rsd-info', + }, remote_rsd: { title: 'Remotes', subtitle: '', diff --git a/frontend/components/admin/pages/add/addConfig.ts b/frontend/components/admin/pages/add/addConfig.ts index 6a71a6e5c..a1319de5a 100644 --- a/frontend/components/admin/pages/add/addConfig.ts +++ b/frontend/components/admin/pages/add/addConfig.ts @@ -1,5 +1,7 @@ // SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all) // SPDX-FileCopyrightText: 2022 - 2023 dv4all +// SPDX-FileCopyrightText: 2025 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2025 Netherlands eScience Center // // SPDX-License-Identifier: Apache-2.0 @@ -26,7 +28,7 @@ export const addConfig = { maxLength: {value: 200, message: 'Maximum length is 200'}, pattern: { value: /^[a-z0-9]+(-[a-z0-9]+)*$/, - message: 'Restricted input violiation. Use letters, numbers and dashes "-" only between other input.' + message: 'Restricted input violation. Use letters, numbers and dashes "-" only between other input.' } } } diff --git a/frontend/components/admin/rsd-info/AddRsdInfo.tsx b/frontend/components/admin/rsd-info/AddRsdInfo.tsx new file mode 100644 index 000000000..474a45607 --- /dev/null +++ b/frontend/components/admin/rsd-info/AddRsdInfo.tsx @@ -0,0 +1,167 @@ +// SPDX-FileCopyrightText: 2025 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2025 Netherlands eScience Center +// +// SPDX-License-Identifier: Apache-2.0 + +import {useState} from 'react' +import Button from '@mui/material/Button' +import AddIcon from '@mui/icons-material/Add' +import useMediaQuery from '@mui/material/useMediaQuery' +import Dialog from '@mui/material/Dialog' +import DialogTitle from '@mui/material/DialogTitle' +import DialogContent from '@mui/material/DialogContent' +import DialogActions from '@mui/material/DialogActions' + +import {useForm} from 'react-hook-form' + +import TextFieldWithCounter from '~/components/form/TextFieldWithCounter' +import ControlledSwitch from '~/components/form/ControlledSwitch' +import SubmitButtonWithListener from '~/components/form/SubmitButtonWithListener' +import {RsdInfo} from './apiRsdInfo' +import {rsdInfoForm} from './config' + +type RsdInfoModalProps=Readonly<{ + onCancel: () => void, + onSubmit: (item: RsdInfo) => void +}> + +const formId='add-remote-rsd-form' + +function RsdInfoModal({onCancel,onSubmit}:RsdInfoModalProps){ + const smallScreen = useMediaQuery('(max-width:600px)') + const {formState:{errors,isValid,isDirty}, control, watch, register, handleSubmit} = useForm({ + mode: 'onChange', + + }) + + // watch for data change in the form + const [key,value] = watch(['key','value']) + + function handleCancel(e:any,reason: 'backdropClick' | 'escapeKeyDown') { + // close only on escape, not if user clicks outside of the modal + if (reason==='escapeKeyDown') onCancel() + } + + return ( + + + {rsdInfoForm.modalTitle} + +
+ + + : undefined + }} + register={register('key', { + ...rsdInfoForm.key.validation + })} + /> + +
+ + : undefined + }} + register={register('value', { + ...rsdInfoForm.value.validation + })} + /> + +
+ +
+ + + + + + + +
+ ) + + function isSubmitDisabled(){ + if (isValid===false) return true + // we need additional check on errors object + // due to custom validation of domain + if (Object.keys(errors).length > 0) return true + if (isDirty===false) return true + return false + } +} + + +export default function AddRsdInfo({onAdd}:Readonly<{onAdd:(data:RsdInfo)=>void}>) { + const [modal,setModal] = useState(false) + + return ( + <> + + { + modal ? setModal(false)} + onSubmit={(data)=>{ + setModal(false) + onAdd(data) + }} + /> + : null + } + + ) +} diff --git a/frontend/components/admin/rsd-info/RsdInfoTable.tsx b/frontend/components/admin/rsd-info/RsdInfoTable.tsx new file mode 100644 index 000000000..db07fe257 --- /dev/null +++ b/frontend/components/admin/rsd-info/RsdInfoTable.tsx @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: 2025 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2025 Netherlands eScience Center +// +// SPDX-License-Identifier: Apache-2.0 + +import {useState} from 'react' + +import Alert from '@mui/material/Alert' +import AlertTitle from '@mui/material/AlertTitle' + +import {useSession} from '~/auth' +import EditableTable, {OrderByProps} from '~/components/table/EditableTable' +import ContentLoader from '~/components/layout/ContentLoader' +import {RsdInfo} from './apiRsdInfo' +import useRsdInfo from './useRsdInfo' + +const styles = { + flex: 1, + overflow: 'auto', + padding: '0.5rem 0rem', + cursor: 'default' +} + +// initial order is on key +const initialOrder:OrderByProps = { + column: 'key', + direction: 'asc' +} + +export default function RsdInfoTable() { + const {token} = useSession() + const [orderBy, setOrderBy] = useState>(initialOrder) + const {loading, columns, rsdInfo} = useRsdInfo({token,orderBy}) + + // console.group('RsdInfoTable') + // console.log('loading...', loading) + // console.log('columns...', columns) + // console.log('rsdInfo...', rsdInfo) + // console.groupEnd() + + if(loading) return + + if (rsdInfo.length === 0) { + return ( +
+ + Rsd info not found + +
+ ) + } + + function onSortColumn(column:keyof RsdInfo) { + if (orderBy && orderBy.column === column) { + if (orderBy.direction === 'asc') { + setOrderBy({ + column, + direction: 'desc' + }) + } else { + setOrderBy({ + column, + direction: 'asc' + }) + } + } else { + setOrderBy({ + column, + direction: 'asc' + }) + } + } + + return ( +
+ +
+ * Click on the value to update the value +
+
+ ) +} diff --git a/frontend/components/admin/rsd-info/apiRsdInfo.ts b/frontend/components/admin/rsd-info/apiRsdInfo.ts new file mode 100644 index 000000000..26212b252 --- /dev/null +++ b/frontend/components/admin/rsd-info/apiRsdInfo.ts @@ -0,0 +1,153 @@ +// SPDX-FileCopyrightText: 2025 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2025 Netherlands eScience Center +// +// SPDX-License-Identifier: Apache-2.0 + +import logger from '~/utils/logger' +import {ApiParams, paginationUrlParams} from '~/utils/postgrestUrl' +import {createJsonHeaders, extractReturnMessage, getBaseUrl} from '~/utils/fetchHelpers' +import {extractCountFromHeader} from '~/utils/extractCountFromHeader' + +export type RsdInfo = { + key: string, + value: string, + public?: boolean, + created_at?: string, + updated_at?: string +} + +export type RsdInfoTable = { + id: string, + key: string, + value: string, + public?: boolean, + created_at?: string, + updated_at?: string + origin?: string + command?: string +} + +export async function getRsdInfo({page, rows, token, searchFor, orderBy}: ApiParams) { + try { + let query = paginationUrlParams({rows, page}) + if (searchFor) { + query+=`&or=(key.ilike.*${searchFor}*,value.ilike.*${searchFor}*)` + } + if (orderBy) { + query+=`&order=${orderBy.column}.${orderBy.direction}` + } else { + query+='&order=key.asc' + } + // complete url + const url = `${getBaseUrl()}/rsd_info?${query}` + + // make request + const resp = await fetch(url,{ + method: 'GET', + headers: { + ...createJsonHeaders(token), + // request record count to be returned + // note: it's returned in the header + 'Prefer': 'count=exact' + }, + }) + + if ([200,206].includes(resp.status)) { + const data: RsdInfo[] = await resp.json() + return { + count: extractCountFromHeader(resp.headers) ?? 0, + rsdInfo: data.map(item=>({ + ...item, + id: item.key, + origin: 'rsd_info' + })) + } + } + logger(`getRsdInfo: ${resp.status}: ${resp.statusText}`,'warn') + return { + count: 0, + rsdInfo: [] + } + } catch (e: any) { + logger(`getRsdInfo: ${e.message}`,'error') + return { + count: 0, + rsdInfo: [] + } + } +} + +export async function createInfo({data,token}: {data:RsdInfo,token: string }) { + try { + // POST + const url = `${getBaseUrl()}/rsd_info` + const resp = await fetch(url, { + method: 'POST', + headers: { + ...createJsonHeaders(token), + 'Prefer': 'return=representation', + }, + body: JSON.stringify(data) + }) + if (resp.status === 201) { + const json:RsdInfo[] = await resp.json() + return { + status: 201, + message: json[0] + } + } + // debugger + return extractReturnMessage(resp, data.key ?? '') + } catch (e: any) { + logger(`createInfo: ${e?.message}`, 'error') + return { + status: 500, + message: e?.message + } + } +} + +export async function patchRsdInfo({id, key, value, token}: {id:string, key: string, value:any, token: string }) { + try { + const url = `${getBaseUrl()}/rsd_info?key=eq.${id}` + // make request + const resp = await fetch(url, { + method: 'PATCH', + headers: { + ...createJsonHeaders(token), + }, + body: JSON.stringify({ + [key]:value + }) + }) + + return extractReturnMessage(resp) + + } catch (e: any) { + logger(`patchRsdInfo: ${e.message}`, 'error') + return { + status: 500, + message: e.message + } + } +} + +export async function deleteInfoByKey({key, token}: { key: string, token: string }) { + try { + // try to find keyword + const url = `${getBaseUrl()}/rsd_info?key=eq.${key}` + const resp = await fetch(url, { + method: 'DELETE', + headers: { + ...createJsonHeaders(token) + } + }) + return extractReturnMessage(resp, key) + } catch (e: any) { + logger(`deleteInfoByKey: ${e?.message}`, 'warn') + return { + status: 500, + message: e?.message + } + } +} diff --git a/frontend/components/admin/rsd-info/config.tsx b/frontend/components/admin/rsd-info/config.tsx new file mode 100644 index 000000000..6f414ed7d --- /dev/null +++ b/frontend/components/admin/rsd-info/config.tsx @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: 2025 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2025 Netherlands eScience Center +// +// SPDX-License-Identifier: Apache-2.0 + +import IconButton from '@mui/material/IconButton' +import DeleteIcon from '@mui/icons-material/Delete' +import {Column} from '~/components/table/EditableTable' +import {patchRsdInfo, RsdInfo, RsdInfoTable} from './apiRsdInfo' +import AddRsdInfo from './AddRsdInfo' + +export function createColumns(token: string, addRsdInfo:(data:RsdInfo)=>void, deleteRsdInfo:(key:string)=>void) { + const columns: Column[] = [{ + key: 'key', + label: 'Key', + type: 'string', + }, { + key: 'value', + label: 'Value*', + type: 'string', + validFn: ({value})=>{ + if (value && value.toString().trim()!=='') return true + return false + }, + patchFn: async (props) => patchRsdInfo({ + ...props, + token + }) + }, { + key: 'public', + label: 'Public', + type: 'boolean', + patchFn: async (props) => patchRsdInfo({ + ...props, + token + }), + disabledFn:(data)=>{ + // remote_name public value cannot be changed + if (data.id==='remote_name') return true + return false + }, + },{ + key: 'created_at', + label: 'Created At', + type: 'datetime', + },{ + key: 'updated_at', + label: 'Updated At', + type: 'datetime', + },{ + key: 'command', + label: 'Add', + // use command type to render column header button using headerFn + type: 'custom', + // render "Add" button in the column header and add modal + headerFn: ()=>{ + return ( + + ) + }, + // render "Delete" button on each row + renderFn: ({key}) => { + return ( + { + deleteRsdInfo(key) + }} + > + + + ) + }, + }] + + return columns +} + + +export const rsdInfoForm = { + modalTitle: 'Add rsd info', + key:{ + label: 'Key*', + help: 'Unique single word without spaces is required.', + validation: { + required: 'Unique key value is required.', + // we do not show error message for this one, we use only maxLength value + maxLength: {value: 100, message: 'Maximum length is 100'}, + pattern: { + // only chars, numbers and _, also exclude from sonar analyses + value: /^[a-z0-9A-Z\_]+([a-z0-9A-Z]+)*$/, // NOSONAR + message: 'Use only letters, numbers and underscore "_".' + } + } + }, + value:{ + label: 'Value*', + help: 'The string value assigned to the key property is required.', + validation: { + required: 'Value is required', + // we do not show error message for this one, we use only maxLength value + maxLength: {value: 250, message: 'Maximum length is 250'} + } + }, + public:{ + label: 'Public', + help: 'Is this info available to everyone accessing the rsd_info endpoint?', + validation: { + required: 'Active flag is required.', + } + } +} diff --git a/frontend/components/admin/rsd-info/index.tsx b/frontend/components/admin/rsd-info/index.tsx new file mode 100644 index 000000000..875de665f --- /dev/null +++ b/frontend/components/admin/rsd-info/index.tsx @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2025 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2025 Netherlands eScience Center +// +// SPDX-License-Identifier: Apache-2.0 + +import Pagination from '~/components/pagination/Pagination' +import Searchbox from '~/components/search/Searchbox' +import RsdInfoTable from './RsdInfoTable' + +export default function RsdInfoPage() { + + return ( +
+
+ + +
+ +
+ ) +} diff --git a/frontend/components/admin/rsd-info/useRsdInfo.tsx b/frontend/components/admin/rsd-info/useRsdInfo.tsx new file mode 100644 index 000000000..746815251 --- /dev/null +++ b/frontend/components/admin/rsd-info/useRsdInfo.tsx @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: 2025 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2025 Netherlands eScience Center +// +// SPDX-License-Identifier: Apache-2.0 + +import {useCallback, useEffect, useState} from 'react' +import usePaginationWithSearch from '~/utils/usePaginationWithSearch' + +import {OrderByProps} from '~/components/table/EditableTable' +import useSnackbar from '~/components/snackbar/useSnackbar' +import {createInfo, deleteInfoByKey, getRsdInfo, RsdInfoTable,RsdInfo} from './apiRsdInfo' +import {createColumns} from './config' + +type useContributorsProps = { + token: string, + orderBy?: OrderByProps +} + +export default function useRsdInfo({token, orderBy}:useContributorsProps) { + const {showErrorMessage} = useSnackbar() + const {searchFor, page, rows, setCount} = usePaginationWithSearch('Find info by key or value') + const [rsdInfo, setRsdInfo] = useState([]) + // show loading only on initial load + const [loading, setLoading] = useState(true) + const [columns] = useState(createColumns(token,addRsdInfo,deleteRsdInfo)) + + const loadRsdInfo = useCallback(async () => { + let abort = false + const {rsdInfo, count} = await getRsdInfo({ + token, + searchFor, + page, + rows, + orderBy + }) + + if (abort === false) { + if (orderBy) { + // update columns order + columns.forEach(col => { + if (col.key === orderBy.column) { + col.order = { + active: true, + direction: orderBy.direction + } + } else { + col.order = { + active: false, + direction: 'asc' + } + } + }) + } + setRsdInfo(rsdInfo) + setCount(count) + setLoading(false) + } + + return ()=>{abort=true} + // we do not include setCount in order to avoid loop + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token, searchFor, page, rows, columns, orderBy]) + + useEffect(() => { + loadRsdInfo() + },[loadRsdInfo]) + + async function addRsdInfo(data:RsdInfo) { + const resp = await createInfo({ + data, + token + }) + if (resp.status === 201) { + await loadRsdInfo() + } else { + showErrorMessage(`Failed to add info. ${resp.message}`) + } + } + + async function deleteRsdInfo(key: string) { + const resp = await deleteInfoByKey({ + key, + token + }) + if (resp.status === 200) { + await loadRsdInfo() + } else { + showErrorMessage(`Failed to delete info. ${resp.message}`) + } + } + + return { + loading, + columns, + rsdInfo + } +} diff --git a/frontend/components/table/EditableCell.tsx b/frontend/components/table/EditableCell.tsx index e94493403..936e9f31b 100644 --- a/frontend/components/table/EditableCell.tsx +++ b/frontend/components/table/EditableCell.tsx @@ -1,32 +1,40 @@ -// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2023 - 2025 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2023 - 2025 Netherlands eScience Center // SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) -// SPDX-FileCopyrightText: 2023 Netherlands eScience Center // SPDX-FileCopyrightText: 2023 dv4all // // SPDX-License-Identifier: Apache-2.0 import {useEffect, useState} from 'react' import useSnackbar from '~/components/snackbar/useSnackbar' +import {ColType} from './EditableTable' +import Switch from '@mui/material/Switch' export type UpdateProps = { id: string, key: string, value: any + type: ColType origin?: string } -type EditableCellProps = { +type EditableCellProps = Readonly<{ params: UpdateProps, patchFn?: (props:UpdateProps)=>Promise<{status:number,message:string}> -} + validFn?: (props:UpdateProps)=>boolean + disabledFn?: (props:UpdateProps)=>boolean +}> -export default function EditableCell({patchFn, params}: EditableCellProps) { +export default function EditableCell({params,patchFn,validFn,disabledFn}: EditableCellProps) { const {showErrorMessage} = useSnackbar() const {value} = params const [localValue, setValue] = useState(value) + const [valid, setValid] = useState(true) // console.group('EditableCell') // console.log('params...', params) + // console.log('localValue...', localValue) + // console.log('valid...', valid) // console.log('value...', value) // console.groupEnd() @@ -34,33 +42,60 @@ export default function EditableCell({patchFn, params}: EditableCellProps) { setValue(value) },[value]) - async function patchValue({target}:{target:HTMLInputElement}) { - if (target.value !== value && patchFn) { - const resp = await patchFn({ - ...params, - value: target.value - }) - if (resp.status !== 200) { - // show error message - showErrorMessage(`Failed to update value. ${resp.message}`) - // reverse back to orginal value + async function patchValue(newValue:any) { + if (patchFn) { + let validValue = true + if (validFn){ + validValue = validFn({ + ...params, + value: newValue + }) + } + // only if valid + if (validValue){ + const resp = await patchFn({ + ...params, + value: newValue + }) + if (resp.status !== 200) { + // show error message + showErrorMessage(`Failed to update value. ${resp.message}`) + // reverse back to original value + setValue(value) + } + }else{ + // reverse back to original value setValue(value) } + // update valid if not in sync + if (validValue!==valid) setValid(validValue) } } + if (params.type==='boolean'){ + return ( + { + setValue(target.checked) + patchValue(target.checked) + }} + /> + ) + } + return ( setValue(target.value)} - onKeyDown={(e) => { - if (e.key === 'Escape') { - setValue(value) - } + onChange={({target})=>{ + setValue(target.value) }} - onBlur={patchValue} + onBlur={({target})=>patchValue(target.value)} /> ) } diff --git a/frontend/components/table/EditableTable.tsx b/frontend/components/table/EditableTable.tsx index a209accb1..b9e841987 100644 --- a/frontend/components/table/EditableTable.tsx +++ b/frontend/components/table/EditableTable.tsx @@ -1,7 +1,7 @@ -// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2023 - 2025 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2023 - 2025 Netherlands eScience Center // SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) // SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all) -// SPDX-FileCopyrightText: 2023 Netherlands eScience Center // SPDX-FileCopyrightText: 2023 dv4all // // SPDX-License-Identifier: Apache-2.0 @@ -20,16 +20,21 @@ export type OrderProps = { direction: 'asc'|'desc' } +export type ColType = 'string' | 'date' | 'datetime' | 'boolean' | 'custom' | 'command' + export type Column = { key: K label: string - type: 'string' | 'date' | 'datetime' | 'boolean' | 'custom' + type: ColType align?: 'left'|'right'|'center'|'justify' className?: string sx?: SxProps order?: OrderProps patchFn?: (props: UpdateProps) => Promise<{ status: number, message: string }> + disabledFn?: (props: UpdateProps) => boolean + validFn?: (props: UpdateProps) => boolean renderFn?: (data:T) => JSX.Element + headerFn?: () => JSX.Element } export type OrderByProps = { @@ -46,8 +51,9 @@ export type MuiTableProps } -export default function EditableTable({ +export default function EditableTable({ columns, data, onSort, className='w-full mb-8 text-sm', stickyHeader=false, sx}: MuiTableProps) { + return ( (col: Column, value: any) { if (value===null || typeof value==='undefined') return '' - let formatedValue = '' + let formattedValue = '' try { switch (col.type) { - case 'boolean': - formatedValue = value ? 'Y' : 'N' - return formatedValue case 'date': - formatedValue = new Date(value).toLocaleDateString() - return formatedValue + formattedValue = new Date(value).toLocaleDateString() + return formattedValue case 'datetime': - formatedValue = new Date(value).toLocaleString() - return formatedValue + formattedValue = new Date(value).toLocaleString() + return formattedValue default: - // formatedValue = value.toString() return value } } catch(e:any) { logger(`formatValue error: ${e.message}`, 'warn') - return formatedValue + return value } } @@ -56,14 +52,17 @@ function TableRow({data id: data.id, key: col.key.toString(), value, - origin: data?.origin + origin: data?.origin, + type: col.type }} patchFn={col?.patchFn} + disabledFn={col?.disabledFn} + validFn={col?.validFn} /> ) } - if (col.type === 'custom' && col.renderFn) { + if (col?.renderFn) { return ( = Readonly<{ + column: Column + onSort: (column: K) => void +}> + +function HeaderCell({column,onSort}: HeaderCellType){ + switch (true){ + // if headerFn present it has priority + case typeof(column?.headerFn)=='function': + return column.headerFn() + // custom type cannot be sorted (use to disable sort option) + case column.type === 'custom': + return {column.label} + // all other column types are sortable + default: + return ( + onSort(column.key)} + > + {column.label} + + ) + } +} + + export default function TableHeader({columns, onSort}: {columns: Column[], onSort:(column:K)=>void}) { return ( @@ -20,23 +48,16 @@ export default function TableHeader({columns, onSort}: {columns.map((col, i) => { return ( col.type!=='custom' ? onSort(col.key) : null} + // style={{cursor: col.type!=='custom' ? 'pointer' : 'default'}} + // onClick={() => col.type!=='custom' ? onSort(col.key) : null} sx={col?.sx} > - {col.type!=='custom' ? - onSort(col.key)} - > - {col.label} - - : - col.label - } + ) })} diff --git a/frontend/pages/admin/rsd-info.tsx b/frontend/pages/admin/rsd-info.tsx new file mode 100644 index 000000000..f04a0263e --- /dev/null +++ b/frontend/pages/admin/rsd-info.tsx @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: 2023 - 2025 Dusan Mijatovic (Netherlands eScience Center) +// SPDX-FileCopyrightText: 2023 - 2025 Netherlands eScience Center +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2023 dv4all +// +// SPDX-License-Identifier: Apache-2.0 + +import Head from 'next/head' + +import {app} from '~/config/app' +import {rowsPerPageOptions} from '~/config/pagination' +import {useUserSettings} from '~/config/UserSettingsContext' +import DefaultLayout from '~/components/layout/DefaultLayout' +import AdminPageWithNav from '~/components/admin/AdminPageWithNav' +import {adminPages} from '~/components/admin/AdminNav' +import {SearchProvider} from '~/components/search/SearchContext' +import {PaginationProvider} from '~/components/pagination/PaginationContext' +import RsdInfoPage from '~/components/admin/rsd-info/index' + +const pageTitle = `${adminPages['rsd_info'].title} | Admin page | ${app.title}` + +const pagination = { + count: 0, + page: 0, + rows: 12, + rowsOptions: [12,24,48], + labelRowsPerPage:'Per page' +} + +export default function AdminRsdInfoPage() { + // use page rows from user settings + const {rsd_page_rows} = useUserSettings() + pagination.rows = rsd_page_rows ?? rowsPerPageOptions[0] + + // console.group('AdminRsdInfoPage') + // console.log('rsd_page_rows...', rsd_page_rows) + // console.groupEnd() + + return ( + + + {pageTitle} + + + + + + + + + + ) +} + +// see documentation https://nextjs.org/docs/basic-features/data-fetching#getserversideprops-server-side-rendering +// export async function getServerSideProps(context:GetServerSidePropsContext) { +// try{ +// const {req} = context +// const token = req?.cookies['rsd_token'] + +// // get links to all pages server side +// // const links = await getPageLinks({is_published: false, token}) + +// return { +// // passed to the page component as props +// props: { +// keywords:[] +// }, +// } +// }catch(e){ +// return { +// notFound: true, +// } +// } +// }