diff --git a/package.json b/package.json index 8ac99d44..c5d626bb 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "@loadable/component": "^5.14.1", "@macrostrat-web/data-sheet-test": "workspace:*", "@macrostrat-web/globe": "workspace:*", + "@macrostrat-web/security": "workspace:*", "@macrostrat/api-utils": "workspace:*", "@macrostrat/api-views": "workspace:*", "@macrostrat/column-components": "workspace:*", diff --git a/packages/security/package.json b/packages/security/package.json new file mode 100644 index 00000000..e72d4dd5 --- /dev/null +++ b/packages/security/package.json @@ -0,0 +1,6 @@ +{ + "name": "@macrostrat-web/security", + "private": true, + "main": "src/index.ts", + "license": "ISC" +} diff --git a/packages/security/src/index.ts b/packages/security/src/index.ts new file mode 100644 index 00000000..1f5a740c --- /dev/null +++ b/packages/security/src/index.ts @@ -0,0 +1,15 @@ + +// Handles fetch requests that require authentication +export const secureFetch = async (url, options) => { + + console.log(url, options) + + const response = await fetch(url, options); + + if (response.status === 401) { + window.open(`${import.meta.env.VITE_MACROSTRAT_INGEST_API}/security/login`, '_blank').focus(); + throw {name: "UnauthorizedError", message: "User is not logged in"} + } + + return response +} \ No newline at end of file diff --git a/src/pages/maps/@id/edit/components/cell/basic.ts b/src/pages/maps/@id/edit/components/cell/basic.ts new file mode 100644 index 00000000..ae0419b7 --- /dev/null +++ b/src/pages/maps/@id/edit/components/cell/basic.ts @@ -0,0 +1,13 @@ +import React from 'react'; + +import {Cell} from "@blueprintjs/table"; + +import hyper from "@macrostrat/hyper"; +import styles from "./main.module.sass"; + +export const h = hyper.styled(styles); + + +export const BasicCell = (...props) => { + return h(Cell, {...props}); +} \ No newline at end of file diff --git a/src/pages/maps/@id/edit/components/cell/index.d.ts b/src/pages/maps/@id/edit/components/cell/index.d.ts new file mode 100644 index 00000000..802d0d2b --- /dev/null +++ b/src/pages/maps/@id/edit/components/cell/index.d.ts @@ -0,0 +1,4 @@ +interface CellProps extends React.HTMLProps { + value: string; + onChange: (value: string) => void; +} \ No newline at end of file diff --git a/src/pages/maps/@id/edit/components/cell/main.module.sass b/src/pages/maps/@id/edit/components/cell/main.module.sass new file mode 100644 index 00000000..91250971 --- /dev/null +++ b/src/pages/maps/@id/edit/components/cell/main.module.sass @@ -0,0 +1,55 @@ +@import "@blueprintjs/core/lib/scss/variables.scss" + +.data-sheet-container, .data-sheet-holder + flex: 1 + position: relative + min-height: 0 + +.data-sheet-container + display: flex + flex-direction: column + +.data-sheet + height: 100% + +:global(.bp4-dark) .data-sheet :global(.bp4-table-quadrant) + background-color: $dark-gray1 + +.input-cell + padding: 0 2px !important + input + width: 100% + height: 100% + padding: 0 8px + z-index: 0 + position: relative + border: none + margin: 0 + font-size: 1em + pointer-events: all + background: transparent + &:focus + outline: none + +.hidden-input + opacity: 0 + position: absolute + width: 0 + +.corner-drag-handle + position: absolute + bottom: 0 + right: 0 + width: 8px + height: 8px + background-color: $dark-gray1 + cursor: ns-resize + background-color: dodgerblue + +.data-sheet-toolbar + display: flex + flex-direction: row + margin-bottom: 4px + +.spacer + flex-grow: 1 \ No newline at end of file diff --git a/src/pages/maps/@id/edit/components/progress-popover/main.module.sass b/src/pages/maps/@id/edit/components/progress-popover/main.module.sass new file mode 100644 index 00000000..4b4f7a21 --- /dev/null +++ b/src/pages/maps/@id/edit/components/progress-popover/main.module.sass @@ -0,0 +1,15 @@ +.progress-popover + z-index: 9999 + position: absolute + bottom: 0px + background: white + padding: 10px + border-radius: 5px + box-shadow: #ececec 5px 5px 5px + width: 200px + left: 50% + transform: translate(-50%, -50%) + + .progress-popover-text + padding-top: 10px + text-align: center \ No newline at end of file diff --git a/src/pages/maps/@id/edit/components/progress-popover/progress-popover.ts b/src/pages/maps/@id/edit/components/progress-popover/progress-popover.ts new file mode 100644 index 00000000..062ab025 --- /dev/null +++ b/src/pages/maps/@id/edit/components/progress-popover/progress-popover.ts @@ -0,0 +1,26 @@ +import { ProgressBar, ProgressBarProps } from "@blueprintjs/core"; + +import hyper from "@macrostrat/hyper"; + +import styles from "./main.module.sass"; +const h = hyper.styled(styles); + +interface ProgressPopoverProps extends React.HTMLProps { + text: string; + value: number; + progressBarProps?: ProgressBarProps; +} + +export default function ProgressPopover({text, value, progressBarProps}: ProgressPopoverProps) { + return h("div", { + className: "progress-popover" + }, [ + h(ProgressBar, { + value: value, + ...progressBarProps + }), + h("div", { + className: "progress-popover-text" + }, text) + ]); +} diff --git a/src/pages/maps/@id/edit/edit-table.ts b/src/pages/maps/@id/edit/edit-table.ts index 06c74cdd..1eb3b385 100644 --- a/src/pages/maps/@id/edit/edit-table.ts +++ b/src/pages/maps/@id/edit/edit-table.ts @@ -1,5 +1,7 @@ import hyper from "@macrostrat/hyper"; + + import { useState, useEffect, useCallback, useRef, useLayoutEffect, useMemo } from "react"; import { HotkeysProvider, InputGroup, Button } from "@blueprintjs/core"; import { Spinner, ButtonGroup } from "@blueprintjs/core"; @@ -14,9 +16,10 @@ import { } from "@blueprintjs/table"; import update from "immutability-helper"; -import { OperatorQueryParameter } from "~/pages/maps/@id/edit/table"; -import { buildURL, Filter } from "~/pages/maps/@id/edit/table-util"; +import { Filters, OperatorQueryParameter, TableUpdate, TableSelection, Selection } from "~/pages/maps/@id/edit/table"; +import { buildURL, Filter, isEmptyArray, submitChange, getTableUpdate } from "~/pages/maps/@id/edit/table-util"; import TableMenu from "~/pages/maps/@id/edit/table-menu"; +import ProgressPopover from "~/pages/maps/@id/edit/components/progress-popover/progress-popover"; import "./override.sass" import "@blueprintjs/table/lib/css/table.css"; @@ -29,21 +32,6 @@ const range = (start, stop, step = 1) => .fill(start) .map((x, y) => x + y * step); -interface Selection { - cols: number[]; - rows: number[]; -} - - -interface Filters { - [key: string]: Filter; -} - -interface TableSelection { - columns: string[]; - filters: Filters; -} - const FINAL_COLUMNS = [ "source_id", "orig_id", @@ -59,6 +47,7 @@ const FINAL_COLUMNS = [ ] export default function EditTable({ url }) { + // Table values const [page, setPage] = useState(0); const [pageSize, setPageSize] = useState(999999); @@ -66,12 +55,12 @@ export default function EditTable({ url }) { const [data, setData] = useState([]); const [dataToggle, setDataToggle] = useState(false); - const [inputValue, setInputValue] = useState(""); const [error, setError] = useState(undefined) const [filters, setFilters] = useState({}) const [group, setGroup] = useState(undefined) const [tableSelection, setTableSelection] = useState({columns: [], filter: new Filter("_pkid", "in", "")}) - + const [tableUpdates, setTableUpdates] = useState([]) + const [updateProgress, setUpdateProgress] = useState(undefined) // Sparse array to hold edited data const [editedData, setEditedData] = useState(new Array()); @@ -81,13 +70,14 @@ export default function EditTable({ url }) { return data.length ? Object.keys(data[0]).filter(x => x != "_pkid") : [] }, [data]) - const onChange = (key, row, text) => { + const onChange = (column, row, text) => { + let rowSpec = {}; - if (text == data[row][key] || (text == "" && data[row][key] == null)) { - rowSpec = { $unset: [key] }; + if (text == data[row][column] || (text == "" && data[row][column] == null)) { + rowSpec = { $unset: [column] }; } else { const rowOp = editedData[row] == null ? "$set" : "$merge"; - rowSpec = { [rowOp]: { [key]: text } }; + rowSpec = { [rowOp]: { [column]: text } }; } const newData = update(editedData, { @@ -133,15 +123,17 @@ export default function EditTable({ url }) { } const cellRenderer = useCallback( - ({ key, row, cell }) => { + ({ columnName, rowIndex, cell }) => { return h( EditableCell2, { onConfirm: (value) => { - onChange(key, row, value); + const tableUpdate = getTableUpdate(value, columnName, rowIndex, data, filters, group) + setTableUpdates([...tableUpdates, tableUpdate]) + onChange(columnName, rowIndex, value); }, - value: editedData[row]?.[key] ?? data[row][key], - intent: intentForCell(key, row), + value: editedData[rowIndex]?.[columnName] ?? data[rowIndex][columnName], + intent: intentForCell(columnName, rowIndex), }, [] ); @@ -203,13 +195,38 @@ export default function EditTable({ url }) { return h(Spinner) } - const columns = nonIdColumns.map((key) => { + const submitTableUpdates = async () => { + + setUpdateProgress(0) + + let index = 0 + for(const update of tableUpdates){ + + try { + await submitChange(url, update) + } catch (e) { + + setUpdateProgress(undefined) + return // If there is an error, stop submitting + } + + index += 1 + setUpdateProgress(index / tableUpdates.length) + } + + setTableUpdates([]) + setEditedData([]) + setDataToggle(!dataToggle) + setUpdateProgress(undefined) + } + + const columns = nonIdColumns.map((columnName) => { return h(Column, { - name: key, - className: FINAL_COLUMNS.includes(key) ? "final-column" : "", + name: columnName, + className: FINAL_COLUMNS.includes(columnName) ? "final-column" : "", columnHeaderCellRenderer: columnHeaderCellRenderer, - cellRenderer: (row, cell) => cellRenderer({"key": key, "row": row, "cell": cell}), - "key": key + cellRenderer: (rowIndex, cell) => cellRenderer({"columnName": columnName, "rowIndex": rowIndex, "cell": cell}), + "key": columnName }) }) @@ -230,12 +247,12 @@ export default function EditTable({ url }) { let selection: TableSelection if(rows == undefined){ - selection = {filter: new Filter("_pkid", "in", ""), columns: selectedColumnKeys} + selection = {filters: new Filter("_pkid", "in", ""), columns: selectedColumnKeys} } else { const dbIds = selectedRowIndices.map((row) => data[row]['_pkid']) const filter = new Filter("_pkid", "in", "(" + dbIds.join(",") + ")") - selection = {columns: selectedColumnKeys, "filter": filter} + selection = {columns: selectedColumnKeys, "filters": [filter]} } setTableSelection(selection) @@ -251,50 +268,21 @@ export default function EditTable({ url }) { name = name.slice(0, 47) + "..." } - return h(RowHeaderCell2, { "name": name }, []); }; - const submitChange = async (value: string) => { - for (const column of tableSelection.columns) { - let updateURL = new URL(url); - - for (const filter of Object.values(tableSelection.filters)) { - updateURL.searchParams.append(...filter.to_array()); - } - - let patch = { [column]: value }; - console.log(patch, JSON.stringify(patch)); - - let response = await fetch(updateURL, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(patch), - }); - - if (response.status != 204) { - console.error("Failed to update", response); - } - } - setDataToggle(!dataToggle); - }; - return h(HotkeysProvider, {}, [ h("div.table-container", {}, [ - h.if(error)("div.warning", {}, [error]), + h.if(error != undefined)("div.warning", {}, [error]), h("div.input-form", {}, [ - h(InputGroup, { - value: inputValue, - className: "update-input-group", - onChange: (e) => setInputValue(e.target.value), - }), h(ButtonGroup, [ h( Button, { - onClick: () => setEditedData(new Array()), + onClick: () => { + setTableUpdates([]); + setEditedData([]); + }, disabled: isEmptyArray(editedData), }, ["Clear changes"] @@ -303,7 +291,7 @@ export default function EditTable({ url }) { Button, { type: "submit", - onClick: () => submitChange(inputValue), + onClick: submitTableUpdates, disabled: isEmptyArray(editedData), intent: "success", }, @@ -323,35 +311,17 @@ export default function EditTable({ url }) { cellRendererDependencies: [editedData], }, columns + ), + h.if(updateProgress != undefined)( + ProgressPopover, + { + text: "Submitting Changes", + value: updateProgress, + progressBarProps: { intent: "success" }, + } ) ]), ]); } -function isEmptyArray(arr) { - return arr.length == 0 || arr.every((x) => x == null); -} - -class TableDataManager { - /** Low-level manager for windowed loading of table data. This will eventually be how - * we work with the data, hopefully. */ - baseURL: string; - totalCount: number; - chunkSize: number = 100; - - init(baseURL: string) { - this.baseURL = baseURL; - } - - async getData(page: number) { - let dataURL = new URL(this.baseURL); - dataURL.searchParams.append("page", page.toString()); - dataURL.searchParams.append("page_size", this.chunkSize.toString()); - - let response = await fetch(dataURL); - let data = await response.json(); - - this.totalCount = Number.parseInt(response.headers.get("X-Total-Count")); - } -} diff --git a/src/pages/maps/@id/edit/table-util.ts b/src/pages/maps/@id/edit/table-util.ts index 8a20aabd..95b3e71a 100644 --- a/src/pages/maps/@id/edit/table-util.ts +++ b/src/pages/maps/@id/edit/table-util.ts @@ -1,4 +1,5 @@ -import {ColumnOperators, Filters} from "./table"; +import { ColumnOperators, Filters, TableSelection, TableUpdate } from "./table"; +import {secureFetch} from "@macrostrat-web/security"; export class Filter { @@ -34,8 +35,6 @@ export class Filter { } - - export function buildURL(baseURL: string, filters: Filter[], group: string | undefined){ let updateURL = new URL(baseURL) @@ -52,4 +51,87 @@ export function buildURL(baseURL: string, filters: Filter[], group: string | und } return updateURL +} + +/** + * Builds a table update from the current table state + */ +export const getTableUpdate = ( + value: string, + columnName: string, + rowIndex: number, + data: any[], + filters: Filters, + group: string | undefined +): TableUpdate => { + + filters = {...filters} + if( group != undefined){ + filters[group] = new Filter(group, "eq", data[rowIndex][group]) + } else { + filters["_pkid"] = new Filter("_pkid", "eq", data[rowIndex]["_pkid"]) + } + + const selection: TableSelection = { + columns: [columnName], + filters: filters + } + + return { + selection, + value: value + } +} + +export const submitChanges = async (url: string, updates: TableUpdate[]) => { + for(const update of updates){ + console.log("Update: ", update) + + // await submitChange(url, update) + } +} + +export const submitChange = async (url: string, {selection, value}: TableUpdate) => { + + // Query per column + for (const column of selection.columns) { + + let updateURL = new URL(url); + + // Add the filters to the query parameters + for (const filter of Object.values(selection.filters)) { + + // Check that the filter is valid + if(!filter.is_valid()){ + continue + } + + console.log("Filter: ", filter) + + const [searchTerm, searchValue] = filter.to_array() + updateURL.searchParams.append(searchTerm, searchValue); + } + + // Create the request body + let patch = { [column]: value }; + + // Send the request + let response = await secureFetch(updateURL, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(patch), + }); + + if (response.status != 204) { + + // Stop execution if the request failed + throw Error("Failed to update"); + } + } +}; + +export function isEmptyArray(arr) { + return arr.length == 0 || arr.every((x) => x == null); } \ No newline at end of file diff --git a/src/pages/maps/@id/edit/table.d.ts b/src/pages/maps/@id/edit/table.d.ts index a57a7077..76f3afa5 100644 --- a/src/pages/maps/@id/edit/table.d.ts +++ b/src/pages/maps/@id/edit/table.d.ts @@ -19,3 +19,24 @@ interface Filters { [key: string]: Filter; } +interface Selection { + cols: number[]; + rows: number[]; +} + + +interface Filters { + [key: string]: Filter; +} + +// An object that represents a selection of rows and columns +interface TableSelection { + columns: string[]; + filters: Filters; +} + +// An object that represents a value update made on top of a specific TableSelection +interface TableUpdate { + selection: TableSelection; + value: string; +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 0f6745fe..8d8434d4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3314,6 +3314,12 @@ __metadata: languageName: unknown linkType: soft +"@macrostrat-web/security@workspace:*, @macrostrat-web/security@workspace:packages/security": + version: 0.0.0-use.local + resolution: "@macrostrat-web/security@workspace:packages/security" + languageName: unknown + linkType: soft + "@macrostrat/api-types@workspace:deps/web-components/packages/api-types": version: 0.0.0-use.local resolution: "@macrostrat/api-types@workspace:deps/web-components/packages/api-types" @@ -3841,6 +3847,7 @@ __metadata: "@loadable/component": ^5.14.1 "@macrostrat-web/data-sheet-test": "workspace:*" "@macrostrat-web/globe": "workspace:*" + "@macrostrat-web/security": "workspace:*" "@macrostrat/api-utils": "workspace:*" "@macrostrat/api-views": "workspace:*" "@macrostrat/column-components": "workspace:*"