diff --git a/.gitignore b/.gitignore index dbcc6364ed..8695a3d148 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ designsafe/templates/react-assets.html designsafe.env mysql.env rabbitmq.env +ngrok.env rabbitmq.conf mysql.cnf @@ -35,6 +36,7 @@ designsafe/apps/dashboard/templates/designsafe/apps/dashboard/index.html designsafe/apps/notifications/templates/designsafe/apps/notifications/index.html designsafe/apps/nco/templates/designsafe/apps/nco/nco_index.html designsafe/apps/nco/templates/designsafe/apps/nco/ttc_grants.html +designsafe/apps/onboarding/templates/designsafe/apps/onboarding/index.html # local config files conf/elasticsearch/logging.yml diff --git a/.pylintrc b/.pylintrc index d8f0f80226..3458e31246 100644 --- a/.pylintrc +++ b/.pylintrc @@ -52,7 +52,7 @@ ignore=CVS,tests.py # ignore-list. The regex matches against paths and can be in Posix or Windows # format. Because '\\' represents the directory delimiter on Windows systems, # it can't be used as an escape character. -ignore-paths=^.*migrations/.*$,^.*_tests/.*$,^.*unit_test.*$ +ignore-paths=^.*migrations/.*$,^.*_tests/.*$,^.*unit_test.*$,^.*test_.*$ # Files or directories matching the regular expression patterns are skipped. # The regex matches against base names, not paths. The default value ignores diff --git a/Makefile b/Makefile index 0104e5c8b2..dc85626ec1 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,9 @@ +NGROK_ENV_FILE = ./conf/env_files/ngrok.env +ifeq ("$(wildcard $(NGROK_ENV_FILE))","") + NGROK_ENV_FILE = ./conf/env_files/ngrok.sample.env +endif + + .PHONY: build build: docker compose -f ./conf/docker/docker-compose.yml build @@ -8,16 +14,16 @@ build-dev: .PHONY: start start: - docker compose -f ./conf/docker/docker-compose-dev.all.debug.yml up + docker compose --env-file $(NGROK_ENV_FILE) -f ./conf/docker/docker-compose-dev.all.debug.yml up .PHONY: stop stop: - docker compose -f ./conf/docker/docker-compose-dev.all.debug.yml down + docker compose --env-file $(NGROK_ENV_FILE) -f ./conf/docker/docker-compose-dev.all.debug.yml down .PHONY: start-m1 start-m1: - docker compose -f ./conf/docker/docker-compose-dev.all.debug.m1.yml up + docker compose --env-file $(NGROK_ENV_FILE) -f ./conf/docker/docker-compose-dev.all.debug.m1.yml up .PHONY: stop-m1 stop-m1: - docker compose -f ./conf/docker/docker-compose-dev.all.debug.m1.yml down + docker compose --env-file $(NGROK_ENV_FILE) -f ./conf/docker/docker-compose-dev.all.debug.m1.yml down diff --git a/README.md b/README.md index b2e91f8b7a..58dc9bc4b8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,3 @@ -[![Build Status](https://travis-ci.org/DesignSafe-CI/portal.svg?branch=master)](https://travis-ci.org/DesignSafe-CI/portal) -[![codecov](https://codecov.io/gh/DesignSafe-CI/portal/branch/master/graph/badge.svg)](https://codecov.io/gh/DesignSafe-CI/portal) - # DesignSafe-CI Portal ## Prequisites for running the portal application @@ -11,8 +8,8 @@ on. - [Install Docker][3] - [Install Docker Compose][4] -- [Install Make][12] -- [Node.js][13] 16.x +- [Install Make][11] +- [Node.js][12] 20.x If you are on a Mac or a Windows machine, the recommended method is to install [Docker Desktop][5], which will install both Docker and Docker Compose, which is required to run Docker on Mac/Windows hosts. @@ -35,18 +32,23 @@ If you are on a Mac or a Windows machine, the recommended method is to install Required variables: - `DJANGO_DEBUG`: should be set to `True` for development - - `DJANGO_SECRET`: should be changed for production + - `DJANGO_SECRET_KEY`: should be changed for production - `TAS_*`: should be set to enable direct access to `django.contrib.admin` - - `AGAVE_*`: should be set to enable Agave API integration (authentication, etc.) + - `TAPIS_*`: should be set to enable Tapis API integration (authentication, etc.) - `RT_*`: should be set to enable ticketing Make a copy of [rabbitmq.sample.env](conf/env_files/rabbitmq.sample.env) then rename it to `rabbitmq.env`. - Make a copy of [external_resource_secrets.sample.py](designsafe/settings/external_resource_secrets.sample.py) - and rename it to `external_resource_secrets.py`. +3. Configure ngrok + + a. Install [ngrok](https://ngrok.com/docs/getting-started/), and create an ngrok account. + + b. Copy [conf/env_files/ngrok.sample.env](conf/env_files/ngrok.sample.env) to `conf/env_files/ngrok.env`. -3. Build the containers and frontend packages + c. In `conf/env_files/ngrok.env`, set the `NGROK_AUTHTOKEN` and `NGROK_DOMAIN` variables using your authtoken and static ngrok domain found in your [ngrok dashboard](https://dashboard.ngrok.com/). + +4. Build the containers and frontend packages 1. Containers: ```sh @@ -72,7 +74,7 @@ If you are on a Mac or a Windows machine, the recommended method is to install npm run start ``` -4. Start local containers +5. Start local containers ``` $ make start @@ -89,11 +91,11 @@ If you are on a Mac or a Windows machine, the recommended method is to install $ ./manage.py createsuperuser ``` -5. Setup local access to the portal: +6. Setup local access to the portal: Add a record to your local hosts file for `127.0.0.1 designsafe.dev` ``` - sudo vim /etc/hosts + $ sudo vim /etc/hosts ``` Now you can navigate to [designsafe.dev](designsafe.dev) in your browser. @@ -178,11 +180,10 @@ See the [DesignSafe Styles Reference][7] for style reference and custom CSS docu ### Updating Python dependencies -For simplicity the Dockerfile uses a `requirements.txt` exported from Poetry. To add a new dependency: +This project uses [Python Poetry](https://python-poetry.org/docs/) to manage dependencies. To add a new dependency: 1. Run `poetry add $NEW_DEPENDENCY`. -2. Run `poetry export > requirements.txt --dev --without-hashes` in the repository root. -3. Rebuild the dev image with `docker-compose -f conf/docker/docker-compose.yml build` +2. Rebuild the dev image with `make build-dev` ## Testing @@ -200,16 +201,14 @@ Django tests should be written according to standard [Django testing procedures] You can run Django tests with the following command: ```shell -$ docker exec -it des_django pytest designsafe +$ docker exec -it des_django pytest -ra designsafe ``` ### Frontend tests -Frontend tests are [Jasmine][9] tests executed using the [Karma engine][10]. Testing -guidelines can be found in the [AngularJS Developer Guide on Unit Testing][11]. +Frontend tests are [Vitest][9] tests executed using [Nx][10]. -To run frontend tests, ensure that all scripts and test scripts are configured in -[`karma-conf.js`](karma-conf.js) and then run the command: +To run frontend tests, run the command: ```shell $ npm run test @@ -217,47 +216,31 @@ $ npm run test ## Development setup -Use `docker-compose` to run the portal in development. The default compose file, -[`docker-compose.yml`](docker-compose.yml) runs the main django server in development +Use `docker compose` to run the portal in development. The default compose file, +[`docker-compose.yml`](conf/docker/docker-compose.yml) runs the main django server in development mode with a redis service for websockets support. You can optionally enable the EF sites for testing. ```shell -$ docker-compose -f conf/docker/docker-compose.yml build -$ docker-compose -f conf/docker/docker-compose-dev.all.debug.yml up -$ npm run dev +$ make build-dev +$ make start +$ npm run start +$ docker run -v `pwd`:`pwd` -w `pwd` -it node:16 /bin/bash -c "npm run dev" ``` When using this compose file, your Tapis Client should be configured with a `callback_url` of `http://$DOCKER_HOST_IP:8000/auth/tapis/callback/`. -For developing some services, e.g. Box.com integration, https support is required. To -enable an Nginx http proxy run using the [`docker-compose-http.yml`](docker-compose-http.yml) -file. This file configures the same services as the default compose file, but it also sets -up an Nginx proxy secured by a self-signed certificate. ```shell $ docker-compose -f docker-compose-http.yml build $ docker-compose -f docker-compose-http.yml up ``` -### Agave filesystem setup -1. Delete all of the old metadata objects using this command: - - `metadata-list Q '{"name": "designsafe metadata"}' | while read x; do metadata-delete $x; done;` -2. Run `dsapi/agave/tools/bin/walker.py` to create the metadata objects for the existing files in your FS. - - `python portal/dsapi/agave/tools/bin/walker.py ` - - `base_folder` is your username, if you want to fix everything under your home dir. - - `command`: - - `files`: Walk through the files and print their path. - - `meta`: Walk through the metadata objs in a filesystem-like manner and print their path. - - `files-fix`: Check if there's a meta obj for every file, if not create the meta obj. - - `meta-fix`: Check if there's a file for every meta obj, if not delete the meta obj. ## Production setup -Production deployment is managed by ansible. See https://github.com/designsafe-ci/ansible. +Production deployment is managed by Camino. See https://github.com/TACC/Camino. [1]: https://docs.docker.com/ @@ -267,8 +250,7 @@ Production deployment is managed by ansible. See https://github.com/designsafe-c [5]: https://docs.docker.com/desktop/ [7]: https://github.com/DesignSafe-CI/portal/wiki/CSS-Styles-Reference [8]: https://docs.djangoproject.com/en/dev/topics/testing/ -[9]: http://jasmine.github.io/1.3/introduction.html -[10]: http://karma-runner.github.io/0.12/intro/installation.html -[11]: https://docs.angularjs.org/guide/unit-testing -[12]: https://www.gnu.org/software/make/ -[13]: https://nodejs.org/ +[9]: https://vitest.dev/ +[10]: https://nx.dev/getting-started/intro +[11]: https://www.gnu.org/software/make/ +[12]: https://nodejs.org/ diff --git a/client/modules/_hooks/src/datafiles/projects/types.ts b/client/modules/_hooks/src/datafiles/projects/types.ts index 29f27e6813..55415964e2 100644 --- a/client/modules/_hooks/src/datafiles/projects/types.ts +++ b/client/modules/_hooks/src/datafiles/projects/types.ts @@ -160,6 +160,7 @@ export type TPreviewTreeData = { uuid: string; value: TEntityValue; order: number; + version?: number; publicationDate?: string; children: TPreviewTreeData[]; }; diff --git a/client/modules/_hooks/src/index.ts b/client/modules/_hooks/src/index.ts index 9cc3682b42..d67631ac08 100644 --- a/client/modules/_hooks/src/index.ts +++ b/client/modules/_hooks/src/index.ts @@ -5,3 +5,4 @@ export * from './workspace'; export * from './datafiles'; export * from './systems'; export * from './notifications'; +export * from './onboarding'; diff --git a/client/modules/_hooks/src/notifications/useNotifications.ts b/client/modules/_hooks/src/notifications/useNotifications.ts index 566a0633ed..cfbcebe900 100644 --- a/client/modules/_hooks/src/notifications/useNotifications.ts +++ b/client/modules/_hooks/src/notifications/useNotifications.ts @@ -6,7 +6,11 @@ import { } from '@tanstack/react-query'; import apiClient from '../apiClient'; -type TPortalEventType = 'data_depot' | 'job' | 'interactive_session_ready'; +type TPortalEventType = + | 'data_depot' + | 'job' + | 'interactive_session_ready' + | 'markAllNotificationsAsRead'; export type TJobStatusNotification = { action_link: string; diff --git a/client/modules/_hooks/src/onboarding/index.ts b/client/modules/_hooks/src/onboarding/index.ts new file mode 100644 index 0000000000..1d5717cfd4 --- /dev/null +++ b/client/modules/_hooks/src/onboarding/index.ts @@ -0,0 +1,2 @@ +export * from './useOnboarding'; +export * from './types'; diff --git a/client/modules/_hooks/src/onboarding/types.ts b/client/modules/_hooks/src/onboarding/types.ts new file mode 100644 index 0000000000..ba32bd3199 --- /dev/null +++ b/client/modules/_hooks/src/onboarding/types.ts @@ -0,0 +1,51 @@ +export type TSetupStepEvent = { + step: string; + username: string; + state: string; + time: string; + message: string; + data?: { + setupComplete: boolean; + } | null; +}; + +export type TOnboardingStep = { + step: string; + displayName: string; + description: string; + userConfirm: string; + staffApprove: string; + staffDeny: string; + state?: string | null; + events: TSetupStepEvent[]; + data?: { + userlink?: { + url: string; + text: string; + }; + } | null; + customStatus?: string | null; +}; + +export type TOnboardingUser = { + username: string; + email: string; + firstName: string; + lastName: string; + isStaff: boolean; + setupComplete: boolean; + steps: TOnboardingStep[]; +}; + +export type TOnboardingAdminList = { + users: TOnboardingUser[]; + total: number; + totalSteps: number; +}; + +export type TOnboardingAdminActions = + | 'staff_approve' + | 'staff_deny' + | 'user_confirm' + | 'complete' + | 'reset'; diff --git a/client/modules/_hooks/src/onboarding/useOnboarding.ts b/client/modules/_hooks/src/onboarding/useOnboarding.ts new file mode 100644 index 0000000000..29373547f8 --- /dev/null +++ b/client/modules/_hooks/src/onboarding/useOnboarding.ts @@ -0,0 +1,113 @@ +import { useQuery, useMutation, useSuspenseQuery } from '@tanstack/react-query'; +import { useSearchParams } from 'react-router-dom'; +import { + TOnboardingUser, + TOnboardingAdminList, + TSetupStepEvent, + TOnboardingAdminActions, +} from './types'; +import apiClient, { type TApiError } from '../apiClient'; + +export type TOnboardingAdminParams = { + showIncompleteOnly?: boolean; + q?: string; + limit?: number; + page?: number; + orderBy?: string; +}; + +type TOnboardingActionBody = { + step: string; + action: TOnboardingAdminActions; +}; + +type TGetOnboardingAdminListResponse = { + response: TOnboardingAdminList; + status: number; +}; + +type TGetOnboardingUserResponse = { + response: TOnboardingUser; + status: number; +}; + +type TSendOnboardingActionResponse = { + response: TSetupStepEvent; + status: number; +}; + +async function getOnboardingAdminList(params: TOnboardingAdminParams) { + const res = await apiClient.get( + `api/onboarding/admin/`, + { + params, + } + ); + return res.data.response; +} + +async function getOnboardingUser(username: string) { + const res = await apiClient.get( + `api/onboarding/user/${username}` + ); + return res.data.response; +} + +async function sendOnboardingAction( + body: TOnboardingActionBody, + username?: string +) { + const res = await apiClient.post( + `api/onboarding/user/${username}/`, + body + ); + return res.data.response; +} + +const getOnboardingAdminListQuery = (queryParams: TOnboardingAdminParams) => ({ + queryKey: ['onboarding', 'adminList', queryParams], + queryFn: () => getOnboardingAdminList(queryParams), +}); +export function useGetOnboardingAdminList() { + const [searchParams] = useSearchParams(); + const q = searchParams.get('q') || undefined; + const showIncompleteOnly = searchParams.get('showIncompleteOnly') || 'false'; + const limit = searchParams.get('limit') || '20'; + const page = searchParams.get('page') || '1'; + const orderBy = searchParams.get('orderBy') || undefined; + return useQuery( + getOnboardingAdminListQuery({ + q, + showIncompleteOnly: showIncompleteOnly === 'true', + limit: +limit, + page: +page, + orderBy, + }) + ); +} + +const getOnboardingUserQuery = (username: string) => ({ + queryKey: ['onboarding', 'user', username], + queryFn: () => getOnboardingUser(username), +}); +export function useGetOnboardingUser(username: string) { + return useQuery(getOnboardingUserQuery(username)); +} +export const useGetOnboardingUserSuspense = (username: string) => { + return useSuspenseQuery(getOnboardingUserQuery(username)); +}; + +export function useSendOnboardingAction() { + return useMutation({ + mutationFn: ({ + body, + username, + }: { + body: TOnboardingActionBody; + username: string; + }) => { + return sendOnboardingAction(body, username); + }, + onError: (err: TApiError) => err, + }); +} diff --git a/client/modules/_hooks/src/useAuthenticatedUser.ts b/client/modules/_hooks/src/useAuthenticatedUser.ts index 794e20176a..88cd0e307d 100644 --- a/client/modules/_hooks/src/useAuthenticatedUser.ts +++ b/client/modules/_hooks/src/useAuthenticatedUser.ts @@ -5,6 +5,8 @@ export type TUser = { email: string; institution: string; homedir: string; + isStaff: boolean; + setupComplete: boolean; }; declare global { diff --git a/client/modules/_hooks/src/workspace/index.ts b/client/modules/_hooks/src/workspace/index.ts index d9b8661bcc..94e373286a 100644 --- a/client/modules/_hooks/src/workspace/index.ts +++ b/client/modules/_hooks/src/workspace/index.ts @@ -18,3 +18,4 @@ export * from './useGetJobs'; export * from './usePostJobs'; export * from './types'; export * from './useGetAllocations'; +export * from './useInteractiveModalContext'; diff --git a/client/modules/_hooks/src/workspace/types.ts b/client/modules/_hooks/src/workspace/types.ts index 9d73e2ef6a..c7832906e8 100644 --- a/client/modules/_hooks/src/workspace/types.ts +++ b/client/modules/_hooks/src/workspace/types.ts @@ -1,6 +1,7 @@ export type TParameterSetNotes = { isHidden?: boolean; fieldType?: string; + inputType?: string; validator?: { regex: string; message: string; @@ -113,6 +114,8 @@ export type TTapisApp = { queueFilter?: string[]; hideQueue?: boolean; hideAllocation?: boolean; + hideMaxMinutes?: boolean; + jobLaunchDescription?: string; }; uuid: string; deleted: boolean; @@ -191,7 +194,7 @@ export type TTapisJob = { stageAppTransactionId?: string; status: string; subscriptions: string; - tags: string[]; + tags: string[] | null; tapisQueue: string; tenant: string; uuid: string; diff --git a/client/modules/_hooks/src/workspace/useInteractiveModalContext.ts b/client/modules/_hooks/src/workspace/useInteractiveModalContext.ts new file mode 100644 index 0000000000..f896c28bf4 --- /dev/null +++ b/client/modules/_hooks/src/workspace/useInteractiveModalContext.ts @@ -0,0 +1,21 @@ +import React, { createContext, useContext } from 'react'; + +type TInteractiveModalDetails = { + show: boolean; + interactiveSessionLink?: string; + message?: string; + openedBySubmit?: boolean; + uuid?: string; +}; + +export type TInteractiveModalContext = [ + TInteractiveModalDetails, + React.Dispatch> +]; + +export const InteractiveModalContext = + createContext(null); + +export const useInteractiveModalContext = () => { + return useContext(InteractiveModalContext); +}; diff --git a/client/modules/_test-fixtures/src/fixtures/onboarding/onboarding-admin-listing.json b/client/modules/_test-fixtures/src/fixtures/onboarding/onboarding-admin-listing.json new file mode 100644 index 0000000000..de44f662ce --- /dev/null +++ b/client/modules/_test-fixtures/src/fixtures/onboarding/onboarding-admin-listing.json @@ -0,0 +1,220 @@ +{ + "status": 200, + "response": { + "users": [ + { + "username": "testuser2", + "lastName": "last2", + "firstName": "first2", + "email": "first2last2@university.edu", + "isStaff": false, + "steps": [ + { + "step": "designsafe.apps.onboarding.steps.project_membership.ProjectMembershipStep", + "displayName": "Checking Project Membership", + "description": "This confirms if you have access to the project. If not, request access and\n wait for the system administrator’s approval.", + "userConfirm": "Request Project Access", + "staffApprove": "Add to DesignSafe project", + "staffDeny": "Deny Project Access Request", + "state": "completed", + "events": [ + { + "step": "designsafe.apps.onboarding.steps.project_membership.ProjectMembershipStep", + "username": "testuser2", + "state": "completed", + "time": "2024-10-16 22:36:52.769105+00:00", + "message": "You have the required project membership to access this portal.", + "data": null + }, + { + "step": "designsafe.apps.onboarding.steps.project_membership.ProjectMembershipStep", + "username": "testuser2", + "state": "processing", + "time": "2024-10-16 22:36:51.250723+00:00", + "message": "Beginning automated processing", + "data": null + }, + { + "step": "designsafe.apps.onboarding.steps.project_membership.ProjectMembershipStep", + "username": "testuser2", + "state": "pending", + "time": "2024-10-16 22:10:27.244633+00:00", + "message": "Awaiting project membership check", + "data": null + } + ], + "data": null + }, + { + "step": "designsafe.apps.onboarding.steps.allocation.AllocationStep", + "displayName": "Allocations", + "description": "Accessing your allocations. If unsuccessful, verify the PI has added you to the allocations for this project.", + "userConfirm": "Confirm", + "staffApprove": "Approve", + "staffDeny": "Deny", + "state": "completed", + "events": [ + { + "step": "designsafe.apps.onboarding.steps.allocation.AllocationStep", + "username": "testuser2", + "state": "completed", + "time": "2024-10-16 22:36:45.804477+00:00", + "message": "Allocations retrieved", + "data": { + "hosts": { + "ls6.tacc.utexas.edu": [ + "TACC-ACI", + "APCD-dev", + "PT2050-DataX", + "DesignSafe-Community", + "IBN22007", + "DesignSafe-DCV", + "DesignSafe-Corral", + "TACC-ACI-CIC", + "DS-HPC1", + "A2CPS" + ], + "data.tacc.utexas.edu": [ + "NeuroNex-3DEM", + "TACC-ACI", + "DesignSafe-Corral" + ], + "ranch.tacc.utexas.edu": ["DesignSafe-Community"], + "vista.tacc.utexas.edu": [ + "TACC-ACI", + "DesignSafe-Corral", + "TACC-ACI-CIC" + ], + "frontera.tacc.utexas.edu": [ + "TACC-ACI", + "DesignSafe-Community", + "DesignSafe-DCV", + "DesignSafe-Corral", + "TACC-ACI-CIC", + "A2CPS", + "DS-HPC1" + ], + "stampede3.tacc.utexas.edu": [ + "TACC-ACI", + "DesignSafe-Community", + "DesignSafe-DCV", + "DesignSafe-Corral", + "TACC-ACI-CIC", + "DS-HPC1" + ] + } + } + }, + { + "step": "designsafe.apps.onboarding.steps.allocation.AllocationStep", + "username": "testuser2", + "state": "processing", + "time": "2024-10-16 22:36:43.466479+00:00", + "message": "Retrieving your allocations", + "data": null + }, + { + "step": "designsafe.apps.onboarding.steps.allocation.AllocationStep", + "username": "testuser2", + "state": "processing", + "time": "2024-10-16 22:36:43.355484+00:00", + "message": "Beginning automated processing", + "data": null + }, + { + "step": "designsafe.apps.onboarding.steps.allocation.AllocationStep", + "username": "testuser2", + "state": "pending", + "time": "2024-10-16 22:36:43.156603+00:00", + "message": "Awaiting allocation retrieval", + "data": null + } + ], + "data": null + }, + { + "step": "designsafe.apps.onboarding.steps.system_access_v3.SystemAccessStepV3", + "displayName": "System Access", + "description": "Setting up access to TACC storage and execution systems. No action required.", + "userConfirm": "Confirm", + "staffApprove": "Approve", + "staffDeny": "Deny", + "state": "completed", + "events": [ + { + "step": "designsafe.apps.onboarding.steps.system_access_v3.SystemAccessStepV3", + "username": "testuser2", + "state": "completed", + "time": "2024-10-16 22:36:50.968677+00:00", + "message": "User is processed.", + "data": null + }, + { + "step": "designsafe.apps.onboarding.steps.system_access_v3.SystemAccessStepV3", + "username": "testuser2", + "state": "processing", + "time": "2024-10-16 22:36:50.851077+00:00", + "message": "Credentials already created for system: cloud.data", + "data": null + }, + { + "step": "designsafe.apps.onboarding.steps.system_access_v3.SystemAccessStepV3", + "username": "testuser2", + "state": "processing", + "time": "2024-10-16 22:36:47.644630+00:00", + "message": "Successfully granted permissions for system: frontera", + "data": null + }, + { + "step": "designsafe.apps.onboarding.steps.system_access_v3.SystemAccessStepV3", + "username": "testuser2", + "state": "processing", + "time": "2024-10-16 22:36:47.065659+00:00", + "message": "Successfully granted permissions for system: stampede3", + "data": null + }, + { + "step": "designsafe.apps.onboarding.steps.system_access_v3.SystemAccessStepV3", + "username": "testuser2", + "state": "processing", + "time": "2024-10-16 22:36:46.605071+00:00", + "message": "Successfully granted permissions for system: cloud.data", + "data": null + }, + { + "step": "designsafe.apps.onboarding.steps.system_access_v3.SystemAccessStepV3", + "username": "testuser2", + "state": "processing", + "time": "2024-10-16 22:36:45.956481+00:00", + "message": "Processing system access for user sal", + "data": null + }, + { + "step": "designsafe.apps.onboarding.steps.system_access_v3.SystemAccessStepV3", + "username": "testuser2", + "state": "processing", + "time": "2024-10-16 22:36:45.883764+00:00", + "message": "Beginning automated processing", + "data": null + }, + { + "step": "designsafe.apps.onboarding.steps.system_access_v3.SystemAccessStepV3", + "username": "testuser2", + "state": "pending", + "time": "2024-10-16 22:10:26.862693+00:00", + "message": "Awaiting TACC systems access.", + "data": null + } + ], + "data": null + } + ], + "setupComplete": true + } + ], + "offset": 0, + "limit": 4, + "total": 400, + "totalSteps": 3 + } +} diff --git a/client/modules/_test-fixtures/src/index.ts b/client/modules/_test-fixtures/src/index.ts index b0fb7efd96..a55a8c75d4 100644 --- a/client/modules/_test-fixtures/src/index.ts +++ b/client/modules/_test-fixtures/src/index.ts @@ -1,3 +1,4 @@ export * from './server'; export * from './render'; export { default as appsListingJson } from './fixtures/workspace/apps-tray-listing.json'; +export { default as onboardingAdminListingJson } from './fixtures/onboarding/onboarding-admin-listing.json'; diff --git a/client/modules/datafiles/src/DatafilesToolbar/DatafilesToolbar.spec.tsx b/client/modules/datafiles/src/DatafilesToolbar/DatafilesToolbar.spec.tsx new file mode 100644 index 0000000000..d9293cb5d2 --- /dev/null +++ b/client/modules/datafiles/src/DatafilesToolbar/DatafilesToolbar.spec.tsx @@ -0,0 +1,225 @@ +import { getToolbarRules } from './DatafilesToolbar'; +import { TFileListing } from '@client/hooks'; + +export const fileFixtures = { + txtFile: { + system: 'project-9876543210668535791-242ac118-0001-012', + name: 'test.txt', + path: '/test.txt', + format: 'raw', + type: 'file', + mimeType: 'text/plain', + lastModified: '2024-10-03T19:44:11Z', + length: 10000, + permissions: 'READ', + } as TFileListing, + + anotherTxtFile: { + system: 'project-9876543210668535791-242ac118-0001-012', + name: 'another_test.txt', + path: '/another_test.txt', + format: 'raw', + type: 'file', + mimeType: 'text/plain', + lastModified: '2024-10-03T19:44:11Z', + length: 10000, + permissions: 'READ', + } as TFileListing, + + hazmapperFile: { + system: 'project-9876543210668535791-242ac118-0001-012', + name: 'test.hazmapper', + path: '/test.hazmapper', + format: 'raw', + type: 'file', + mimeType: '', + lastModified: '2024-10-04T00:50:22Z', + length: 100, + permissions: 'READ', + } as TFileListing, + + subDirectory: { + system: 'project-9876543210668535791-242ac118-0001-012', + name: 'test', + path: '/test', + format: 'folder', + type: 'dir', + mimeType: '', + lastModified: '2024-10-03T19:44:51Z', + length: 4096, + permissions: 'READ', + } as TFileListing, +}; + +describe('getToolbarRules', () => { + const USER_WORK_SYSTEM = 'user_work_system'; + + test('should disable all buttons when no files are selected', () => { + const selectedFiles: TFileListing[] = []; + const isAuthenticated = true; + const isReadOnly = false; + const system = 'other_system'; + + const rules = getToolbarRules( + selectedFiles, + isReadOnly, + isAuthenticated, + system, + USER_WORK_SYSTEM + ); + + expect(rules.canPreview).toBe(false); + expect(rules.canRename).toBe(false); + expect(rules.canCopy).toBe(false); + expect(rules.canMove).toBe(false); + expect(rules.canTrash).toBe(false); + expect(rules.canDownload).toBe(false); + }); + + test('should enable all buttons when single "normal" file selected', () => { + const selectedFiles: TFileListing[] = [fileFixtures.txtFile]; + + const isAuthenticated = true; + const isReadOnly = false; + const system = 'other_system'; + + const rules = getToolbarRules( + selectedFiles, + isReadOnly, + isAuthenticated, + system, + USER_WORK_SYSTEM + ); + + expect(rules.canPreview).toBe(true); + expect(rules.canRename).toBe(true); + expect(rules.canCopy).toBe(true); + expect(rules.canMove).toBe(true); + expect(rules.canTrash).toBe(true); + expect(rules.canDownload).toBe(true); + }); + + test('should disable all buttons except preview when single hazmapper file selected', () => { + const selectedFiles: TFileListing[] = [fileFixtures.hazmapperFile]; + + const isAuthenticated = true; + const isReadOnly = false; + const system = 'other_system'; + + const rules = getToolbarRules( + selectedFiles, + isReadOnly, + isAuthenticated, + system, + USER_WORK_SYSTEM + ); + + expect(rules.canPreview).toBe(true); + expect(rules.canRename).toBe(false); + expect(rules.canCopy).toBe(false); + expect(rules.canMove).toBe(false); + expect(rules.canTrash).toBe(false); + expect(rules.canDownload).toBe(false); + }); + + test('should enable all buttons except rename + preview when multiple "normal" files selected', () => { + const selectedFiles: TFileListing[] = [ + fileFixtures.txtFile, + fileFixtures.anotherTxtFile, + ]; + + const isAuthenticated = true; + const isReadOnly = false; + const system = 'other_system'; + + const rules = getToolbarRules( + selectedFiles, + isReadOnly, + isAuthenticated, + system, + USER_WORK_SYSTEM + ); + + expect(rules.canPreview).toBe(false); + expect(rules.canRename).toBe(false); + expect(rules.canCopy).toBe(true); + expect(rules.canMove).toBe(true); + expect(rules.canTrash).toBe(true); + expect(rules.canDownload).toBe(true); + }); + + test('should disable all buttons when a .hazmapper file is part of multiple selection', () => { + const selectedFiles: TFileListing[] = [ + fileFixtures.txtFile, + fileFixtures.hazmapperFile, + ]; + + const isAuthenticated = true; + const isReadOnly = false; + const system = 'other_system'; + + const rules = getToolbarRules( + selectedFiles, + isReadOnly, + isAuthenticated, + system, + USER_WORK_SYSTEM + ); + + expect(rules.canPreview).toBe(false); + expect(rules.canRename).toBe(false); + expect(rules.canCopy).toBe(false); + expect(rules.canMove).toBe(false); + expect(rules.canTrash).toBe(false); + expect(rules.canDownload).toBe(false); + }); + + test('should disable all buttons except preview/download when single selection but not authenticated', () => { + const selectedFiles: TFileListing[] = [fileFixtures.txtFile]; + + const isAuthenticated = false; + const isReadOnly = false; + const system = 'other_system'; + + const rules = getToolbarRules( + selectedFiles, + isReadOnly, + isAuthenticated, + system, + USER_WORK_SYSTEM + ); + + expect(rules.canPreview).toBe(true); + expect(rules.canRename).toBe(false); + expect(rules.canCopy).toBe(false); + expect(rules.canMove).toBe(false); + expect(rules.canTrash).toBe(false); + expect(rules.canDownload).toBe(true); + }); + + test('should disable all buttons except download when multiple selection but not authenticated', () => { + const selectedFiles: TFileListing[] = [ + fileFixtures.txtFile, + fileFixtures.anotherTxtFile, + ]; + + const isAuthenticated = false; + const isReadOnly = false; + const system = 'other_system'; + + const rules = getToolbarRules( + selectedFiles, + isReadOnly, + isAuthenticated, + system, + USER_WORK_SYSTEM + ); + + expect(rules.canPreview).toBe(false); + expect(rules.canRename).toBe(false); + expect(rules.canCopy).toBe(false); + expect(rules.canMove).toBe(false); + expect(rules.canTrash).toBe(false); + expect(rules.canDownload).toBe(true); + }); +}); diff --git a/client/modules/datafiles/src/DatafilesToolbar/DatafilesToolbar.tsx b/client/modules/datafiles/src/DatafilesToolbar/DatafilesToolbar.tsx index 3bc79721e1..47309fc5f5 100644 --- a/client/modules/datafiles/src/DatafilesToolbar/DatafilesToolbar.tsx +++ b/client/modules/datafiles/src/DatafilesToolbar/DatafilesToolbar.tsx @@ -7,12 +7,65 @@ import { USER_WORK_SYSTEM, useSelectedFiles, useSelectedFilesForSystem, + TFileListing, } from '@client/hooks'; import DatafilesModal from '../DatafilesModal/DatafilesModal'; import TrashButton from './TrashButton'; import { Button, ButtonProps, ConfigProvider, ThemeConfig } from 'antd'; import { useMatches, useParams } from 'react-router-dom'; +type ToolbarRules = { + canPreview: boolean; + canRename: boolean; + canCopy: boolean; + canMove: boolean; + canTrash: boolean; + canDownload: boolean; +}; + +/** + * Get the rules for enabling or disabling toolbar buttons based on the selected files, user permissions, + * system, and read-only mode. + */ +export function getToolbarRules( + selectedFiles: TFileListing[], + isReadOnly: boolean, + isAuthenticated: boolean, + system: string, + USER_WORK_SYSTEM: string +): ToolbarRules { + const notContainingHazmapperFile = selectedFiles.every( + (file) => !file.path.endsWith('.hazmapper') + ); + + return { + canPreview: selectedFiles.length === 1 && selectedFiles[0].type === 'file', + canRename: + isAuthenticated && + selectedFiles.length === 1 && + !isReadOnly && + notContainingHazmapperFile, + canCopy: + isAuthenticated && + selectedFiles.length >= 1 && + notContainingHazmapperFile, + canMove: + isAuthenticated && + selectedFiles.length >= 1 && + !isReadOnly && + notContainingHazmapperFile, + canTrash: + isAuthenticated && + selectedFiles.length >= 1 && + !isReadOnly && + notContainingHazmapperFile, + canDownload: + selectedFiles.length >= 1 && + system !== USER_WORK_SYSTEM && + notContainingHazmapperFile, + }; +} + const toolbarTheme: ThemeConfig = { components: { Button: { @@ -41,10 +94,10 @@ export const DatafilesToolbar: React.FC<{ searchInput?: React.ReactNode }> = ({ const { user } = useAuthenticatedUser(); const matches = useMatches(); - const isProjects = matches.find((m) => m.id === 'project'); - const isPublished = matches.find((m) => m.id === 'published'); - const isEntityListing = matches.find((m) => m.id === 'entity-listing'); - const isNees = matches.find((m) => m.id === 'nees'); + const isProjects = !!matches.find((m) => m.id === 'project'); + const isPublished = !!matches.find((m) => m.id === 'published'); + const isEntityListing = !!matches.find((m) => m.id === 'entity-listing'); + const isNees = !!matches.find((m) => m.id === 'nees'); const isReadOnly = isPublished || isNees || system === 'designsafe.storage.community'; @@ -82,32 +135,14 @@ export const DatafilesToolbar: React.FC<{ searchInput?: React.ReactNode }> = ({ : listingSelectedFiles; const rules = useMemo( - function () { - // Rules for which toolbar buttons are active for a given selection. - return { - canPreview: - selectedFiles.length === 1 && selectedFiles[0].type === 'file', - canRename: - user && - selectedFiles.length === 1 && - !isReadOnly && - !selectedFiles[0].path.endsWith('.hazmapper'), - canCopy: - user && - selectedFiles.length >= 1 && - !selectedFiles[0].path.endsWith('.hazmapper'), - canTrash: - user && - selectedFiles.length >= 1 && - !isReadOnly && - !selectedFiles[0].path.endsWith('.hazmapper'), - // Disable downloads from frontera.work until we have a non-flaky mount on ds-download. - canDownload: - selectedFiles.length >= 1 && - system !== USER_WORK_SYSTEM && - !selectedFiles[0].path.endsWith('.hazmapper'), - }; - }, + () => + getToolbarRules( + selectedFiles, + isReadOnly, + !!user, + system, + USER_WORK_SYSTEM + ), [selectedFiles, isReadOnly, user, system] ); @@ -138,7 +173,7 @@ export const DatafilesToolbar: React.FC<{ searchInput?: React.ReactNode }> = ({ {({ onClick }) => ( diff --git a/client/modules/datafiles/src/nees/NeesDetails.tsx b/client/modules/datafiles/src/nees/NeesDetails.tsx index c1f057af53..9118a7e319 100644 --- a/client/modules/datafiles/src/nees/NeesDetails.tsx +++ b/client/modules/datafiles/src/nees/NeesDetails.tsx @@ -346,13 +346,17 @@ export const NeesDetails: React.FC<{ neesId: string }> = ({ neesId }) => { Sponsors {neesProjectData?.sponsor - ? neesProjectData?.sponsor?.map((u) => ( -
- - {u.name} - -
- )) + ? neesProjectData?.sponsor?.map((u) => + u.url != 'None' ? ( +
+ + {u.name} + +
+ ) : ( +
{u.name}
+ ) + ) : 'No Sponsors Listed'} diff --git a/client/modules/datafiles/src/projects/ProjectCitation/ProjectCitation.tsx b/client/modules/datafiles/src/projects/ProjectCitation/ProjectCitation.tsx index 28c9fe72e7..2bd28c938a 100644 --- a/client/modules/datafiles/src/projects/ProjectCitation/ProjectCitation.tsx +++ b/client/modules/datafiles/src/projects/ProjectCitation/ProjectCitation.tsx @@ -14,7 +14,9 @@ export const ProjectCitation: React.FC<{ const { data } = useProjectDetail(projectId); const entityDetails = data?.entities.find((e) => e.uuid === entityUuid); const authors = - entityDetails?.value.authors?.filter((a) => a.fname && a.lname) ?? []; + entityDetails?.value.authors?.filter( + (a) => a.fname && a.lname && a.authorship !== false + ) ?? []; if (!data || !entityDetails) return null; return (
@@ -44,7 +46,8 @@ export const PublishedCitation: React.FC<{ (child) => child.uuid === entityUuid && child.version === version ); - const authors = entityDetails?.value.authors ?? []; + const authors = + entityDetails?.value.authors?.filter((a) => a.authorship !== false) ?? []; if (!data || !entityDetails) return null; const doi = @@ -64,8 +67,8 @@ export const PublishedCitation: React.FC<{ ) .join(', ')}{' '} ({new Date(entityDetails.publicationDate).getFullYear()}). " - {entityDetails.value.title}", in {data.baseProject.title}. - DesignSafe-CI.{' '} + {entityDetails.value.title}", in {data.baseProject.title} + {(version ?? 1) > 1 && [Version {version}]}. DesignSafe-CI.{' '} {doi && ( ) : ( - + )}
{citationMetrics && ( diff --git a/client/modules/onboarding/.babelrc b/client/modules/onboarding/.babelrc new file mode 100644 index 0000000000..1ea870ead4 --- /dev/null +++ b/client/modules/onboarding/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@nx/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage" + } + ] + ], + "plugins": [] +} diff --git a/client/modules/onboarding/.eslintrc.json b/client/modules/onboarding/.eslintrc.json new file mode 100644 index 0000000000..3ebb9c6f3a --- /dev/null +++ b/client/modules/onboarding/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["plugin:@nx/react", "../../.eslintrc.base.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/client/modules/onboarding/README.md b/client/modules/onboarding/README.md new file mode 100644 index 0000000000..459dd2f5b2 --- /dev/null +++ b/client/modules/onboarding/README.md @@ -0,0 +1,7 @@ +# onboarding + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test onboarding` to execute the unit tests via [Vitest](https://vitest.dev/). diff --git a/client/modules/onboarding/project.json b/client/modules/onboarding/project.json new file mode 100644 index 0000000000..261f1ba5a4 --- /dev/null +++ b/client/modules/onboarding/project.json @@ -0,0 +1,20 @@ +{ + "name": "onboarding", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "modules/onboarding/src", + "projectType": "library", + "tags": [], + "targets": { + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"] + }, + "test": { + "executor": "@nx/vite:test", + "outputs": ["{options.reportsDirectory}"], + "options": { + "reportsDirectory": "../../coverage/modules/onboarding" + } + } + } +} diff --git a/client/modules/onboarding/src/OnboardingActions/OnboardingActions.module.css b/client/modules/onboarding/src/OnboardingActions/OnboardingActions.module.css new file mode 100644 index 0000000000..d83946f5ae --- /dev/null +++ b/client/modules/onboarding/src/OnboardingActions/OnboardingActions.module.css @@ -0,0 +1,21 @@ +.root { + display: flex; + align-items: center; + justify-content: space-between; + flex-grow: 1; +} + +.action { + padding-bottom: 0; + padding-top: 0; + font-weight: bold; +} +.onboarding-action__loading { + display: inline-block; + width: auto; +} + +.onboarding-action__loading .inline { + width: 16px; + height: 16px; +} diff --git a/client/modules/onboarding/src/OnboardingActions/OnboardingActions.tsx b/client/modules/onboarding/src/OnboardingActions/OnboardingActions.tsx new file mode 100644 index 0000000000..dae8bcf51b --- /dev/null +++ b/client/modules/onboarding/src/OnboardingActions/OnboardingActions.tsx @@ -0,0 +1,132 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; +import { Alert, Spin } from 'antd'; +import { SecondaryButton } from '@client/common-components'; +import { + TOnboardingStep, + useAuthenticatedUser, + useSendOnboardingAction, +} from '@client/hooks'; +import styles from './OnboardingActions.module.css'; + +const OnboardingActions = ({ step }: { step: TOnboardingStep }) => { + const { user: authenticatedUser } = useAuthenticatedUser(); + const params = useParams(); + const { + mutate: sendOnboardingAction, + isPending, + error, + } = useSendOnboardingAction(); + + // If the route loaded shows we are viewing a different user + // (such as an admin viewing a user) then pull the username for + // actions from the route. Otherwise, use the username of whomever is logged in + const username = params.username || (authenticatedUser?.username as string); + + if (error) { + return ( + + ); + } + + return ( + <> + {isPending ? : null} + + {authenticatedUser?.isStaff && step.state === 'staffwait' ? ( + + + sendOnboardingAction({ + body: { action: 'staff_approve', step: step.step }, + username, + }) + } + > + {step.staffApprove} + +     + + sendOnboardingAction({ + body: { action: 'staff_deny', step: step.step }, + username, + }) + } + > + {step.staffDeny} + + + ) : null} + {step.state === 'userwait' ? ( + step.data?.userlink ? ( + + {step.data?.userlink?.text} + + ) : ( + + sendOnboardingAction({ + body: { action: 'user_confirm', step: step.step }, + username, + }) + } + > + {step.userConfirm} + + ) + ) : null} + {authenticatedUser?.isStaff ? ( + + + sendOnboardingAction({ + body: { action: 'reset', step: step.step }, + username, + }) + } + > + Admin Reset + +     + + sendOnboardingAction({ + body: { action: 'complete', step: step.step }, + username, + }) + } + > + Admin Skip + + + ) : null} + + + ); +}; + +export default OnboardingActions; diff --git a/client/modules/onboarding/src/OnboardingAdminSearchbar/OnboardingAdminSearchbar.module.css b/client/modules/onboarding/src/OnboardingAdminSearchbar/OnboardingAdminSearchbar.module.css new file mode 100644 index 0000000000..72a68f9397 --- /dev/null +++ b/client/modules/onboarding/src/OnboardingAdminSearchbar/OnboardingAdminSearchbar.module.css @@ -0,0 +1,62 @@ +.container { + display: flex; + flex-direction: row; + align-items: center; +} + +/* Children */ + +/* Each direct child except the last */ +.container > *:not(:last-child) { + margin-right: 20px; /* 20px design * 1.2 design-to-app ratio */ +} +.query-fieldset { + width: 560px; /* 70px + 396px design * 1.2 design-to-app ratio */ +} +/* Ensure `.clear-button` text does not wrap at (arbitrary) 1280px laptop width */ +/* WARN: Non-standard un-documented first-party breakpoint */ +@media (max-width: 1700px) { + .query-fieldset { + width: 360px; + } +} + +@media (max-width: 768px) { + .query-fieldset { + width: 260px; + } +} +/* FP-563: Support count in status message */ +.summary-fieldset { + /* No styles necessary, but defining class for consistency */ +} +/* NOTE: Whenever filter and/or status message are restored, this selector must select the rightmost element of those */ +.clear-button { + /* .filter-fieldset { */ + margin-left: auto; /* this is how to "justify-self" on flex children */ +} +.clear-button { + /* composes: c-button--as-link from '../../styles/components/c-button.css'; */ + + /* RFC: This style might be best provided from an external yet-to-be-created class for table-top nav links */ + font-weight: bold; +} + +/* Children (of `-fieldset`) */ + +.input { + /* composes: form-control from '../../styles/components/bootstrap.form.css'; */ +} +.output { + /* … */ +} + +/* Hacks */ + +.container, +.submit-button, +.clear-button, +.input { + /* RFE: This style should be inherited from cascade of global styles */ + font-size: 0.75rem; /* 12px (16px design * 1.2 design-to-app ratio) */ +} diff --git a/client/modules/onboarding/src/OnboardingAdminSearchbar/OnboardingAdminSearchbar.tsx b/client/modules/onboarding/src/OnboardingAdminSearchbar/OnboardingAdminSearchbar.tsx new file mode 100644 index 0000000000..7f74e7a920 --- /dev/null +++ b/client/modules/onboarding/src/OnboardingAdminSearchbar/OnboardingAdminSearchbar.tsx @@ -0,0 +1,57 @@ +import React, { useEffect, useState } from 'react'; +import { SearchOutlined } from '@ant-design/icons'; +import { Form, Input } from 'antd'; +import { useSearchParams } from 'react-router-dom'; +import { SecondaryButton } from '@client/common-components'; +// import styles from './OnboardingAdminSearchbar.module.css'; + +export const OnboardingAdminSearchbar: React.FC<{ disabled: boolean }> = ({ + disabled, +}) => { + const [form] = Form.useForm(); + const [searchParams, setSearchParams] = useSearchParams(); + const [query, setQuery] = useState(searchParams.get('q')); + const onSubmit = (queryString: string) => { + const newSearchParams = searchParams; + if (queryString) { + newSearchParams.set('q', queryString); + } else { + newSearchParams.delete('q'); + } + newSearchParams.delete('page'); + + setSearchParams(newSearchParams); + }; + + useEffect(() => {}, [searchParams, query]); + + return ( +
onSubmit(data.query)} + form={form} + name="onboarding_search" + style={{ display: 'inline-flex' }} + disabled={disabled} + > + + + + } + > + { + form.resetFields(); + setQuery(null); + searchParams.set('page', '1'); + searchParams.delete('q'); + setSearchParams(searchParams); + }} + > + Clear Search + +
+ ); +}; diff --git a/client/modules/onboarding/src/OnboardingEventLogModal/OnboardingEventLogModal.module.css b/client/modules/onboarding/src/OnboardingEventLogModal/OnboardingEventLogModal.module.css new file mode 100644 index 0000000000..958f10d5b9 --- /dev/null +++ b/client/modules/onboarding/src/OnboardingEventLogModal/OnboardingEventLogModal.module.css @@ -0,0 +1,22 @@ +.event-list > div { + padding: 1em; + margin-left: 1em; + margin-right: 1em; +} + +.log-detail { + margin-left: 1em; +} + +.event-list { + max-height: 30em; + overflow-y: scroll; +} + +.event-list > div:nth-child(even) { + background-color: var(--global-color-primary--x-light); +} + +.event-list > div:not(:last-child) { + border-bottom: 1px solid var(--global-color-primary--dark); +} diff --git a/client/modules/onboarding/src/OnboardingEventLogModal/OnboardingEventLogModal.tsx b/client/modules/onboarding/src/OnboardingEventLogModal/OnboardingEventLogModal.tsx new file mode 100644 index 0000000000..f7d300a673 --- /dev/null +++ b/client/modules/onboarding/src/OnboardingEventLogModal/OnboardingEventLogModal.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Modal } from 'antd'; +import { formatDateTime } from '@client/workspace'; +import { TOnboardingStep, TOnboardingUser } from '@client/hooks'; +import styles from './OnboardingEventLogModal.module.css'; + +export const OnboardingEventLogModal: React.FC<{ + params: { + user: TOnboardingUser; + step: TOnboardingStep; + }; + handleCancel: () => void; +}> = ({ params: { user, step }, handleCancel }) => { + return ( + +
+ {step.events.map((event) => ( +
+
{formatDateTime(new Date(event.time))}
+
{event.message}
+
+ ))} +
+
+ ); +}; diff --git a/client/modules/onboarding/src/OnboardingStatus/OnboardingStatus.module.css b/client/modules/onboarding/src/OnboardingStatus/OnboardingStatus.module.css new file mode 100644 index 0000000000..2dfd35420c --- /dev/null +++ b/client/modules/onboarding/src/OnboardingStatus/OnboardingStatus.module.css @@ -0,0 +1,12 @@ +.root { + overflow: hidden; +} + +.processing { + display: flex; + align-items: center; +} + +.processing > * { + margin-right: 0.5em; +} diff --git a/client/modules/onboarding/src/OnboardingStatus/OnboardingStatus.spec.tsx b/client/modules/onboarding/src/OnboardingStatus/OnboardingStatus.spec.tsx new file mode 100644 index 0000000000..22321ac2b1 --- /dev/null +++ b/client/modules/onboarding/src/OnboardingStatus/OnboardingStatus.spec.tsx @@ -0,0 +1,99 @@ +import { render } from '@client/test-fixtures'; +import { OnboardingStatus } from './OnboardingStatus'; +import { TOnboardingStep } from '@client/hooks'; +import { describe, it, expect } from 'vitest'; + +describe('OnboardingStatus Component', () => { + const renderComponent = (step: TOnboardingStep) => + render(); + + it('should render Preparing tag for pending state', () => { + const step = { state: 'pending' } as TOnboardingStep; + const { getByText } = renderComponent(step); + expect(getByText('Preparing')).toBeTruthy(); + }); + + it('should render Waiting for Staff Approval tag for staffwait state', () => { + const step = { state: 'staffwait' } as TOnboardingStep; + const { getByText } = renderComponent(step); + expect(getByText('Waiting for Staff Approval')).toBeTruthy(); + }); + + it('should render Waiting for User tag for userwait state', () => { + const step = { state: 'userwait' } as TOnboardingStep; + const { getByText } = renderComponent(step); + expect(getByText('Waiting for User')).toBeTruthy(); + }); + + it('should render Unsuccessful tag for failed state', () => { + const step = { state: 'failed' } as TOnboardingStep; + const { getByText } = renderComponent(step); + expect(getByText('Unsuccessful')).toBeTruthy(); + }); + + it('should render Unsuccessful tag for error state', () => { + const step = { state: 'error' } as TOnboardingStep; + const { getByText } = renderComponent(step); + expect(getByText('Unsuccessful')).toBeTruthy(); + }); + + it('should render Unavailable tag for null state', () => { + const step = { state: null } as TOnboardingStep; + const { getByText } = renderComponent(step); + expect(getByText('Unavailable')).toBeTruthy(); + }); + + it('should render Completed tag for completed state', () => { + const step = { state: 'completed' } as TOnboardingStep; + const { getByText } = renderComponent(step); + expect(getByText('Completed')).toBeTruthy(); + }); + + it('should render Processing tag and Spin for processing state', () => { + const step = { state: 'processing' } as TOnboardingStep; + const { getByText, container } = renderComponent(step); + expect(getByText('Processing')).toBeTruthy(); + expect(container.querySelector('.ant-spin')).toBeTruthy(); + }); + + it('should render custom status if customStatus is present', () => { + const step = { + state: 'pending', + customStatus: 'Custom Status', + } as TOnboardingStep; + const { getByText } = renderComponent(step); + expect(getByText('Custom Status')).toBeTruthy(); + }); + + it('should render default tag for unknown state', () => { + const step = { state: 'unknown' } as TOnboardingStep; + const { getByText } = renderComponent(step); + expect(getByText('unknown')).toBeTruthy(); + }); + + it('should render null if no state is provided', () => { + const step = {} as TOnboardingStep; + const { container } = renderComponent(step); + expect(container.firstChild).toBeNull(); + }); + + it('should render correct color for each state', () => { + const states = [ + { state: 'pending', color: 'blue' }, + { state: 'staffwait', color: 'blue' }, + { state: 'userwait', color: 'gold' }, + { state: 'failed', color: 'red' }, + { state: 'error', color: 'red' }, + { state: null, color: 'volcano' }, + { state: 'completed', color: 'green' }, + { state: 'processing', color: 'blue' }, + ]; + + states.forEach(({ state, color }) => { + const step = { state } as TOnboardingStep; + const { container } = renderComponent(step); + const tag = container.querySelector('.ant-tag'); + expect(Object.values(tag?.classList || {})).toContain(`ant-tag-${color}`); + }); + }); +}); diff --git a/client/modules/onboarding/src/OnboardingStatus/OnboardingStatus.tsx b/client/modules/onboarding/src/OnboardingStatus/OnboardingStatus.tsx new file mode 100644 index 0000000000..96b5fd0c20 --- /dev/null +++ b/client/modules/onboarding/src/OnboardingStatus/OnboardingStatus.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { Tag, Spin } from 'antd'; +import { TOnboardingStep } from '@client/hooks'; +import styles from './OnboardingStatus.module.css'; + +const getContents = (step: TOnboardingStep) => { + let color = ''; + switch (step.state) { + case 'processing': + case 'pending': + color = 'blue'; + break; + case 'failed': + case 'error': + color = 'red'; + break; + case 'staffwait': + case 'userwait': + color = 'gold'; + break; + case 'completed': + color = 'green'; + break; + case null: + color = 'volcano'; + break; + default: + color = 'blue'; + } + if ('customStatus' in step) { + return {step.customStatus}; + } + switch (step.state) { + case 'pending': + return Preparing; + case 'staffwait': + return Waiting for Staff Approval; + case 'userwait': + return Waiting for User; + case 'failed': + case 'error': + return Unsuccessful; + case null: + return Unavailable; + case 'completed': + return Completed; + case 'processing': + return ( + + Processing + + + ); + default: + if (step.state) { + return {step.state}; + } + return null; + } +}; + +export const OnboardingStatus = ({ step }: { step: TOnboardingStep }) => { + const contents = getContents(step); + if (!contents) { + return null; + } + return {getContents(step)}; +}; diff --git a/client/modules/onboarding/src/OnboardingStep/OnboardingStep.module.css b/client/modules/onboarding/src/OnboardingStep/OnboardingStep.module.css new file mode 100644 index 0000000000..a1d6277863 --- /dev/null +++ b/client/modules/onboarding/src/OnboardingStep/OnboardingStep.module.css @@ -0,0 +1,23 @@ +.root { + padding-top: 1em; + padding-bottom: 1em; + margin-bottom: 1em; + border-bottom: 1px solid var(--global-color-primary--normal); +} + +.name { + font-weight: bold; +} + +.description { + padding-bottom: 1em; +} + +.status { + display: flex; + align-items: center; +} + +.disabled { + color: var(--global-color-primary--light); +} diff --git a/client/modules/onboarding/src/OnboardingStep/OnboardingStep.tsx b/client/modules/onboarding/src/OnboardingStep/OnboardingStep.tsx new file mode 100644 index 0000000000..5353ee6e99 --- /dev/null +++ b/client/modules/onboarding/src/OnboardingStep/OnboardingStep.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import parse from 'html-react-parser'; +import { OnboardingStatus } from '../OnboardingStatus/OnboardingStatus'; +import OnboardingActions from '../OnboardingActions/OnboardingActions'; +import { TOnboardingStep } from '@client/hooks'; +import styles from './OnboardingStep.module.css'; + +export const OnboardingStep = ({ step }: { step: TOnboardingStep }) => { + const styleName = `${styles.root} ${ + step.state === styles.pending ? 'disabled' : '' + }`; + return ( +
+
{step.displayName}
+
{parse(step.description)}
+
+ + +
+
+ ); +}; diff --git a/client/modules/onboarding/src/index.ts b/client/modules/onboarding/src/index.ts new file mode 100644 index 0000000000..2bfb5de63d --- /dev/null +++ b/client/modules/onboarding/src/index.ts @@ -0,0 +1,4 @@ +export * from './OnboardingStep/OnboardingStep'; +export * from './OnboardingStatus/OnboardingStatus'; +export * from './OnboardingEventLogModal/OnboardingEventLogModal'; +export * from './OnboardingAdminSearchbar/OnboardingAdminSearchbar'; diff --git a/client/modules/onboarding/src/vitest.setup.ts b/client/modules/onboarding/src/vitest.setup.ts new file mode 100644 index 0000000000..5e646e1a35 --- /dev/null +++ b/client/modules/onboarding/src/vitest.setup.ts @@ -0,0 +1,6 @@ +import { beforeAll, afterEach, afterAll } from 'vitest'; +import { server } from '@client/test-fixtures'; + +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); diff --git a/client/modules/onboarding/tsconfig.json b/client/modules/onboarding/tsconfig.json new file mode 100644 index 0000000000..424447ef63 --- /dev/null +++ b/client/modules/onboarding/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "allowJs": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../tsconfig.json" +} diff --git a/client/modules/onboarding/tsconfig.lib.json b/client/modules/onboarding/tsconfig.lib.json new file mode 100644 index 0000000000..a6ed0a0c2b --- /dev/null +++ b/client/modules/onboarding/tsconfig.lib.json @@ -0,0 +1,23 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [ + "node", + + "@nx/react/typings/cssmodule.d.ts", + "@nx/react/typings/image.d.ts" + ] + }, + "exclude": [ + "**/*.spec.ts", + "**/*.test.ts", + "**/*.spec.tsx", + "**/*.test.tsx", + "**/*.spec.js", + "**/*.test.js", + "**/*.spec.jsx", + "**/*.test.jsx" + ], + "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"] +} diff --git a/client/modules/onboarding/tsconfig.spec.json b/client/modules/onboarding/tsconfig.spec.json new file mode 100644 index 0000000000..3c002c215a --- /dev/null +++ b/client/modules/onboarding/tsconfig.spec.json @@ -0,0 +1,26 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest" + ] + }, + "include": [ + "vite.config.ts", + "vitest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ] +} diff --git a/client/modules/onboarding/vite.config.ts b/client/modules/onboarding/vite.config.ts new file mode 100644 index 0000000000..9f808f7aa0 --- /dev/null +++ b/client/modules/onboarding/vite.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + root: __dirname, + cacheDir: '../../node_modules/.vite/modules/onboarding', + + plugins: [react(), nxViteTsPaths()], + + // Uncomment this if you are using workers. + // worker: { + // plugins: [ nxViteTsPaths() ], + // }, + + test: { + globals: true, + cache: { dir: '../../node_modules/.vitest' }, + environment: 'jsdom', + include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + reporters: ['default'], + setupFiles: ['./src/vitest.setup.ts'], + coverage: { + reportsDirectory: '../../coverage/modules/onboarding', + provider: 'v8', + }, + }, +}); diff --git a/client/modules/workspace/src/AppsSubmissionDetails/AppsSubmissionDetails.tsx b/client/modules/workspace/src/AppsSubmissionDetails/AppsSubmissionDetails.tsx index f0a423b161..eb29f70f93 100644 --- a/client/modules/workspace/src/AppsSubmissionDetails/AppsSubmissionDetails.tsx +++ b/client/modules/workspace/src/AppsSubmissionDetails/AppsSubmissionDetails.tsx @@ -12,7 +12,7 @@ import { import { useFormContext, useWatch, FieldValues } from 'react-hook-form'; import { z, ZodTypeAny } from 'zod'; import { TField, fieldDisplayOrder } from '../AppsWizard/AppsFormSchema'; -import { PrimaryButton } from '@client/common-components'; +import { JobSubmitButton } from '../JobSubmitButton/JobSubmitButton'; import styles from './AppsSubmissionDetails.module.css'; import { TTapisApp } from '@client/hooks'; @@ -255,14 +255,11 @@ export const AppsSubmissionDetails: React.FC<{ style={itemStyle} className={styles.root} extra={ - - Submit Job - + interactive={definition.notes.isInteractive} + /> } /> diff --git a/client/modules/workspace/src/AppsSubmissionForm/AppsSubmissionForm.tsx b/client/modules/workspace/src/AppsSubmissionForm/AppsSubmissionForm.tsx index a78684f15a..92eda37da7 100644 --- a/client/modules/workspace/src/AppsSubmissionForm/AppsSubmissionForm.tsx +++ b/client/modules/workspace/src/AppsSubmissionForm/AppsSubmissionForm.tsx @@ -1,10 +1,11 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react'; import { NavLink } from 'react-router-dom'; -import { Layout, Form, Col, Row, Alert, Button } from 'antd'; +import { Layout, Form, Col, Row, Alert, Button, Space } from 'antd'; import { z } from 'zod'; import { useForm, FormProvider } from 'react-hook-form'; import { Link } from 'react-router-dom'; import { zodResolver } from '@hookform/resolvers/zod'; +import { JobSubmitButton } from '../JobSubmitButton/JobSubmitButton'; import { useGetAppsSuspense, useGetJobSuspense, @@ -19,6 +20,8 @@ import { TJobBody, useGetAllocationsSuspense, TTapisJob, + useInteractiveModalContext, + TInteractiveModalContext, } from '@client/hooks'; import { AppsSubmissionDetails } from '../AppsSubmissionDetails/AppsSubmissionDetails'; import { AppsWizard } from '../AppsWizard/AppsWizard'; @@ -73,6 +76,9 @@ export const AppsSubmissionForm: React.FC = () => { data: TTapisJob; }; + const [, setInteractiveModalDetails] = + useInteractiveModalContext() as TInteractiveModalContext; + const { definition, license, defaultSystemNeedsKeys } = app; const defaultStorageHost = defaultStorageSystem.host; @@ -174,12 +180,14 @@ export const AppsSubmissionForm: React.FC = () => { type FieldNameUnion = keyof typeof fieldValues; const getSteps = (): TStep => { - const formSteps: TStep = { - configuration: getConfigurationStep(configuration.fields), - ...(definition.notes.isInteractive - ? {} - : { outputs: getOutputsStep(outputs.fields) }), - }; + const formSteps: TStep = {}; + + if (configuration.fields && Object.keys(configuration.fields).length) { + formSteps.configuration = getConfigurationStep(configuration.fields); + } + if (!definition.notes.isInteractive) { + formSteps.outputs = getOutputsStep(outputs.fields); + } if (fileInputs.fields && Object.keys(fileInputs.fields).length) { formSteps.inputs = getInputsStep(fileInputs.fields); } @@ -217,16 +225,16 @@ export const AppsSubmissionForm: React.FC = () => { outputs: outputs.fields, }); - const initialSteps = useMemo( - () => getSteps(), - [ - fileInputs.fields, - parameterSet.fields, - configuration.fields, - outputs.fields, - fields, - ] - ); + const initialSteps = useMemo(() => { + const steps = getSteps(); + return Object.keys(steps).length > 0 ? steps : {}; + }, [ + fileInputs.fields, + parameterSet.fields, + configuration.fields, + outputs.fields, + fields, + ]); const getInitialCurrentStep = (steps: TStep) => { if (steps.inputs) return 'inputs'; @@ -311,23 +319,25 @@ export const AppsSubmissionForm: React.FC = () => { } // Note: currently configuration is the only - // step that needs. This can be more generic + // step that needs update. This can be more generic // in future if the fields shape is same between // Step and Submission Detail View (mostly related to env vars) useEffect(() => { - const updatedConfigurationStep = getConfigurationStep( - fields.configuration as { [key: string]: TField } - ); + if (configuration.fields && Object.keys(configuration.fields).length) { + const updatedConfigurationStep = getConfigurationStep( + fields.configuration as { [key: string]: TField } + ); - const updatedSteps: TStep = { - ...steps, - configuration: { - ...steps.configuration, - ...updatedConfigurationStep, - }, - }; + const updatedSteps: TStep = { + ...steps, + configuration: { + ...steps.configuration, + ...updatedConfigurationStep, + }, + }; - setSteps(updatedSteps); + setSteps(updatedSteps); + } }, [fields]); // next step transition does not block on invalid fields @@ -368,6 +378,13 @@ export const AppsSubmissionForm: React.FC = () => { setPushKeysSystem(submitResult.execSys); } else if (isSuccess) { reset(initialValues); + if (definition.notes.isInteractive) { + setInteractiveModalDetails({ + show: true, + openedBySubmit: true, + uuid: submitResult.uuid, + }); + } } }, [submitResult]); @@ -516,20 +533,22 @@ export const AppsSubmissionForm: React.FC = () => { return ( <> - {submitResult && !submitResult.execSys && ( - - Job submitted successfully. Monitor its progress in{' '} - Job Status. - - } - type="success" - closable - showIcon - style={{ marginBottom: '1rem' }} - /> - )} + {submitResult && + !submitResult.execSys && + !definition.notes.isInteractive && ( + + Job submitted successfully. Monitor its progress in{' '} + Job Status. + + } + type="success" + closable + showIcon + style={{ marginBottom: '1rem' }} + /> + )} {missingAllocation && ( { >
- - - - - - + {Object.keys(steps || {}).length === 0 ? ( + + +
+ {isSuccess ? ( + + Session has been launched. You can view status in{' '} + Job Status. + + ) : ( + definition.notes.jobLaunchDescription ?? + 'This job is pre-configured. No input is necessary to submit the job.' + )} +
+
+ +
+
+ + ) : ( + <> + + + + + + + + )}
diff --git a/client/modules/workspace/src/AppsWizard/AppsFormSchema.ts b/client/modules/workspace/src/AppsWizard/AppsFormSchema.ts index 4a04a58181..625d4a9814 100644 --- a/client/modules/workspace/src/AppsWizard/AppsFormSchema.ts +++ b/client/modules/workspace/src/AppsWizard/AppsFormSchema.ts @@ -19,6 +19,7 @@ import { getExecSystemsFromApp, getExecSystemFromId, getAppQueueValues, + getAppRuntimeLabel, getQueueValueForExecSystem, getQueueMaxMinutes, isAppTypeBATCH, @@ -56,8 +57,7 @@ export type TField = { parameterSet?: string; description?: string; options?: TFieldOptions[]; - tapisFile?: boolean; - tapisFileSelectionMode?: string; + fileSettings?: TAppFileSettings; placeholder?: string; readOnly?: boolean; }; @@ -96,7 +96,7 @@ export type TAppFormSchema = { }; }; -export const inputFileRegex = /^tapis:\/\/(?[^/]+)/; +export const tapisInputFileRegex = /^tapis:\/\/(?[^/]+)/; export const fieldDisplayOrder: Record = { configuration: [ @@ -109,6 +109,14 @@ export const fieldDisplayOrder: Record = { outputs: ['name', 'archiveSystemId', 'archiveSystemDir'], }; +export type TAppFilePathRepresentation = 'FullTapisPath' | 'NameOnly'; +export type TAppFileSelectionMode = 'both' | 'file' | 'directory'; + +export type TAppFileSettings = { + fileNameRepresentation: TAppFilePathRepresentation; + fileSelectionMode: TAppFileSelectionMode; +}; + // See https://github.com/colinhacks/zod/issues/310 for Zod issue const emptyStringToUndefined = z.literal('').transform(() => undefined); function asOptionalField(schema: T) { @@ -135,10 +143,13 @@ export const getConfigurationSchema = ( ); } - configurationSchema['maxMinutes'] = getMaxMinutesValidation( - definition, - queue - ); + if (!definition.notes.hideMaxMinutes) { + configurationSchema['maxMinutes'] = getMaxMinutesValidation( + definition, + queue + ); + } + if (!definition.notes.hideNodeCountAndCoresPerNode) { configurationSchema['nodeCount'] = getNodeCountValidation( definition, @@ -177,7 +188,9 @@ export const getConfigurationFields = ( if (definition.jobType === 'BATCH' && !definition.notes.hideQueue) { configurationFields['execSystemLogicalQueue'] = { - description: 'Select the queue this job will execute on.', + description: `Select the queue this ${getAppRuntimeLabel( + definition + )} will execute on.`, label: 'Queue', name: 'configuration.execSystemLogicalQueue', key: 'configuration.execSystemLogicalQueue', @@ -192,8 +205,9 @@ export const getConfigurationFields = ( if (definition.jobType === 'BATCH' && !definition.notes.hideAllocation) { configurationFields['allocation'] = { - description: - 'Select the project allocation you would like to use with this job submission.', + description: `Select the project allocation you would like to use with this ${getAppRuntimeLabel( + definition + )} submission.`, label: 'Allocation', name: 'configuration.allocation', key: 'configuration.allocation', @@ -209,18 +223,27 @@ export const getConfigurationFields = ( }; } - configurationFields['maxMinutes'] = { - description: `The maximum number of minutes you expect this job to run for. Maximum possible is ${getQueueMaxMinutes( - definition, - defaultExecSystem, - queue?.name - )} minutes. After this amount of time your job will end. Shorter run times result in shorter queue wait times.`, - label: 'Maximum Job Runtime (minutes)', - name: 'configuration.maxMinutes', - key: 'configuration.maxMinutes', - required: true, - type: 'number', - }; + if (!definition.notes.hideMaxMinutes) { + configurationFields['maxMinutes'] = { + description: `The maximum number of minutes you expect this ${getAppRuntimeLabel( + definition + )} to run for. Maximum possible is ${getQueueMaxMinutes( + definition, + defaultExecSystem, + queue?.name + )} minutes. After this amount of time your ${getAppRuntimeLabel( + definition + )} will end. Shorter run times result in shorter queue wait times.`, + label: `Maximum ${getAppRuntimeLabel( + definition, + true + )} Runtime (minutes)`, + name: 'configuration.maxMinutes', + key: 'configuration.maxMinutes', + required: true, + type: 'number', + }; + } if (!definition.notes.hideNodeCountAndCoresPerNode) { configurationFields['nodeCount'] = { @@ -316,6 +339,12 @@ const FormSchema = ( name: `parameters.${parameterSet}.${label}`, key: paramId, type: 'text', + ...(param.notes?.inputType === 'fileInput' && { + fileSettings: { + fileNameRepresentation: 'NameOnly', + fileSelectionMode: 'file', + }, + }), }; if (param.notes?.enum_values) { @@ -399,11 +428,14 @@ const FormSchema = ( required: input.inputMode === 'REQUIRED', name: `inputs.${input.name}`, key: `inputs.${input.name}`, - tapisFile: true, type: 'text', placeholder: 'Browse Data Files', readOnly: input.inputMode === 'FIXED', - tapisFileSelectionMode: input.notes?.selectionMode ?? 'both', + fileSettings: { + fileNameRepresentation: 'FullTapisPath', + fileSelectionMode: + (input.notes?.selectionMode as TAppFileSelectionMode) ?? 'both', + }, }; appFields.fileInputs.schema[input.name] = z.string(); @@ -493,8 +525,10 @@ const FormSchema = ( : ''; } - appFields.configuration.defaults['maxMinutes'] = - definition.jobAttributes.maxMinutes; + if (!definition.notes.hideMaxMinutes) { + appFields.configuration.defaults['maxMinutes'] = + definition.jobAttributes.maxMinutes; + } if (!definition.notes.hideNodeCountAndCoresPerNode) { appFields.configuration.defaults['nodeCount'] = diff --git a/client/modules/workspace/src/AppsWizard/FormField.tsx b/client/modules/workspace/src/AppsWizard/FormField.tsx index 1ec10262f0..8979c0b017 100644 --- a/client/modules/workspace/src/AppsWizard/FormField.tsx +++ b/client/modules/workspace/src/AppsWizard/FormField.tsx @@ -2,30 +2,32 @@ import React, { useState, useEffect } from 'react'; import { Button, Form, Input, Select } from 'antd'; import { FormItem } from 'react-hook-form-antd'; import { useFormContext, useWatch } from 'react-hook-form'; -import { TFieldOptions, inputFileRegex } from '../AppsWizard/AppsFormSchema'; +import { + TFieldOptions, + tapisInputFileRegex, + TAppFileSettings, +} from '../AppsWizard/AppsFormSchema'; import { SecondaryButton } from '@client/common-components'; import { SelectModal } from '../SelectModal/SelectModal'; export const FormField: React.FC<{ name: string; - tapisFile?: boolean; parameterSet?: string; description?: string; label: string; required?: boolean; type: string; - tapisFileSelectionMode?: string; + fileSettings?: TAppFileSettings; placeholder?: string; options?: TFieldOptions[]; }> = ({ name, - tapisFile = false, parameterSet = null, description, label, required = false, type, - tapisFileSelectionMode = null, + fileSettings = null, ...props }) => { const { resetField, control, getValues, setValue, trigger } = @@ -39,16 +41,17 @@ export const FormField: React.FC<{ setIsModalOpen(true); }; useEffect(() => { - if (tapisFile) { + setStorageSystem(null); + + if (fileSettings?.fileNameRepresentation === 'FullTapisPath') { const inputFileValue = getValues(name); - const match = inputFileValue?.match(inputFileRegex); - if (match && match.groups) { + const match = inputFileValue?.match(tapisInputFileRegex); + + if (match?.groups) { setStorageSystem(match.groups.storageSystem); - } else { - setStorageSystem(null); } } - }, [tapisFile, name, fieldState]); + }, [fileSettings, name, fieldState]); if (parameterSet) { parameterSetLabel = ( @@ -89,7 +92,7 @@ export const FormField: React.FC<{ /> ) : (
- {tapisFile && ( + {fileSettings && ( Select @@ -133,11 +136,11 @@ export const FormField: React.FC<{ {/* Select Modal has Form and input which cause state sharing with above FormItem So, SelectModal is outside FormItem. */} - {tapisFile && ( + {fileSettings && ( setIsModalOpen(false)} onSelect={(value: string) => { diff --git a/client/modules/workspace/src/InteractiveSessionModal/InteractiveSessionModal.module.css b/client/modules/workspace/src/InteractiveSessionModal/InteractiveSessionModal.module.css index 06d6f18f3f..9a64708802 100644 --- a/client/modules/workspace/src/InteractiveSessionModal/InteractiveSessionModal.module.css +++ b/client/modules/workspace/src/InteractiveSessionModal/InteractiveSessionModal.module.css @@ -1,13 +1,8 @@ -.session-modal-header { - background-color: #f4f4f4; - h5 { - font-weight: normal; - } -} - .session-modal-body { display: flex; flex-direction: column; + margin-bottom: 25px; + min-height: 225px; & > * { margin: 0.4rem; } @@ -17,3 +12,7 @@ color: grey; font-style: italic; } + +.icon { + margin-left: 10px; +} diff --git a/client/modules/workspace/src/InteractiveSessionModal/InteractiveSessionModal.tsx b/client/modules/workspace/src/InteractiveSessionModal/InteractiveSessionModal.tsx index d184d61a55..0b30e0f59e 100644 --- a/client/modules/workspace/src/InteractiveSessionModal/InteractiveSessionModal.tsx +++ b/client/modules/workspace/src/InteractiveSessionModal/InteractiveSessionModal.tsx @@ -1,41 +1,82 @@ -import { Modal } from 'antd'; import React from 'react'; -import { PrimaryButton } from '@client/common-components'; +import { Modal } from 'antd'; +import { PrimaryButton, Icon } from '@client/common-components'; +import { + useInteractiveModalContext, + TInteractiveModalContext, +} from '@client/hooks'; import styles from './InteractiveSessionModal.module.css'; -export const InteractiveSessionModal: React.FC<{ - isOpen: boolean; - interactiveSessionLink: string; - message?: string; - onCancel: VoidFunction; -}> = ({ isOpen, interactiveSessionLink, message, onCancel }) => { +export const InteractiveSessionModal = () => { + const [interactiveModalDetails, setInteractiveModalDetails] = + useInteractiveModalContext() as TInteractiveModalContext; + + const { interactiveSessionLink, message, openedBySubmit, show } = + interactiveModalDetails; + return ( Open Session} - width="500px" - open={isOpen} - footer={ - - Connect - + title={ +

+ Interactive Session is {interactiveSessionLink ? 'Ready' : 'Queueing'} +

+ } + width="650px" + open={show} + footer={null} + onCancel={() => + setInteractiveModalDetails({ + show: false, + }) } - onCancel={onCancel} >
- - Click the button below to connect to the interactive session. - +
+ + Connect + {interactiveSessionLink && ( + + )} + +
+ {openedBySubmit && !interactiveSessionLink && ( + + While you wait, you can either: +
    +
  • Keep this pop-up open and wait to connect.
  • +
  • + Close this pop-up and wait for a notification via{' '} + Job Status. +
  • +
+
+ )} {message && {message}} - To end the job, quit the application within the session. - - Files may take some time to appear in the output location after the - job has ended. - - - For security purposes, this is the URL that the connect button will - open: - - {interactiveSessionLink} + {interactiveSessionLink && ( + <> + + To end the job, quit the application within the session. + + + Files may take some time to appear in the output location after + the job has ended. + + + For security purposes, this is the URL that the connect button + will open: + + {interactiveSessionLink} + + )}
); diff --git a/client/modules/workspace/src/JobSubmitButton/JobSubmitButton.tsx b/client/modules/workspace/src/JobSubmitButton/JobSubmitButton.tsx new file mode 100644 index 0000000000..e66428b24c --- /dev/null +++ b/client/modules/workspace/src/JobSubmitButton/JobSubmitButton.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { PrimaryButton } from '@client/common-components'; + +export const JobSubmitButton: React.FC<{ + loading: boolean; + interactive: boolean | undefined; + disabled?: boolean; + success?: boolean; +}> = ({ loading, interactive, disabled = false, success = false }) => { + return ( + + {interactive ? 'Launch Session' : 'Submit Job'} + {success && } + + ); +}; diff --git a/client/modules/workspace/src/JobsDetailModal/JobsDetailModal.tsx b/client/modules/workspace/src/JobsDetailModal/JobsDetailModal.tsx index e4bc16be9a..9fa9ff0cd0 100644 --- a/client/modules/workspace/src/JobsDetailModal/JobsDetailModal.tsx +++ b/client/modules/workspace/src/JobsDetailModal/JobsDetailModal.tsx @@ -80,6 +80,7 @@ export const JobsDetailModalBody: React.FC<{ > ), ...(jobData.remoteOutcome && { 'Remote Outcome': jobData.remoteOutcome }), + ...(jobData.remoteJobId && { 'Remote Job ID': jobData.remoteJobId }), }; if (jobData.remoteOutcome) { @@ -235,7 +236,7 @@ export const JobsDetailModalBody: React.FC<{ (isInteractiveJob(jobData) ? ( @@ -296,21 +297,19 @@ export const JobsDetailModal: React.FC<{ uuid: string }> = ({ uuid }) => { -
- Job Detail: {uuid} - {jobData && ( -
-
Job UUID:
-
{jobData.uuid}
-
Application:
-
{JSON.parse(jobData.notes).label || jobData.appId}
-
System:
-
{jobData.execSystemId}
-
- )} -
- +
+ Job Detail: {uuid} + {jobData && ( +
+
Job UUID:
+
{jobData.uuid}
+
Application:
+
{JSON.parse(jobData.notes).label || jobData.appId}
+
System:
+
{jobData.execSystemId}
+
+ )} +
} width="60%" open={isModalOpen} diff --git a/client/modules/workspace/src/JobsListing/JobsListing.tsx b/client/modules/workspace/src/JobsListing/JobsListing.tsx index d2dcf3404d..398d69cf4a 100644 --- a/client/modules/workspace/src/JobsListing/JobsListing.tsx +++ b/client/modules/workspace/src/JobsListing/JobsListing.tsx @@ -1,4 +1,5 @@ -import React, { useMemo, useState, useEffect } from 'react'; +import React, { useMemo, useEffect } from 'react'; +import useWebSocket from 'react-use-websocket'; import { TableProps, Row, Flex, Button as AntButton } from 'antd'; import type { ButtonSize } from 'antd/es/button'; import { useQueryClient } from '@tanstack/react-query'; @@ -12,6 +13,8 @@ import { TJobPostOperations, useReadNotifications, TGetNotificationsResponse, + useInteractiveModalContext, + TInteractiveModalContext, } from '@client/hooks'; import { JobsListingTable, @@ -25,7 +28,6 @@ import { isInteractiveJob, isTerminalState, } from '../utils'; -import { InteractiveSessionModal } from '../InteractiveSessionModal'; import styles from './JobsListing.module.css'; import { formatDateTimeFromValue } from '../utils/timeFormat'; import { JobsReuseInputsButton } from '../JobsReuseInputsButton/JobsReuseInputsButton'; @@ -58,16 +60,24 @@ export const JobActionButton: React.FC<{ const InteractiveSessionButtons: React.FC<{ uuid: string; - interactiveSessionLink: string; + interactiveSessionLink?: string; message?: string; }> = ({ uuid, interactiveSessionLink, message }) => { - const [interactiveModalState, setInteractiveModalState] = useState(false); + const [, setInteractiveModalDetails] = + useInteractiveModalContext() as TInteractiveModalContext; return ( <> setInteractiveModalState(true)} + onClick={() => + setInteractiveModalDetails({ + show: true, + interactiveSessionLink, + message, + uuid: uuid, + }) + } > Open @@ -77,12 +87,6 @@ const InteractiveSessionButtons: React.FC<{ title="End" size="small" /> - setInteractiveModalState(false)} - /> ); }; @@ -96,12 +100,16 @@ export const JobsListing: React.FC> = ({ markRead: false, }); const { mutate: readNotifications } = useReadNotifications(); + const { sendMessage } = useWebSocket( + `wss://${window.location.host}/ws/websockets/` + ); // mark all as read on component mount useEffect(() => { readNotifications({ eventTypes: ['interactive_session_ready', 'job'], }); + sendMessage('markAllNotificationsAsRead'); // update unread count state queryClient.setQueryData( @@ -170,7 +178,7 @@ export const JobsListing: React.FC> = ({ ) : ( @@ -242,9 +250,5 @@ export const JobsListing: React.FC> = ({ [interactiveSessionNotifs] ); - return ( - <> - - - ); + return ; }; diff --git a/client/modules/workspace/src/JobsListing/JobsListingTable/JobsListingTable.tsx b/client/modules/workspace/src/JobsListing/JobsListingTable/JobsListingTable.tsx index d3d11deb8e..4cffd2b64f 100644 --- a/client/modules/workspace/src/JobsListing/JobsListingTable/JobsListingTable.tsx +++ b/client/modules/workspace/src/JobsListing/JobsListingTable/JobsListingTable.tsx @@ -91,9 +91,11 @@ export const JobsListingTable: React.FC< isLoading, ]); - const lastNotificationJobUUID = lastMessage - ? (JSON.parse(lastMessage.data) as TJobStatusNotification).extra.uuid - : ''; + const lastMessageJSON = lastMessage?.data + ? (JSON.parse(lastMessage.data) as TJobStatusNotification) + : null; + const lastNotificationJobUUID = + lastMessageJSON?.event_type === 'job' ? lastMessageJSON.extra.uuid : ''; const unreadJobUUIDs = unreadNotifs?.notifs.map((x) => x.extra.uuid) ?? []; /* RENDER THE TABLE */ diff --git a/client/modules/workspace/src/SelectModal/SelectModal.tsx b/client/modules/workspace/src/SelectModal/SelectModal.tsx index fbbb4906f2..01a6cdd719 100644 --- a/client/modules/workspace/src/SelectModal/SelectModal.tsx +++ b/client/modules/workspace/src/SelectModal/SelectModal.tsx @@ -23,6 +23,8 @@ import { TFileListing, } from '@client/hooks'; +import { TAppFileSettings } from '../AppsWizard/AppsFormSchema'; + import { BaseFileListingBreadcrumb, FileListingTable, @@ -152,7 +154,7 @@ const getParentFolder = ( function getFilesColumns( api: string, path: string, - selectionMode: string, + appFileSettings: TAppFileSettings, searchTerm: string | null, clearSearchTerm: () => void, selectionCallback: (path: string) => void, @@ -233,9 +235,11 @@ function getFilesColumns( title: '', render: (_, record, index) => { const selectionModeAllowed = - (record.type === 'dir' && selectionMode === 'directory') || - (record.type === 'file' && selectionMode === 'file') || - selectionMode === 'both'; + (record.type === 'dir' && + appFileSettings.fileSelectionMode === 'directory') || + (record.type === 'file' && + appFileSettings.fileSelectionMode === 'file') || + appFileSettings.fileSelectionMode === 'both'; const isNotRoot = index > 0 || record.system.startsWith(projectPrefix) || @@ -244,9 +248,15 @@ function getFilesColumns( return shouldRenderSelectButton ? ( - selectionCallback(`${api}://${record.system}${record.path}`) - } + onClick={() => { + const lastPartOfPath = record.path.split('/').pop() ?? ''; + const filePath = + appFileSettings.fileNameRepresentation === 'FullTapisPath' + ? `${api}://${record.system}${record.path}` + : lastPartOfPath; + + selectionCallback(filePath); + }} > Select @@ -259,11 +269,11 @@ function getFilesColumns( export const SelectModal: React.FC<{ inputLabel: string; system: string | null; - selectionMode: string; + appFileSettings: TAppFileSettings; isOpen: boolean; onClose: () => void; onSelect: (value: string) => void; -}> = ({ inputLabel, system, selectionMode, isOpen, onClose, onSelect }) => { +}> = ({ inputLabel, system, appFileSettings, isOpen, onClose, onSelect }) => { const [isModalOpen, setIsModalOpen] = useState(false); const [searchTerm, setSearchTerm] = useState(null); const [form] = Form.useForm(); @@ -420,7 +430,7 @@ export const SelectModal: React.FC<{ getFilesColumns( selectedApi, selectedPath, - selectionMode, + appFileSettings, searchTerm, clearSearchTerm, (selection: string) => selectCallback(selection), @@ -435,7 +445,7 @@ export const SelectModal: React.FC<{ selectedSystem, selectedPath, systemLabel, - selectionMode, + appFileSettings, selectCallback, ] ); diff --git a/client/modules/workspace/src/Toast/Notifications.module.css b/client/modules/workspace/src/Toast/Notifications.module.css index 44beb6563c..44ca35802c 100644 --- a/client/modules/workspace/src/Toast/Notifications.module.css +++ b/client/modules/workspace/src/Toast/Notifications.module.css @@ -1,3 +1,13 @@ +.root { + cursor: pointer; + background: #f4f4f4; + border: 1px solid #222222; + &:hover { + border-color: #5695c4; + background: #aac7ff; + } +} + .toast-is-error { color: #eb6e6e; } diff --git a/client/modules/workspace/src/Toast/Toast.tsx b/client/modules/workspace/src/Toast/Toast.tsx index 1956cd1102..b8941eb95c 100644 --- a/client/modules/workspace/src/Toast/Toast.tsx +++ b/client/modules/workspace/src/Toast/Toast.tsx @@ -1,11 +1,17 @@ import React, { useEffect } from 'react'; import useWebSocket from 'react-use-websocket'; import { useQueryClient } from '@tanstack/react-query'; -import { notification } from 'antd'; +import { notification, Flex } from 'antd'; +import { RightOutlined } from '@ant-design/icons'; import { useNavigate } from 'react-router-dom'; import { Icon } from '@client/common-components'; -import { TJobStatusNotification } from '@client/hooks'; -import { getToastMessage } from '../utils'; +import { + TJobStatusNotification, + TGetNotificationsResponse, + useInteractiveModalContext, + TInteractiveModalContext, +} from '@client/hooks'; +import { getToastMessage, isTerminalState } from '../utils'; import styles from './Notifications.module.css'; const Notifications = () => { @@ -14,36 +20,82 @@ const Notifications = () => { ); const [api, contextHolder] = notification.useNotification({ maxCount: 1 }); + const [interactiveModalDetails, setInteractiveModalDetails] = + useInteractiveModalContext() as TInteractiveModalContext; const queryClient = useQueryClient(); const navigate = useNavigate(); const handleNotification = (notification: TJobStatusNotification) => { - if ( - notification.event_type === 'job' || - notification.event_type === 'interactive_session_ready' - ) { - queryClient.invalidateQueries({ - queryKey: ['workspace', 'notifications'], - }); - queryClient.invalidateQueries({ - queryKey: ['workspace', 'jobsListing'], - }); - api.open({ - message: getToastMessage(notification), - placement: 'bottomLeft', - icon: , - className: `${ - notification.extra.status === 'FAILED' && styles['toast-is-error'] - }`, - closeIcon: false, - duration: 5, - onClick: () => { - navigate('/history'); - }, - style: { cursor: 'pointer' }, - }); + switch (notification.event_type) { + case 'interactive_session_ready': + if (interactiveModalDetails.show) { + setInteractiveModalDetails({ + ...interactiveModalDetails, + interactiveSessionLink: notification.action_link, + message: notification.message, + }); + } + /* falls through */ + case 'job': + queryClient.invalidateQueries({ + queryKey: ['workspace', 'notifications'], + }); + queryClient.invalidateQueries({ + queryKey: ['workspace', 'jobsListing'], + }); + api.open({ + message: ( + + {getToastMessage(notification)} + + + ), + placement: 'bottomLeft', + icon: , + className: `${ + notification.extra.status === 'FAILED' && styles['toast-is-error'] + } ${styles.root}`, + closeIcon: false, + duration: 5, + onClick: () => { + navigate('/history'); + }, + }); + + // close interactive session modal if job is ended + if ( + isTerminalState(notification.extra.status) && + notification.extra.uuid === interactiveModalDetails.uuid + ) { + setInteractiveModalDetails({ + show: false, + }); + } + + break; + case 'markAllNotificationsAsRead': + // update unread count state + queryClient.setQueryData( + [ + 'workspace', + 'notifications', + { + eventTypes: ['interactive_session_ready', 'job'], + read: false, + markRead: false, + }, + ], + (oldData: TGetNotificationsResponse) => { + return { + ...oldData, + notifs: [], + unread: 0, + }; + } + ); + break; } }; @@ -53,7 +105,7 @@ const Notifications = () => { } }, [lastMessage]); - return <>{contextHolder}; + return contextHolder; }; export default Notifications; diff --git a/client/modules/workspace/src/index.ts b/client/modules/workspace/src/index.ts index 694e66c4f2..826f4ff469 100644 --- a/client/modules/workspace/src/index.ts +++ b/client/modules/workspace/src/index.ts @@ -9,3 +9,4 @@ export * from './SystemsPushKeysModal/SystemsPushKeysModal'; export * from './Toast'; export * from './utils'; export * from './constants'; +export * from './InteractiveSessionModal'; diff --git a/client/modules/workspace/src/utils/apps.ts b/client/modules/workspace/src/utils/apps.ts index e8da75d452..99925b9e2c 100644 --- a/client/modules/workspace/src/utils/apps.ts +++ b/client/modules/workspace/src/utils/apps.ts @@ -508,3 +508,24 @@ export const getOnDemandEnvVariables = ( ); return includeOnDemandVars; }; + +/** + * Returns 'interactive session' as app type if it is interactive, otherwise 'job' + * + * @param definition - TTapisApp + * @param titleCase - boolean, default is false. + * @returns string + */ +export const getAppRuntimeLabel = ( + definition: TTapisApp, + titleCase: boolean = false +): string => { + const label = definition.notes.isInteractive ? 'interactive session' : 'job'; + + return titleCase + ? label + .split(' ') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') + : label; +}; diff --git a/client/modules/workspace/src/utils/index.ts b/client/modules/workspace/src/utils/index.ts index c882054cfb..f859596cb7 100644 --- a/client/modules/workspace/src/utils/index.ts +++ b/client/modules/workspace/src/utils/index.ts @@ -3,3 +3,4 @@ export * from './systems'; export * from './apps'; export * from './truncateMiddle'; export * from './notifications'; +export * from './timeFormat'; diff --git a/client/modules/workspace/src/utils/jobs.ts b/client/modules/workspace/src/utils/jobs.ts index 89b58a00fd..3915fe9923 100644 --- a/client/modules/workspace/src/utils/jobs.ts +++ b/client/modules/workspace/src/utils/jobs.ts @@ -68,7 +68,7 @@ export function getOutputPath(job: TTapisJob) { } export function isInteractiveJob(job: TTapisJob) { - return job.tags.includes('isInteractive'); + return job.tags?.includes('isInteractive'); } export function getJobInteractiveSessionInfo( diff --git a/client/src/datafiles/layouts/DataFilesBaseLayout.tsx b/client/src/datafiles/layouts/DataFilesBaseLayout.tsx index e2ba4c4c71..50fac4cb95 100644 --- a/client/src/datafiles/layouts/DataFilesBaseLayout.tsx +++ b/client/src/datafiles/layouts/DataFilesBaseLayout.tsx @@ -12,6 +12,11 @@ const { Sider } = Layout; const DataFilesRoot: React.FC = () => { const { user } = useAuthenticatedUser(); + + if (user && !user.setupComplete) { + window.location.replace(`${window.location.origin}/onboarding/setup`); + } + const defaultPath = user?.username ? '/tapis/designsafe.storage.default' : '/public/designsafe.storage.published'; diff --git a/client/src/datafiles/layouts/published/PublishedDetailLayout.tsx b/client/src/datafiles/layouts/published/PublishedDetailLayout.tsx index 2de053ad81..0319b8f218 100644 --- a/client/src/datafiles/layouts/published/PublishedDetailLayout.tsx +++ b/client/src/datafiles/layouts/published/PublishedDetailLayout.tsx @@ -50,6 +50,11 @@ export const PublishedDetailLayout: React.FC = () => { const { data, isError } = usePublicationDetail(projectId ?? ''); const { allVersions } = usePublicationVersions(projectId ?? ''); const version = (projectId ?? '').split('v')[1]; + const selectedVersion = + version || + searchParams.get('version') || + Math.max(...allVersions).toString(); + useEffect(() => { if (version) { const newSearchParams = new URLSearchParams(searchParams); @@ -62,11 +67,6 @@ export const PublishedDetailLayout: React.FC = () => { useEffect(() => { if (!data) return; - const selectedVersion = - version || - searchParams.get('version') || - Math.max(...allVersions).toString(); - data?.baseProject.projectType !== 'other' && apiClient.get( `/api/datafiles/tapis/public/listing/designsafe.storage.published/${projectId}${ @@ -75,7 +75,7 @@ export const PublishedDetailLayout: React.FC = () => { : '' }` ); - }, [data, allVersions, searchParams, version, projectId]); + }, [data, selectedVersion, searchParams, projectId]); if (isError) { return ( @@ -171,6 +171,7 @@ export const PublishedDetailLayout: React.FC = () => {
diff --git a/client/src/main.tsx b/client/src/main.tsx index ecba772410..3aa5fc4f8e 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -5,6 +5,7 @@ import { RouterProvider } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import workspaceRouter from './workspace/workspaceRouter'; import datafilesRouter from './datafiles/datafilesRouter'; +import onboardingRouter from './onboarding/onboardingRouter'; import { ConfigProvider, ThemeConfig } from 'antd'; const queryClient = new QueryClient(); @@ -73,3 +74,17 @@ if (datafilesElement) { ); } + +const onboardingElement = document.getElementById('onboarding-root'); +if (onboardingElement) { + const onboardingRoot = ReactDOM.createRoot(onboardingElement as HTMLElement); + onboardingRoot.render( + + + + + + + + ); +} diff --git a/client/src/onboarding/layouts/OnboardingAdminLayout.module.css b/client/src/onboarding/layouts/OnboardingAdminLayout.module.css new file mode 100644 index 0000000000..e8944ba27a --- /dev/null +++ b/client/src/onboarding/layouts/OnboardingAdminLayout.module.css @@ -0,0 +1,248 @@ +.truncate-with-ellipsis { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Required to constrain table within flex box */ +.root { + position: relative; +} + +.container { + display: flex; + flex-direction: column; + padding: 20px 40px 20px 20px; + position: absolute; + max-height: 100%; + width: 100%; + height: 100%; +} + +.container-header { + display: flex; + justify-content: space-between; + align-items: baseline; + border-bottom: 1px solid #707070; + padding-bottom: 8px; + margin-bottom: 1.5em; + + @media (max-width: 768px) { + flex-direction: column; + align-items: flex-start; + } +} + +.search-checkbox-container { + display: flex; + align-items: center; + justify-content: space-between; + + @media (max-width: 990px) { + flex-direction: column; + align-items: flex-start; + } +} + +.checkbox-label-container { + display: flex; + align-items: center; + font-size: 14px; + margin: 0 0 0 20px; + + @media (max-width: 768px) { + margin-left: 0; + margin-top: 20px; + } + + @media (max-width: 991px) { + margin-left: 0; + margin-top: 10px; + } +} + +.label { + font-weight: bold; + margin: 0 0 0 10px; +} +.paginator-container { + width: 100%; + display: flex; + justify-content: center; + margin-top: 1em; +} + +.user-container { + flex-grow: 0; + overflow-y: scroll; + position: relative; +} + +.users { + --cell-horizontal-padding: 0.35em; /* horizontal cell padding for inter-column buffer */ + --cell-vertical-padding: 0.35em; + /* TODO: After, FP-103, use `composes:` not `@extend` */ + height: 100%; + align-items: stretch; + width: 100%; + overflow-y: scroll; + font-size: 14px; + + thead { + user-select: none; + color: var(--global-color-primary--x-dark); + border-bottom: 1px solid #707070; + + .-sort-asc, + .sort-desc { + color: var(--global-color-primary--xx-dark); + } + + /* Match horizontal padding of `td` elements in table to align properly */ + th { + padding: var(--cell-vertical-padding) var(--cell-horizontal-padding); + + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + td { + padding: var(--cell-vertical-padding) var(--cell-horizontal-padding); + } +} + +.user > td { + /* Difference between tall unwrapped cell height and basic text cell height */ + --greatest-height-diff: 4.78px; /* value obtained form live render */ + /* FAQ: We want only the top space, not the top & bottom space combined */ + --vertical-offset: calc(var(--greatest-height-diff) / 2); +} +.user > td:not(.has-wrappable-content) { + /* Tweak `vertical-align`'s `top` to look like `middle` when nothing wraps */ + padding-top: calc(var(--cell-horizontal-padding) + var(--vertical-offset)); + vertical-align: top; +} +.user > td:not(.has-wrappable-content):not(.status) { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.user:nth-child(4n), +.user:nth-child(4n-1) > td:not(.staffwait) { + background-color: #f4f4f4; +} + +.username { + display: inline-block; /* needed to make margin-top work with span */ + margin-top: 5px; /* adjust value as needed to create more space between fullname and username */ + font-weight: bold; +} + +/* HACK: This selector has knowledge of sibling component's internal markup */ +/* FAQ: Because we cannot pass `className`to via */ +/* FAQ: Because has a superfluous root element */ +.status > span > span /* i.e. 's */ { + white-space: nowrap; +} + +.reset { + /* … */ +} + +.approve-container { + i { + font-size: 14px; + } + + --vertical-buffer: 0.125em; /* vertical space between wrapped buttons */ + + /* Add space above all buttons to match space below each */ + padding-top: var(--vertical-buffer); + /* Remove space above and bellow buttons */ + margin-top: calc(-1 * var(--vertical-buffer)); + margin-bottom: calc(-1 * var(--vertical-buffer)); +} + +.approve { + display: inline; /* Do not use `inline-block` because value was `flex` */ + /* Add space below each button */ + margin-bottom: var(--vertical-buffer); + + font-size: 14px; + border-radius: 0; + padding-top: 0; + padding-bottom: 0; +} + +.approve:not(:last-child) { + margin-right: 1em; +} + +.approve > *:nth-child(1) { + margin-right: 0.5em; +} + +.action-link { + padding: 0; + + font-size: 14px; +} +.action-link:not(:first-child) { + margin-left: 0.25em; +} +.action-link:not(:last-child) { + margin-right: 0.25em; +} + +.highlightCell:has(> *.staffwait) { + background-color: #e6f4ff; +} + +.users { + table-layout: fixed; + + col:nth-child(1) { + width: 12%; + } + col:nth-child(2) { + width: 14%; + } + col:nth-child(3) { + width: 27%; + } + th:nth-child(4) { + width: 33%; + } + col:nth-child(4) { + width: 18%; + } + col:nth-child(5) { + width: 15%; + } + col:nth-child(6) { + width: 14%; + } +} + +.no-users-placeholder { + display: flex; + align-items: center; + justify-content: center; + height: 90%; +} + +/* NOTE: Mimicked on: DataFiles, DataFilesProjectsList, DataFilesProjectFileListing */ +.root-placeholder { + flex-grow: 1; + + display: flex; + align-items: center; + justify-content: center; +} + +.onboarding-admin__action-spinner .inline { + width: 10px; + height: 10px; + font-size: 14px; +} diff --git a/client/src/onboarding/layouts/OnboardingAdminLayout.tsx b/client/src/onboarding/layouts/OnboardingAdminLayout.tsx new file mode 100644 index 0000000000..2abd2e1a23 --- /dev/null +++ b/client/src/onboarding/layouts/OnboardingAdminLayout.tsx @@ -0,0 +1,418 @@ +import React, { useState, useEffect } from 'react'; +import { + Alert, + Layout, + Checkbox, + Table, + TableColumnType, + Dropdown, + Space, + Typography, + Flex, +} from 'antd'; +import type { MenuProps } from 'antd'; +import { CheckOutlined, CloseOutlined, DownOutlined } from '@ant-design/icons'; +import { useSearchParams } from 'react-router-dom'; +import { SecondaryButton, Spinner } from '@client/common-components'; +import { + OnboardingStatus, + OnboardingEventLogModal, + OnboardingAdminSearchbar, +} from '@client/onboarding'; +import styles from './OnboardingAdminLayout.module.css'; +import { + TOnboardingStep, + TOnboardingUser, + useSendOnboardingAction, + useGetOnboardingAdminList, + TOnboardingAdminList, +} from '@client/hooks'; + +const OnboardingApproveActions: React.FC<{ + step: TOnboardingStep; + username: string; +}> = ({ step, username }) => { + const { + mutate: sendOnboardingAction, + isPending, + variables, + } = useSendOnboardingAction(); + + return ( +
+ + sendOnboardingAction({ + body: { action: 'staff_approve', step: step.step }, + username, + }) + } + loading={isPending && variables?.body.action === 'staff_approve'} + disabled={isPending && variables?.body.action === 'staff_deny'} + icon={} + > + Approve + + + sendOnboardingAction({ + body: { action: 'staff_deny', step: step.step }, + username, + }) + } + loading={isPending && variables?.body.action === 'staff_deny'} + disabled={isPending && variables?.body.action === 'staff_approve'} + icon={} + > + Deny + +
+ ); +}; + +const OnboardingResetLinks: React.FC<{ + step: TOnboardingStep; + username: string; +}> = ({ step, username }) => { + const { + mutate: sendOnboardingAction, + isPending, + variables, + } = useSendOnboardingAction(); + + return ( +
+ + sendOnboardingAction({ + body: { action: 'reset', step: step.step }, + username, + }) + } + loading={isPending && variables?.body.action === 'reset'} + > + Reset + + | + + sendOnboardingAction({ + body: { action: 'complete', step: step.step }, + username, + }) + } + loading={isPending && variables?.body.action === 'complete'} + > + Skip + +
+ ); +}; + +const OnboardingAdminList: React.FC<{ + data: TOnboardingAdminList; + viewLogCallback: (user: TOnboardingUser, step: TOnboardingStep) => void; +}> = ({ data, viewLogCallback }) => { + const [searchParams, setSearchParams] = useSearchParams(); + + type TOnboardingAdminTableRowData = { + user: TOnboardingUser; + step: TOnboardingStep; + index: number; + }; + + const columns: TableColumnType[] = [ + { + title: 'User', + dataIndex: 'user', + className: styles.highlightCell, + render: (user: TOnboardingUser, record) => ( + + {`${user.firstName} ${user.lastName}`} +
+ {user.username} +
+ ), + onCell: (record) => ({ + rowSpan: record.index === 0 ? record.user.steps.length : 0, + }), + }, + { + title: 'Step', + dataIndex: 'step', + className: styles.highlightCell, + render: (step: TOnboardingStep) => ( + + {step.displayName} + + ), + }, + { + title: 'Status', + dataIndex: 'step', + key: 'status', + className: styles.highlightCell, + render: (step: TOnboardingStep) => ( + + + + ), + }, + { + title: 'Administrative Actions', + dataIndex: 'step', + key: 'actions', + className: styles.highlightCell, + render: (step: TOnboardingStep, record) => ( + + + {step.state === 'staffwait' && ( + + )} + + ), + }, + { + title: 'Log', + dataIndex: 'step', + key: 'log', + className: styles.highlightCell, + render: (step: TOnboardingStep, record) => ( + + viewLogCallback(record.user, step)} + > + View Log + + + ), + }, + ]; + + const { users, total, totalSteps } = data; + + const dataSource: TOnboardingAdminTableRowData[] = []; + users.forEach((user) => { + user.steps.forEach((step, index) => { + return dataSource.push({ user, step, index }); + }); + }); + + return ( + <> + + { + searchParams.set('page', page.toString()); + setSearchParams(searchParams); + }, + }} + sticky + /> + + ); +}; + +const OnboardingAdminTable: React.FC<{ + data?: TOnboardingAdminList; + isLoading: boolean; + isError: boolean; +}> = ({ data, isLoading, isError }) => { + const [eventLogModalParams, setEventLogModalParams] = useState<{ + user: TOnboardingUser; + step: TOnboardingStep; + } | null>(null); + + // Update modal step data if the user is updated + useEffect(() => { + if (eventLogModalParams?.user && data) { + setEventLogModalParams({ + user: eventLogModalParams.user, + step: + data.users + .find((user) => user.username === eventLogModalParams.user.username) + ?.steps.find( + (step) => step.step === eventLogModalParams.step.step + ) || eventLogModalParams.step, + }); + } + }, [data]); + + if (isLoading) { + return ; + } + if (isError || !data) { + return ( +
+ +
+ ); + } + + const viewLogCallback = (user: TOnboardingUser, step: TOnboardingStep) => + setEventLogModalParams({ user, step }); + + const { users } = data; + + return ( + <> + {users.length === 0 && ( +
+ +
+ )} +
+ {users.length > 0 && ( + + )} +
+ {eventLogModalParams && ( + setEventLogModalParams(null)} + /> + )} + + ); +}; + +const OnboardingAdminLayout = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const { data, isError, isLoading } = useGetOnboardingAdminList(); + + useEffect(() => {}, [searchParams]); + + const toggleShowIncomplete = () => { + const showIncompleteOnly = searchParams.get('showIncompleteOnly'); + const newSearchParams = searchParams; + if (!showIncompleteOnly) { + newSearchParams.set('showIncompleteOnly', 'true'); + } else { + newSearchParams.delete('showIncompleteOnly'); + newSearchParams.delete('page'); + } + + setSearchParams(newSearchParams); + }; + + const { Header } = Layout; + const headerStyle = { + background: 'transparent', + paddingLeft: 0, + paddingRight: 0, + borderBottom: '1px solid #707070', + fontSize: 16, + }; + + const setOrderBy = (orderBy: string) => { + searchParams.set('orderBy', orderBy); + setSearchParams(searchParams); + }; + + const orderByItems: MenuProps['items'] = [ + { + key: 'default', + label: 'Default (Date Joined, Incomplete, Last Name, First Name)', + }, + { + key: 'last_name', + label: 'Last Name (A-Z)', + }, + { + key: 'first_name', + label: 'First Name (A-Z)', + }, + { + key: '-date_joined', + label: 'Date Joined (Newest First)', + }, + { + key: 'profile__setup_complete', + label: 'Setup Complete (Incomplete First)', + }, + ]; + + return ( + + +
Administrator Controls
+
+ + + setOrderBy(key), + }} + > + + + Order By + + + + + + +
+ +
+
+ ); +}; + +export default OnboardingAdminLayout; diff --git a/client/src/onboarding/layouts/OnboardingBaseLayout.tsx b/client/src/onboarding/layouts/OnboardingBaseLayout.tsx new file mode 100644 index 0000000000..e50b2652e2 --- /dev/null +++ b/client/src/onboarding/layouts/OnboardingBaseLayout.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { Outlet } from 'react-router-dom'; +import { Flex, Layout } from 'antd'; +import OnboardingWebsocketHandler from './OnboardingWebsocketHandler'; + +const OnboardingRoot: React.FC = () => { + return ( + + + + + + + ); +}; + +export default OnboardingRoot; diff --git a/client/src/onboarding/layouts/OnboardingUserLayout.module.css b/client/src/onboarding/layouts/OnboardingUserLayout.module.css new file mode 100644 index 0000000000..ae8590744d --- /dev/null +++ b/client/src/onboarding/layouts/OnboardingUserLayout.module.css @@ -0,0 +1,10 @@ +.access { + text-align: right; +} + +.content { + padding-bottom: 1em; +} +.content > * { + max-width: 768px; /* ~640px design * 1.2 design-to-app ratio */ +} diff --git a/client/src/onboarding/layouts/OnboardingUserLayout.tsx b/client/src/onboarding/layouts/OnboardingUserLayout.tsx new file mode 100644 index 0000000000..ffb785420d --- /dev/null +++ b/client/src/onboarding/layouts/OnboardingUserLayout.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { useParams, Link } from 'react-router-dom'; +import { Layout, Space } from 'antd'; +import { PrimaryButton } from '@client/common-components'; +import { OnboardingStep } from '@client/onboarding'; +import { + useAuthenticatedUser, + useGetOnboardingUserSuspense, +} from '@client/hooks'; +import styles from './OnboardingUserLayout.module.css'; + +export const OnboardingUserLayout: React.FC = () => { + const { user: authenticatedUser } = useAuthenticatedUser(); + const { username } = useParams(); + const { data: onboardingUser } = useGetOnboardingUserSuspense( + username || (authenticatedUser?.username as string) + ); + + const { Header } = Layout; + const headerStyle = { + background: 'transparent', + paddingLeft: 0, + paddingRight: 0, + borderBottom: '1px solid #707070', + fontSize: 16, + }; + + return ( + + +
+ {authenticatedUser?.isStaff + ? `Onboarding Administration for ${onboardingUser.username} - ${onboardingUser.lastName}, ${onboardingUser.firstName}` + : 'The following steps must be completed before accessing the portal'} +
+ <> + {onboardingUser.steps.map((step) => ( + + ))} +
+ + Get Help + +      + + Continue + +
+ +
+
+ ); +}; diff --git a/client/src/onboarding/layouts/OnboardingWebsocketHandler.tsx b/client/src/onboarding/layouts/OnboardingWebsocketHandler.tsx new file mode 100644 index 0000000000..5e94c4d0e1 --- /dev/null +++ b/client/src/onboarding/layouts/OnboardingWebsocketHandler.tsx @@ -0,0 +1,75 @@ +import { useEffect } from 'react'; +import useWebSocket from 'react-use-websocket'; +import { useQueryClient } from '@tanstack/react-query'; +import { + TSetupStepEvent, + TOnboardingUser, + TOnboardingAdminList, +} from '@client/hooks'; + +function updateAdminUsersFromEvent( + oldData: TOnboardingAdminList, + event: TSetupStepEvent +) { + return { + ...oldData, + users: oldData.users.map((user) => + user.username === event.username + ? { ...updateUserFromEvent(user, event) } + : user + ), + }; +} + +function updateUserFromEvent(oldData: TOnboardingUser, event: TSetupStepEvent) { + return { + ...oldData, + setupComplete: !!event.data?.setupComplete, + steps: [ + ...oldData.steps.map((step) => { + if (step.step === event.step) { + return { + ...step, + state: event.state, + events: [event, ...step.events], + }; + } + return step; + }), + ], + }; +} + +const OnboardingWebsocketHandler = () => { + const { lastMessage } = useWebSocket( + `wss://${window.location.host}/ws/websockets/` + ); + const queryClient = useQueryClient(); + const processSetupEvent = (event: TSetupStepEvent) => { + queryClient.setQueriesData( + { queryKey: ['onboarding', 'adminList'], exact: false }, + (oldData) => + (oldData as TOnboardingAdminList)?.users + ? updateAdminUsersFromEvent(oldData as TOnboardingAdminList, event) + : oldData + ); + queryClient.setQueryData( + ['onboarding', 'user', event.username], + (oldData: TOnboardingUser) => + oldData ? updateUserFromEvent(oldData, event) : oldData + ); + }; + + useEffect(() => { + if (lastMessage !== null) { + const event = JSON.parse(lastMessage.data); + if (event.event_type === 'setup_event') { + processSetupEvent(event.setup_event); + } + } + }, [lastMessage]); + + return null; +}; + +export default OnboardingWebsocketHandler; diff --git a/client/src/onboarding/onboardingRouter.tsx b/client/src/onboarding/onboardingRouter.tsx new file mode 100644 index 0000000000..7eb65f5c94 --- /dev/null +++ b/client/src/onboarding/onboardingRouter.tsx @@ -0,0 +1,49 @@ +import { Suspense } from 'react'; +import { Layout } from 'antd'; +import { createBrowserRouter, Navigate } from 'react-router-dom'; +import { Spinner } from '@client/common-components'; +import OnboardingAdminLayout from './layouts/OnboardingAdminLayout'; +import { OnboardingUserLayout } from './layouts/OnboardingUserLayout'; +import OnboardingBaseLayout from './layouts/OnboardingBaseLayout'; + +const onboardingRouter = createBrowserRouter( + [ + { + id: 'root', + path: '/', + element: , + children: [ + { + path: '', + element: , + }, + { + id: 'admin', + path: 'admin', + element: , + }, + { + path: `setup/:username?`, + element: ( + + + + } + > + + + ), + }, + { + path: '*', + element: , + }, + ], + }, + ], + { basename: '/onboarding' } +); + +export default onboardingRouter; diff --git a/client/src/workspace/layouts/AppsViewLayout.tsx b/client/src/workspace/layouts/AppsViewLayout.tsx index b78563fd94..ee3ebf0d67 100644 --- a/client/src/workspace/layouts/AppsViewLayout.tsx +++ b/client/src/workspace/layouts/AppsViewLayout.tsx @@ -51,6 +51,7 @@ export const AppsViewLayout: React.FC = () => { View User Guide @@ -59,7 +60,9 @@ export const AppsViewLayout: React.FC = () => { {htmlApp ? ( -
+
{parse(htmlApp.html as string)}
) : ( diff --git a/client/src/workspace/layouts/WorkspaceBaseLayout.tsx b/client/src/workspace/layouts/WorkspaceBaseLayout.tsx index e364998a03..35fa21ce7a 100644 --- a/client/src/workspace/layouts/WorkspaceBaseLayout.tsx +++ b/client/src/workspace/layouts/WorkspaceBaseLayout.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Outlet } from 'react-router-dom'; import { Flex, Layout } from 'antd'; import { @@ -7,6 +7,7 @@ import { useGetAppParams, AppsBreadcrumb, Toast, + InteractiveSessionModal, } from '@client/workspace'; import { Spinner } from '@client/common-components'; import { @@ -14,6 +15,8 @@ import { useAppsListing, usePrefetchGetSystems, usePrefetchGetAllocations, + InteractiveModalContext, + useAuthenticatedUser, } from '@client/hooks'; import styles from './layout.module.css'; @@ -23,8 +26,16 @@ const WorkspaceRoot: React.FC = () => { usePrefetchGetApps(useGetAppParams()); usePrefetchGetSystems(); usePrefetchGetAllocations(); + const { user } = useAuthenticatedUser(); + + if (user && !user.setupComplete) { + window.location.replace(`${window.location.origin}/onboarding/setup`); + } const { data, isLoading } = useAppsListing(); + const [interactiveModalDetails, setInteractiveModalDetails] = useState({ + show: false, + }); if (!data || isLoading) return ( @@ -41,7 +52,9 @@ const WorkspaceRoot: React.FC = () => { }; return ( - <> + { - + + ); }; diff --git a/client/src/workspace/layouts/layout.module.css b/client/src/workspace/layouts/layout.module.css index 2668643dac..dc48d3cc45 100644 --- a/client/src/workspace/layouts/layout.module.css +++ b/client/src/workspace/layouts/layout.module.css @@ -14,6 +14,12 @@ overflow: auto; } +.html-app-container { + & p { + margin-block: 1em 2em; + } +} + .appDetail-placeholder-message { display: flex; justify-content: center; diff --git a/client/src/workspace/workspaceRouter.tsx b/client/src/workspace/workspaceRouter.tsx index d9b3bf7c95..ad21288298 100644 --- a/client/src/workspace/workspaceRouter.tsx +++ b/client/src/workspace/workspaceRouter.tsx @@ -7,6 +7,13 @@ import { JobsListingLayout } from './layouts/JobsListingLayout'; import { AppsViewLayout } from './layouts/AppsViewLayout'; import { AppsPlaceholderLayout } from './layouts/AppsPlaceholderLayout'; +const getBaseName = () => { + if (window.location.pathname.startsWith('/rw/workspace')) { + return '/rw/workspace'; + } + return '/workspace'; +}; + const workspaceRouter = createBrowserRouter( [ { @@ -44,7 +51,7 @@ const workspaceRouter = createBrowserRouter( ], }, ], - { basename: '/rw/workspace' } + { basename: getBaseName() } ); export default workspaceRouter; diff --git a/client/tsconfig.base.json b/client/tsconfig.base.json index c66e21b834..613b2c6399 100644 --- a/client/tsconfig.base.json +++ b/client/tsconfig.base.json @@ -18,6 +18,7 @@ "@client/common-components": ["modules/_common_components/src/index.ts"], "@client/datafiles": ["modules/datafiles/src/index.ts"], "@client/hooks": ["modules/_hooks/src/index.ts"], + "@client/onboarding": ["modules/onboarding/src/index.ts"], "@client/test-fixtures": ["modules/_test-fixtures/src/index.ts"], "@client/workspace": ["modules/workspace/src/index.ts"] } diff --git a/conf/docker/docker-compose-dev.all.debug.m1.yml b/conf/docker/docker-compose-dev.all.debug.m1.yml index edbe69b19c..8337c2219f 100644 --- a/conf/docker/docker-compose-dev.all.debug.m1.yml +++ b/conf/docker/docker-compose-dev.all.debug.m1.yml @@ -77,7 +77,10 @@ services: django: image: designsafeci/portal:tapis-v3 - env_file: ../env_files/designsafe.env + env_file: + - path: ../env_files/designsafe.env + - path: ../env_files/ngrok.env + required: false links: - memcached:memcached - rabbitmq:rabbitmq @@ -98,7 +101,10 @@ services: websockets: image: designsafeci/portal:tapis-v3 - env_file: ../env_files/designsafe.env + env_file: + - path: ../env_files/designsafe.env + - path: ../env_files/ngrok.env + required: false volumes: - ../../.:/srv/www/designsafe - ../../data/media:/srv/www/designsafe/media @@ -108,7 +114,10 @@ services: workers: image: designsafeci/portal:tapis-v3 - env_file: ../env_files/designsafe.env + env_file: + - path: ../env_files/designsafe.env + - path: ../env_files/ngrok.env + required: false links: - memcached:memcached - rabbitmq:rabbitmq @@ -125,6 +134,20 @@ services: container_name: des_workers hostname: des_workers + ngrok: + image: ngrok/ngrok:latest + platform: "linux/amd64" + environment: + NGROK_AUTHTOKEN: ${NGROK_AUTHTOKEN} + command: + - "http" + - --url=${NGROK_DOMAIN} + - "https://host.docker.internal:443" + ports: + - 4040:4040 + container_name: des_ngrok + hostname: des_ngrok + volumes: redis_data_v3: des_postgres_data_v3: diff --git a/conf/docker/docker-compose-dev.all.debug.yml b/conf/docker/docker-compose-dev.all.debug.yml index 22d5c62155..9fb100962a 100644 --- a/conf/docker/docker-compose-dev.all.debug.yml +++ b/conf/docker/docker-compose-dev.all.debug.yml @@ -73,7 +73,10 @@ services: django: image: designsafeci/portal:tapis-v3 - env_file: ../env_files/designsafe.env + env_file: + - path: ../env_files/designsafe.env + - path: ../env_files/ngrok.env + required: false links: - memcached:memcached - rabbitmq:rabbitmq @@ -94,7 +97,10 @@ services: websockets: image: designsafeci/portal:tapis-v3 - env_file: ../env_files/designsafe.env + env_file: + - path: ../env_files/designsafe.env + - path: ../env_files/ngrok.env + required: false volumes: - ../../.:/srv/www/designsafe - ../../data/media:/srv/www/designsafe/media @@ -104,7 +110,10 @@ services: workers: image: designsafeci/portal:tapis-v3 - env_file: ../env_files/designsafe.env + env_file: + - path: ../env_files/designsafe.env + - path: ../env_files/ngrok.env + required: false links: - memcached:memcached - rabbitmq:rabbitmq @@ -121,6 +130,19 @@ services: container_name: des_workers hostname: des_workers + ngrok: + image: ngrok/ngrok:latest + environment: + NGROK_AUTHTOKEN: ${NGROK_AUTHTOKEN} + command: + - "http" + - --url=${NGROK_DOMAIN} + - "https://host.docker.internal:443" + ports: + - 4040:4040 + container_name: des_ngrok + hostname: des_ngrok + volumes: redis_data_v3: des_postgres_data_v3: diff --git a/conf/env_files/designsafe.sample.env b/conf/env_files/designsafe.sample.env index 5c13942f68..6c9d2824f4 100644 --- a/conf/env_files/designsafe.sample.env +++ b/conf/env_files/designsafe.sample.env @@ -1,5 +1,10 @@ ### -# Set to "True" to enable Django DEBUG mode. +# Set to `True` to enable React (e.g. the Workspace) +# +RENDER_REACT=True + +### +# Set to `True` to enable Django DEBUG mode. # https://docs.djangoproject.com/en/2.2/ref/settings/#std:setting-DEBUG # DJANGO_DEBUG=True @@ -80,7 +85,6 @@ AGAVE_CLIENT_SECRET= AGAVE_SUPER_TOKEN= AGAVE_STORAGE_SYSTEM= -AGAVE_WORKING_SYSTEM= AGAVE_JWT_PUBKEY= AGAVE_JWT_ISSUER= AGAVE_JWT_HEADER= @@ -189,3 +193,12 @@ MONGO_USER= MONGO_PASS= MONGO_HOST= MONGO_DB= + +## +# Miscellaneous +# + +DATACITE_USER= +DATACITE_PASS= +DATACITE_URL= +DATACITE_SHOULDER= diff --git a/conf/env_files/ngrok.sample.env b/conf/env_files/ngrok.sample.env new file mode 100644 index 0000000000..65ae75620a --- /dev/null +++ b/conf/env_files/ngrok.sample.env @@ -0,0 +1,5 @@ +# Get authtoken from https://dashboard.ngrok.com/get-started/your-authtoken +NGROK_AUTHTOKEN=your_ngrok_authtoken + +# Get static domain from https://dashboard.ngrok.com/domains +NGROK_DOMAIN=your-ngrok-subdomain.ngrok-free.app diff --git a/designsafe/apps/accounts/migrations/0022_designsafeprofile_setup_complete.py b/designsafe/apps/accounts/migrations/0022_designsafeprofile_setup_complete.py new file mode 100644 index 0000000000..a9181c2f51 --- /dev/null +++ b/designsafe/apps/accounts/migrations/0022_designsafeprofile_setup_complete.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.11 on 2024-10-15 23:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("designsafe_accounts", "0021_designsafeprofile_homedir"), + ] + + operations = [ + migrations.AddField( + model_name="designsafeprofile", + name="setup_complete", + field=models.BooleanField(default=False), + ), + ] diff --git a/designsafe/apps/accounts/models.py b/designsafe/apps/accounts/models.py index 33ef9d1d0a..ddd0ce232a 100644 --- a/designsafe/apps/accounts/models.py +++ b/designsafe/apps/accounts/models.py @@ -79,6 +79,9 @@ class DesignSafeProfile(models.Model): update_required = models.BooleanField(default=True) last_updated = models.DateTimeField(auto_now=True, null=True) + # Default to False. If PORTAL_USER_ACCOUNT_SETUP_STEPS is empty, setup_complete will be set to True on first login + setup_complete = models.BooleanField(default=False) + def send_mail(self, subject, body=None): send_mail(subject, body, diff --git a/designsafe/apps/accounts/templates/designsafe/apps/accounts/base.html b/designsafe/apps/accounts/templates/designsafe/apps/accounts/base.html index e0c074ed59..b70f0557f4 100644 --- a/designsafe/apps/accounts/templates/designsafe/apps/accounts/base.html +++ b/designsafe/apps/accounts/templates/designsafe/apps/accounts/base.html @@ -9,7 +9,7 @@

{{title}}

View Details
-
+
\ No newline at end of file diff --git a/designsafe/static/scripts/ng-designsafe/components/notification-badge/notification-badge.component.html b/designsafe/static/scripts/ng-designsafe/components/notification-badge/notification-badge.component.html index bd706a3cba..dfd2466a21 100644 --- a/designsafe/static/scripts/ng-designsafe/components/notification-badge/notification-badge.component.html +++ b/designsafe/static/scripts/ng-designsafe/components/notification-badge/notification-badge.component.html @@ -1,17 +1,18 @@ -