diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 00000000..67cf8715 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,32 @@ +module.exports = { + "overrides": [ + { + "files": ["oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/**/*"], + "env": { + "browser": true, + "es2021": true + }, + "extends": [ + "eslint:recommended", + "plugin:react/recommended", + "plugin:react-hooks/recommended", + "plugin:jsx-a11y/recommended" + ], + "parserOptions": { + "sourceType": "module", + "ecmaFeatures": { + "jsx": true + } + }, + "settings": { + "react": { + "version": "detect" + } + }, + "plugins": ["react", "react-hooks", "jsx-a11y"], + "rules": { + "react/prop-types": "off", + } + } + ] +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index a5f3201c..5d4f1e92 100644 --- a/.gitignore +++ b/.gitignore @@ -90,5 +90,8 @@ dist/ tests/thesis thesis -**/ui/theme/**/todo.md -node_modules \ No newline at end of file +todo.md +node_modules + +dummy-record.js +jsconfig.json diff --git a/oarepo_requests/invenio_patches.py b/oarepo_requests/invenio_patches.py index a5202144..449cad0d 100644 --- a/oarepo_requests/invenio_patches.py +++ b/oarepo_requests/invenio_patches.py @@ -7,6 +7,7 @@ RequestSearchRequestArgsSchema, RequestsResourceConfig, ) +from invenio_requests.resources.events.config import RequestCommentsResourceConfig from invenio_requests.services.requests.config import ( RequestSearchOptions, RequestsServiceConfig, @@ -14,7 +15,7 @@ from marshmallow import fields from opensearch_dsl.query import Bool, Term -from oarepo_requests.resources.ui import OARepoRequestsUIJSONSerializer +from oarepo_requests.resources.ui import OARepoRequestsUIJSONSerializer, OARepoRequestEventsUIJSONSerializer class RequestOwnerFilterParam(FilterParam): @@ -61,7 +62,7 @@ class ExtendedRequestSearchRequestArgsSchema(RequestSearchRequestArgsSchema): def override_invenio_requests_config(blueprint, *args, **kwargs): - with blueprint.app.app_context(): + with (blueprint.app.app_context()): # this monkey patch should be done better (support from invenio) RequestsServiceConfig.search = EnhancedRequestSearchOptions RequestsResourceConfig.request_search_args = ( @@ -69,9 +70,12 @@ def override_invenio_requests_config(blueprint, *args, **kwargs): ) class LazySerializer: + def __init__(self, serializer_cls): + self.serializer_cls = serializer_cls + @cached_property def __instance(self): - return OARepoRequestsUIJSONSerializer() + return self.serializer_cls() @property def serialize_object_list(self): @@ -83,7 +87,15 @@ def serialize_object(self): RequestsResourceConfig.response_handlers = { "application/json": ResponseHandler(JSONSerializer(), headers=etag_headers), - "application/vnd.inveniordm.v1+json": ResponseHandler(LazySerializer()), + "application/vnd.inveniordm.v1+json": ResponseHandler(LazySerializer(OARepoRequestsUIJSONSerializer), + headers=etag_headers), + } + + RequestCommentsResourceConfig.response_handlers = { + "application/vnd.inveniordm.v1+json": ResponseHandler( + LazySerializer(OARepoRequestEventsUIJSONSerializer), headers=etag_headers + ), + **RequestCommentsResourceConfig.response_handlers } from invenio_requests.proxies import current_request_type_registry @@ -97,7 +109,7 @@ def serialize_object(self): "declined": _("Declined"), "cancelled": _("Cancelled"), } - status._label = _("Status") + status._label = _("Request status") # add extra request types dynamically type._value_labels = { diff --git a/oarepo_requests/translations/cs/LC_MESSAGES/messages.mo b/oarepo_requests/translations/cs/LC_MESSAGES/messages.mo index 0c2c0f41..5f9c9669 100644 Binary files a/oarepo_requests/translations/cs/LC_MESSAGES/messages.mo and b/oarepo_requests/translations/cs/LC_MESSAGES/messages.mo differ diff --git a/oarepo_requests/translations/cs/LC_MESSAGES/messages.po b/oarepo_requests/translations/cs/LC_MESSAGES/messages.po index 215bd69b..89cfa3ea 100644 --- a/oarepo_requests/translations/cs/LC_MESSAGES/messages.po +++ b/oarepo_requests/translations/cs/LC_MESSAGES/messages.po @@ -220,3 +220,71 @@ msgstr "Publikovat" msgid "Close" msgstr "Zavřít" + +#: /Users/miroslavbauer/Code/github.com/oarepo/oarepo-requests/oarepo_requests/ui/templates/semantic-ui/oarepo_requests_ui/RequestDetail.jinja:3 +msgid "Request" +msgstr "" + +msgid "Pending" +msgstr "" + +msgid "request" +msgstr "" + +msgid "Error while submitting comment." +msgstr "" + +msgid "Add comment" +msgstr "" + +msgid "optional" +msgstr "" + +msgid "Your comment here..." +msgstr "" + +msgid "Comment was not created successfully." +msgstr "" + +msgid "Comment" +msgstr "" + +msgid "Error while submitting the comment" +msgstr "" + +msgid "Back to requests" +msgstr "" + +msgid "Record" +msgstr "" + +msgid "preview" +msgstr "" + +msgid "to top" +msgstr "" + +msgid "Loading timeline..." +msgstr "" + +msgid "Error while fetching timeline events" +msgstr "" + +msgid "commented" +msgstr "" + +msgid "icon" +msgstr "" + +msgid "this request" +msgstr "" + +msgid "Comment must be at least 1 character long." +msgstr "" + +msgid "Invalid format." +msgstr "" + +#: /Users/miroslavbauer/Code/github.com/oarepo/oarepo-requests/oarepo_requests/invenio_patches.py:100 +msgid "Request status" +msgstr "Stav žádosti" diff --git a/oarepo_requests/translations/en/LC_MESSAGES/messages.po b/oarepo_requests/translations/en/LC_MESSAGES/messages.po index d8e9ea74..87968b72 100644 --- a/oarepo_requests/translations/en/LC_MESSAGES/messages.po +++ b/oarepo_requests/translations/en/LC_MESSAGES/messages.po @@ -217,5 +217,73 @@ msgstr "" msgid "Close" msgstr "" +#: /Users/miroslavbauer/Code/github.com/oarepo/oarepo-requests/oarepo_requests/ui/templates/semantic-ui/oarepo_requests_ui/RequestDetail.jinja:3 +msgid "Request" +msgstr "" + +msgid "Pending" +msgstr "" + +msgid "request" +msgstr "" + +msgid "Error while submitting comment." +msgstr "" + +msgid "Add comment" +msgstr "" + +msgid "optional" +msgstr "" + +msgid "Your comment here..." +msgstr "" + +msgid "Comment was not created successfully." +msgstr "" + +msgid "Comment" +msgstr "" + +msgid "Error while submitting the comment" +msgstr "" + +msgid "Back to requests" +msgstr "" + +msgid "Record" +msgstr "" + +msgid "preview" +msgstr "" + +msgid "to top" +msgstr "" + +msgid "Loading timeline..." +msgstr "" + +msgid "Error while fetching timeline events" +msgstr "" + +msgid "commented" +msgstr "" + +msgid "icon" +msgstr "" + +msgid "this request" +msgstr "" + +msgid "Comment must be at least 1 character long." +msgstr "" + +msgid "Invalid format." +msgstr "" + +#: /Users/miroslavbauer/Code/github.com/oarepo/oarepo-requests/oarepo_requests/invenio_patches.py:100 +msgid "Request status" +msgstr "" + #~ msgid "No status" #~ msgstr "" diff --git a/oarepo_requests/translations/messages.mo b/oarepo_requests/translations/messages.mo index 354e6caa..fee6abd3 100644 Binary files a/oarepo_requests/translations/messages.mo and b/oarepo_requests/translations/messages.mo differ diff --git a/oarepo_requests/translations/messages.pot b/oarepo_requests/translations/messages.pot index 5f71ac65..39ea6a16 100644 --- a/oarepo_requests/translations/messages.pot +++ b/oarepo_requests/translations/messages.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2024-06-19 14:40+0200\n" +"POT-Creation-Date: 2024-07-15 09:46+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -38,7 +38,7 @@ msgid "Cancelled" msgstr "" #: /Users/miroslavbauer/Code/github.com/oarepo/oarepo-requests/oarepo_requests/invenio_patches.py:100 -msgid "Status" +msgid "Request status" msgstr "" #: /Users/miroslavbauer/Code/github.com/oarepo/oarepo-requests/oarepo_requests/invenio_patches.py:106 @@ -193,7 +193,14 @@ msgstr "" msgid "Request publishing of a draft" msgstr "" -msgid "Loading request types" +#: /Users/miroslavbauer/Code/github.com/oarepo/oarepo-requests/oarepo_requests/ui/templates/semantic-ui/oarepo_requests_ui/RequestDetail.jinja:3 +msgid "Request" +msgstr "" + +msgid "Cancel request" +msgstr "" + +msgid "Requests" msgstr "" msgid "Error loading request types" @@ -205,20 +212,71 @@ msgstr "" msgid "No status" msgstr "" -msgid "Requests" +msgid "Pending" msgstr "" msgid "Error loading requests" msgstr "" -msgid "Loading requests" +msgid "Close" msgstr "" -msgid "Cannot send request. Please try again later." +msgid "request" msgstr "" -msgid "Cancel request" +msgid "Error while submitting comment." msgstr "" -msgid "Close" +msgid "Add comment" +msgstr "" + +msgid "optional" +msgstr "" + +msgid "Your comment here..." +msgstr "" + +msgid "Comment was not created successfully." +msgstr "" + +msgid "Comment" +msgstr "" + +msgid "Error while submitting the comment" +msgstr "" + +msgid "Back to requests" +msgstr "" + +msgid "Record" +msgstr "" + +msgid "preview" +msgstr "" + +msgid "to top" +msgstr "" + +msgid "Status" +msgstr "" + +msgid "Loading timeline..." +msgstr "" + +msgid "Error while fetching timeline events" +msgstr "" + +msgid "commented" +msgstr "" + +msgid "icon" +msgstr "" + +msgid "this request" +msgstr "" + +msgid "Comment must be at least 1 character long." +msgstr "" + +msgid "Invalid format." msgstr "" diff --git a/oarepo_requests/ui/__init__.py b/oarepo_requests/ui/__init__ .py similarity index 100% rename from oarepo_requests/ui/__init__.py rename to oarepo_requests/ui/__init__ .py diff --git a/oarepo_requests/ui/config.py b/oarepo_requests/ui/config.py new file mode 100644 index 00000000..cf61f581 --- /dev/null +++ b/oarepo_requests/ui/config.py @@ -0,0 +1,87 @@ +import inspect +import marshmallow as ma +from flask import current_app + +from oarepo_ui.resources.config import UIResourceConfig +from oarepo_ui.resources.links import UIRecordLink +from oarepo_ui.resources.components import AllowedHtmlTagsComponent +from oarepo_runtime.services.custom_fields import CustomFields, InlinedCustomFields + +from invenio_base.utils import obj_or_import_string +from invenio_pidstore.errors import PIDDeletedError, PIDDoesNotExistError, PIDUnregistered +from invenio_records_resources.services.errors import PermissionDeniedError +from invenio_records_resources.proxies import current_service_registry + + +def _get_custom_fields_ui_config(key, **kwargs): + return current_app.config.get(f"{key}_UI", []) + + +class RequestUIResourceConfig(UIResourceConfig): + url_prefix = "/requests" + api_service = "requests" + blueprint_name = "oarepo_requests_ui" + template_folder = "templates" + templates = { + "detail": "RequestDetail", + } + routes = { + "detail": "/", + } + ui_serializer_class = "oarepo_requests.resources.ui.OARepoRequestsUIJSONSerializer" + ui_links_item = { + "self": UIRecordLink("{+ui}{+url_prefix}/{id}"), + } + components = [AllowedHtmlTagsComponent] + + error_handlers = { + PIDDeletedError: "tombstone", + PIDDoesNotExistError: "not_found", + PIDUnregistered: "not_found", + KeyError: "not_found", + PermissionDeniedError: "permission_denied", + } + + request_view_args = {"pid_value": ma.fields.Str()} + + @property + def ui_serializer(self): + return obj_or_import_string(self.ui_serializer_class)() + + def custom_fields(self, **kwargs): + api_service = current_service_registry.get(self.api_service) + # get the record class + record_class = getattr(api_service, "record_cls", None) + ui = [] + ret = { + "ui": ui, + } + if not record_class: + return ret + # try to get custom fields from the record + for fld_name, fld in sorted(inspect.getmembers(record_class)): + if isinstance(fld, InlinedCustomFields): + prefix = "" + elif isinstance(fld, CustomFields): + prefix = fld.key + "." + else: + continue + + ui_config = _get_custom_fields_ui_config(fld.config_key, **kwargs) + if not ui_config: + continue + + for section in ui_config: + ui.append( + { + **section, + "fields": [ + { + **field, + "field": prefix + field["field"], + } + for field in section.get("fields", []) + ], + } + ) + return ret diff --git a/oarepo_requests/ui/resource.py b/oarepo_requests/ui/resource.py new file mode 100644 index 00000000..6e5566e3 --- /dev/null +++ b/oarepo_requests/ui/resource.py @@ -0,0 +1,180 @@ +from flask import g +from flask_resources import ( + route, + resource_requestctx, +) +from invenio_records_resources.proxies import current_service_registry +from invenio_records_resources.resources.records.resource import ( + request_read_args, + request_view_args, +) +from invenio_records_resources.services import LinksTemplate +from oarepo_ui.proxies import current_oarepo_ui +from oarepo_ui.resources.resource import UIResource +from oarepo_ui.resources.templating.data import FieldData + +from oarepo_requests.ui.config import RequestUIResourceConfig + + +def make_links_absolute(links, api_prefix): + # make links absolute + for k, v in list(links.items()): + if not isinstance(v, str): + continue + if not v.startswith("/") and not v.startswith("https://"): + v = f"/api{api_prefix}{v}" + links[k] = v + + +class RequestUIResource(UIResource): + config: RequestUIResourceConfig + + @property + def api_service(self): + return current_service_registry.get(self.config.api_service) + + def create_url_rules(self): + """Create the URL rules for the record resource.""" + routes = [] + route_config = self.config.routes + for route_name, route_url in route_config.items(): + routes.append(route("GET", route_url, getattr(self, route_name))) + return routes + + def expand_detail_links(self, identity, record): + """Get links for this result item.""" + tpl = LinksTemplate( + self.config.ui_links_item, {"url_prefix": self.config.url_prefix} + ) + return tpl.expand(identity, record) + + def _get_custom_fields(self, **kwargs): + return self.config.custom_fields(identity=g.identity, **kwargs) + + @request_read_args + @request_view_args + def detail(self): + """Returns item detail page.""" + api_record = self.api_service.read(g.identity, resource_requestctx.view_args["pid_value"]) + render_method = self.get_jinjax_macro( + "detail", + identity=g.identity, + args=resource_requestctx.args, + view_args=resource_requestctx.view_args, + ) + + # TODO: handle permissions UI way - better response than generic error + record = self.config.ui_serializer.dump_obj(api_record.to_dict()) + record.setdefault("links", {}) + + ui_links = self.expand_detail_links(identity=g.identity, record=api_record) + + record["links"].update( + { + "ui_links": ui_links, + } + ) + + make_links_absolute(record["links"], self.config.url_prefix) + + extra_context = dict() + # TODO: this needs to be reimplemented in: + # https://linear.app/ducesnet/issue/BE-346/on-request-detail-page-generate-form-config-for-the-comment-stream + form_config = {} + + self.run_components( + "form_config", + api_record=api_record, + record=record, + identity=g.identity, + form_config=form_config, + extra_context=extra_context, + args=resource_requestctx.args, + view_args=resource_requestctx.view_args, + ui_links=ui_links, + ) + + self.run_components( + "before_ui_detail", + api_record=api_record, + record=record, + identity=g.identity, + extra_context=extra_context, + args=resource_requestctx.args, + view_args=resource_requestctx.view_args, + ui_links=ui_links, + custom_fields=self._get_custom_fields( + api_record=api_record, resource_requestctx=resource_requestctx + ), + ) + + metadata = dict(record.get("metadata", record)) + render_kwargs = { + **extra_context, + "extra_context": extra_context, # for backward compatibility + "metadata": metadata, + "ui": dict(record.get("ui", record)), + "record": record, + "form_config": form_config, + "api_record": api_record, + "ui_links": ui_links, + "context": current_oarepo_ui.catalog.jinja_env.globals, + "d": FieldData(record, self.ui_model), + } + + return current_oarepo_ui.catalog.render( + render_method, + **render_kwargs, + ) + + @property + def ui_model(self): + return current_oarepo_ui.ui_models.get( + self.config.api_service.replace("-", "_"), {} + ) + + def get_jinjax_macro( + self, + template_type, + identity=None, + args=None, + view_args=None, + default_macro=None, + ): + """ + Returns which jinjax macro (name of the macro, including optional namespace in the form of "namespace.Macro") + should be used for rendering the template. + """ + if default_macro: + return self.config.templates.get(template_type, default_macro) + return self.config.templates[template_type] + + def tombstone(self, error, *args, **kwargs): + return current_oarepo_ui.catalog.render( + self.get_jinjax_macro( + "tombstone", + identity=g.identity, + default_macro="Tombstone", + ), + pid=getattr(error, 'pid_value', None) or getattr(error, "pid", None) + ) + + def not_found(self, error, *args, **kwargs): + return current_oarepo_ui.catalog.render( + self.get_jinjax_macro( + "not_found", + identity=g.identity, + default_macro="NotFound", + ), + pid=getattr(error, 'pid_value', None) or getattr(error, "pid", None) + ) + + def permission_denied(self, error, *args, **kwargs): + return current_oarepo_ui.catalog.render( + self.get_jinjax_macro( + "permission_denied", + identity=g.identity, + default_macro="PermissionDenied", + ), + pid=getattr(error, 'pid_value', None) or getattr(error, "pid", None) + ) diff --git a/oarepo_requests/ui/templates/semantic-ui/oarepo_requests_ui/RequestDetail.jinja b/oarepo_requests/ui/templates/semantic-ui/oarepo_requests_ui/RequestDetail.jinja new file mode 100644 index 00000000..763f3d44 --- /dev/null +++ b/oarepo_requests/ui/templates/semantic-ui/oarepo_requests_ui/RequestDetail.jinja @@ -0,0 +1,32 @@ +{#def record, form_config, extra_context, ui #} + +{%- set title = _("Request") ~ ": " ~ record.name ~ " | " ~ _(config.THEME_SITENAME) %} + +{% extends "oarepo_ui/detail.html" %} + +{%- block page_body %} + {# Custom Dashboard Header #} +
+ +
+ {# Main content #} +
+ {#
{{ record | pprint }}
+
{{ extra_context | pprint }}
+
{{ ui | pprint }}
#} +{%- endblock page_body %} + +{%- block javascript %} + {{super()}} + {{webpack["oarepo_requests_ui_request_detail.js"]}} + {{webpack["oarepo_requests_ui_components.js"]}} + {{webpack["oarepo_requests_ui_components.css"]}} +{%- endblock javascript %} diff --git a/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/CreateRequestButtonGroup.jsx b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/CreateRequestButtonGroup.jsx index 4f822846..5b71dbe6 100644 --- a/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/CreateRequestButtonGroup.jsx +++ b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/CreateRequestButtonGroup.jsx @@ -1,11 +1,12 @@ import React from "react"; import { i18next } from "@translations/oarepo_requests_ui/i18next"; - -import { Segment, Header, Button, Dimmer, Loader, Placeholder, Message } from "semantic-ui-react"; +import { Segment, Header, Button, Placeholder, Message, Icon } from "semantic-ui-react"; import _isEmpty from "lodash/isEmpty"; -import { RequestModal } from "./RequestModal"; +import { RequestModal, CreateRequestModalContent } from "."; +import { mapLinksToActions } from "./actions"; +import { useRequestContext } from "../contexts"; /** * @typedef {import("../types").Request} Request @@ -13,47 +14,50 @@ import { RequestModal } from "./RequestModal"; */ /** - * @param {{ requestTypes: RequestType[], isLoading: boolean, loadingError: Error, fetchNewRequests: () => void }} props + * @param {{ requestTypes: RequestType[], isLoading: boolean, loadingError: Error }} props */ -export const CreateRequestButtonGroup = ({ requestTypes, isLoading, loadingError, fetchNewRequests }) => { +export const CreateRequestButtonGroup = ({ recordLoading, recordLoadingError }) => { + const { requestTypes } = useRequestContext(); const createRequests = requestTypes.filter(requestType => requestType.links.actions?.create); return ( - -
{i18next.t("Create Request")}
- - - {i18next.t("Loading request types")}... - - {isLoading ? - {Array.from({ length: 3 }).map((_, index) => ( + +
{i18next.t("Requests")}
+ {recordLoading ? + + {Array.from({ length: 2 }).map((_, index) => ( - - - + ))} : - loadingError ? - - {i18next.t("Error loading request types")} -

{loadingError?.message}

-
: - !_isEmpty(createRequests) ? - - {createRequests.map((requestType) => ( + recordLoadingError ? + + {i18next.t("Error loading request types")} +

{recordLoadingError?.message}

+
: + !_isEmpty(createRequests) ? + + {createRequests.map((requestType) => { + const header = !_isEmpty(requestType?.title) ? requestType.title : (!_isEmpty(requestType?.name) ? requestType.name : requestType.type); + const modalActions = mapLinksToActions(requestType); + return ( } - fetchNewRequests={fetchNewRequests} + requestType={requestType} + header={header} + trigger={ + - - {formWillBeRendered && - - } - - } - {requestModalType === REQUEST_TYPE.CANCEL && - - } - {requestModalType === REQUEST_TYPE.ACCEPT && - <> - - - - } - {requestModalType === REQUEST_TYPE.CREATE && (!isEventModal && - <> - {requestType?.payload_ui && - - } - - || - ) - } - - - - - + + + + + } + + ); }; - -RequestModal.propTypes = { - request: PropTypes.object.isRequired, - requestModalType: PropTypes.oneOf(["create", "submit", "cancel", "accept", "view_only"]).isRequired, - requestTypes: PropTypes.array, - isEventModal: PropTypes.bool, - triggerButton: PropTypes.element, -}; \ No newline at end of file diff --git a/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/RequestModalContent.jsx b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/RequestModalContent.jsx index bdae1762..eb971124 100644 --- a/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/RequestModalContent.jsx +++ b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/RequestModalContent.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useContext } from "react"; +import React, { useEffect } from "react"; import PropTypes from "prop-types"; import { i18next } from "@translations/oarepo_requests_ui/i18next"; @@ -6,13 +6,14 @@ import { Button, Grid, List, Form, Divider, Comment } from "semantic-ui-react"; import _isEmpty from "lodash/isEmpty"; import _sortBy from "lodash/sortBy"; import { useFormikContext } from "formik"; - import { CustomFields } from "react-invenio-forms"; -import { RequestModal, ModalContentSideInfo } from "."; -import { RequestContext } from "../contexts"; +import { CreateRequestModalContent, ModalContentSideInfo, RequestModal } from "."; +import { SubmitEvent } from "./actions"; +import { useRequestsApi } from "../utils/hooks"; +import { useRequestContext } from "../contexts"; import { fetchUpdated as fetchNewEvents } from "../utils"; -import { REQUEST_TYPE } from "../utils/objects"; +import { REQUEST_TYPE, REQUEST_MODAL_TYPE } from "../utils/objects"; import ReadOnlyCustomFields from "./common/ReadOnlyCustomFields"; /** @@ -22,11 +23,13 @@ import ReadOnlyCustomFields from "./common/ReadOnlyCustomFields"; * @typedef {import("../types").Event} Event */ -/** @param {{ request: Request, requestModalType: RequestTypeEnum, requestType: RequestType, customSubmitHandler: (e) => void }} props */ -export const RequestModalContent = ({ request, requestType, requestModalType, customSubmitHandler }) => { +/** @param {{ request: Request, requestModalType: RequestTypeEnum, requestType: RequestType, onCompletedAction: (e) => void }} props */ +export const RequestModalContent = ({ request, requestType, requestModalType, onCompletedAction }) => { /** @type {{requests: Request[], setRequests: (requests: Request[]) => void}} */ - const { requests, setRequests } = useContext(RequestContext); - + const { requests, setRequests } = useRequestContext(); + const { doAction } = useRequestsApi(request, onCompletedAction); + const { submitForm, setErrors, setSubmitting } = useFormikContext(); + const actualRequest = requests.find(req => req.id === request.id); useEffect(() => { @@ -43,17 +46,20 @@ export const RequestModalContent = ({ request, requestType, requestModalType, cu console.error(error); }); } - }, [actualRequest, setRequests]); + }, [setRequests, request.links?.events, request.id]); - const { handleSubmit } = useFormikContext(); - - const onSubmit = (event) => { - if (_isFunction(customSubmitHandler)) { - customSubmitHandler(event?.nativeEvent?.submitter?.name); - } else { - handleSubmit(event); + // This function can only be triggered if submit form is rendered + const onFormSubmit = async (event) => { + event.preventDefault(); + try { + await submitForm(); + doAction(REQUEST_TYPE.SUBMIT, true); + } catch (error) { + setErrors({ api: error }); + } finally { + setSubmitting(false); } - } + }; const payloadUI = requestType?.payload_ui; const eventTypes = requestType?.event_types; @@ -66,8 +72,8 @@ export const RequestModalContent = ({ request, requestType, requestModalType, cu events = _sortBy(actualRequest.events, ['updated']); } - const renderSubmitForm = requestModalType === REQUEST_TYPE.SUBMIT && payloadUI; - const renderReadOnlyData = (requestModalType === REQUEST_TYPE.ACCEPT || requestModalType === REQUEST_TYPE.CANCEL) && request?.payload; + const renderSubmitForm = requestModalType === REQUEST_MODAL_TYPE.SUBMIT_FORM && payloadUI; + const renderReadOnlyData = requestModalType === REQUEST_MODAL_TYPE.READ_ONLY && request?.payload; return ( @@ -79,11 +85,11 @@ export const RequestModalContent = ({ request, requestType, requestModalType, cu {(renderSubmitForm || renderReadOnlyData) && - + {renderSubmitForm && -
+ } {eventTypes.map(event => ( - } /> + + } + actions={[ + { name: REQUEST_TYPE.CREATE, component: SubmitEvent } + ]} + ContentComponent={CreateRequestModalContent} + /> ))} } @@ -160,13 +177,13 @@ export const RequestModalContent = ({ request, requestType, requestModalType, cu } - + || /* No Submit Form (no PayloadUI for this request type) nor Payload (read only data) available for this Request */ - + } @@ -176,7 +193,7 @@ export const RequestModalContent = ({ request, requestType, requestModalType, cu RequestModalContent.propTypes = { request: PropTypes.object.isRequired, - requestType: PropTypes.object.isRequired, + requestType: PropTypes.object, requestModalType: PropTypes.string.isRequired, - customSubmitHandler: PropTypes.func, + onCompletedAction: PropTypes.func, }; \ No newline at end of file diff --git a/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/actions/Accept.jsx b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/actions/Accept.jsx new file mode 100644 index 00000000..e015ba95 --- /dev/null +++ b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/actions/Accept.jsx @@ -0,0 +1,20 @@ +import React from "react"; + +import { i18next } from "@translations/oarepo_requests_ui/i18next"; +import { Button, Icon } from "semantic-ui-react"; + +import { REQUEST_TYPE } from "../../utils/objects"; +import { useRequestsApi } from "../../utils/hooks"; + +const Accept = ({ request, requestType, onSubmit, ...props }) => { + const { doAction } = useRequestsApi(request, onSubmit); + + return ( + + ); +} + +export default Accept; \ No newline at end of file diff --git a/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/actions/Cancel.jsx b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/actions/Cancel.jsx new file mode 100644 index 00000000..99ea46d6 --- /dev/null +++ b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/actions/Cancel.jsx @@ -0,0 +1,20 @@ +import React from "react"; + +import { i18next } from "@translations/oarepo_requests_ui/i18next"; +import { Button, Icon } from "semantic-ui-react"; + +import { REQUEST_TYPE } from "../../utils/objects"; +import { useRequestsApi } from "../../utils/hooks"; + +const Cancel = ({ request, requestType, onSubmit, ...props }) => { + const { doAction } = useRequestsApi(request, onSubmit); + + return ( + + ); +}; + +export default Cancel; \ No newline at end of file diff --git a/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/actions/Create.jsx b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/actions/Create.jsx new file mode 100644 index 00000000..f9f97c72 --- /dev/null +++ b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/actions/Create.jsx @@ -0,0 +1,15 @@ +import React from "react"; + +import { i18next } from "@translations/oarepo_requests_ui/i18next"; +import { Button, Icon } from "semantic-ui-react"; + +const Create = ({ request, requestType, onSubmit, ...props }) => { + return ( + + ); +} + +export default Create; \ No newline at end of file diff --git a/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/actions/CreateAndSubmit.jsx b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/actions/CreateAndSubmit.jsx new file mode 100644 index 00000000..3d46a942 --- /dev/null +++ b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/actions/CreateAndSubmit.jsx @@ -0,0 +1,32 @@ +import React from "react"; + +import _isEmpty from "lodash/isEmpty"; +import { i18next } from "@translations/oarepo_requests_ui/i18next"; +import { Button, Icon } from "semantic-ui-react"; + +import { useRequestsApi } from "../../utils/hooks"; + +const CreateAndSubmit = ({ request, requestType, onSubmit, ...props }) => { + const { doCreateAndSubmitAction } = useRequestsApi(requestType, onSubmit); + + const formWillBeRendered = !_isEmpty(requestType?.payload_ui); + let extraProps; + if (formWillBeRendered) { + extraProps = { type: "submit", form: "request-form", name: "create-and-submit-request" }; + } else { + extraProps = { onClick: () => doCreateAndSubmitAction() }; + } + + return ( + + ); +} + +export default CreateAndSubmit; \ No newline at end of file diff --git a/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/actions/Decline.jsx b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/actions/Decline.jsx new file mode 100644 index 00000000..1c337ebe --- /dev/null +++ b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/actions/Decline.jsx @@ -0,0 +1,20 @@ +import React from "react"; + +import { i18next } from "@translations/oarepo_requests_ui/i18next"; +import { Button, Icon } from "semantic-ui-react"; + +import { REQUEST_TYPE } from "../../utils/objects"; +import { useRequestsApi } from "../../utils/hooks"; + +const Decline = ({ request, requestType, onSubmit, ...props }) => { + const { doAction } = useRequestsApi(request, onSubmit); + + return ( + + ); +}; + +export default Decline; \ No newline at end of file diff --git a/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/actions/Save.jsx b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/actions/Save.jsx new file mode 100644 index 00000000..ee3cbc81 --- /dev/null +++ b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/actions/Save.jsx @@ -0,0 +1,20 @@ +import React from "react"; + +import { i18next } from "@translations/oarepo_requests_ui/i18next"; +import { Button, Icon } from "semantic-ui-react"; + +import { REQUEST_TYPE } from "../../utils/objects"; +import { useRequestsApi } from "../../utils/hooks"; + +const Save = ({ request, requestType, onSubmit, ...props }) => { + const { doAction } = useRequestsApi(request, onSubmit); + + return ( + + ); +}; + +export default Save; \ No newline at end of file diff --git a/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/actions/Submit.jsx b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/actions/Submit.jsx new file mode 100644 index 00000000..803ba8c3 --- /dev/null +++ b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/actions/Submit.jsx @@ -0,0 +1,33 @@ +import React from "react"; + +import _isEmpty from "lodash/isEmpty"; +import { i18next } from "@translations/oarepo_requests_ui/i18next"; +import { Button, Icon } from "semantic-ui-react"; + +import { REQUEST_TYPE } from "../../utils/objects"; +import { useRequestsApi } from "../../utils/hooks"; + +const Submit = ({ request, requestType, onSubmit, ...props }) => { + const { doAction } = useRequestsApi(request, onSubmit); + + const formWillBeRendered = !_isEmpty(requestType?.payload_ui); + let extraProps; + if (formWillBeRendered) { + extraProps = { type: "submit", form: "request-form", name: "submit-request" }; + } else { + extraProps = { onClick: () => doAction(REQUEST_TYPE.SUBMIT) }; + } + + return ( + + ); +} + +export default Submit; \ No newline at end of file diff --git a/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/actions/SubmitEvent.jsx b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/actions/SubmitEvent.jsx new file mode 100644 index 00000000..04585168 --- /dev/null +++ b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/actions/SubmitEvent.jsx @@ -0,0 +1,33 @@ +import React from "react"; + +import _isEmpty from "lodash/isEmpty"; +import { i18next } from "@translations/oarepo_requests_ui/i18next"; +import { Button, Icon } from "semantic-ui-react"; + +import { REQUEST_TYPE } from "../../utils/objects"; +import { useRequestsApi } from "../../utils/hooks"; + +const SubmitEvent = ({ request: eventType, requestType, onSubmit, ...props }) => { + const { doAction } = useRequestsApi(eventType, onSubmit); + + const formWillBeRendered = !_isEmpty(eventType?.payload_ui); + let extraProps; + if (formWillBeRendered) { + extraProps = { type: "submit", form: "request-form", name: "submit-event" }; + } else { + extraProps = { onClick: () => doAction(REQUEST_TYPE.CREATE) }; + } + + return ( + + ); +} + +export default SubmitEvent; \ No newline at end of file diff --git a/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/actions/index.js b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/actions/index.js new file mode 100644 index 00000000..e94df554 --- /dev/null +++ b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/actions/index.js @@ -0,0 +1,42 @@ +import { REQUEST_TYPE } from "../../utils/objects"; +import Accept from "./Accept"; +import Decline from "./Decline"; +import Cancel from "./Cancel"; +import Create from "./Create"; +import Submit from "./Submit"; +import Save from "./Save"; +import CreateAndSubmit from "./CreateAndSubmit"; +import SubmitEvent from "./SubmitEvent"; + +export const mapLinksToActions = (requestOrRequestType) => { + const actionComponents = []; + for (const actionKey of Object.keys(requestOrRequestType.links?.actions)) { + switch (actionKey) { + case REQUEST_TYPE.ACCEPT: + actionComponents.push({ name: REQUEST_TYPE.ACCEPT, component: Accept }); + actionComponents.push({ name: REQUEST_TYPE.DECLINE, component: Decline }); + break; + case REQUEST_TYPE.CANCEL: + actionComponents.push({ name: REQUEST_TYPE.CANCEL, component: Cancel }); + break; + case REQUEST_TYPE.CREATE: + // requestOrRequestType is requestType here + if (requestOrRequestType?.payload_ui) { + actionComponents.push({ name: REQUEST_TYPE.CREATE, component: Create }); + } + actionComponents.push({ name: REQUEST_TYPE.SUBMIT, component: CreateAndSubmit }); + break; + case REQUEST_TYPE.SUBMIT: + actionComponents.push({ name: REQUEST_TYPE.SUBMIT, component: Submit }); + if (requestOrRequestType?.payload_ui) { + actionComponents.push({ name: REQUEST_TYPE.SAVE, component: Save }); + } + break; + default: + break; + } + } + return actionComponents; +} + +export { Accept, Decline, Cancel, Create, Submit, Save, CreateAndSubmit, SubmitEvent }; \ No newline at end of file diff --git a/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/common/index.js b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/common/index.js index 61bdc7f0..8c2eadf6 100644 --- a/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/common/index.js +++ b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/common/index.js @@ -1,2 +1,2 @@ export { default as ReadOnlyCustomFields } from "./ReadOnlyCustomFields"; -export { default as DefaultView } from "./DefaultView"; \ No newline at end of file +export { default as DefaultView } from "./DefaultView"; diff --git a/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/index.js b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/index.js index 9272a0c3..c8a4c365 100644 --- a/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/index.js +++ b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/components/index.js @@ -5,4 +5,6 @@ export { RecordRequests } from "./RecordRequests"; export { RequestModalContent } from "./RequestModalContent"; export { CreateRequestModalContent } from "./CreateRequestModalContent"; export { RequestList } from "./RequestList"; -export { ModalContentSideInfo } from "./ModalContentSideInfo"; \ No newline at end of file +export { ModalContentSideInfo } from "./ModalContentSideInfo"; +export * from "./common"; +export * from "./actions"; \ No newline at end of file diff --git a/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/contexts/ConfirmModalContext.jsx b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/contexts/ConfirmModalContext.jsx new file mode 100644 index 00000000..e5e74bbe --- /dev/null +++ b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/contexts/ConfirmModalContext.jsx @@ -0,0 +1,19 @@ +import React, { createContext, useContext } from "react"; +import { useConfirmDialog } from "../utils/hooks"; + +const ConfirmModalContext = createContext(); + +export const ConfirmModalContextProvider = ({ children, isEventModal = false }) => { + const confirmDialogStateAndHelpers = useConfirmDialog(isEventModal); + return ( + + {typeof children === 'function' + ? children(confirmDialogStateAndHelpers) + : children} + + ); +}; + +export const useConfirmModalContext = () => { + return useContext(ConfirmModalContext); +}; \ No newline at end of file diff --git a/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/contexts/RequestContext.jsx b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/contexts/RequestContext.jsx index ce71376f..0c8c02d0 100644 --- a/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/contexts/RequestContext.jsx +++ b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/contexts/RequestContext.jsx @@ -1,6 +1,6 @@ -import React, { createContext } from "react"; +import React, { createContext, useContext } from "react"; -export const RequestContext = createContext(); +const RequestContext = createContext(); export const RequestContextProvider = ({ children, requests }) => { return ( @@ -9,3 +9,7 @@ export const RequestContextProvider = ({ children, requests }) => { ); }; + +export const useRequestContext = () => { + return useContext(RequestContext); +} \ No newline at end of file diff --git a/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/contexts/index.js b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/contexts/index.js index abf50903..ad499629 100644 --- a/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/contexts/index.js +++ b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/contexts/index.js @@ -1 +1,2 @@ -export { RequestContext, RequestContextProvider } from './RequestContext'; \ No newline at end of file +export { RequestContextProvider, useRequestContext } from './RequestContext'; +export { ConfirmModalContextProvider, useConfirmModalContext } from './ConfirmModalContext'; \ No newline at end of file diff --git a/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/index.js b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/index.js index 90a07f39..e7727c19 100644 --- a/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/index.js +++ b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/index.js @@ -1,17 +1,17 @@ import React from "react"; import ReactDOM from "react-dom"; -import _isEmpty from "lodash/isEmpty"; - import { RecordRequests } from "./components"; const recordRequestsAppDiv = document.getElementById("record-requests"); -let record = JSON.parse(recordRequestsAppDiv.dataset.record); +if (recordRequestsAppDiv) { + const record = JSON.parse(recordRequestsAppDiv.dataset.record); -ReactDOM.render( - , - recordRequestsAppDiv -); \ No newline at end of file + ReactDOM.render( + , + recordRequestsAppDiv + ); +} diff --git a/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/utils/hooks.js b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/utils/hooks.js index f79d20ad..3a9444c5 100644 --- a/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/utils/hooks.js +++ b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/utils/hooks.js @@ -1,15 +1,23 @@ -import React, { useState } from "react"; +import React, { useState, useCallback } from "react"; + +import _isEmpty from "lodash/isEmpty"; +import axios from "axios"; import { i18next } from "@translations/oarepo_requests_ui/i18next"; import { Button } from "semantic-ui-react"; +import { useFormikContext } from "formik"; +import { isDeepEmpty } from "../utils"; +import { useConfirmModalContext } from "../contexts"; import { REQUEST_TYPE } from "./objects"; /** * @typedef {import("semantic-ui-react").ConfirmProps} ConfirmProps */ -export const useConfirmDialog = (formik, sendRequest, isEventModal) => { +export const useConfirmDialog = (isEventModal = false) => { + const { setSubmitting } = useFormikContext(); + /** @type {[ConfirmProps, (props: ConfirmProps) => void]} */ const [confirmDialogProps, setConfirmDialogProps] = useState({ open: false, @@ -20,17 +28,17 @@ export const useConfirmDialog = (formik, sendRequest, isEventModal) => { onConfirm: () => setConfirmDialogProps(props => ({ ...props, open: false })) }); - const confirmAction = (requestType, createAndSubmit = false) => { + const confirmAction = useCallback((onConfirm, requestType, createAndSubmit = false) => { /** @type {ConfirmProps} */ let newConfirmDialogProps = { open: true, onConfirm: () => { setConfirmDialogProps(props => ({ ...props, open: false })); - sendRequest(requestType); + onConfirm(); }, onCancel: () => { setConfirmDialogProps(props => ({ ...props, open: false })); - formik.setSubmitting(false); + setSubmitting(false); } }; @@ -65,13 +73,89 @@ export const useConfirmDialog = (formik, sendRequest, isEventModal) => { confirmButton: , onConfirm: () => { setConfirmDialogProps(props => ({ ...props, open: false })); - sendRequest(REQUEST_TYPE.CREATE, createAndSubmit); + onConfirm(); } } } setConfirmDialogProps(props => ({ ...props, ...newConfirmDialogProps })); - }; + }, [setSubmitting, isEventModal]); return { confirmDialogProps, confirmAction }; } + +export const useRequestsApi = (request, onSubmit) => { + const { + values: formValues, + resetForm, + setSubmitting, + setErrors, + } = useFormikContext(); + const { confirmAction } = useConfirmModalContext(); + + const setError = error => { setErrors({ api: error }); }; + + const callApi = async (url, method, data = formValues, doNotHandleResolve = false) => { + const promise = axios({ + method: method, + url: url, + data: data, + headers: { 'Content-Type': 'application/json' } + }); + + if (doNotHandleResolve) { + return promise; + } + + return promise + .then(() => { + resetForm(); + }) + .catch(error => { + setError(error); + throw error; + }); + }; + + const createAndSubmitRequest = () => onSubmit(async () => { + const createdRequest = await callApi(request.links?.actions?.create, 'post', formValues, true); + await callApi(createdRequest.data?.links?.actions?.submit, 'post', {}, true); + resetForm(); + }, (error) => { + setError(error); + }); + + const doCreateAndSubmitAction = (waitForConfirmation = false) => { + setSubmitting(true); + setErrors({}); + if (waitForConfirmation) { + confirmAction(createAndSubmitRequest, REQUEST_TYPE.SUBMIT, true); + } else { + createAndSubmitRequest(); + } + }; + + const sendRequest = async (actionUrl, requestType) => { + actionUrl = request.links?.actions[requestType]; + if (requestType === REQUEST_TYPE.SAVE) { + return callApi(actionUrl, 'put'); + } else if (requestType === REQUEST_TYPE.ACCEPT) { // Reload page after succesful "Accept" operation + await callApi(actionUrl, 'post'); + window.location.reload(); + return; + } + const mappedData = !isDeepEmpty(formValues) ? {} : formValues; + return callApi(actionUrl, 'post', mappedData); + }; + + const doAction = async (requestType, waitForConfirmation = false) => { + const actionUrl = request.links.actions[requestType]; + if (waitForConfirmation) { + confirmAction(() => onSubmit(() => sendRequest(actionUrl, requestType)), requestType); + } else { + onSubmit(() => sendRequest(actionUrl, requestType)); + } + }; + + return { doAction, doCreateAndSubmitAction }; +} \ No newline at end of file diff --git a/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/utils/index.js b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/utils/index.js index fecd8035..5b09a832 100644 --- a/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/utils/index.js +++ b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/utils/index.js @@ -2,7 +2,6 @@ import axios from "axios"; import _sortBy from "lodash/sortBy"; import _concat from "lodash/concat"; -import _has from "lodash/has"; import _partition from "lodash/partition"; import _isEmpty from "lodash/isEmpty"; import _isFunction from "lodash/isFunction"; diff --git a/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/utils/objects.js b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/utils/objects.js index 512d0b08..2381627d 100644 --- a/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/utils/objects.js +++ b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/record-requests/utils/objects.js @@ -6,3 +6,8 @@ export const REQUEST_TYPE = { DECLINE: 'decline', SAVE: 'save', } + +export const REQUEST_MODAL_TYPE = { + READ_ONLY: 'read-only', + SUBMIT_FORM: 'submit-form', +} \ No newline at end of file diff --git a/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/request-detail/components/ActionButtons.jsx b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/request-detail/components/ActionButtons.jsx new file mode 100644 index 00000000..17a45634 --- /dev/null +++ b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/request-detail/components/ActionButtons.jsx @@ -0,0 +1,77 @@ +import React from "react"; + +import { i18next } from "@translations/oarepo_requests_ui/i18next"; +import { Button } from "semantic-ui-react"; +import _isEmpty from "lodash/isEmpty"; +import axios from "axios"; + +import { ConfirmModal } from "."; + +const callApi = async (url, method = "GET", data = null) => { + if (_isEmpty(url)) { + console.log("URL parameter is missing or invalid."); + } + data = { data: data }; + if (_isEmpty(data.data?.payload?.content)) { + data = null; + } + return axios({ + url: url, + method: method, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/vnd.inveniordm.v1+json' + }, + ...data +})}; + +export const ActionButtons = ({ request }) => { + return ( + <> + {request.links?.actions?.submit && + callApi(request.links.actions.submit, "POST", values)} + triggerButton={ + + {submitButton} + + + )} + + + ); +}; + +ConfirmModal.propTypes = { + request: PropTypes.object.isRequired, + requestModalHeader: PropTypes.string, + handleSubmit: PropTypes.func, + TriggerButton: PropTypes.func, + submitButton: PropTypes.element, +}; \ No newline at end of file diff --git a/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/request-detail/components/EventSubmitForm.jsx b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/request-detail/components/EventSubmitForm.jsx new file mode 100644 index 00000000..09d78f16 --- /dev/null +++ b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/request-detail/components/EventSubmitForm.jsx @@ -0,0 +1,105 @@ +import React, { useState, useRef } from "react"; + +import { i18next } from "@translations/oarepo_requests_ui/i18next"; +import { Button, Message, FormField } from "semantic-ui-react"; +import _isEmpty from "lodash/isEmpty"; +import axios from "axios"; +import { RichEditor, RichInputField } from "react-invenio-forms"; +import { Formik, Form } from "formik"; + +import { useSanitizeInput } from "@js/oarepo_ui"; +import { CommentPayloadSchema } from "../utils"; + +export const EventSubmitForm = ({ request, setEvents }) => { + const [error, setError] = useState(null); + const { sanitizeInput } = useSanitizeInput() + + const editorRef = useRef(null); + + const callApi = async (url, method = "POST", data = null) => { + if (_isEmpty(url)) { + console.log("URL parameter is missing or invalid."); + } + return axios({ + url: url, + method: method, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/vnd.inveniordm.v1+json' + }, + data: data + })}; + + const onSubmit = async (values, { setSubmitting, resetForm }) => { + setSubmitting(true); + setError(null); + try { + const response = await callApi(request.links?.comments, "POST", values); + if (response.status !== 201) { + throw new Error(i18next.t("Comment was not created successfully.")); + } + setEvents((events) => [...events, response.data]); + } catch (error) { + setError(error); + } finally { + editorRef.current.setContent(""); + resetForm(); + setSubmitting(false); + } + }; + + return ( + + {({ values, isSubmitting, setFieldValue, setFieldTouched }) => ( +
+ + + {error && ( + + {i18next.t("Error while submitting the comment")} +

{error?.message}

+
+ )} +
+
+ + + +
+ + +
{requestHeader}
+ {request?.description && + + {request.description} + + } + {/* {renderReadOnlyData ? + + {Object.keys(request.payload).map(key => ( + + + {key} + import(`@js/oarepo_requests/components/common/${widget}.jsx`), + (widget) => import(`react-invenio-forms`) + ]} + /> + + + ))} + : null + } */} + +
+
+ + + + setActiveTab('timeline')} + /> + setActiveTab('topic')} + /> + + + + + + {activeTab === 'timeline' && } + {activeTab === 'topic' && } + + +
+ + + + + ); +} diff --git a/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/request-detail/components/SideRequestInfo.jsx b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/request-detail/components/SideRequestInfo.jsx new file mode 100644 index 00000000..83dbcd05 --- /dev/null +++ b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/request-detail/components/SideRequestInfo.jsx @@ -0,0 +1,61 @@ +import React from "react"; + +import { i18next } from "@translations/oarepo_requests_ui/i18next"; +import { Icon, List } from "semantic-ui-react"; +import _has from "lodash/has"; + +import { getRequestStatusIcon } from "../utils"; + +export const SideRequestInfo = ({ request }) => { + const statusIcon = getRequestStatusIcon(request?.status_code); + + return ( + + + + {i18next.t("Creator")} + + + + {_has(request, "created_by.links.self") ? {request.created_by.label} : request.created_by?.label} + + + + + + {i18next.t("Receiver")} + + + + {_has(request, "receiver.links.self") ? {request.receiver.label} : request.receiver?.label} + + + + {/*
+ {i18next.t("Request type")} +
+ + + */} + + + + {i18next.t("Status")} + + + {statusIcon && } + {request.status} + + + + + + {i18next.t("Created")} + + + {request.created} + + +
+ ) +}; diff --git a/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/request-detail/components/Timeline.jsx b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/request-detail/components/Timeline.jsx new file mode 100644 index 00000000..f86d71cf --- /dev/null +++ b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/request-detail/components/Timeline.jsx @@ -0,0 +1,83 @@ +import React, { useState, useEffect, useCallback, useRef } from "react"; + +import { i18next } from "@translations/oarepo_requests_ui/i18next"; +import { Message, Feed, Dimmer, Loader, Placeholder, Segment } from "semantic-ui-react"; +import _isEmpty from "lodash/isEmpty"; +import axios from "axios"; +import { delay } from "bluebird"; + +import { EventSubmitForm, TimelineEvent } from "."; + +export const Timeline = ({ request }) => { + const [events, setEvents] = useState([]); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const abortControllerRef = useRef(new AbortController()); + + const fetchEvents = useCallback(async () => { + const abortController = abortControllerRef.current; + setIsLoading(true); + setError(null); + try { + await delay(2000); // TODO: The backend is super slow to resolve the Timeline. Added super slow delay. + const response = await axios.get(request.links?.timeline, { + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/vnd.inveniordm.v1+json' + }, + signal: abortController.signal + }); + if (!abortController.signal.aborted) { + setEvents(response.data.hits.hits); + } + } catch (error) { + setError(error); + } finally { + if (!abortController.signal.aborted) { + setIsLoading(false); + } + } + }, [request.links.timeline]); + + useEffect(() => { + const abortController = abortControllerRef.current; + fetchEvents(); + return () => { + abortController.abort(); + } + }, [fetchEvents]); + + return ( + <> + + + {i18next.t("Loading timeline...")} + + {error ? + + {i18next.t("Error while fetching timeline events")} +

{error?.message}

+
: + isLoading ? + + + {Array.from({ length: events.length < 5 ? 5 : events.length }).map((_, index) => ( + + + + + ))} + + : + !_isEmpty(events) ? + + {events.map(event => )} + : + null + } +
+ + + ); +} diff --git a/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/request-detail/components/TimelineEvent.jsx b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/request-detail/components/TimelineEvent.jsx new file mode 100644 index 00000000..3971e659 --- /dev/null +++ b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/request-detail/components/TimelineEvent.jsx @@ -0,0 +1,38 @@ +import React, { memo } from "react"; + +import { i18next } from "@translations/oarepo_requests_ui/i18next"; +import { Icon, Feed } from "semantic-ui-react"; +import _has from "lodash/has"; +import { useSanitizeInput } from "@js/oarepo_ui"; + +import { hasAll, hasAny, getRequestStatusIcon } from "../utils"; + +const TimelineEvent = ({ event }) => { + const isRenderable = hasAll(event, 'created', 'payload') && hasAny(event.payload, 'event', 'content'); + const eventLabel = isRenderable ? event.payload?.event ?? i18next.t("commented") : null; + const eventIcon = getRequestStatusIcon(eventLabel) ?? { name: 'user circle', color: 'grey' }; + const { sanitizeInput } = useSanitizeInput() + + return isRenderable ? ( + + + + + + + {_has(event, "created_by.label") ? + <>{event.created_by.label} {eventLabel} {i18next.t('this request')}{event.created} : + <>{i18next.t('Request')} {eventLabel}{event.created} + } + + {_has(event.payload, "content") && + +
+ + } + + + ) : null; +}; + +export default memo(TimelineEvent, (prevProps, nextProps) => prevProps.event.updated === nextProps.event.updated); diff --git a/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/request-detail/components/TopicPreview.jsx b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/request-detail/components/TopicPreview.jsx new file mode 100644 index 00000000..34d232c2 --- /dev/null +++ b/oarepo_requests/ui/theme/assets/semantic-ui/js/oarepo_requests_ui/request-detail/components/TopicPreview.jsx @@ -0,0 +1,52 @@ +import React, { useRef, useEffect } from "react"; + +import { Grid, Loader, Segment } from "semantic-ui-react"; + +export const TopicPreview = ({ request }) => { + const iframeRef = useRef(null); + const [pxHeight, setPxHeight] = React.useState(0); + const [loading, setLoading] = React.useState(true); + + const updateIframeHeight = () => { + setPxHeight(iframeRef.current.contentWindow.document.body.scrollHeight); + } + + useEffect(() => { + const iframe = iframeRef.current; + if (iframe) { + iframe.contentWindow.addEventListener("resize", updateIframeHeight); + } + return () => { + iframe.contentWindow.removeEventListener("resize", updateIframeHeight); + } + }, []); + + return ( + + + {loading && + + + + } +