From 5c382f69803915372c3f7a7cc1047b7a6696500a Mon Sep 17 00:00:00 2001 From: Luccas Mateus Date: Thu, 14 Nov 2024 08:06:06 -0300 Subject: [PATCH] Applications as objects (#582) * Applications as objects * Progress * Progress * Progress * Progress * Forgot to commit * Applications as objs frontend * Fix build * Fix tests * Tests * Fix build * Fix build * Fix tests * Fix test * Trigger CI * Rm test * Fix unit tests --- .../wri/logic/action/action_helpers.py | 76 ++++- .../ckanext/wri/logic/action/create.py | 52 +++- .../ckanext/wri/logic/action/get.py | 57 +++- .../ckanext/wri/logic/action/update.py | 5 +- .../ckanext/wri/logic/auth/auth.py | 5 +- .../wri/tests/test_dataset_permissions.py | 1 + .../ckanext/wri/tests/test_schema.py | 9 +- deployment/frontend/public/sitemap-0.xml | 28 +- .../applications/ApplicationCard.tsx | 44 +++ .../applications/ApplicationSearch.tsx | 51 ++++ .../applications/DatasetApplication.tsx | 40 +++ .../src/components/applications/Hero.tsx | 92 +++++++ .../applications/TopicsSearchResults.tsx | 30 ++ .../src/components/dashboard/Layout.tsx | 50 +++- .../src/components/dashboard/_shared/Row.tsx | 2 +- .../applications/ApplicationCard.tsx | 189 +++++++++++++ .../applications/ApplicationList.tsx | 60 ++++ .../applications/forms/ApplicationForm.tsx | 148 ++++++++++ .../forms/CreateApplicationForm.tsx | 91 ++++++ .../forms/EditApplicationForm.tsx | 209 ++++++++++++++ .../datasets/admin/EditDatasetForm.tsx | 7 +- .../dashboard/datasets/admin/MulText.tsx | 25 +- .../datasets/admin/metadata/Overview.tsx | 53 +++- .../components/datasets/sections/About.tsx | 36 ++- .../search/FilteredSearchLayout.tsx | 260 +++++++++++++++++- .../src/interfaces/search.interface.ts | 2 +- .../frontend/src/pages/applications/404.tsx | 41 +++ .../pages/applications/[applicationName].tsx | 106 +++++++ .../frontend/src/pages/applications/index.tsx | 158 +++++++++++ .../applications/[applicationName]/edit.tsx | 58 ++++ .../pages/dashboard/applications/index.tsx | 60 ++++ .../dashboard/applications/new/index.tsx | 13 + .../pages/datasets/[datasetName]/index.tsx | 1 + .../frontend/src/pages/search_advanced.tsx | 1 - .../frontend/src/schema/application.schema.ts | 22 ++ deployment/frontend/src/schema/ckan.schema.ts | 43 ++- .../frontend/src/schema/dataset.schema.ts | 2 +- deployment/frontend/src/server/api/root.ts | 2 + .../src/server/api/routers/applications.ts | 188 +++++++++++++ .../src/server/api/routers/dataset.ts | 47 +++- .../frontend/src/server/api/routers/topics.ts | 34 +-- deployment/frontend/src/utils/apiUtils.ts | 5 +- e2e-tests/cypress/e2e/applicatoin_crud.cy.js | 40 +++ e2e-tests/cypress/e2e/dashboard.cy.js | 12 + .../cypress/e2e/dataset_create_and_read.cy.js | 1 - e2e-tests/cypress/support/e2e.js | 18 ++ package-lock.json | 6 - 47 files changed, 2355 insertions(+), 125 deletions(-) create mode 100644 deployment/frontend/src/components/applications/ApplicationCard.tsx create mode 100644 deployment/frontend/src/components/applications/ApplicationSearch.tsx create mode 100644 deployment/frontend/src/components/applications/DatasetApplication.tsx create mode 100644 deployment/frontend/src/components/applications/Hero.tsx create mode 100644 deployment/frontend/src/components/applications/TopicsSearchResults.tsx create mode 100644 deployment/frontend/src/components/dashboard/applications/ApplicationCard.tsx create mode 100644 deployment/frontend/src/components/dashboard/applications/ApplicationList.tsx create mode 100644 deployment/frontend/src/components/dashboard/applications/forms/ApplicationForm.tsx create mode 100644 deployment/frontend/src/components/dashboard/applications/forms/CreateApplicationForm.tsx create mode 100644 deployment/frontend/src/components/dashboard/applications/forms/EditApplicationForm.tsx create mode 100644 deployment/frontend/src/pages/applications/404.tsx create mode 100644 deployment/frontend/src/pages/applications/[applicationName].tsx create mode 100644 deployment/frontend/src/pages/applications/index.tsx create mode 100644 deployment/frontend/src/pages/dashboard/applications/[applicationName]/edit.tsx create mode 100644 deployment/frontend/src/pages/dashboard/applications/index.tsx create mode 100644 deployment/frontend/src/pages/dashboard/applications/new/index.tsx create mode 100644 deployment/frontend/src/schema/application.schema.ts create mode 100644 deployment/frontend/src/server/api/routers/applications.ts create mode 100644 e2e-tests/cypress/e2e/applicatoin_crud.cy.js delete mode 100644 package-lock.json diff --git a/ckan-backend-dev/src/ckanext-wri/ckanext/wri/logic/action/action_helpers.py b/ckan-backend-dev/src/ckanext-wri/ckanext/wri/logic/action/action_helpers.py index dd1abafd3..dde492758 100644 --- a/ckan-backend-dev/src/ckanext-wri/ckanext/wri/logic/action/action_helpers.py +++ b/ckan-backend-dev/src/ckanext-wri/ckanext/wri/logic/action/action_helpers.py @@ -1,5 +1,8 @@ import json import logging +from ckan.authz import users_role_for_group_or_org +import ckan.plugins.toolkit as tk +from ckan.common import _, config, current_user from ckan.types import DataDict from ckan.logic.validators import email_validator @@ -61,7 +64,10 @@ def _check_type(actors: str, data_dict: DataDict, actor_type: str) -> DataDict: actors = actors.strip() if ( - actors[0] == '"' and actors[-1] == '"' or actors[0] == "'" and actors[-1] == "'" + actors[0] == '"' + and actors[-1] == '"' + or actors[0] == "'" + and actors[-1] == "'" ) and len(actors) > 1: actors = actors[1:-1] @@ -90,3 +96,71 @@ def stringify_actor_objects(data_dict: DataDict) -> DataDict: data_dict = _check_type(actors, data_dict, key) return data_dict + + +def _fix_application_field(data_dict): + """ + When "applications" field is provided, add dataset to the + application + """ + applications = data_dict.get("applications", None) + + if applications is not None and len(applications) > 0: + application_names = [ + group.get("name") for group in data_dict.get("applications", []) + ] + priviliged_context = {"ignore_auth": True} + + group_list_action = tk.get_action("group_list") + group_list_data_dict = { + "type": "application", + "groups": application_names, + "include_extras": True, + "all_fields": True, + } + group_list = group_list_action(priviliged_context, group_list_data_dict) + + application_groups = [ + {"name": x.get("name"), "type": "application"} for x in group_list + ] + groups = [ + {"name": x.get("name"), "type": "group"} + for x in data_dict.get("groups", []) + ] + groups += application_groups + data_dict["groups"] = groups + data_dict["applications"] = [x.get("name") for x in group_list] + return data_dict + +def _fix_user_group_permission(data_dict): + """ + By default, any user should be able to create datasets + with any application or topic. + To do that, add user as member of groups. + """ + groups = data_dict.get("groups", []) + if not hasattr(current_user, "id"): + return + user_id = current_user.name + + if len(groups) > 0 and user_id: + priviliged_context = {"ignore_auth": True} + group_member_create_action = tk.get_action("group_member_create") + + for group in groups: + group_id = group.get("name") + capacity = users_role_for_group_or_org(group_id, user_id) + if capacity not in ["member", "editor", "admin"]: + group_member_create_data_dict = { + "id": group.get("name"), + "username": user_id, + "role": "member", + } + group_member_create_action( + priviliged_context, group_member_create_data_dict + ) + return data_dict + + +def _before_dataset_create_or_update(context, data_dict, is_update=False): + _fix_user_group_permission(data_dict) diff --git a/ckan-backend-dev/src/ckanext-wri/ckanext/wri/logic/action/create.py b/ckan-backend-dev/src/ckanext-wri/ckanext/wri/logic/action/create.py index 831246227..b4a2f21b3 100644 --- a/ckan-backend-dev/src/ckanext-wri/ckanext/wri/logic/action/create.py +++ b/ckan-backend-dev/src/ckanext-wri/ckanext/wri/logic/action/create.py @@ -1,3 +1,4 @@ +from pprint import pprint from typing_extensions import TypeAlias, Any import logging import requests @@ -5,6 +6,7 @@ import json from typing import Any, Union, cast import six +from ckan.common import _, config, current_user from ckanext.wri.model.notification import Notification, notification_dictize from ckanext.wri.model.pending_datasets import PendingDatasets @@ -26,8 +28,10 @@ import ckan.lib.uploader as uploader import ckan.lib.plugins as lib_plugins import ckan.lib.dictization.model_save as model_save - -from ckanext.wri.logic.action.action_helpers import stringify_actor_objects +from ckanext.wri.logic.action.action_helpers import ( + stringify_actor_objects, + _before_dataset_create_or_update, +) import uuid NotificationGetUserViewedActivity: TypeAlias = None @@ -204,7 +208,7 @@ def notification_create( user_notifications = Notification( recipient_id=recipient_id, - sender_id=sender_id if sender_id else '', + sender_id=sender_id if sender_id else "", activity_type=activity_type, object_type=object_type, object_id=object_id, @@ -285,7 +289,9 @@ def migrate_dataset(context: Context, data_dict: DataDict): if not dataset_id: if not gfw_dataset: - raise tk.ValidationError(_("Dataset 'rw_dataset_id' or 'gfw_dataset' is required")) + raise tk.ValidationError( + _("Dataset 'rw_dataset_id' or 'gfw_dataset' is required") + ) else: data_dict["gfw_only"] = True @@ -414,7 +420,27 @@ def package_create(context: Context, data_dict: DataDict): data_dict = stringify_actor_objects(data_dict) + _before_dataset_create_or_update(context, data_dict) dataset = l.action.create.package_create(context, data_dict) + if dataset.get("groups"): + # This is necessary because the pending dataset doesnt have any of the logic that package_show has + groups = [ + tk.get_action("group_show")(context, {"id": group.get("name")}) + for group in dataset.get("groups") + ] + groups = [ + { + "id": group.get("id"), + "name": group.get("name"), + "display_name": group.get("display_name"), + "title": group.get("title"), + "description": group.get("description"), + "image_display_url": group.get("image_display_url"), + "type": group.get("type"), + } + for group in groups + ] + dataset["groups"] = groups if data_dict.get("owner_org"): org = tk.get_action("organization_show")( context, {"id": data_dict.get("owner_org")} @@ -458,16 +484,18 @@ def package_create(context: Context, data_dict: DataDict): context, {"dataset_id": dataset.get("id")} ) - if (dataset.get("visibility_type") == "internal"): + if dataset.get("visibility_type") == "internal": print("INTERNAL PENDING DATASET") - __import__('pprint').pprint(pending_dataset) + __import__("pprint").pprint(pending_dataset) return dataset # IMPORTANT: This function includes an override/change for authors/maintainers (the call to stringify_actor_objects). # This is not a 1:1 match with the original function, though all other logic is the same. -def old_package_create(context: Context, data_dict: DataDict) -> ActionResult.PackageCreate: +def old_package_create( + context: Context, data_dict: DataDict +) -> ActionResult.PackageCreate: """Create a new dataset (package). You must be authorized to create new datasets. If you specify any groups @@ -721,8 +749,8 @@ def resource_create( if not data_dict.get("url"): data_dict["url"] = "" - if not data_dict.get('id'): - data_dict['id'] = str(uuid.uuid4()) + if not data_dict.get("id"): + data_dict["id"] = str(uuid.uuid4()) package_show_context: Union[Context, Any] = dict(context, for_update=True) pkg_dict = _get_action("package_show")(package_show_context, {"id": package_id}) @@ -764,15 +792,15 @@ def resource_create( # package_show until after commit package = context["package"] assert package - upload.upload(data_dict['id'], uploader.get_max_resource_size()) + upload.upload(data_dict["id"], uploader.get_max_resource_size()) model.repo.commit() # Run package show again to get out actual last_resource updated_pkg_dict = _get_action("package_show")(context, {"id": package_id}) resource = updated_pkg_dict["resources"][-1] - if not resource.get('id'): - resource['id'] = data_dict['id'] + if not resource.get("id"): + resource["id"] = data_dict["id"] # Add the default views to the new resource logic.get_action("resource_create_default_resource_views")( diff --git a/ckan-backend-dev/src/ckanext-wri/ckanext/wri/logic/action/get.py b/ckan-backend-dev/src/ckanext-wri/ckanext/wri/logic/action/get.py index b7364d2f5..b1cbc3502 100644 --- a/ckan-backend-dev/src/ckanext-wri/ckanext/wri/logic/action/get.py +++ b/ckan-backend-dev/src/ckanext-wri/ckanext/wri/logic/action/get.py @@ -414,13 +414,21 @@ def package_search(context: Context, data_dict: DataDict) -> ActionResult.Packag group_names.extend(facets.get(field_name, {}).keys()) groups = ( - session.query(model.Group.name, model.Group.title) + session.query(model.Group.name, model.Group.title) + # type_ignore_reason: incomplete SQLAlchemy types + .filter(model.Group.name.in_(group_names)).all() # type: ignore + if group_names + else [] + ) + _groups = ( + session.query(model.Group.name, model.Group.type) # type_ignore_reason: incomplete SQLAlchemy types .filter(model.Group.name.in_(group_names)).all() # type: ignore if group_names else [] ) group_titles_by_name = dict(groups) + group_types_by_name = dict(_groups) # Transform facets into a more useful data structure. restructured_facets: dict[str, Any] = {} @@ -431,9 +439,14 @@ def package_search(context: Context, data_dict: DataDict) -> ActionResult.Packag new_facet_dict["name"] = key_ if key in ("groups", "organization"): display_name = group_titles_by_name.get(key_, key_) + group_type = group_types_by_name.get(key_, key_) display_name = ( display_name if display_name and display_name.strip() else key_ ) + group_type = ( + group_type if group_type and group_type.strip() else key_ + ) + new_facet_dict["type"] = group_type new_facet_dict["display_name"] = display_name elif key == "license_id": license = model.Package.get_license_register().get(key_) @@ -554,6 +567,36 @@ def pending_dataset_show(context: Context, data_dict: DataDict): try: pending_dataset = PendingDatasets.get(package_id=package_id) + if pending_dataset and pending_dataset.get('package_data'): + package_data = pending_dataset['package_data'] + if package_data.get("groups", None) is not None: + _groups = [ + tk.get_action("group_show")(context, {"id": group.get('name')}) + for group in package_data.get("groups") + ] + groups = [ + { + "description": group.get("description"), + "display_name": group.get("display_name"), + "id": group.get("id"), + "image_display_url": group.get("image_display_url"), + "name": group.get("name"), + "title": group.get("title"), + "type": group.get("type"), + "homepage_url": group.get("homepage_url", None) if 'homepage_url' in group else None, + "contact_url": group.get("contact_url", None) if 'contact_url' in group else None, + "help_url": group.get("help_url", None) if 'help_url' in group else None, + } + for group in _groups + ] + for group in groups: + if group.get('help_url') is None: + del group['help_url'] + if group.get('contact_url') is None: + del group['contact_url'] + if group.get('homepage_url') is None: + del group['homepage_url'] + pending_dataset['package_data']["groups"] = groups except Exception as e: log.error(e) raise tk.ValidationError(e) @@ -581,8 +624,9 @@ def pending_diff_show(context: Context, data_dict: DataDict): try: pending_dataset = PendingDatasets.get(package_id=package_id) if pending_dataset is not None: - context["for_approval"] = True - pending_dataset = pending_dataset.get("package_data") + # context["for_approval"] = True + pending_dataset = get_action('pending_dataset_show')(context, { "package_id": package_id}) + pending_dataset = pending_dataset['package_data'] existing_dataset = get_action("package_show")(context, {"id": package_id}) dataset_diff = _diff(existing_dataset, pending_dataset) except Exception as e: @@ -1356,9 +1400,7 @@ def _add_group_types(context: Context, data_dict: DataDict): } if group_type == "application": - group_dict_updates = { - "type": group_type - } + group_dict_updates = {"type": group_type} group.update(group_dict_updates) for key, value in new_group_dict.items(): @@ -1372,8 +1414,7 @@ def _add_group_types(context: Context, data_dict: DataDict): group.update({"type": group_type}) updated_package_groups.append(group) - data_dict["groups"] = updated_package_groups - data_dict["applications"] = package_applications if package_applications else [] + data_dict["groups"] = updated_package_groups + package_applications except Exception as e: log.error(f"Error adding group types: {e}") diff --git a/ckan-backend-dev/src/ckanext-wri/ckanext/wri/logic/action/update.py b/ckan-backend-dev/src/ckanext-wri/ckanext/wri/logic/action/update.py index 5d05b3850..5fd145096 100644 --- a/ckan-backend-dev/src/ckanext-wri/ckanext/wri/logic/action/update.py +++ b/ckan-backend-dev/src/ckanext-wri/ckanext/wri/logic/action/update.py @@ -19,7 +19,7 @@ GroupNotificationParams, send_group_notification, ) -from ckanext.wri.logic.action.action_helpers import stringify_actor_objects +from ckanext.wri.logic.action.action_helpers import stringify_actor_objects, _before_dataset_create_or_update import ckan.plugins.toolkit as tk import ckan.logic as logic from ckan.common import _ @@ -167,8 +167,6 @@ def pending_dataset_update(context: Context, data_dict: DataDict): if not package_data: raise tk.ValidationError(_("package_data is required")) - tk.check_access("package_create", context, package_data) - pending_dataset = None try: @@ -468,6 +466,7 @@ def old_package_patch(context: Context, data_dict: DataDict) -> ActionResult.Pac You must be authorized to edit the dataset and the groups that it belongs to. """ + _before_dataset_create_or_update(context, data_dict) _check_access("package_patch", context, data_dict) show_context: Context = { diff --git a/ckan-backend-dev/src/ckanext-wri/ckanext/wri/logic/auth/auth.py b/ckan-backend-dev/src/ckanext-wri/ckanext/wri/logic/auth/auth.py index 08693cf18..76f13c109 100644 --- a/ckan-backend-dev/src/ckanext-wri/ckanext/wri/logic/auth/auth.py +++ b/ckan-backend-dev/src/ckanext-wri/ckanext/wri/logic/auth/auth.py @@ -117,7 +117,6 @@ def package_update(up_func, context, data_dict): # (ie if user is a collaborator) if user_obj: if user_obj.id == package.creator_user_id: - print("CREATOR ID") return {"success": True} if ( authz.user_is_collaborator_on_dataset( @@ -173,11 +172,9 @@ def pending_dataset_show(context: Context, data_dict: DataDict) -> AuthResult: def pending_dataset_update(context: Context, data_dict: DataDict) -> AuthResult: - #print("PENDING DATASET UPDATE", flush=True) - #print(data_dict, flush=True) + print("CHECKING PENDING DATASET UPDATE AUTH", flush=True) return tk.check_access("package_update", context, data_dict) - def pending_dataset_delete(context: Context, data_dict: DataDict) -> AuthResult: return tk.check_access("package_delete", context, data_dict) diff --git a/ckan-backend-dev/src/ckanext-wri/ckanext/wri/tests/test_dataset_permissions.py b/ckan-backend-dev/src/ckanext-wri/ckanext/wri/tests/test_dataset_permissions.py index d4203fed0..2491c9834 100644 --- a/ckan-backend-dev/src/ckanext-wri/ckanext/wri/tests/test_dataset_permissions.py +++ b/ckan-backend-dev/src/ckanext-wri/ckanext/wri/tests/test_dataset_permissions.py @@ -17,6 +17,7 @@ def test_package_create_public(mail_user): title="Test Group", description="A description of the group", ) + group_dict = factories.Group() userobj_sysadmin = factories.Sysadmin() userobj_org_admin = factories.User() diff --git a/ckan-backend-dev/src/ckanext-wri/ckanext/wri/tests/test_schema.py b/ckan-backend-dev/src/ckanext-wri/ckanext/wri/tests/test_schema.py index d7a0e78fb..b7c8175c7 100644 --- a/ckan-backend-dev/src/ckanext-wri/ckanext/wri/tests/test_schema.py +++ b/ckan-backend-dev/src/ckanext-wri/ckanext/wri/tests/test_schema.py @@ -132,11 +132,10 @@ def test_package_create(mail_user): assert result["learn_more"] == dataset["learn_more"] assert result["cautions"] == dataset["cautions"] assert result["methodology"] == dataset["methodology"] - assert application_group_dict["id"] in [group["id"] for group in result["applications"]] - assert application_group_dict["name"] in [group["name"] for group in result["applications"]] - assert application_group_dict["title"] in [group["title"] for group in result["applications"]] - assert application_group_dict["type"] in [group["type"] for group in result["applications"]] - assert application_group_dict["homepage_url"] in [group["homepage_url"] for group in result["applications"]] + assert application_group_dict["id"] in [group["id"] for group in result["groups"]] + assert application_group_dict["name"] in [group["name"] for group in result["groups"]] + assert application_group_dict["title"] in [group["title"] for group in result["groups"]] + assert application_group_dict["type"] in [group["type"] for group in result["groups"]] invalid_urls = ["invalid_url_1", "invalid_url_2", "invalid_url_3"] diff --git a/deployment/frontend/public/sitemap-0.xml b/deployment/frontend/public/sitemap-0.xml index d85b15f87..bc4e9645a 100644 --- a/deployment/frontend/public/sitemap-0.xml +++ b/deployment/frontend/public/sitemap-0.xml @@ -1,15 +1,19 @@ -http://localhost:30002024-09-09T13:34:55.316Zdaily0.7 -http://localhost:3000/auth/password-reset2024-09-09T13:34:55.316Zdaily0.7 -http://localhost:3000/datasets/4042024-09-09T13:34:55.316Zdaily0.7 -http://localhost:3000/search2024-09-09T13:34:55.316Zdaily0.7 -http://localhost:3000/search_advanced2024-09-09T13:34:55.316Zdaily0.7 -http://localhost:3000/teams2024-09-09T13:34:55.316Zdaily0.7 -http://localhost:3000/topics2024-09-09T13:34:55.316Zdaily0.7 -http://localhost:3000/user-guide2024-09-09T13:34:55.316Zdaily0.7 -http://localhost:3000/user-guide/basic-definition2024-09-09T13:34:55.316Zdaily0.7 -http://localhost:3000/user-guide/dataset-view-pages2024-09-09T13:34:55.316Zdaily0.7 -http://localhost:3000/user-guide/explore-data-and-advance-search2024-09-09T13:34:55.316Zdaily0.7 -http://localhost:3000/user-guide/teams-topics2024-09-09T13:34:55.316Zdaily0.7 +http://localhost:30002024-11-13T16:21:13.529Zdaily0.7 +http://localhost:3000/applications2024-11-13T16:21:13.529Zdaily0.7 +http://localhost:3000/applications/4042024-11-13T16:21:13.529Zdaily0.7 +http://localhost:3000/auth/password-reset2024-11-13T16:21:13.529Zdaily0.7 +http://localhost:3000/datasets/4042024-11-13T16:21:13.529Zdaily0.7 +http://localhost:3000/search2024-11-13T16:21:13.529Zdaily0.7 +http://localhost:3000/search_advanced2024-11-13T16:21:13.529Zdaily0.7 +http://localhost:3000/teams2024-11-13T16:21:13.529Zdaily0.7 +http://localhost:3000/teams/4042024-11-13T16:21:13.529Zdaily0.7 +http://localhost:3000/topics2024-11-13T16:21:13.529Zdaily0.7 +http://localhost:3000/topics/4042024-11-13T16:21:13.529Zdaily0.7 +http://localhost:3000/user-guide2024-11-13T16:21:13.529Zdaily0.7 +http://localhost:3000/user-guide/basic-definition2024-11-13T16:21:13.529Zdaily0.7 +http://localhost:3000/user-guide/dataset-view-pages2024-11-13T16:21:13.529Zdaily0.7 +http://localhost:3000/user-guide/explore-data-and-advance-search2024-11-13T16:21:13.529Zdaily0.7 +http://localhost:3000/user-guide/teams-topics2024-11-13T16:21:13.529Zdaily0.7 \ No newline at end of file diff --git a/deployment/frontend/src/components/applications/ApplicationCard.tsx b/deployment/frontend/src/components/applications/ApplicationCard.tsx new file mode 100644 index 000000000..53e35cca5 --- /dev/null +++ b/deployment/frontend/src/components/applications/ApplicationCard.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import Image from 'next/image' +import { Application } from '@/schema/ckan.schema' +import { api } from '@/utils/api' +import Link from 'next/link' + +export default function ApplicationCard({ + application, +}: { + application: Application +}) { + return ( + +
+ {`Application +
+
+

+ {application.title} +

+
+
+ application.description +
+
+ + {application.package_count} + datasets + +
+ + ) +} diff --git a/deployment/frontend/src/components/applications/ApplicationSearch.tsx b/deployment/frontend/src/components/applications/ApplicationSearch.tsx new file mode 100644 index 000000000..fa34ec666 --- /dev/null +++ b/deployment/frontend/src/components/applications/ApplicationSearch.tsx @@ -0,0 +1,51 @@ +import React, { useRef } from 'react' +import { MagnifyingGlassIcon } from '@heroicons/react/20/solid' +import type { SearchInput } from '@/schema/search.schema' +import Spinner from '@/components/_shared/Spinner' + +export default function ApplicationSearch({ + setQuery, + query, + isLoading, +}: { + setQuery: React.Dispatch> + query: string + isLoading: boolean +}) { + return ( + + ) +} diff --git a/deployment/frontend/src/components/applications/DatasetApplication.tsx b/deployment/frontend/src/components/applications/DatasetApplication.tsx new file mode 100644 index 000000000..04b9ebe1d --- /dev/null +++ b/deployment/frontend/src/components/applications/DatasetApplication.tsx @@ -0,0 +1,40 @@ +import React, { useState } from 'react' +import DatasetHorizontalCard from '@/components/search/DatasetHorizontalCard' +import { api } from '@/utils/api' +import Spinner from '../_shared/Spinner' +import { Application } from '@/schema/ckan.schema' +import Pagination from '@/components/datasets/Pagination' +import type { SearchInput } from '@/schema/search.schema' + +export default function DatasetApplication({ application }: { application: Application }) { + const [query, setQuery] = useState({ + search: '', + fq: { + groups: application.name, + }, + page: { + start: 0, + rows: 100, + }, + }) + const { data, isLoading } = api.dataset.getAllDataset.useQuery(query) + + return ( +
+
+ Datasets associated with {application.title ?? application.name}{' '} + {isLoading ? : `(${data?.count})`} +
+ {isLoading ? ( + + ) : ( + <> + {data?.datasets.map((dataset, number) => ( + + ))} + + + )} +
+ ) +} diff --git a/deployment/frontend/src/components/applications/Hero.tsx b/deployment/frontend/src/components/applications/Hero.tsx new file mode 100644 index 000000000..cd95b61fb --- /dev/null +++ b/deployment/frontend/src/components/applications/Hero.tsx @@ -0,0 +1,92 @@ +import Image from 'next/image' +import { Button } from '../_shared/Button' +import { ChevronLeftIcon } from '@heroicons/react/20/solid' +import { useState } from 'react' +import { ClipboardDocumentIcon } from '@heroicons/react/24/outline' +import { Application, GroupTree, GroupsmDetails } from '@/schema/ckan.schema' +import Link from 'next/link' +import { useSession } from 'next-auth/react' +import { api } from '@/utils/api' + +export function Hero({ application }: { application: Application }) { + const { data: session } = useSession() + return ( +
+
+ Application name +
+ + + See all applications + +
+
+
+
+ {application.title} +
+
+ {application?.description} +
+
+
+ {application.package_count} Dataset(s) +
+
+ +
+
+ ) +} + +function CopyLink() { + const [clicked, setClicked] = useState(false) + return ( + <> + {!clicked ? ( + + ) : ( + + )} + + ) +} diff --git a/deployment/frontend/src/components/applications/TopicsSearchResults.tsx b/deployment/frontend/src/components/applications/TopicsSearchResults.tsx new file mode 100644 index 000000000..a1c4bd699 --- /dev/null +++ b/deployment/frontend/src/components/applications/TopicsSearchResults.tsx @@ -0,0 +1,30 @@ +import Topic from '@/interfaces/topic.interface' +import CardsGrid from '../_shared/CardsGrid' +import Container from '../_shared/Container' +import ApplicationCard from './ApplicationCard' +import { Application } from '@/schema/ckan.schema' + +export default function ApplicationSearchResults({ + applications, + count, + filtered, +}: { + applications: Application[] + count: number + filtered: boolean +}) { + return ( + + + {count} {!filtered ? 'top level topics' : 'topics'} + + + className="mt-5" + items={applications} + Card={({ item: application }) => { + return + }} + /> + + ) +} diff --git a/deployment/frontend/src/components/dashboard/Layout.tsx b/deployment/frontend/src/components/dashboard/Layout.tsx index 6c4ce7d91..dcb775fd6 100644 --- a/deployment/frontend/src/components/dashboard/Layout.tsx +++ b/deployment/frontend/src/components/dashboard/Layout.tsx @@ -43,7 +43,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { href: '/approval-request', active: false, count: 1, - isSysAdmin: true, + isSysAdmin: false, }, { name: 'Activity Stream', @@ -73,6 +73,13 @@ export default function Layout({ children }: { children: React.ReactNode }) { count: 0, isSysAdmin: false, }, + { + name: 'Applications', + href: '/applications', + active: false, + count: 0, + isSysAdmin: true, + }, { name: 'Users', href: '/users', @@ -178,8 +185,16 @@ export default function Layout({ children }: { children: React.ReactNode }) { {navigation.map((item) => { + if ( + item.isSysAdmin && + !session?.user.sysadmin + ) { + return <> + } return ( - + {item.name == 'Requests for approval' ? ( <> @@ -313,6 +328,12 @@ export default function Layout({ children }: { children: React.ReactNode }) { {navigation.map((item) => { + if ( + item.isSysAdmin && + !session?.user.sysadmin + ) { + return <> + } return ( {item.name == @@ -426,16 +447,21 @@ export default function Layout({ children }: { children: React.ReactNode }) {
- {!sidebarOpen &&
- -
} + {!sidebarOpen && ( +
+ +
+ )}
{children}
diff --git a/deployment/frontend/src/components/dashboard/_shared/Row.tsx b/deployment/frontend/src/components/dashboard/_shared/Row.tsx index 961c70f8a..aa5fd8ff3 100644 --- a/deployment/frontend/src/components/dashboard/_shared/Row.tsx +++ b/deployment/frontend/src/components/dashboard/_shared/Row.tsx @@ -38,7 +38,7 @@ export default function Row({ rowMain, rowSub, isDropDown, controlButtons, linkB const { data: session } = useSession() // state event to change hover effect on desktop to click effect on mobile // const [isHover, setIsHover] = useState(false) - const enableControlDiv = (isDropDown ?? controlButtons ?? linkButton) ? true : false + const enableControlDiv = (isDropDown || controlButtons || linkButton) ? true : false return (
import('@/components/_shared/Modal'), { + ssr: false, +});; +import { useRouter } from 'next/router' +import { LoaderButton, Button } from '@/components/_shared/Button' +import { ExclamationTriangleIcon } from '@heroicons/react/24/outline' +import { Dialog } from '@headlessui/react' +import Image from 'next/image' +import type { Application } from '@/schema/ckan.schema' + + +function ApplicationProfile({ application }: { application: Application }) { + return ( +
+
+
+
+ +
+
+
+

+ {application?.title || application.name} +

+ {application?.description ? ( + + {application.description} + + ) : ( + '' + )} +
+
+
+ ) +} + +export default function ApplicationCard() { + const [query, setQuery] = useState({ search: '', page: { start: 0, rows: 10 } }) + const { data: applications , isLoading, refetch } = api.applications.getAllApplications.useQuery() + const [open, setOpen] = useState(false) + const router = useRouter() + const [selectedApplication, setSelectedApplication] = useState(null) + const deleteApplication = api.applications.deleteDashBoardApplication.useMutation({ + onSuccess: async (data) => { + await refetch(); + setOpen(false) + notify(`Successfully deleted the ${selectedApplication?.name} application`, 'error') + } + }) + + const handleOpenModal = (application: Application) => { + setSelectedApplication(application) + setOpen(true) + } + + const filteredApplications = applications?.filter((application) => { + return application.name.toLowerCase().includes(query.search.toLowerCase()) + }) + const paginatedApplications = filteredApplications?.slice(query.page.start, query.page.start + query.page.rows) + + return ( +
+ } /> +
+ { + (isLoading || !paginatedApplications) ?
: ( + paginatedApplications.map((application, index) => { + return ( +
+ } + linkButton={{ + label: "View application", + link: `../applications/${application.name}`, + }} + controlButtons={[ + { + label: "Edit", + color: 'bg-wri-gold hover:bg-yellow-400', + icon: , + tooltip: { + id: `edit-tooltip-${application.name}`, + content: "Edit application" + }, + onClick: () => { + router.push(`/dashboard/applications/${application.name}/edit`) + } + }, + { + label: "Delete", + color: 'bg-red-600 hover:bg-red-500', + icon: , + tooltip: { + id: `delete-tooltip-${application.name}`, + content: "Delete application" + }, + onClick: () => handleOpenModal(application) + }, + ]} + rowSub={null} + isDropDown={false} + /> +
+ + ) + }) + ) + } + + { + selectedApplication && ( + +
+
+
+
+ + Delete Application + +
+

+ Are you sure you want to delete this application? +

+
+
+
+
+ deleteApplication.mutate(selectedApplication.id)} + id={selectedApplication.name} + > + Delete Application + + +
+
+ ) + } + +
+
+ ) +} diff --git a/deployment/frontend/src/components/dashboard/applications/ApplicationList.tsx b/deployment/frontend/src/components/dashboard/applications/ApplicationList.tsx new file mode 100644 index 000000000..3e47a1bbb --- /dev/null +++ b/deployment/frontend/src/components/dashboard/applications/ApplicationList.tsx @@ -0,0 +1,60 @@ +import React from 'react' +import { Tab } from '@headlessui/react' +import { PlusSmallIcon, PlusCircleIcon } from '@heroicons/react/24/outline' +import Link from 'next/link' +import ApplicationCard from './ApplicationCard' + +const tabs = [ + { + id: 'allteams', + content: , + title: 'All applications', + }, +] + +export default function ApplicationList() { + return ( +
+ + + {tabs.map((tab) => ( + + {({ selected }) => ( +
+ {tab.title} +
+ )} +
+ ))} + +
+
+ +
+ Add application +
+ +
+ + {tabs.map((tab) => ( + + {tab.content} + + ))} + +
+
+ ) +} diff --git a/deployment/frontend/src/components/dashboard/applications/forms/ApplicationForm.tsx b/deployment/frontend/src/components/dashboard/applications/forms/ApplicationForm.tsx new file mode 100644 index 000000000..fcf0ac6b9 --- /dev/null +++ b/deployment/frontend/src/components/dashboard/applications/forms/ApplicationForm.tsx @@ -0,0 +1,148 @@ +import { UseFormReturn } from 'react-hook-form' +import { ApplicationFormType } from '@/schema/application.schema' +import { ErrorDisplay, InputGroup } from '@/components/_shared/InputGroup' +import { Input } from '@/components/_shared/SimpleInput' +import { TextArea } from '@/components/_shared/SimpleTextArea' +import { ImageUploader } from '../../_shared/ImageUploader' +import { UploadResult } from '@uppy/core' +import DefaultTooltip from '@/components/_shared/Tooltip' +import { InformationCircleIcon } from '@heroicons/react/24/outline' + +export default function ApplicationForm({ + formObj, + editing = false, +}: { + formObj: UseFormReturn + editing?: boolean +}) { + const { + register, + setValue, + watch, + formState: { errors, isSubmitting }, + } = formObj + return ( +
+
+ + + + + + + + /applications/ + + + + + +
+
+ setValue('image_url', '')} + defaultImage={ + watch('image_url') && + watch('image_display_url') + } + onUploadSuccess={(response: UploadResult) => { + const url = + response.successful[0]?.uploadURL ?? + null + const name = url ? url.split('/').pop() : '' + setValue('image_url', name) + }} + /> +
+
+
+
+
+ +