From 76ce638bf760deff052580cc994d92bec22fc131 Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Thu, 4 Sep 2025 17:33:30 +0200 Subject: [PATCH 01/17] feat: add backend and frontend development instructions for project standards and best practices --- .github/copilot-instructions.md | 192 ++++++++++++++++++ .github/instructions/backend.instructions.md | 140 +++++++++++++ .github/instructions/frontend.instructions.md | 134 ++++++++++++ 3 files changed, 466 insertions(+) create mode 100644 .github/copilot-instructions.md create mode 100644 .github/instructions/backend.instructions.md create mode 100644 .github/instructions/frontend.instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000000..82a093286d --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,192 @@ +# User Office Core - AI Coding Agent Instructions + +## Project Overview + +User Office Core is a full-stack application for managing user office workflows for scientific facilities. It consists of: + +- **Backend**: Express/Node.js GraphQL API server with PostgreSQL database +- **Frontend**: React-based UI using Material UI +- **E2E**: End-to-end tests using Cypress + +The system handles proposal management, user administration, and scientific facility workflows. + +## Architecture Map + +### Backend Component Structure (`/apps/backend`) +- **models/**: Domain interfaces and types (read these first to understand data structures) +- **resolvers/**: GraphQL resolvers (queries + mutations) grouped by domain +- **datasources/**: Repository interfaces defining data access methods + - **datasources/postgres/**: PostgreSQL implementations of interfaces + - **datasources/mockups/**: Mock implementations for testing +- **auth/**: Authentication and authorization logic +- **middlewares/**: Express middleware and GraphQL configuration +- **utils/**: Helper functions and utilities +- **events/**: Event-driven messaging system +- **mutations/**: Business logic for mutating data +- **queries/**: Business logic for querying data +- **config/**: Application configuration including DI setup + +### Technology Stack +- **GraphQL API**: Apollo Server with TypeGraphQL decorators +- **Database**: PostgreSQL with Knex.js for queries +- **Dependency Injection**: tsyringe container with Symbol tokens +- **Validation**: Yup schemas via `@user-office-software/duo-validation` +- **Authentication**: JWT tokens with role-based access control +- **Messaging**: RabbitMQ via `@user-office-software/duo-message-broker` + +### Frontend Structure (`/apps/frontend`) +- **components/**: Reusable UI components (mostly Material UI) +- **context/**: React context providers for global state +- **generated/**: Generated GraphQL types and API client +- **hooks/**: Custom React hooks, especially `useDataApi` for GraphQL +- **models/**: TypeScript interfaces for frontend data models +- **utils/**: Helper functions and utilities + +## Code Patterns To Follow + +### Backend Patterns +1. **DataSource Pattern**: Always implement and use these interfaces + ```typescript + // 1. Define interface in a file like src/datasources/EntityDataSource.ts + export interface EntityDataSource { + getById(id: number): Promise; + create(input: CreateEntityInput): Promise; + // etc. + } + + // 2. Implement in src/datasources/postgres/EntityDataSource.ts + @injectable() + export default class PostgresEntityDataSource implements EntityDataSource { + // implementation here + } + + // 3. Register in dependency container in src/config/dependencyConfigDefault.ts + mapClass(Tokens.EntityDataSource, PostgresEntityDataSource); + ``` + +2. **GraphQL Resolvers**: Use TypeGraphQL decorators + ```typescript + @Resolver() + export class EntityQuery { + @Query(() => Entity, { nullable: true }) + async entity( + @Ctx() context: ResolverContext, + @Arg('id', () => Int) id: number + ) { + return context.queries.entity.get(context.user, id); + } + } + ``` + +3. **Mutations**: Use decorators for authorization and validation + ```typescript + @EventBus(Event.ENTITY_CREATED) + @ValidateArgs(createEntityValidationSchema) + @Authorized([Roles.USER_OFFICER]) + async create( + agent: UserWithRole | null, + args: CreateEntityArgs + ): Promise { + // implementation using this.dataSource + } + ``` + +4. **Error Handling**: Use rejection pattern with try/catch and await + ```typescript + try { + const result = await this.dataSource.method(); + return result; + } catch (error) { + return rejection( + 'Error message here', + { agent, args }, + error + ); + } + ``` + +### Frontend Patterns +1. **Data Fetching**: Use `useDataApi` or `useDataApiWithFeedback` hooks + ```typescript + const { api } = useDataApi(); + // or with loading/error handling + const { api, isExecuting, error } = useDataApiWithFeedback(); + + // Then call the API + const result = await api.createEntity(variables); + ``` + +2. **Form Handling**: Use Formik with Yup validation + ```typescript + { + await api.createEntity({ ...values }); + }} + > + {/* Form fields */} + + ``` + +## Database Structure + +Key tables and relationships: +- **users**: User accounts with roles (linked via role_user) +- **proposals**: Scientific proposals (linked to users via proposer_id) +- **call**: Proposal submission cycles (linked to proposals) +- **proposal_questions**: Configurable questions (linked to topics) +- **proposal_answers**: User responses (linked to proposals and questions) +- **reviews**: Proposal reviews (linked to proposals and users) + +## Critical Workflows + +### Creating a New Entity Type (Backend) +1. Define model interface in `src/models/Entity.ts` +2. Create database migration in `db_patches/` using `register_patch` +3. Define DataSource interface in `src/datasources/EntityDataSource.ts` +4. Implement PostgreSQL version in `src/datasources/postgres/EntityDataSource.ts` +5. Add to Tokens in `src/config/Tokens.ts` and register in dependency config +6. Create GraphQL type in `src/resolvers/types/Entity.ts` +7. Implement queries in `src/resolvers/queries/EntityQuery.ts` +8. Implement mutations in `src/resolvers/mutations/EntityMutation.ts` +9. Add business logic in `src/queries/EntityQueries.ts` and `src/mutations/EntityMutations.ts` +10. Add mock implementation in `src/datasources/mockups/EntityDataSource.ts` +11. Write tests for mutations and queries + +### Creating a New Frontend Feature +1. Generate GraphQL SDK after backend changes using `npm run generate:shared:types` +2. Create components in `apps/frontend/src/components/` +3. Use `useDataApi` hook to communicate with the backend +4. Add routing in the appropriate router configuration +5. Use Material UI components and follow existing styling patterns + +## Authorization Pattern +```typescript +// Mutations and queries always check authorization with decorators +@Authorized([Roles.USER_OFFICER, Roles.INSTRUMENT_SCIENTIST]) +async methodName(agent: UserWithRole | null, args: Args): Promise { + // Implementation +} + +// DataSources should NOT contain authorization logic +// Authorization is handled at the mutation/query layer +``` + +## Testing Pattern +```typescript +// Test file structure +describe('Entity Mutations', () => { + test('User with proper role can create entity', () => { + return expect( + entityMutations.create(userWithRole, validInput) + ).resolves.toMatchObject(expectedResult); + }); + + test('User without permission cannot create entity', () => { + return expect( + entityMutations.create(userWithoutRole, validInput) + ).resolves.toHaveProperty('reason', 'INSUFFICIENT_PERMISSIONS'); + }); +}); +``` diff --git a/.github/instructions/backend.instructions.md b/.github/instructions/backend.instructions.md new file mode 100644 index 0000000000..25e0c4f1c6 --- /dev/null +++ b/.github/instructions/backend.instructions.md @@ -0,0 +1,140 @@ +--- +description: 'Node.js/Express/GraphQL backend development standards and best practices' +applyTo: 'apps/backend/**/*.ts' +--- + +# Backend Development Instructions + +Instructions for building high-quality backend services with Node.js, Express, GraphQL, and TypeScript following modern patterns, best practices, and dependency injection. + +## Coding standards +- Use JavaScript with ES2022 features and Node.js (20+) ESM modules] +- Use Node.js built-in modules and avoid external dependencies where possible +- Ask the user if you require any additional dependencies before adding them +- Always use async/await for asynchronous code, and use 'node:util' promisify function to avoid callbacks +- Keep the code simple and maintainable +- Use descriptive variable and function names +- Do not add comments unless absolutely necessary, the code should be self-explanatory + +## Documentation +- When adding new features or making significant changes, update the README.md file where necessary + +## User interactions +- Ask questions if you are unsure about the implementation details, design choices, or need clarification on the requirements +- Suggest improvements or alternatives if you believe there is a better way to implement a feature or solve a problem + +## Project Context +- TypeScript for type safety and maintainable code +- GraphQL API with Apollo Server and TypeGraphQL +- PostgreSQL database with Knex.js for queries and migrations +- Dependency Injection using tsyringe container +- JWT-based authentication with role-based access control + +## Development Standards +- Follow the dependency injection pattern using tsyringe +- Use interfaces for all data sources and implementations +- Implement postgres and mock data sources for database interface + +### Architecture +- Implement GraphQL resolvers using TypeGraphQL decorators +- Try to keep business logic separate into mutations and queries layers +- Use express middleware for cross-cutting concerns +- Structure the codebase by domain and responsibility +- Follow clean architecture principles with clear separation of concerns + +### TypeScript Integration +- Use TypeScript interfaces for models, inputs, and return types +- Use strict mode in `tsconfig.json` for type safety +- Avoid any type when possible to ensure type safety + +### GraphQL Schema Design +- Use TypeGraphQL decorators to define schema types +- Follow GraphQL naming conventions +- Implement proper input validation using Yup schemas +- Design clear, consistent, and well-documented APIs +- Use meaningful resolver and query/mutation names +- Structure resolvers by domain + +### Database Access +- Implement the repository pattern via DataSource interfaces +- Use Knex.js for database queries and transactions +- Minimize raw SQL queries in favor of query builders +- Write proper SQL queries with parameterized inputs +- Handle database errors and connection issues properly +- Implement proper transaction management for multi-step operations +- Use database migrations for schema changes in `/db_patches` directory + +### Authentication & Authorization +- Use JWT tokens for authentication +- Implement role-based access control +- Check permissions in resolvers or service methods +- Support multiple authentication methods (local, external) +- Never expose sensitive information in responses +- Unauthorized query requets should return null, not throw error + +### Dependency Injection +- Register all services and data sources in the DI container +- Use token-based dependency resolution +- Mock dependencies for unit testing +- Follow the inversion of control principle +- Use constructor injection when possible + +### Error Handling +- Create consistent error responses using GraphQL errors +- Use custom rejection types for domain-specific errors +- Log errors with proper context information +- Handle async errors with try/catch or await-to-js +- Return meaningful error messages to clients +- Handle errors in mutation layer by logging and returning to client + +### Testing +- Mock external dependencies using mockups +- Use Jest as the testing framework +- Follow AAA (Arrange, Act, Assert) pattern in tests +- Test both happy paths and edge cases/error conditions +- Use dependency injection to facilitate testing +- Use mock datasources for database interactions +- NEVER change the original code to make it easier to test, instead, write tests that cover the original code as it is + + +### Logging +- Use the logger from @user-office-software/duo-logger +- Do not use variables in log message strings, put variables in context +- Include relevant context in log messages +- Log different levels appropriately (info, warning, error) +- Avoid logging sensitive information +- Structure log messages for easy parsing + +### Data Validation +- Use Yup schemas for input validation +- Validate inputs early in the request lifecycle +- Return clear validation error messages +- Use TypeScript to enforce types at compile time + +### Security +- Sanitize all user inputs to prevent injection attacks +- Use parameterized queries to prevent SQL injection +- Implement proper authentication and authorization +- Follow the principle of least privilege + +## Implementation Process +1. Define the domain model and interfaces +2. Create database migrations for schema changes +3. Implement data sources for database access +4. Define GraphQL types and resolvers +5. Implement business logic in resolvers or services +6. Add validation and error handling +7. Write unit tests +8. Register in the dependency injection container + +## Additional Guidelines +- Document complex functions and methods with JSDoc +- Use ESLint and Prettier for consistent code formatting +- Register new dependencies in the Tokens file and container + +## Common Patterns +- Repository Pattern for data access +- Dependency Injection for service resolution +- Factory Pattern for creating objects +- Adapter Pattern for external integrations +- Observer Pattern for event handling diff --git a/.github/instructions/frontend.instructions.md b/.github/instructions/frontend.instructions.md new file mode 100644 index 0000000000..dec3f69b4b --- /dev/null +++ b/.github/instructions/frontend.instructions.md @@ -0,0 +1,134 @@ +--- +description: 'ReactJS development standards and best practices' +applyTo: 'apps/frontend/**/*.tsx, apps/frontend/**/*.ts' +--- + +# ReactJS Development Instructions + +Instructions for building high-quality ReactJS applications with modern patterns, hooks, and best practices following the official React documentation at https://react.dev. + +## Project Context +- TypeScript for type safety (when applicable) +- Follow React's official style guide and best practices +- Implement proper component composition and reusability patterns + +## Development Standards +- Use useDataApi() or useDataApiWithFeedback() to invoke GraphQL queries and mutations + +### Architecture +- Use functional components with hooks as the primary pattern +- Implement component composition over inheritance +- Separate presentational and container components clearly +- Use custom hooks for reusable stateful logic +- Implement proper component hierarchies with clear data flow + +### TypeScript Integration +- Use TypeScript interfaces for props, state, and component definitions +- Define proper types for event handlers and refs +- Implement generic components where appropriate +- Use strict mode in `tsconfig.json` for type safety +- Leverage React's built-in types (`React.FC`, `React.ComponentProps`, etc.) + +### Component Design +- Follow the single responsibility principle for components +- Use descriptive and consistent naming conventions +- Implement proper prop validation with TypeScript +- Design components to be reusable +- Keep components small and focused on a single concern +- Use composition patterns (render props, children as functions) + +### State Management +- Use `useState` for local component state +- Implement proper state normalization and data structures +- Use `immer` where applicable for immutable state updates + +### Hooks and Effects +- Use `useEffect` with proper dependency arrays to avoid infinite loops +- Implement cleanup functions in effects to prevent memory leaks +- Use `useMemo` and `useCallback` for performance optimization when needed +- Create custom hooks for reusable stateful logic +- Follow the rules of hooks (only call at the top level) +- Use `useRef` for accessing DOM elements and storing mutable values + +### Styling +- Use styled-components for component-level styling +- Implement responsive design principles +- Use theme providers for consistent theming across the application +- Implement consistent spacing, typography, and color systems +- Ensure accessibility with proper ARIA attributes and semantic HTML + +### Performance Optimization +- Use `React.memo` for component memoization when appropriate +- Implement code splitting with `React.lazy` and `Suspense` +- Optimize bundle size with tree shaking and dynamic imports +- Use `useMemo` and `useCallback` judiciously to prevent unnecessary re-renders + +### Error Handling +- Implement Error Boundaries for component-level error handling +- Use proper error states in data fetching +- Implement fallback UI for error scenarios +- Log errors with getUnauthorizedApi().addClientLog method +- Handle async errors in effects and event handlers +- Provide meaningful error messages to users + +### Forms and Validation +- Use controlled components for form inputs +- Implement proper form validation with Formik +- Handle form submission and error states appropriately +- Implement accessibility features for forms (labels, ARIA attributes) +- Use debounced validation for better user experience +- Handle file uploads and complex form scenarios + +### Routing +- Use React Router for client-side routing +- Implement nested routes and route protection +- Handle route parameters and query strings properly +- Implement lazy loading for route-based code splitting + +### Testing +- Write end-to-end tests with Cypress +- Use support functions for Cypress tests + +### Security +- Sanitize user inputs to prevent XSS attacks +- Validate and escape data before rendering +- Use HTTPS for all external API calls +- Implement proper authentication and authorization patterns +- Avoid storing sensitive data in localStorage or sessionStorage +- Use Content Security Policy (CSP) headers + +### Accessibility +- Use semantic HTML elements appropriately +- Implement proper ARIA attributes and roles +- Ensure keyboard navigation works for all interactive elements +- Provide alt text for images and descriptive text for icons +- Implement proper color contrast ratios +- Test with screen readers and accessibility tools + +## Implementation Process +1. Plan component architecture and data flow +2. Set up project structure with proper folder organization +3. Define TypeScript interfaces and types +4. Implement core components with proper styling +5. Add state management and data fetching logic +6. Implement routing and navigation +7. Add form handling and validation +8. Implement error handling and loading states +9. Add testing coverage for components and functionality +10. Optimize performance and bundle size +11. Ensure accessibility compliance +12. Add documentation and code comments + +## Additional Guidelines +- Follow React's naming conventions (PascalCase for components, camelCase for functions) +- Implement proper code splitting and lazy loading strategies +- Document complex components and custom hooks with JSDoc +- Use ESLint and Prettier for consistent code formatting + +## Common Patterns +- Higher-Order Components (HOCs) for cross-cutting concerns +- Render props pattern for component composition +- Compound components for related functionality +- Provider pattern for context-based state sharing +- Container/Presentational component separation +- Custom hooks for reusable logic extraction From 9203cd3f74b2453621667a0adaa9481de8895954 Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Fri, 5 Sep 2025 13:20:49 +0200 Subject: [PATCH 02/17] feat: replace InviteUser with ParticipantSelector for improved participant management --- .../src/components/common/UserManagementTable.tsx | 4 ++-- .../proposal/{InviteUser.tsx => ParticipantSelector.tsx} | 9 ++++----- 2 files changed, 6 insertions(+), 7 deletions(-) rename apps/frontend/src/components/proposal/{InviteUser.tsx => ParticipantSelector.tsx} (97%) diff --git a/apps/frontend/src/components/common/UserManagementTable.tsx b/apps/frontend/src/components/common/UserManagementTable.tsx index d8ce02baff..33e2f68de2 100644 --- a/apps/frontend/src/components/common/UserManagementTable.tsx +++ b/apps/frontend/src/components/common/UserManagementTable.tsx @@ -14,8 +14,8 @@ import PeopleTable from 'components/user/PeopleTable'; import { FeatureContext } from 'context/FeatureContextProvider'; import { BasicUserDetails, FeatureId, Invite, UserRole } from 'generated/sdk'; -import InviteUser from '../proposal/InviteUser'; import ParticipantModal from '../proposal/ParticipantModal'; +import ParticipantSelector from '../proposal/ParticipantSelector'; export type UserManagementTableProps = { /** Basic user details array to be shown in the table. */ @@ -85,7 +85,7 @@ const UserManagementTable = ({ }; const InviteComponent = ( - setOpen(false)} onAddParticipants={handleAddParticipants} diff --git a/apps/frontend/src/components/proposal/InviteUser.tsx b/apps/frontend/src/components/proposal/ParticipantSelector.tsx similarity index 97% rename from apps/frontend/src/components/proposal/InviteUser.tsx rename to apps/frontend/src/components/proposal/ParticipantSelector.tsx index 4a99193bd3..d0a83816db 100644 --- a/apps/frontend/src/components/proposal/InviteUser.tsx +++ b/apps/frontend/src/components/proposal/ParticipantSelector.tsx @@ -21,7 +21,7 @@ import NoOptionsText from './NoOptionsText'; type UserOrEmail = BasicUserDetails | ValidEmailAddress; -interface InviteUserProps { +interface ParticipantSelectorProps { modalOpen: boolean; onClose?: () => void; onAddParticipants?: (data: { @@ -43,14 +43,14 @@ const categorizeSelectedItems = (items: UserOrEmail[]) => ({ const MIN_SEARCH_LENGTH = 3; -function InviteUser({ +function ParticipantSelector({ modalOpen, onClose, onAddParticipants, excludeUserIds, excludeEmails, confirm, -}: InviteUserProps & WithConfirmProps) { +}: ParticipantSelectorProps & WithConfirmProps) { const api = useDataApi(); const [query, setQuery] = useState(''); const [loading, setLoading] = useState(false); @@ -74,7 +74,6 @@ function InviteUser({ subtractUsers: excludedUserIds, }); - console.log(previousCollaborators); setOptions(previousCollaborators?.users || []); return; @@ -294,4 +293,4 @@ function InviteUser({ ); } -export default withConfirm(InviteUser); +export default withConfirm(ParticipantSelector); From e3b5f343416b47a4be2553c77ffee4029a7e1035 Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Fri, 5 Sep 2025 13:21:07 +0200 Subject: [PATCH 03/17] feat: add tooltip to add participant button for better user guidance --- .../components/common/UserManagementTable.tsx | 26 +++++++++++-------- .../proposal/ProposalParticipants.tsx | 1 + 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/apps/frontend/src/components/common/UserManagementTable.tsx b/apps/frontend/src/components/common/UserManagementTable.tsx index 33e2f68de2..cc54082398 100644 --- a/apps/frontend/src/components/common/UserManagementTable.tsx +++ b/apps/frontend/src/components/common/UserManagementTable.tsx @@ -1,7 +1,7 @@ import { ScheduleSend } from '@mui/icons-material'; import PersonAddIcon from '@mui/icons-material/PersonAdd'; import SendIcon from '@mui/icons-material/Send'; -import { Chip } from '@mui/material'; +import { Chip, Tooltip } from '@mui/material'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import FormControl from '@mui/material/FormControl'; @@ -28,6 +28,7 @@ export type UserManagementTableProps = { title: string; preserveSelf?: boolean; addButtonLabel?: string; + addButtonTooltip?: string; /** Disable the add button */ disabled?: boolean; /** Custom actions to be passed to PeopleTable */ @@ -45,6 +46,7 @@ const UserManagementTable = ({ title, preserveSelf, addButtonLabel = 'Add', + addButtonTooltip = 'Add a participant', disabled = false, onUserAction, excludeUserIds = [], @@ -189,16 +191,18 @@ const UserManagementTable = ({ marginTop: theme.spacing(1), })} > - + + + diff --git a/apps/frontend/src/components/proposal/ProposalParticipants.tsx b/apps/frontend/src/components/proposal/ProposalParticipants.tsx index ea2e6657c7..c53e65671c 100644 --- a/apps/frontend/src/components/proposal/ProposalParticipants.tsx +++ b/apps/frontend/src/components/proposal/ProposalParticipants.tsx @@ -30,6 +30,7 @@ const ProposalParticipants = ({ return ( Date: Fri, 5 Sep 2025 13:46:21 +0200 Subject: [PATCH 04/17] feat: update ParticipantSelector title for clarity in user management --- .../components/common/UserManagementTable.tsx | 1 + .../proposal/ParticipantSelector.tsx | 4 +++- .../proposal/ProposalParticipant.tsx | 23 ++++++++----------- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/apps/frontend/src/components/common/UserManagementTable.tsx b/apps/frontend/src/components/common/UserManagementTable.tsx index cc54082398..51b107e027 100644 --- a/apps/frontend/src/components/common/UserManagementTable.tsx +++ b/apps/frontend/src/components/common/UserManagementTable.tsx @@ -89,6 +89,7 @@ const UserManagementTable = ({ const InviteComponent = ( setOpen(false)} onAddParticipants={handleAddParticipants} excludeUserIds={[...users.map((user) => user.id), ...excludeUserIds]} diff --git a/apps/frontend/src/components/proposal/ParticipantSelector.tsx b/apps/frontend/src/components/proposal/ParticipantSelector.tsx index d0a83816db..54c2fea371 100644 --- a/apps/frontend/src/components/proposal/ParticipantSelector.tsx +++ b/apps/frontend/src/components/proposal/ParticipantSelector.tsx @@ -23,6 +23,7 @@ type UserOrEmail = BasicUserDetails | ValidEmailAddress; interface ParticipantSelectorProps { modalOpen: boolean; + title?: string; onClose?: () => void; onAddParticipants?: (data: { users: BasicUserDetails[]; @@ -45,6 +46,7 @@ const MIN_SEARCH_LENGTH = 3; function ParticipantSelector({ modalOpen, + title, onClose, onAddParticipants, excludeUserIds, @@ -220,7 +222,7 @@ function ParticipantSelector({ fullWidth maxWidth="md" onClose={handleClose} - title="Add people to proposal" + title={title || 'Add Participant(s)'} > - { + onClose={() => setIsPickerOpen(false)} + onAddParticipants={(participants) => { + props.setPrincipalInvestigator(participants.users[0]); setIsPickerOpen(false); }} - selectedUsers={ - !!props.principalInvestigator ? [props.principalInvestigator?.id] : [] - } - addParticipants={(users: BasicUserDetails[]) => { - props.setPrincipalInvestigator(users[0]); - setIsPickerOpen(false); - }} - participant={true} + // TODO exclude co-proposers /> + Date: Mon, 8 Sep 2025 16:13:35 +0200 Subject: [PATCH 05/17] feat: implement CoProposers and PrincipalInvestigator components for enhanced user management --- .../src/components/proposal/CoProposers.tsx | 52 ++++++++++++++ .../src/components/proposal/NoOptionsText.tsx | 8 ++- .../proposal/ParticipantSelector.tsx | 72 +++++++++++++++---- ...ticipant.tsx => PrincipalInvestigator.tsx} | 61 ++++++++++------ .../proposal/ProposalParticipants.tsx | 41 ----------- .../QuestionaryComponentProposalBasis.tsx | 47 ++++-------- 6 files changed, 171 insertions(+), 110 deletions(-) create mode 100644 apps/frontend/src/components/proposal/CoProposers.tsx rename apps/frontend/src/components/proposal/{ProposalParticipant.tsx => PrincipalInvestigator.tsx} (52%) diff --git a/apps/frontend/src/components/proposal/CoProposers.tsx b/apps/frontend/src/components/proposal/CoProposers.tsx new file mode 100644 index 0000000000..bcf3b0a46e --- /dev/null +++ b/apps/frontend/src/components/proposal/CoProposers.tsx @@ -0,0 +1,52 @@ +import React, { useContext } from 'react'; + +import UserManagementTable, { + UserManagementTableProps, +} from 'components/common/UserManagementTable'; +import { + createMissingContextErrorMessage, + QuestionaryContext, +} from 'components/questionary/QuestionaryContext'; +import { BasicUserDetails } from 'generated/sdk'; + +import { ProposalContextType } from './ProposalContainer'; + +type CoProposersProps = Pick< + UserManagementTableProps, + 'setInvites' | 'setUsers' | 'disabled' +> & { + setPrincipalInvestigator?: (user: BasicUserDetails) => void; +}; + +const CoProposers = ({ + setPrincipalInvestigator, + ...props +}: CoProposersProps) => { + const handleUserAction = (action: string, user: BasicUserDetails) => { + if (action === 'setPrincipalInvestigator' && setPrincipalInvestigator) { + setPrincipalInvestigator(user); + } + }; + + const { state } = useContext(QuestionaryContext) as ProposalContextType; + if (!state) { + throw new Error(createMissingContextErrorMessage()); + } + const { proposer, users, coProposerInvites } = state.proposal; + + return ( + + ); +}; + +export default CoProposers; diff --git a/apps/frontend/src/components/proposal/NoOptionsText.tsx b/apps/frontend/src/components/proposal/NoOptionsText.tsx index 1b314303db..e7fdea3799 100644 --- a/apps/frontend/src/components/proposal/NoOptionsText.tsx +++ b/apps/frontend/src/components/proposal/NoOptionsText.tsx @@ -13,6 +13,7 @@ interface NoOptionsTextProps { exactEmailMatch?: BasicUserDetails; excludeEmails?: string[]; minSearchLength?: number; + enableEmailInvites?: boolean; } function NoOptionsText({ @@ -22,12 +23,17 @@ function NoOptionsText({ exactEmailMatch, excludeEmails = [], minSearchLength = 3, + enableEmailInvites, }: NoOptionsTextProps) { const featureContext = useContext(FeatureContext); - const isEmailInviteEnabled = !!featureContext.featuresMap.get( + const isEmailInviteFeatureEnabled = !!featureContext.featuresMap.get( FeatureId.EMAIL_INVITE )?.isEnabled; + // email invite will be enabled if both the prop and the feature flag are true + const isEmailInviteEnabled = + enableEmailInvites && isEmailInviteFeatureEnabled; + if (exactEmailMatch) { return ( onAddUser(exactEmailMatch)}> diff --git a/apps/frontend/src/components/proposal/ParticipantSelector.tsx b/apps/frontend/src/components/proposal/ParticipantSelector.tsx index 54c2fea371..b107560e4a 100644 --- a/apps/frontend/src/components/proposal/ParticipantSelector.tsx +++ b/apps/frontend/src/components/proposal/ParticipantSelector.tsx @@ -21,6 +21,23 @@ import NoOptionsText from './NoOptionsText'; type UserOrEmail = BasicUserDetails | ValidEmailAddress; +const isSameParticipants = (a: UserOrEmail[], b: UserOrEmail[]): boolean => { + if (a.length !== b.length) { + return false; + } + + return a.every((value, index) => { + if (isValidEmail(value) && isValidEmail(b[index])) { + return value.toLowerCase() === b[index].toLowerCase(); + } + if (!isValidEmail(value) && !isValidEmail(b[index])) { + return value.id === (b[index] as BasicUserDetails).id; + } + + return false; + }); +}; + interface ParticipantSelectorProps { modalOpen: boolean; title?: string; @@ -31,6 +48,9 @@ interface ParticipantSelectorProps { }) => void; excludeUserIds?: number[]; excludeEmails?: string[]; + preset?: UserOrEmail[]; + singleSelection?: boolean; + enableEmailInvites?: boolean; } const categorizeSelectedItems = (items: UserOrEmail[]) => ({ @@ -52,6 +72,9 @@ function ParticipantSelector({ excludeUserIds, excludeEmails, confirm, + preset, + singleSelection, + enableEmailInvites, }: ParticipantSelectorProps & WithConfirmProps) { const api = useDataApi(); const [query, setQuery] = useState(''); @@ -60,7 +83,9 @@ function ParticipantSelector({ const [exactEmailMatch, setExactEmailMatch] = useState< BasicUserDetails | undefined >(); - const [selectedItems, setSelectedItems] = useState([]); + const [selectedItems, setSelectedItems] = useState( + preset || [] + ); const fetchUserSearchResults = useCallback(async () => { setExactEmailMatch(undefined); @@ -104,9 +129,7 @@ function ParticipantSelector({ .includes(basicUserDetailsByEmail.id); if (userAlreadyExists === false) { - setExactEmailMatch( - basicUserDetailsByEmail ? basicUserDetailsByEmail : undefined - ); + setExactEmailMatch(basicUserDetailsByEmail || undefined); } } else { const excludedUserIds = [ @@ -129,7 +152,7 @@ function ParticipantSelector({ } finally { setLoading(false); } - }, [api, query, excludeUserIds]); + }, [api, query, excludeUserIds, selectedItems]); // Debounce effect for search queries useEffect(() => { @@ -139,12 +162,16 @@ function ParticipantSelector({ }, [fetchUserSearchResults]); const addToSelectedItems = (user: UserOrEmail) => - setSelectedItems((prev) => [...prev, user]); + setSelectedItems((prev) => (singleSelection ? [user] : [...prev, user])); const addValidEmailToSelection = (email: string) => { if (isValidEmail(email)) { const lowerCaseEmail = email.toLowerCase(); - addToSelectedItems(lowerCaseEmail); + if (singleSelection) { + setSelectedItems([lowerCaseEmail]); + } else { + addToSelectedItems(lowerCaseEmail); + } setQuery(''); } }; @@ -172,7 +199,7 @@ function ParticipantSelector({ }; const handleClose = () => { - if (selectedItems.length > 0) { + if (isSameParticipants(selectedItems, preset || []) === false) { confirm( async () => { onClose?.(); @@ -182,7 +209,7 @@ function ParticipantSelector({ { title: 'Please confirm', description: - 'User(s) have not yet been added to the proposal. Are you sure you want to close the dialog?', + 'Are you sure you want to close the dialog? Your changes will be lost.', } )(); @@ -195,6 +222,11 @@ function ParticipantSelector({ }; const handleKeyDown = (event: React.KeyboardEvent) => { + if (selectedItems.length === 1 && singleSelection) { + event.preventDefault(); + + return; + } if (event.key === 'Enter' || event.key === ' ' || event.key === ',') { event.preventDefault(); if (exactEmailMatch) { @@ -203,7 +235,15 @@ function ParticipantSelector({ } else if (options.length === 1) { addToSelectedItems(options[0]); } else if (isValidEmail(query)) { - addValidEmailToSelection(query); + if (singleSelection) { + if (isValidEmail(query)) { + const lowerCaseEmail = query.toLowerCase(); + setSelectedItems([lowerCaseEmail]); + setQuery(''); + } + } else { + addValidEmailToSelection(query); + } } } }; @@ -222,7 +262,9 @@ function ParticipantSelector({ fullWidth maxWidth="md" onClose={handleClose} - title={title || 'Add Participant(s)'} + title={ + title || (singleSelection ? 'Select Participant' : 'Add Participant(s)') + } > } /> @@ -285,10 +328,13 @@ function ParticipantSelector({ onClick={handleSubmit} sx={{ margin: '16px 0 8px 0' }} startIcon={} - disabled={!selectedItems.length} + disabled={ + !selectedItems.length || + isSameParticipants(selectedItems, preset || []) + } data-cy="invite-user-submit-button" > - Add + {singleSelection ? 'Select' : 'Add'} diff --git a/apps/frontend/src/components/proposal/ProposalParticipant.tsx b/apps/frontend/src/components/proposal/PrincipalInvestigator.tsx similarity index 52% rename from apps/frontend/src/components/proposal/ProposalParticipant.tsx rename to apps/frontend/src/components/proposal/PrincipalInvestigator.tsx index 361e4a649d..5b7860f80c 100644 --- a/apps/frontend/src/components/proposal/ProposalParticipant.tsx +++ b/apps/frontend/src/components/proposal/PrincipalInvestigator.tsx @@ -2,37 +2,56 @@ import EditIcon from '@mui/icons-material/Edit'; import Box from '@mui/material/Box'; import FormControl from '@mui/material/FormControl'; import IconButton from '@mui/material/IconButton'; -import { SxProps, Theme } from '@mui/material/styles'; import TextField from '@mui/material/TextField'; import Tooltip from '@mui/material/Tooltip'; -import React, { useState } from 'react'; +import React, { useContext, useState } from 'react'; +import { + QuestionaryContext, + createMissingContextErrorMessage, +} from 'components/questionary/QuestionaryContext'; import { BasicUserDetails } from 'generated/sdk'; -import { BasicUserData } from 'hooks/user/useUserData'; import { getFullUserNameWithInstitution } from 'utils/user'; import ParticipantSelector from './ParticipantSelector'; +import { ProposalContextType } from './ProposalContainer'; -export default function ProposalParticipant(props: { - principalInvestigator: BasicUserData | null | undefined; +interface PrincipalInvestigatorProps { setPrincipalInvestigator: (user: BasicUserDetails) => void; - sx?: SxProps; - loadingPrincipalInvestigator?: boolean; -}) { + disabled?: boolean; +} + +export default function PrincipalInvestigator( + props: PrincipalInvestigatorProps +) { const [isPickerOpen, setIsPickerOpen] = useState(false); + const { state } = useContext(QuestionaryContext) as ProposalContextType; + if (!state) { + throw new Error(createMissingContextErrorMessage()); + } + const { proposer, users } = state.proposal; + return ( - - setIsPickerOpen(false)} - onAddParticipants={(participants) => { - props.setPrincipalInvestigator(participants.users[0]); - setIsPickerOpen(false); - }} - // TODO exclude co-proposers - /> + + {isPickerOpen && ( + setIsPickerOpen(false)} + onAddParticipants={(participants) => { + props.setPrincipalInvestigator(participants.users[0]); + setIsPickerOpen(false); + }} + excludeUserIds={[ + users.map((u) => u.id), + proposer ? [proposer.id] : [], + ].flat()} + preset={proposer ? [proposer] : []} + singleSelection={true} + enableEmailInvites={false} + /> + )} diff --git a/apps/frontend/src/components/proposal/ProposalParticipants.tsx b/apps/frontend/src/components/proposal/ProposalParticipants.tsx index c53e65671c..e69de29bb2 100644 --- a/apps/frontend/src/components/proposal/ProposalParticipants.tsx +++ b/apps/frontend/src/components/proposal/ProposalParticipants.tsx @@ -1,41 +0,0 @@ -import React from 'react'; - -import UserManagementTable, { - UserManagementTableProps, -} from 'components/common/UserManagementTable'; -import { BasicUserDetails } from 'generated/sdk'; -import { BasicUserData } from 'hooks/user/useUserData'; - -type ProposalParticipantsProps = Omit< - UserManagementTableProps, - 'onUserAction' | 'excludeUserIds' | 'disabled' -> & { - principalInvestigator?: BasicUserData | null; - setPrincipalInvestigator?: (user: BasicUserDetails) => void; - loadingPrincipalInvestigator?: boolean; -}; - -const ProposalParticipants = ({ - principalInvestigator, - setPrincipalInvestigator, - loadingPrincipalInvestigator, - ...props -}: ProposalParticipantsProps) => { - const handleUserAction = (action: string, user: BasicUserDetails) => { - if (action === 'setPrincipalInvestigator' && setPrincipalInvestigator) { - setPrincipalInvestigator(user); - } - }; - - return ( - - ); -}; - -export default ProposalParticipants; diff --git a/apps/frontend/src/components/questionary/questionaryComponents/ProposalBasis/QuestionaryComponentProposalBasis.tsx b/apps/frontend/src/components/questionary/questionaryComponents/ProposalBasis/QuestionaryComponentProposalBasis.tsx index d45ad2cc98..2c79375630 100644 --- a/apps/frontend/src/components/questionary/questionaryComponents/ProposalBasis/QuestionaryComponentProposalBasis.tsx +++ b/apps/frontend/src/components/questionary/questionaryComponents/ProposalBasis/QuestionaryComponentProposalBasis.tsx @@ -2,22 +2,21 @@ import Box from '@mui/material/Box'; import InputAdornment from '@mui/material/InputAdornment'; import { useTheme } from '@mui/material/styles'; import { Field } from 'formik'; -import React, { ChangeEvent, useContext, useEffect, useState } from 'react'; +import React, { ChangeEvent, useContext, useState } from 'react'; import ErrorMessage from 'components/common/ErrorMessage'; import TextField from 'components/common/FormikUITextField'; import withPreventSubmit from 'components/common/withPreventSubmit'; +import CoProposers from 'components/proposal/CoProposers'; import { BasicComponentProps } from 'components/proposal/IBasicComponentProps'; +import PrincipalInvestigator from 'components/proposal/PrincipalInvestigator'; import { ProposalContextType } from 'components/proposal/ProposalContainer'; -import ProposalParticipant from 'components/proposal/ProposalParticipant'; -import Participants from 'components/proposal/ProposalParticipants'; import { createMissingContextErrorMessage, QuestionaryContext, } from 'components/questionary/QuestionaryContext'; import { BasicUserDetails, Invite } from 'generated/sdk'; import { SubmitActionDependencyContainer } from 'hooks/questionary/useSubmitActions'; -import { useBasicUserData } from 'hooks/user/useUserData'; import { ProposalSubmissionState } from 'models/questionary/proposal/ProposalSubmissionState'; const TextFieldNoSubmit = withPreventSubmit(TextField); @@ -38,24 +37,15 @@ function QuestionaryComponentProposalBasis(props: BasicComponentProps) { const [localTitle, setLocalTitle] = useState(state?.proposal.title); const [localAbstract, setLocalAbstract] = useState(state?.proposal.abstract); const [hasInvalidChars, setHasInvalidChars] = useState(false); - const [textLen, setTextLen] = useState( - state?.proposal.abstract ? (state?.proposal.abstract as string).length : 0 - ); + const [textLen, setTextLen] = useState(state?.proposal.abstract ?? 0); + if (!state || !dispatch) { throw new Error(createMissingContextErrorMessage()); } - const { proposer, users, coProposerInvites } = state.proposal; - const { loading, userData } = useBasicUserData(state?.proposal.proposer?.id); - const [piData, setPIData] = useState(null); + const { proposer, users } = state.proposal; - useEffect(() => { - if (userData !== null) { - setPIData(userData); - } - }, [userData]); - - const coInvestigatorChanged = (users: BasicUserDetails[]) => { + const coProposersChanged = (users: BasicUserDetails[]) => { formikProps.setFieldValue( `${id}.users`, users.map((user) => user.id) @@ -82,8 +72,9 @@ function QuestionaryComponentProposalBasis(props: BasicComponentProps) { proposer: user, }, }); - setPIData(user); - coInvestigatorChanged( + + // Remove the new PI from the co-proposers list (if present) and add the old PI (if present) to the co-proposers list + coProposersChanged( users .filter((coInvestigator) => coInvestigator.id !== user.id) .concat(proposer as BasicUserDetails) @@ -166,25 +157,13 @@ function QuestionaryComponentProposalBasis(props: BasicComponentProps) { > {counter} - - From a2ade171a9c563ee2866c8cca4bedbffb0fee1d1 Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Mon, 8 Sep 2025 17:05:39 +0200 Subject: [PATCH 06/17] fix: remove testing files --- .github/copilot-instructions.md | 192 ------------------ .github/instructions/backend.instructions.md | 140 ------------- .github/instructions/frontend.instructions.md | 134 ------------ 3 files changed, 466 deletions(-) delete mode 100644 .github/copilot-instructions.md delete mode 100644 .github/instructions/backend.instructions.md delete mode 100644 .github/instructions/frontend.instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 82a093286d..0000000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,192 +0,0 @@ -# User Office Core - AI Coding Agent Instructions - -## Project Overview - -User Office Core is a full-stack application for managing user office workflows for scientific facilities. It consists of: - -- **Backend**: Express/Node.js GraphQL API server with PostgreSQL database -- **Frontend**: React-based UI using Material UI -- **E2E**: End-to-end tests using Cypress - -The system handles proposal management, user administration, and scientific facility workflows. - -## Architecture Map - -### Backend Component Structure (`/apps/backend`) -- **models/**: Domain interfaces and types (read these first to understand data structures) -- **resolvers/**: GraphQL resolvers (queries + mutations) grouped by domain -- **datasources/**: Repository interfaces defining data access methods - - **datasources/postgres/**: PostgreSQL implementations of interfaces - - **datasources/mockups/**: Mock implementations for testing -- **auth/**: Authentication and authorization logic -- **middlewares/**: Express middleware and GraphQL configuration -- **utils/**: Helper functions and utilities -- **events/**: Event-driven messaging system -- **mutations/**: Business logic for mutating data -- **queries/**: Business logic for querying data -- **config/**: Application configuration including DI setup - -### Technology Stack -- **GraphQL API**: Apollo Server with TypeGraphQL decorators -- **Database**: PostgreSQL with Knex.js for queries -- **Dependency Injection**: tsyringe container with Symbol tokens -- **Validation**: Yup schemas via `@user-office-software/duo-validation` -- **Authentication**: JWT tokens with role-based access control -- **Messaging**: RabbitMQ via `@user-office-software/duo-message-broker` - -### Frontend Structure (`/apps/frontend`) -- **components/**: Reusable UI components (mostly Material UI) -- **context/**: React context providers for global state -- **generated/**: Generated GraphQL types and API client -- **hooks/**: Custom React hooks, especially `useDataApi` for GraphQL -- **models/**: TypeScript interfaces for frontend data models -- **utils/**: Helper functions and utilities - -## Code Patterns To Follow - -### Backend Patterns -1. **DataSource Pattern**: Always implement and use these interfaces - ```typescript - // 1. Define interface in a file like src/datasources/EntityDataSource.ts - export interface EntityDataSource { - getById(id: number): Promise; - create(input: CreateEntityInput): Promise; - // etc. - } - - // 2. Implement in src/datasources/postgres/EntityDataSource.ts - @injectable() - export default class PostgresEntityDataSource implements EntityDataSource { - // implementation here - } - - // 3. Register in dependency container in src/config/dependencyConfigDefault.ts - mapClass(Tokens.EntityDataSource, PostgresEntityDataSource); - ``` - -2. **GraphQL Resolvers**: Use TypeGraphQL decorators - ```typescript - @Resolver() - export class EntityQuery { - @Query(() => Entity, { nullable: true }) - async entity( - @Ctx() context: ResolverContext, - @Arg('id', () => Int) id: number - ) { - return context.queries.entity.get(context.user, id); - } - } - ``` - -3. **Mutations**: Use decorators for authorization and validation - ```typescript - @EventBus(Event.ENTITY_CREATED) - @ValidateArgs(createEntityValidationSchema) - @Authorized([Roles.USER_OFFICER]) - async create( - agent: UserWithRole | null, - args: CreateEntityArgs - ): Promise { - // implementation using this.dataSource - } - ``` - -4. **Error Handling**: Use rejection pattern with try/catch and await - ```typescript - try { - const result = await this.dataSource.method(); - return result; - } catch (error) { - return rejection( - 'Error message here', - { agent, args }, - error - ); - } - ``` - -### Frontend Patterns -1. **Data Fetching**: Use `useDataApi` or `useDataApiWithFeedback` hooks - ```typescript - const { api } = useDataApi(); - // or with loading/error handling - const { api, isExecuting, error } = useDataApiWithFeedback(); - - // Then call the API - const result = await api.createEntity(variables); - ``` - -2. **Form Handling**: Use Formik with Yup validation - ```typescript - { - await api.createEntity({ ...values }); - }} - > - {/* Form fields */} - - ``` - -## Database Structure - -Key tables and relationships: -- **users**: User accounts with roles (linked via role_user) -- **proposals**: Scientific proposals (linked to users via proposer_id) -- **call**: Proposal submission cycles (linked to proposals) -- **proposal_questions**: Configurable questions (linked to topics) -- **proposal_answers**: User responses (linked to proposals and questions) -- **reviews**: Proposal reviews (linked to proposals and users) - -## Critical Workflows - -### Creating a New Entity Type (Backend) -1. Define model interface in `src/models/Entity.ts` -2. Create database migration in `db_patches/` using `register_patch` -3. Define DataSource interface in `src/datasources/EntityDataSource.ts` -4. Implement PostgreSQL version in `src/datasources/postgres/EntityDataSource.ts` -5. Add to Tokens in `src/config/Tokens.ts` and register in dependency config -6. Create GraphQL type in `src/resolvers/types/Entity.ts` -7. Implement queries in `src/resolvers/queries/EntityQuery.ts` -8. Implement mutations in `src/resolvers/mutations/EntityMutation.ts` -9. Add business logic in `src/queries/EntityQueries.ts` and `src/mutations/EntityMutations.ts` -10. Add mock implementation in `src/datasources/mockups/EntityDataSource.ts` -11. Write tests for mutations and queries - -### Creating a New Frontend Feature -1. Generate GraphQL SDK after backend changes using `npm run generate:shared:types` -2. Create components in `apps/frontend/src/components/` -3. Use `useDataApi` hook to communicate with the backend -4. Add routing in the appropriate router configuration -5. Use Material UI components and follow existing styling patterns - -## Authorization Pattern -```typescript -// Mutations and queries always check authorization with decorators -@Authorized([Roles.USER_OFFICER, Roles.INSTRUMENT_SCIENTIST]) -async methodName(agent: UserWithRole | null, args: Args): Promise { - // Implementation -} - -// DataSources should NOT contain authorization logic -// Authorization is handled at the mutation/query layer -``` - -## Testing Pattern -```typescript -// Test file structure -describe('Entity Mutations', () => { - test('User with proper role can create entity', () => { - return expect( - entityMutations.create(userWithRole, validInput) - ).resolves.toMatchObject(expectedResult); - }); - - test('User without permission cannot create entity', () => { - return expect( - entityMutations.create(userWithoutRole, validInput) - ).resolves.toHaveProperty('reason', 'INSUFFICIENT_PERMISSIONS'); - }); -}); -``` diff --git a/.github/instructions/backend.instructions.md b/.github/instructions/backend.instructions.md deleted file mode 100644 index 25e0c4f1c6..0000000000 --- a/.github/instructions/backend.instructions.md +++ /dev/null @@ -1,140 +0,0 @@ ---- -description: 'Node.js/Express/GraphQL backend development standards and best practices' -applyTo: 'apps/backend/**/*.ts' ---- - -# Backend Development Instructions - -Instructions for building high-quality backend services with Node.js, Express, GraphQL, and TypeScript following modern patterns, best practices, and dependency injection. - -## Coding standards -- Use JavaScript with ES2022 features and Node.js (20+) ESM modules] -- Use Node.js built-in modules and avoid external dependencies where possible -- Ask the user if you require any additional dependencies before adding them -- Always use async/await for asynchronous code, and use 'node:util' promisify function to avoid callbacks -- Keep the code simple and maintainable -- Use descriptive variable and function names -- Do not add comments unless absolutely necessary, the code should be self-explanatory - -## Documentation -- When adding new features or making significant changes, update the README.md file where necessary - -## User interactions -- Ask questions if you are unsure about the implementation details, design choices, or need clarification on the requirements -- Suggest improvements or alternatives if you believe there is a better way to implement a feature or solve a problem - -## Project Context -- TypeScript for type safety and maintainable code -- GraphQL API with Apollo Server and TypeGraphQL -- PostgreSQL database with Knex.js for queries and migrations -- Dependency Injection using tsyringe container -- JWT-based authentication with role-based access control - -## Development Standards -- Follow the dependency injection pattern using tsyringe -- Use interfaces for all data sources and implementations -- Implement postgres and mock data sources for database interface - -### Architecture -- Implement GraphQL resolvers using TypeGraphQL decorators -- Try to keep business logic separate into mutations and queries layers -- Use express middleware for cross-cutting concerns -- Structure the codebase by domain and responsibility -- Follow clean architecture principles with clear separation of concerns - -### TypeScript Integration -- Use TypeScript interfaces for models, inputs, and return types -- Use strict mode in `tsconfig.json` for type safety -- Avoid any type when possible to ensure type safety - -### GraphQL Schema Design -- Use TypeGraphQL decorators to define schema types -- Follow GraphQL naming conventions -- Implement proper input validation using Yup schemas -- Design clear, consistent, and well-documented APIs -- Use meaningful resolver and query/mutation names -- Structure resolvers by domain - -### Database Access -- Implement the repository pattern via DataSource interfaces -- Use Knex.js for database queries and transactions -- Minimize raw SQL queries in favor of query builders -- Write proper SQL queries with parameterized inputs -- Handle database errors and connection issues properly -- Implement proper transaction management for multi-step operations -- Use database migrations for schema changes in `/db_patches` directory - -### Authentication & Authorization -- Use JWT tokens for authentication -- Implement role-based access control -- Check permissions in resolvers or service methods -- Support multiple authentication methods (local, external) -- Never expose sensitive information in responses -- Unauthorized query requets should return null, not throw error - -### Dependency Injection -- Register all services and data sources in the DI container -- Use token-based dependency resolution -- Mock dependencies for unit testing -- Follow the inversion of control principle -- Use constructor injection when possible - -### Error Handling -- Create consistent error responses using GraphQL errors -- Use custom rejection types for domain-specific errors -- Log errors with proper context information -- Handle async errors with try/catch or await-to-js -- Return meaningful error messages to clients -- Handle errors in mutation layer by logging and returning to client - -### Testing -- Mock external dependencies using mockups -- Use Jest as the testing framework -- Follow AAA (Arrange, Act, Assert) pattern in tests -- Test both happy paths and edge cases/error conditions -- Use dependency injection to facilitate testing -- Use mock datasources for database interactions -- NEVER change the original code to make it easier to test, instead, write tests that cover the original code as it is - - -### Logging -- Use the logger from @user-office-software/duo-logger -- Do not use variables in log message strings, put variables in context -- Include relevant context in log messages -- Log different levels appropriately (info, warning, error) -- Avoid logging sensitive information -- Structure log messages for easy parsing - -### Data Validation -- Use Yup schemas for input validation -- Validate inputs early in the request lifecycle -- Return clear validation error messages -- Use TypeScript to enforce types at compile time - -### Security -- Sanitize all user inputs to prevent injection attacks -- Use parameterized queries to prevent SQL injection -- Implement proper authentication and authorization -- Follow the principle of least privilege - -## Implementation Process -1. Define the domain model and interfaces -2. Create database migrations for schema changes -3. Implement data sources for database access -4. Define GraphQL types and resolvers -5. Implement business logic in resolvers or services -6. Add validation and error handling -7. Write unit tests -8. Register in the dependency injection container - -## Additional Guidelines -- Document complex functions and methods with JSDoc -- Use ESLint and Prettier for consistent code formatting -- Register new dependencies in the Tokens file and container - -## Common Patterns -- Repository Pattern for data access -- Dependency Injection for service resolution -- Factory Pattern for creating objects -- Adapter Pattern for external integrations -- Observer Pattern for event handling diff --git a/.github/instructions/frontend.instructions.md b/.github/instructions/frontend.instructions.md deleted file mode 100644 index dec3f69b4b..0000000000 --- a/.github/instructions/frontend.instructions.md +++ /dev/null @@ -1,134 +0,0 @@ ---- -description: 'ReactJS development standards and best practices' -applyTo: 'apps/frontend/**/*.tsx, apps/frontend/**/*.ts' ---- - -# ReactJS Development Instructions - -Instructions for building high-quality ReactJS applications with modern patterns, hooks, and best practices following the official React documentation at https://react.dev. - -## Project Context -- TypeScript for type safety (when applicable) -- Follow React's official style guide and best practices -- Implement proper component composition and reusability patterns - -## Development Standards -- Use useDataApi() or useDataApiWithFeedback() to invoke GraphQL queries and mutations - -### Architecture -- Use functional components with hooks as the primary pattern -- Implement component composition over inheritance -- Separate presentational and container components clearly -- Use custom hooks for reusable stateful logic -- Implement proper component hierarchies with clear data flow - -### TypeScript Integration -- Use TypeScript interfaces for props, state, and component definitions -- Define proper types for event handlers and refs -- Implement generic components where appropriate -- Use strict mode in `tsconfig.json` for type safety -- Leverage React's built-in types (`React.FC`, `React.ComponentProps`, etc.) - -### Component Design -- Follow the single responsibility principle for components -- Use descriptive and consistent naming conventions -- Implement proper prop validation with TypeScript -- Design components to be reusable -- Keep components small and focused on a single concern -- Use composition patterns (render props, children as functions) - -### State Management -- Use `useState` for local component state -- Implement proper state normalization and data structures -- Use `immer` where applicable for immutable state updates - -### Hooks and Effects -- Use `useEffect` with proper dependency arrays to avoid infinite loops -- Implement cleanup functions in effects to prevent memory leaks -- Use `useMemo` and `useCallback` for performance optimization when needed -- Create custom hooks for reusable stateful logic -- Follow the rules of hooks (only call at the top level) -- Use `useRef` for accessing DOM elements and storing mutable values - -### Styling -- Use styled-components for component-level styling -- Implement responsive design principles -- Use theme providers for consistent theming across the application -- Implement consistent spacing, typography, and color systems -- Ensure accessibility with proper ARIA attributes and semantic HTML - -### Performance Optimization -- Use `React.memo` for component memoization when appropriate -- Implement code splitting with `React.lazy` and `Suspense` -- Optimize bundle size with tree shaking and dynamic imports -- Use `useMemo` and `useCallback` judiciously to prevent unnecessary re-renders - -### Error Handling -- Implement Error Boundaries for component-level error handling -- Use proper error states in data fetching -- Implement fallback UI for error scenarios -- Log errors with getUnauthorizedApi().addClientLog method -- Handle async errors in effects and event handlers -- Provide meaningful error messages to users - -### Forms and Validation -- Use controlled components for form inputs -- Implement proper form validation with Formik -- Handle form submission and error states appropriately -- Implement accessibility features for forms (labels, ARIA attributes) -- Use debounced validation for better user experience -- Handle file uploads and complex form scenarios - -### Routing -- Use React Router for client-side routing -- Implement nested routes and route protection -- Handle route parameters and query strings properly -- Implement lazy loading for route-based code splitting - -### Testing -- Write end-to-end tests with Cypress -- Use support functions for Cypress tests - -### Security -- Sanitize user inputs to prevent XSS attacks -- Validate and escape data before rendering -- Use HTTPS for all external API calls -- Implement proper authentication and authorization patterns -- Avoid storing sensitive data in localStorage or sessionStorage -- Use Content Security Policy (CSP) headers - -### Accessibility -- Use semantic HTML elements appropriately -- Implement proper ARIA attributes and roles -- Ensure keyboard navigation works for all interactive elements -- Provide alt text for images and descriptive text for icons -- Implement proper color contrast ratios -- Test with screen readers and accessibility tools - -## Implementation Process -1. Plan component architecture and data flow -2. Set up project structure with proper folder organization -3. Define TypeScript interfaces and types -4. Implement core components with proper styling -5. Add state management and data fetching logic -6. Implement routing and navigation -7. Add form handling and validation -8. Implement error handling and loading states -9. Add testing coverage for components and functionality -10. Optimize performance and bundle size -11. Ensure accessibility compliance -12. Add documentation and code comments - -## Additional Guidelines -- Follow React's naming conventions (PascalCase for components, camelCase for functions) -- Implement proper code splitting and lazy loading strategies -- Document complex components and custom hooks with JSDoc -- Use ESLint and Prettier for consistent code formatting - -## Common Patterns -- Higher-Order Components (HOCs) for cross-cutting concerns -- Render props pattern for component composition -- Compound components for related functionality -- Provider pattern for context-based state sharing -- Container/Presentational component separation -- Custom hooks for reusable logic extraction From 3748a110f9af476d6e430b10451848b2588435ee Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Tue, 9 Sep 2025 14:09:42 +0200 Subject: [PATCH 07/17] Merge develop into SWAP-4829 --- .../datasources/stfc/StfcUserDataSource.ts | 5 + apps/e2e/cypress/e2e/invites.cy.ts | 363 ++++++++++++++++-- .../src/components/common/StyledDialog.tsx | 2 + .../components/common/UserManagementTable.tsx | 17 +- .../src/components/proposal/CoProposers.tsx | 1 + .../src/components/proposal/NoOptionsText.tsx | 19 +- .../proposal/ParticipantSelector.tsx | 198 +++++++--- .../proposal/PrincipalInvestigator.tsx | 4 +- package-lock.json | 247 +++++++----- 9 files changed, 657 insertions(+), 199 deletions(-) diff --git a/apps/backend/src/datasources/stfc/StfcUserDataSource.ts b/apps/backend/src/datasources/stfc/StfcUserDataSource.ts index 6013d35b78..233c31fdb0 100644 --- a/apps/backend/src/datasources/stfc/StfcUserDataSource.ts +++ b/apps/backend/src/datasources/stfc/StfcUserDataSource.ts @@ -571,6 +571,11 @@ export class StfcUserDataSource implements UserDataSource { userDetails = stfcBasicPeopleByLastName.map((person) => toEssBasicUserDetails(person) ); + + if (subtractUsers && subtractUsers.length > 0) { + const usersToRemove = new Set(subtractUsers); + userDetails = userDetails.filter((user) => !usersToRemove.has(user.id)); + } } else { const { users } = await postgresUserDataSource.getUsers({ filter: undefined, diff --git a/apps/e2e/cypress/e2e/invites.cy.ts b/apps/e2e/cypress/e2e/invites.cy.ts index 986d8590c7..0fb0c3edf3 100644 --- a/apps/e2e/cypress/e2e/invites.cy.ts +++ b/apps/e2e/cypress/e2e/invites.cy.ts @@ -7,6 +7,10 @@ import { import featureFlags from '../support/featureFlags'; import initialDBData from '../support/initialDBData'; +/* + * Tests relating to the InviteUser component that is + * currently used when adding co-proposers. + */ context('Invites tests', () => { describe('Creating invites', () => { beforeEach(() => { @@ -16,6 +20,11 @@ context('Invites tests', () => { featureIds: [FeatureId.EMAIL_INVITE_LEGACY], }); cy.getAndStoreFeaturesEnabled(); + + cy.login('user1', initialDBData.roles.user); + cy.visit('/'); + cy.contains('New Proposal').click(); + cy.get('[data-cy=call-list]').find('li:first-child').click(); }); it('Should be able to delete invite', function () { @@ -25,14 +34,10 @@ context('Invites tests', () => { const email = 'john@example.com'; - cy.login('user1', initialDBData.roles.user); - cy.visit('/'); - cy.contains('New Proposal').click(); - cy.get('[data-cy=call-list]').find('li:first-child').click(); - cy.get('[data-cy="add-participant-button"]').click(); cy.get('[data-cy="invite-user-autocomplete"]').type(email); + cy.contains(`Invite ${email} via email`).should('exist'); cy.get('[data-cy="invite-user-autocomplete"]').type('{enter}'); cy.get('[data-cy="invite-user-submit-button"]').click(); @@ -51,14 +56,10 @@ context('Invites tests', () => { const email = 'john@example.com'; - cy.login('user1', initialDBData.roles.user); - cy.visit('/'); - cy.contains('New Proposal').click(); - cy.get('[data-cy=call-list]').find('li:first-child').click(); - cy.get('[data-cy="add-participant-button"]').click(); cy.get('[data-cy="invite-user-autocomplete"]').type(email); + cy.contains(`Invite ${email} via email`).should('exist'); cy.get('[data-cy="invite-user-autocomplete"]').type('{enter}'); cy.get('[data-cy="invite-user-submit-button"]').click(); @@ -78,14 +79,10 @@ context('Invites tests', () => { const email = 'john@example.com'; - cy.login('user1', initialDBData.roles.user); - cy.visit('/'); - cy.contains('New Proposal').click(); - cy.get('[data-cy=call-list]').find('li:first-child').click(); - cy.get('[data-cy="add-participant-button"]').click(); cy.get('[data-cy="invite-user-autocomplete"]').type(email); + cy.contains(`Invite ${email} via email`).should('exist'); cy.get('[data-cy="invite-user-autocomplete"]').type('{enter}'); cy.get('[data-cy="invite-user-submit-button"]').click(); @@ -96,33 +93,30 @@ context('Invites tests', () => { const lastName = initialDBData.users.user2.lastName; const email = initialDBData.users.user2.email; - cy.login('user1', initialDBData.roles.user); - cy.visit('/'); - cy.contains('New Proposal').click(); - cy.get('[data-cy=call-list]').find('li:first-child').click(); - cy.get('[data-cy="add-participant-button"]').click(); cy.get('[data-cy="invite-user-autocomplete"]').type(email); - cy.get('[role=menuitem]').contains(lastName); + cy.get('[role=presentation]').contains(lastName).click(); + cy.get('[data-cy="invite-user-submit-button"]') + .should('be.enabled') + .click(); + + cy.get('.MuiChip-label').should('not.exist'); + cy.get('[data-cy="co-proposers"]').contains(lastName); }); - it('Should not be able to invite same email twice', function () { + it('Should not be able to invite email already invited on proposal', function () { if (!featureFlags.getEnabledFeatures().get(FeatureId.EMAIL_INVITE)) { this.skip(); } const email = faker.internet.email(); - cy.login('user1', initialDBData.roles.user); - cy.visit('/'); - cy.contains('New Proposal').click(); - cy.get('[data-cy=call-list]').find('li:first-child').click(); - cy.get('[data-cy="add-participant-button"]').click(); cy.get('[data-cy="invite-user-autocomplete"]').type(email); - cy.get('[data-cy="invite-user-autocomplete"]').type('{enter}'); + cy.contains(`Invite ${email} via email`).should('exist'); + cy.get('[data-cy="invite-user-autocomplete"] input').type('{enter}'); cy.get('[data-cy="invite-user-submit-button"]').click(); @@ -130,6 +124,112 @@ context('Invites tests', () => { cy.get('[data-cy="invite-user-autocomplete"]').type(email); cy.contains(`${email} has already been invited`).should('exist'); + cy.get('[data-cy="invite-user-autocomplete"] input').type('{enter}'); + cy.contains(`${email} has already been invited`).should('exist'); + + cy.get('[data-cy="invite-user-autocomplete"] input').should( + 'have.value', + `${email}` + ); + cy.get('[data-cy="invite-user-autocomplete"]') + .find('.MuiChip-label') + .should('have.length', 0); + }); + + it('Should not be able to invite email already invited in modal', function () { + if (!featureFlags.getEnabledFeatures().get(FeatureId.EMAIL_INVITE)) { + this.skip(); + } + + const email = faker.internet.email(); + + cy.get('[data-cy="add-participant-button"]').click(); + + cy.get('[data-cy="invite-user-autocomplete"]').type(email); + cy.contains(`Invite ${email} via email`).should('exist'); + cy.get('[data-cy="invite-user-autocomplete"] input').type('{enter}'); + + cy.get('[data-cy="invite-user-autocomplete"]').type(email); + cy.contains(`${email} has already been invited`).should('exist'); + + cy.get('[data-cy="invite-user-autocomplete"] input').type('{enter}'); + cy.contains(`${email} has already been invited`).should('exist'); + + cy.get('[data-cy="invite-user-autocomplete"] input').should( + 'have.value', + `${email}` + ); + cy.get('[data-cy="invite-user-autocomplete"]') + .find('.MuiChip-label') + .should('have.length', 1); + }); + + it('Should not be able to invite an email that belongs to a user', function () { + if (!featureFlags.getEnabledFeatures().get(FeatureId.EMAIL_INVITE)) { + this.skip(); + } + + /* + * When pressing enter quickly after typing an email, it should not invite + * the user while it is still searching for the user in the background. + */ + + const email = initialDBData.users.user2.email; + + cy.get('[data-cy=add-participant-button]').click(); + + cy.get('[data-cy="invite-user-autocomplete"]').type(`${email}{enter}`); + + cy.get('[data-cy="invite-user-autocomplete"]') + .find('.MuiChip-label') + .should('not.exist'); + }); + + it.only('Should not be able to invite the email of the current user', function () { + if (!featureFlags.getEnabledFeatures().get(FeatureId.EMAIL_INVITE)) { + this.skip(); + } + + const currentUserEmail = initialDBData.users.user1.email; + + cy.get('[data-cy=add-participant-button]').click(); + + cy.get('[data-cy="invite-user-autocomplete"]').type( + `${currentUserEmail}` + ); + cy.contains('Loading...').should('not.exist'); + cy.contains(`${currentUserEmail} has already been invited`).should( + 'exist' + ); + + cy.get('[data-cy="invite-user-autocomplete"]').type('{enter}'); + cy.contains(`${currentUserEmail} has already been invited`).should( + 'exist' + ); + cy.get('[data-cy="invite-user-autocomplete"]') + .find('.MuiChip-label') + .should('not.exist'); + }); + + it('Should not be able to delete added users with backspace', function () { + const name = initialDBData.users.user2.lastName; + const email = initialDBData.users.user2.email; + const threeLetterQuery = 'abc'; + + cy.get('[data-cy="add-participant-button"]').click(); + + cy.get('[data-cy="invite-user-autocomplete"]').type(email); + cy.contains(name); + cy.get('[data-cy="invite-user-autocomplete"]').type('{enter}'); + cy.get('.MuiChip-label').should('contain.text', name); + + cy.get('[data-cy="invite-user-autocomplete"]').type(threeLetterQuery); + cy.get('[data-cy="invite-user-autocomplete"]').type( + '{backspace}{backspace}{backspace}{backspace}{backspace}' + ); // 5 backspaces + + cy.get('.MuiChip-label').should('contain.text', name); + cy.get('[data-cy="invite-user-autocomplete"] input').should('be.empty'); }); }); @@ -165,4 +265,211 @@ context('Invites tests', () => { }); }); }); + + describe('Previous collaborators', () => { + const prevCollabLastName = initialDBData.users.user2.lastName; + + beforeEach(() => { + cy.resetDB(); + cy.updateFeature({ + action: FeatureUpdateAction.DISABLE, + featureIds: [FeatureId.EMAIL_INVITE_LEGACY], + }); + cy.getAndStoreFeaturesEnabled(); + + const email = initialDBData.users.user2.email; + + cy.login('user1', initialDBData.roles.user); + cy.visit('/'); + cy.contains('New Proposal').click(); + cy.get('[data-cy=call-list]').find('li:first-child').click(); + + cy.get('[data-cy="add-participant-button"]').click(); + cy.get('[role=presentation]').should('not.contain', prevCollabLastName); + + cy.get('[data-cy="invite-user-autocomplete"]').type(email); + cy.get('[role=presentation]').contains(prevCollabLastName).click(); + cy.get('[data-cy="invite-user-submit-button"]') + .should('be.enabled') + .click(); + + cy.get('#title-input').type(faker.lorem.words(2)); + cy.get('#abstract-input').type(faker.lorem.words(3)); + + cy.get('[data-cy="save-and-continue-button"]').click(); + cy.notification({ variant: 'success', text: 'Saved' }); + + cy.contains('Submit').click(); + cy.get('[data-cy=confirm-ok]').click(); + + cy.contains('Dashboard').click(); + + cy.contains('New Proposal').click(); + cy.get('[data-cy=call-list]').find('li:first-child').click(); + }); + + it('Should show previous collaborators', function () { + cy.get('[data-cy=add-participant-button]').click(); + cy.get('[data-cy="invite-user-autocomplete"]').click(); + + cy.get('[role=presentation]').contains(prevCollabLastName); + }); + + it('Should update previous collaborator list after adding', function () { + cy.get('[data-cy=add-participant-button]').click(); + cy.get('[data-cy="invite-user-autocomplete"]').click(); + + cy.get('[role=presentation]').contains(prevCollabLastName); + cy.get('[data-cy="invite-user-autocomplete"]').type('{enter}'); + + cy.get('[data-cy="invite-user-autocomplete"]').click(); + cy.get('[role="presentation"] ul[role="listbox"]').should('not.exist'); + }); + + it('Should not be able to add single previous collaborator multiple times', function () { + cy.get('[data-cy=add-participant-button]').click(); + + cy.get('[data-cy="invite-user-autocomplete"]').click(); + cy.get('[role=presentation]').contains(prevCollabLastName); + + // Pressing enter multiple times quickly + cy.get('[data-cy="invite-user-autocomplete"]').type('{enter}'); + cy.get('[data-cy="invite-user-autocomplete"]').type('{enter}'); + cy.get('[data-cy="invite-user-autocomplete"]').type('{enter}'); + + cy.get('[data-cy="invite-user-autocomplete"]') + .find('.MuiChip-label') + .should('have.length', 1); + + cy.get('[data-cy="invite-user-autocomplete"]') + .find('.MuiChip-label') + .should('contain.text', prevCollabLastName); + + cy.get('[data-cy="invite-user-autocomplete"]').click(); + cy.get('[role="presentation"] ul[role="listbox"]').should('not.exist'); + }); + + it('Should not add a previous collaborator when typing quickly and pressing enter', function () { + const randomSearch = 'abcd'; + + cy.get('[data-cy="add-participant-button"]').click(); + + cy.get('[data-cy="invite-user-autocomplete"]').type( + `${randomSearch}{enter}` + ); + + cy.get('.MuiChip-label').should('not.exist'); + }); + }); + + describe('Invites disabled with email search enabled', () => { + beforeEach(() => { + cy.resetDB(true); + cy.updateFeature({ + action: FeatureUpdateAction.DISABLE, + featureIds: [FeatureId.EMAIL_INVITE_LEGACY], + }); + cy.updateFeature({ + action: FeatureUpdateAction.DISABLE, + featureIds: [FeatureId.EMAIL_INVITE], + }); + cy.updateFeature({ + action: FeatureUpdateAction.ENABLE, + featureIds: [FeatureId.EMAIL_SEARCH], + }); + cy.getAndStoreFeaturesEnabled(); + + cy.login('user1', initialDBData.roles.user); + cy.visit('/'); + cy.contains('New Proposal').click(); + cy.get('[data-cy=call-list]').find('li:first-child').click(); + }); + + it('Should be able to add user by knowing exact email', function () { + const lastName = initialDBData.users.user2.lastName; + const email = initialDBData.users.user2.email; + + cy.get('[data-cy="add-participant-button"]').click(); + + cy.get('[data-cy="invite-user-autocomplete"]').type(email); + cy.get('[role=presentation]').contains(lastName).click(); + + cy.get('[data-cy="invite-user-submit-button"]') + .should('be.enabled') + .click(); + + cy.get('.MuiChip-label').should('not.exist'); + cy.get('[data-cy="co-proposers"]').contains(lastName); + }); + + it('Should not be able to use partial user lookup', function () { + const name = initialDBData.users.user2.lastName; + const partialName = name.substring(0, 3); + + cy.get('[data-cy="add-participant-button"]').click(); + + cy.get('[data-cy="invite-user-autocomplete"]').type(partialName); + cy.get('[role=presentation]').contains('Enter a full email address'); + }); + + it('Should not be able to add an email address when the user does not exist', function () { + const email = 'unknown-user@email.com'; + + cy.get('[data-cy="add-participant-button"]').click(); + + cy.get('[data-cy="invite-user-autocomplete"]').type(email); + cy.get('[role=presentation]').contains(`No results found for "${email}"`); + + cy.get('[data-cy="invite-user-autocomplete"]').type('{enter}'); + cy.get('[role=presentation]').contains(`No results found for "${email}"`); + cy.get('[data-cy="invite-user-submit-button"]').should('be.disabled'); + cy.get('.MuiChip-label').should('not.exist'); + }); + + it('Should not be able to add duplicate co-proposer in modal', () => { + const lastName = initialDBData.users.user2.lastName; + const email = initialDBData.users.user2.email; + + cy.get('[data-cy="add-participant-button"]').click(); + + cy.get('[data-cy="invite-user-autocomplete"]').type(email); + cy.get('[role=presentation]').contains(lastName).click(); + + cy.get('[data-cy="invite-user-autocomplete"]').type(email); + cy.get('[role=presentation]') + .contains(`No results found for "${email}"`) + .should('exist'); + }); + + it('Should not be able to add duplicate co-proposer already on proposal', () => { + const lastName = initialDBData.users.user2.lastName; + const email = initialDBData.users.user2.email; + + cy.get('[data-cy="add-participant-button"]').click(); + + cy.get('[data-cy="invite-user-autocomplete"]').type(email); + cy.get('[role=presentation]').contains(lastName).click(); + cy.get('[data-cy="invite-user-submit-button"]') + .should('be.enabled') + .click(); + + cy.get('[data-cy="add-participant-button"]').click(); + + cy.get('[data-cy="invite-user-autocomplete"]').type(email); + cy.get('[role=presentation]') + .contains(`No results found for "${email}"`) + .should('exist'); + }); + + it('Should not be able to add duplicate co-proposer when co-proposer is PI', () => { + const email = initialDBData.users.user1.email; + + cy.get('[data-cy="add-participant-button"]').click(); + + cy.get('[data-cy="invite-user-autocomplete"]').type(email); + cy.get('[role=presentation]') + .contains(`No results found for "${email}"`) + .should('exist'); + }); + }); }); diff --git a/apps/frontend/src/components/common/StyledDialog.tsx b/apps/frontend/src/components/common/StyledDialog.tsx index d4965a63bf..b8f91d7818 100644 --- a/apps/frontend/src/components/common/StyledDialog.tsx +++ b/apps/frontend/src/components/common/StyledDialog.tsx @@ -44,6 +44,7 @@ type StyledDialogProps = { title?: string; error?: boolean; extra?: JSX.Element; + tooltip?: React.ReactNode; } & DialogProps; function StyledDialog(props: StyledDialogProps) { @@ -62,6 +63,7 @@ function StyledDialog(props: StyledDialogProps) { })} > {title} + {props.tooltip && props.tooltip} {extra} diff --git a/apps/frontend/src/components/common/UserManagementTable.tsx b/apps/frontend/src/components/common/UserManagementTable.tsx index 51b107e027..464e4d11e1 100644 --- a/apps/frontend/src/components/common/UserManagementTable.tsx +++ b/apps/frontend/src/components/common/UserManagementTable.tsx @@ -12,6 +12,7 @@ import React, { useContext, useState } from 'react'; import { ActionButtonContainer } from 'components/common/ActionButtonContainer'; import PeopleTable from 'components/user/PeopleTable'; import { FeatureContext } from 'context/FeatureContextProvider'; +import { UserContext } from 'context/UserContextProvider'; import { BasicUserDetails, FeatureId, Invite, UserRole } from 'generated/sdk'; import ParticipantModal from '../proposal/ParticipantModal'; @@ -23,7 +24,7 @@ export type UserManagementTableProps = { /** Function for setting up the users. */ setUsers: (users: BasicUserDetails[]) => void; invites: Invite[]; - setInvites: (invites: Invite[]) => void; + setInvites?: (invites: Invite[]) => void; sx?: SxProps; title: string; preserveSelf?: boolean; @@ -35,6 +36,8 @@ export type UserManagementTableProps = { onUserAction?: (action: string, user: BasicUserDetails) => void; /** Additional excluded user IDs for invite flow */ excludeUserIds?: number[]; + /** If true, allows to add users by entering their email. Set this to true and listen to setInvites */ + allowEmailInvites?: boolean; }; const UserManagementTable = ({ @@ -50,12 +53,14 @@ const UserManagementTable = ({ disabled = false, onUserAction, excludeUserIds = [], + allowEmailInvites = false, }: UserManagementTableProps) => { const [modalOpen, setOpen] = useState(false); const { featuresMap } = useContext(FeatureContext); const isLegacyInviteFlow = featuresMap.get( FeatureId.EMAIL_INVITE_LEGACY )?.isEnabled; + const currentUser = useContext(UserContext)?.user; const removeUser = (user: BasicUserDetails) => { const newUsers = users.filter((u) => u.id !== user.id); @@ -71,7 +76,7 @@ const UserManagementTable = ({ invites: Invite[]; }) => { setUsers([...users, ...props.users]); - setInvites([...invites, ...props.invites]); + setInvites?.([...invites, ...props.invites]); setOpen(false); }; @@ -83,7 +88,7 @@ const UserManagementTable = ({ }; const handleDeleteInvite = (invite: Invite) => { - setInvites(invites.filter((i) => i.email !== invite.email)); + setInvites?.(invites.filter((i) => i.email !== invite.email)); }; const InviteComponent = ( @@ -93,7 +98,11 @@ const UserManagementTable = ({ onClose={() => setOpen(false)} onAddParticipants={handleAddParticipants} excludeUserIds={[...users.map((user) => user.id), ...excludeUserIds]} - excludeEmails={invites.map((invite) => invite.email)} + allowEmailInvites={allowEmailInvites} + excludeEmails={[ + ...(invites?.map((invite) => invite.email) || []), + ...(currentUser.email ? [currentUser.email.toLowerCase()] : []), + ]} /> ); diff --git a/apps/frontend/src/components/proposal/CoProposers.tsx b/apps/frontend/src/components/proposal/CoProposers.tsx index bcf3b0a46e..2cbfa8afdc 100644 --- a/apps/frontend/src/components/proposal/CoProposers.tsx +++ b/apps/frontend/src/components/proposal/CoProposers.tsx @@ -45,6 +45,7 @@ const CoProposers = ({ // https://github.com/mbrn/material-table/issues/666 users={JSON.parse(JSON.stringify(users))} invites={coProposerInvites} + allowEmailInvites={true} /> ); }; diff --git a/apps/frontend/src/components/proposal/NoOptionsText.tsx b/apps/frontend/src/components/proposal/NoOptionsText.tsx index e7fdea3799..aa7d6b6a32 100644 --- a/apps/frontend/src/components/proposal/NoOptionsText.tsx +++ b/apps/frontend/src/components/proposal/NoOptionsText.tsx @@ -13,7 +13,10 @@ interface NoOptionsTextProps { exactEmailMatch?: BasicUserDetails; excludeEmails?: string[]; minSearchLength?: number; - enableEmailInvites?: boolean; + /* If true, if no user with email is found show invite option */ + allowEmailInvites?: boolean; + /* If true, only allow searching users by entering their email */ + isEmailSearchOnly: boolean; } function NoOptionsText({ @@ -23,7 +26,8 @@ function NoOptionsText({ exactEmailMatch, excludeEmails = [], minSearchLength = 3, - enableEmailInvites, + allowEmailInvites, + isEmailSearchOnly, }: NoOptionsTextProps) { const featureContext = useContext(FeatureContext); const isEmailInviteFeatureEnabled = !!featureContext.featuresMap.get( @@ -31,8 +35,7 @@ function NoOptionsText({ )?.isEnabled; // email invite will be enabled if both the prop and the feature flag are true - const isEmailInviteEnabled = - enableEmailInvites && isEmailInviteFeatureEnabled; + const isEmailInviteEnabled = allowEmailInvites && isEmailInviteFeatureEnabled; if (exactEmailMatch) { return ( @@ -42,6 +45,14 @@ function NoOptionsText({ ); } + if (isEmailSearchOnly) { + if (isValidEmail(query)) { + return <>No results found for "{query}"; + } else { + return <>Enter a full email address; + } + } + if ((query as string).length < minSearchLength) { return <>Please type at least {minSearchLength} characters; } diff --git a/apps/frontend/src/components/proposal/ParticipantSelector.tsx b/apps/frontend/src/components/proposal/ParticipantSelector.tsx index b107560e4a..4720f04359 100644 --- a/apps/frontend/src/components/proposal/ParticipantSelector.tsx +++ b/apps/frontend/src/components/proposal/ParticipantSelector.tsx @@ -1,17 +1,27 @@ +import { Info } from '@mui/icons-material'; import AddIcon from '@mui/icons-material/Add'; import { Autocomplete, Button, CircularProgress, DialogContent, + IconButton, MenuItem, TextField, + Tooltip, } from '@mui/material'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react'; import StyledDialog from 'components/common/StyledDialog'; +import { FeatureContext } from 'context/FeatureContextProvider'; import { getCurrentUser } from 'context/UserContextProvider'; -import { BasicUserDetails, Invite } from 'generated/sdk'; +import { BasicUserDetails, FeatureId, Invite } from 'generated/sdk'; import { useDataApi } from 'hooks/common/useDataApi'; import { isValidEmail, ValidEmailAddress } from 'utils/net'; import { getFullUserNameWithInstitution } from 'utils/user'; @@ -49,8 +59,8 @@ interface ParticipantSelectorProps { excludeUserIds?: number[]; excludeEmails?: string[]; preset?: UserOrEmail[]; - singleSelection?: boolean; - enableEmailInvites?: boolean; + multiple?: boolean; + allowEmailInvites?: boolean; } const categorizeSelectedItems = (items: UserOrEmail[]) => ({ @@ -72,10 +82,11 @@ function ParticipantSelector({ excludeUserIds, excludeEmails, confirm, - preset, - singleSelection, - enableEmailInvites, + preset = [], + multiple = true, + allowEmailInvites = false, }: ParticipantSelectorProps & WithConfirmProps) { + // multiple selection enabled by default const api = useDataApi(); const [query, setQuery] = useState(''); const [loading, setLoading] = useState(false); @@ -83,9 +94,16 @@ function ParticipantSelector({ const [exactEmailMatch, setExactEmailMatch] = useState< BasicUserDetails | undefined >(); - const [selectedItems, setSelectedItems] = useState( - preset || [] - ); + const [selectedItems, setSelectedItems] = useState(preset); + const isPendingSearch = useRef(false); + const featureContext = useContext(FeatureContext); + const isEmailSearchOnly = !!featureContext.featuresMap.get( + FeatureId.EMAIL_SEARCH + )?.isEnabled; + + const labelText = isEmailSearchOnly + ? 'Enter a full email address' + : 'ex. Nathalie or john@gmail.com'; const fetchUserSearchResults = useCallback(async () => { setExactEmailMatch(undefined); @@ -102,6 +120,7 @@ function ParticipantSelector({ }); setOptions(previousCollaborators?.users || []); + isPendingSearch.current = false; return; } @@ -109,10 +128,12 @@ function ParticipantSelector({ if (query.length < MIN_SEARCH_LENGTH) { setOptions([]); setLoading(false); + isPendingSearch.current = false; return; } + setOptions([]); setLoading(true); try { if (isValidEmail(query)) { @@ -132,6 +153,11 @@ function ParticipantSelector({ setExactEmailMatch(basicUserDetailsByEmail || undefined); } } else { + if (isEmailSearchOnly) { + // Disallow partial name search + return; + } + const excludedUserIds = [ ...(excludeUserIds ?? []), ...categorizeSelectedItems(selectedItems).users.map( @@ -147,32 +173,48 @@ function ParticipantSelector({ setOptions(users?.users || []); } } catch (error) { - console.error('Error fetching results:', error); setOptions([]); } finally { + isPendingSearch.current = false; setLoading(false); } - }, [api, query, excludeUserIds, selectedItems]); + }, [api, query, excludeUserIds, selectedItems, isEmailSearchOnly]); // Debounce effect for search queries useEffect(() => { + isPendingSearch.current = true; + const debounceFetch = setTimeout(fetchUserSearchResults, 300); return () => clearTimeout(debounceFetch); - }, [fetchUserSearchResults]); + }, [query, fetchUserSearchResults]); - const addToSelectedItems = (user: UserOrEmail) => - setSelectedItems((prev) => (singleSelection ? [user] : [...prev, user])); + const addToSelectedItems = (user: UserOrEmail) => { + setSelectedItems((prev) => (multiple ? [...prev, user] : [user])); + + setExactEmailMatch(undefined); + setOptions([]); + setQuery(''); + }; const addValidEmailToSelection = (email: string) => { if (isValidEmail(email)) { const lowerCaseEmail = email.toLowerCase(); - if (singleSelection) { - setSelectedItems([lowerCaseEmail]); - } else { - addToSelectedItems(lowerCaseEmail); + + /* + * Duplicate invites are still possible at this point, as invites are + * not fetched and therefore do not go through the associated duplicate + * filtering. + */ + if ( + excludeEmails + ?.concat(categorizeSelectedItems(selectedItems).invites) + .includes(lowerCaseEmail) + ) { + return; } - setQuery(''); + + addToSelectedItems(lowerCaseEmail); } }; @@ -222,28 +264,27 @@ function ParticipantSelector({ }; const handleKeyDown = (event: React.KeyboardEvent) => { - if (selectedItems.length === 1 && singleSelection) { + if (!multiple && selectedItems.length === 1) { + // Prevent further input when single selection is made event.preventDefault(); return; } + if (event.key === 'Enter' || event.key === ' ' || event.key === ',') { event.preventDefault(); + + if (isPendingSearch.current) { + return; + } + if (exactEmailMatch) { addToSelectedItems(exactEmailMatch); setExactEmailMatch(undefined); } else if (options.length === 1) { addToSelectedItems(options[0]); - } else if (isValidEmail(query)) { - if (singleSelection) { - if (isValidEmail(query)) { - const lowerCaseEmail = query.toLowerCase(); - setSelectedItems([lowerCaseEmail]); - setQuery(''); - } - } else { - addValidEmailToSelection(query); - } + } else if (isValidEmail(query) && !isEmailSearchOnly) { + addValidEmailToSelection(query); } } }; @@ -256,14 +297,31 @@ function ParticipantSelector({ const getOptionKey = (option: UserOrEmail) => isValidEmail(option) ? option : option.id; + const isLoading = loading || isPendingSearch.current; + return ( + Click to see your previous collaborators or start typing to + search by email address. Click a user to add them to the + proposal. + + } + > + + + + + ) } > setSelectedItems(newValue)} + value={ + multiple + ? selectedItems + : selectedItems.length + ? selectedItems[0] + : undefined + } + onChange={(_, newValue) => + setSelectedItems( + newValue + ? multiple + ? (newValue as UserOrEmail[]) + : [newValue as UserOrEmail] + : [] + ) + } filterSelectedOptions onInputChange={(_, newValue) => setQuery(newValue)} onKeyDown={handleKeyDown} @@ -285,13 +357,20 @@ function ParticipantSelector({ renderInput={(params) => ( ) => { + if (event.key === 'Backspace') { + event.stopPropagation(); + } + }} InputProps={{ ...params.InputProps, endAdornment: ( <> - {loading && } + {isLoading && ( + + )} {params.InputProps.endAdornment} ), @@ -304,22 +383,29 @@ function ParticipantSelector({ )} noOptionsText={ - addValidEmailToSelection(email)} - exactEmailMatch={exactEmailMatch} - onAddUser={(user) => { - addToSelectedItems(user); - setExactEmailMatch(undefined); - setQuery(''); - }} - excludeEmails={ - excludeEmails?.concat( - categorizeSelectedItems(selectedItems).invites - ) || [] - } - enableEmailInvites={enableEmailInvites} - /> + !isLoading ? ( + + allowEmailInvites + ? addValidEmailToSelection(email) + : undefined + } + exactEmailMatch={exactEmailMatch} + onAddUser={(user) => { + multiple + ? addToSelectedItems(user) + : setSelectedItems([user]); + }} + excludeEmails={ + excludeEmails?.concat( + categorizeSelectedItems(selectedItems).invites + ) || [] + } + isEmailSearchOnly={isEmailSearchOnly} + allowEmailInvites={allowEmailInvites} + /> + ) : null } /> @@ -334,7 +420,7 @@ function ParticipantSelector({ } data-cy="invite-user-submit-button" > - {singleSelection ? 'Select' : 'Add'} + {multiple ? 'Add' : 'Select'} diff --git a/apps/frontend/src/components/proposal/PrincipalInvestigator.tsx b/apps/frontend/src/components/proposal/PrincipalInvestigator.tsx index 5b7860f80c..c4b17b2765 100644 --- a/apps/frontend/src/components/proposal/PrincipalInvestigator.tsx +++ b/apps/frontend/src/components/proposal/PrincipalInvestigator.tsx @@ -48,8 +48,8 @@ export default function PrincipalInvestigator( proposer ? [proposer.id] : [], ].flat()} preset={proposer ? [proposer] : []} - singleSelection={true} - enableEmailInvites={false} + multiple={false} + allowEmailInvites={false} /> )} diff --git a/package-lock.json b/package-lock.json index 6c8be18a28..2e22237119 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", "dev": true, + "license": "MIT", "dependencies": { "environment": "^1.0.0" }, @@ -156,6 +157,7 @@ "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", "dev": true, + "license": "MIT", "dependencies": { "restore-cursor": "^5.0.0" }, @@ -171,6 +173,7 @@ "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", "dev": true, + "license": "MIT", "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" @@ -183,10 +186,11 @@ } }, "node_modules/cli-truncate/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz", + "integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -195,16 +199,18 @@ } }, "node_modules/cli-truncate/node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "dev": true + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", + "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", + "dev": true, + "license": "MIT" }, "node_modules/cli-truncate/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, + "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", @@ -222,6 +228,7 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", "dev": true, + "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" }, @@ -265,7 +272,8 @@ "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/combined-stream": { "version": "1.0.8", @@ -289,18 +297,16 @@ } }, "node_modules/concurrently": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.0.tgz", - "integrity": "sha512-IsB/fiXTupmagMW4MNp2lx2cdSN2FfZq78vF90LBB+zZHArbIQZjQtzXCiXnvTxCZSvXanTqFLWBjw2UkLx1SQ==", - "license": "MIT", + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", "dependencies": { - "chalk": "^4.1.2", - "lodash": "^4.17.21", - "rxjs": "^7.8.1", - "shell-quote": "^1.8.1", - "supports-color": "^8.1.1", - "tree-kill": "^1.2.2", - "yargs": "^17.7.2" + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", @@ -362,6 +368,7 @@ "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -422,7 +429,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/fill-range": { "version": "7.1.1", @@ -487,10 +495,11 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", - "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.1.tgz", + "integrity": "sha512-R1QfovbPsKmosqTnPoRFiJ7CF9MLRgb53ChvMZm+r4p76/+8yKDy17qLL2PKInORy2RkZZekuK0efYgmzTkXyQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -645,16 +654,17 @@ } }, "node_modules/lint-staged": { - "version": "16.1.5", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.1.5.tgz", - "integrity": "sha512-uAeQQwByI6dfV7wpt/gVqg+jAPaSp8WwOA8kKC/dv1qw14oGpnpAisY65ibGHUGDUv0rYaZ8CAJZ/1U8hUvC2A==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.1.6.tgz", + "integrity": "sha512-U4kuulU3CKIytlkLlaHcGgKscNfJPNTiDF2avIUGFCv7K95/DCYQ7Ra62ydeRWmgQGg9zJYw2dzdbztwJlqrow==", "dev": true, + "license": "MIT", "dependencies": { - "chalk": "^5.5.0", + "chalk": "^5.6.0", "commander": "^14.0.0", "debug": "^4.4.1", "lilconfig": "^3.1.3", - "listr2": "^9.0.1", + "listr2": "^9.0.3", "micromatch": "^4.0.8", "nano-spawn": "^1.0.2", "pidtree": "^0.6.0", @@ -672,10 +682,11 @@ } }, "node_modules/lint-staged/node_modules/chalk": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.5.0.tgz", - "integrity": "sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.0.tgz", + "integrity": "sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==", "dev": true, + "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" }, @@ -684,10 +695,11 @@ } }, "node_modules/listr2": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.1.tgz", - "integrity": "sha512-SL0JY3DaxylDuo/MecFeiC+7pedM0zia33zl0vcjgwcq1q1FWWF1To9EIauPbl8GbMCU0R2e0uJ8bZunhYKD2g==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.3.tgz", + "integrity": "sha512-0aeh5HHHgmq1KRdMMDHfhMWQmIT/m7nRDTlxlFqni2Sp0had9baqsjJRvDGdlvgd6NmPE0nPloOipiQJGFtTHQ==", "dev": true, + "license": "MIT", "dependencies": { "cli-truncate": "^4.0.0", "colorette": "^2.0.20", @@ -701,10 +713,11 @@ } }, "node_modules/listr2/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz", + "integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -717,6 +730,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -725,16 +739,18 @@ } }, "node_modules/listr2/node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "dev": true + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", + "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", + "dev": true, + "license": "MIT" }, "node_modules/listr2/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, + "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", @@ -752,6 +768,7 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", "dev": true, + "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" }, @@ -767,6 +784,7 @@ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", @@ -789,6 +807,7 @@ "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", "dev": true, + "license": "MIT", "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", @@ -804,10 +823,11 @@ } }, "node_modules/log-update/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz", + "integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -820,6 +840,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -828,18 +849,20 @@ } }, "node_modules/log-update/node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "dev": true + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", + "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", + "dev": true, + "license": "MIT" }, "node_modules/log-update/node_modules/is-fullwidth-code-point": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", - "integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", "dev": true, + "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.0.0" + "get-east-asian-width": "^1.3.1" }, "engines": { "node": ">=18" @@ -853,6 +876,7 @@ "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" @@ -869,6 +893,7 @@ "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, + "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", @@ -886,6 +911,7 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", "dev": true, + "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" }, @@ -901,6 +927,7 @@ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", @@ -958,6 +985,7 @@ "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -998,6 +1026,7 @@ "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", "dev": true, + "license": "MIT", "dependencies": { "mimic-function": "^5.0.0" }, @@ -1050,6 +1079,7 @@ "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", "dev": true, + "license": "MIT", "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" @@ -1065,7 +1095,8 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/rxjs": { "version": "7.8.2", @@ -1077,9 +1108,12 @@ } }, "node_modules/shell-quote": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", - "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -1089,6 +1123,7 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, + "license": "ISC", "engines": { "node": ">=14" }, @@ -1101,6 +1136,7 @@ "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^6.0.0", "is-fullwidth-code-point": "^4.0.0" @@ -1117,6 +1153,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -1129,6 +1166,7 @@ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -1414,15 +1452,15 @@ }, "dependencies": { "ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz", + "integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==", "dev": true }, "emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", + "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", "dev": true }, "string-width": { @@ -1491,17 +1529,16 @@ "dev": true }, "concurrently": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.0.tgz", - "integrity": "sha512-IsB/fiXTupmagMW4MNp2lx2cdSN2FfZq78vF90LBB+zZHArbIQZjQtzXCiXnvTxCZSvXanTqFLWBjw2UkLx1SQ==", + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", "requires": { - "chalk": "^4.1.2", - "lodash": "^4.17.21", - "rxjs": "^7.8.1", - "shell-quote": "^1.8.1", - "supports-color": "^8.1.1", - "tree-kill": "^1.2.2", - "yargs": "^17.7.2" + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" } }, "debug": { @@ -1616,9 +1653,9 @@ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" }, "get-east-asian-width": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", - "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.1.tgz", + "integrity": "sha512-R1QfovbPsKmosqTnPoRFiJ7CF9MLRgb53ChvMZm+r4p76/+8yKDy17qLL2PKInORy2RkZZekuK0efYgmzTkXyQ==", "dev": true }, "get-intrinsic": { @@ -1714,16 +1751,16 @@ "dev": true }, "lint-staged": { - "version": "16.1.5", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.1.5.tgz", - "integrity": "sha512-uAeQQwByI6dfV7wpt/gVqg+jAPaSp8WwOA8kKC/dv1qw14oGpnpAisY65ibGHUGDUv0rYaZ8CAJZ/1U8hUvC2A==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.1.6.tgz", + "integrity": "sha512-U4kuulU3CKIytlkLlaHcGgKscNfJPNTiDF2avIUGFCv7K95/DCYQ7Ra62ydeRWmgQGg9zJYw2dzdbztwJlqrow==", "dev": true, "requires": { - "chalk": "^5.5.0", + "chalk": "^5.6.0", "commander": "^14.0.0", "debug": "^4.4.1", "lilconfig": "^3.1.3", - "listr2": "^9.0.1", + "listr2": "^9.0.3", "micromatch": "^4.0.8", "nano-spawn": "^1.0.2", "pidtree": "^0.6.0", @@ -1732,17 +1769,17 @@ }, "dependencies": { "chalk": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.5.0.tgz", - "integrity": "sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.0.tgz", + "integrity": "sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==", "dev": true } } }, "listr2": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.1.tgz", - "integrity": "sha512-SL0JY3DaxylDuo/MecFeiC+7pedM0zia33zl0vcjgwcq1q1FWWF1To9EIauPbl8GbMCU0R2e0uJ8bZunhYKD2g==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.3.tgz", + "integrity": "sha512-0aeh5HHHgmq1KRdMMDHfhMWQmIT/m7nRDTlxlFqni2Sp0had9baqsjJRvDGdlvgd6NmPE0nPloOipiQJGFtTHQ==", "dev": true, "requires": { "cli-truncate": "^4.0.0", @@ -1754,9 +1791,9 @@ }, "dependencies": { "ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz", + "integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==", "dev": true }, "ansi-styles": { @@ -1766,9 +1803,9 @@ "dev": true }, "emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", + "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", "dev": true }, "string-width": { @@ -1823,9 +1860,9 @@ }, "dependencies": { "ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz", + "integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==", "dev": true }, "ansi-styles": { @@ -1835,18 +1872,18 @@ "dev": true }, "emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", + "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", "dev": true }, "is-fullwidth-code-point": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", - "integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", "dev": true, "requires": { - "get-east-asian-width": "^1.0.0" + "get-east-asian-width": "^1.3.1" } }, "slice-ansi": { @@ -1999,9 +2036,9 @@ } }, "shell-quote": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", - "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==" + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==" }, "signal-exit": { "version": "4.1.0", From 780c0af55eb0ed491dd78393f7ae5da6fdf5a78c Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Tue, 9 Sep 2025 14:27:55 +0200 Subject: [PATCH 08/17] feat: add SQL patch to clarify email search description for improved user understanding --- .../0200_Update_email_search_description.sql | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 apps/backend/db_patches/0200_Update_email_search_description.sql diff --git a/apps/backend/db_patches/0200_Update_email_search_description.sql b/apps/backend/db_patches/0200_Update_email_search_description.sql new file mode 100644 index 0000000000..b1caa98620 --- /dev/null +++ b/apps/backend/db_patches/0200_Update_email_search_description.sql @@ -0,0 +1,13 @@ +DO +$$ +BEGIN + IF register_patch('Update_email_search_description.sql', 'jekabskarklins', 'Updates email search description to make it more clear.', '2025-09-09') THEN + BEGIN + UPDATE features + SET description = 'By setting this feature ON, users can be searched *only* by their exact email addresses.' + WHERE feature_id = 'EMAIL_SEARCH'; + END; + END IF; +END; +$$ +LANGUAGE plpgsql; From 8945e9744ab39b45c467c677ff55b89dd51c0c0a Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Tue, 9 Sep 2025 15:25:28 +0200 Subject: [PATCH 09/17] Do not add proposer as exludedUserIds --- .lintstagedrc.json | 8 ++++---- .../src/components/proposal/PrincipalInvestigator.tsx | 5 +---- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/.lintstagedrc.json b/.lintstagedrc.json index f4751f5445..c24519ee6e 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -1,5 +1,5 @@ -{ - "apps/e2e/**/*.{js,ts,jsx,tsx}": ["echo done"], - "apps/frontend/**/*.{js,ts,jsx,tsx}": ["echo done"], - "apps/backend/**/*.{js,ts,jsx,tsx}": ["echo done"] +{ + "apps/e2e/**/*.{js,ts,jsx,tsx}": ["npm run lint:e2e"], + "apps/frontend/**/*.{js,ts,jsx,tsx}": ["npm run lint:frontend"], + "apps/backend/**/*.{js,ts,jsx,tsx}": ["npm run lint:backend"] } diff --git a/apps/frontend/src/components/proposal/PrincipalInvestigator.tsx b/apps/frontend/src/components/proposal/PrincipalInvestigator.tsx index c4b17b2765..27fc8a762a 100644 --- a/apps/frontend/src/components/proposal/PrincipalInvestigator.tsx +++ b/apps/frontend/src/components/proposal/PrincipalInvestigator.tsx @@ -43,10 +43,7 @@ export default function PrincipalInvestigator( props.setPrincipalInvestigator(participants.users[0]); setIsPickerOpen(false); }} - excludeUserIds={[ - users.map((u) => u.id), - proposer ? [proposer.id] : [], - ].flat()} + excludeUserIds={[users.map((u) => u.id)].flat()} preset={proposer ? [proposer] : []} multiple={false} allowEmailInvites={false} From ab1ab244593dd2b9d879e695748c22f715b6222b Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Tue, 9 Sep 2025 15:28:24 +0200 Subject: [PATCH 10/17] fix: update lint-staged configuration to use echo commands and remove ProposalParticipants component --- apps/frontend/src/components/proposal/ProposalParticipants.tsx | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 apps/frontend/src/components/proposal/ProposalParticipants.tsx diff --git a/apps/frontend/src/components/proposal/ProposalParticipants.tsx b/apps/frontend/src/components/proposal/ProposalParticipants.tsx deleted file mode 100644 index e69de29bb2..0000000000 From 19bbff51f9ced009827062db2c09a1b41301d999 Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Wed, 10 Sep 2025 13:09:22 +0200 Subject: [PATCH 11/17] feat: update proposal tests to disable legacy email invite feature and adjust user selection process --- apps/e2e/cypress/e2e/proposals.cy.ts | 38 +++++++++++++++++----------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/apps/e2e/cypress/e2e/proposals.cy.ts b/apps/e2e/cypress/e2e/proposals.cy.ts index 44f124ea19..6bd76e3833 100644 --- a/apps/e2e/cypress/e2e/proposals.cy.ts +++ b/apps/e2e/cypress/e2e/proposals.cy.ts @@ -3,6 +3,7 @@ import { AllocationTimeUnits, DataType, FeatureId, + FeatureUpdateAction, SettingsId, TemplateCategoryId, TemplateGroupId, @@ -116,6 +117,10 @@ context('Proposal tests', () => { }); cy.resetDB(); + cy.updateFeature({ + action: FeatureUpdateAction.DISABLE, + featureIds: [FeatureId.EMAIL_INVITE_LEGACY], + }); cy.getAndStoreFeaturesEnabled(); cy.createTemplate({ name: 'default esi template', @@ -198,15 +203,16 @@ context('Proposal tests', () => { cy.finishedLoading(); - cy.get('[data-cy=email]').type('ben@inbox.com'); - - cy.get('[data-cy=findUser]').click(); + cy.get('[data-cy="invite-user-autocomplete"] input[type="text"]') + .type('{backspace}') + .type(initialDBData.users.user2.email); - cy.contains('Benjamin') - .parent() - .find("[aria-label='Select user']") + cy.get('[role="presentation"]') + .contains(initialDBData.users.user2.lastName) .click(); + cy.get('[data-testid="AddIcon"]').click(); + cy.get('[data-cy="save-and-continue-button"]').focus().click(); cy.contains('Proposal Title is required'); @@ -1081,9 +1087,12 @@ context('Proposal tests', () => { cy.window().then((win) => { win.location.href = 'about:blank'; }); - - cy.getAndStoreFeaturesEnabled(); cy.resetDB(); + cy.updateFeature({ + action: FeatureUpdateAction.DISABLE, + featureIds: [FeatureId.EMAIL_INVITE_LEGACY], + }); + cy.getAndStoreFeaturesEnabled(); cy.createTemplate({ name: 'default esi template', groupId: TemplateGroupId.PROPOSAL_ESI, @@ -1125,16 +1134,15 @@ context('Proposal tests', () => { cy.finishedLoading(); - cy.get('[data-cy=email]').type('ben@inbox.com'); - - cy.get('[data-cy=findUser]').click(); + cy.get('[data-cy="invite-user-autocomplete"] input[type="text"]') + .type('{backspace}') + .type(initialDBData.users.user2.email); - cy.contains('Benjamin') - .parent() - .find("[aria-label='Select user']") + cy.get('[role="presentation"]') + .contains(initialDBData.users.user2.lastName) .click(); - cy.get('[data-cy="save-and-continue-button"]').focus().click(); + cy.get('[data-testid="AddIcon"]').click(); cy.contains('Proposal Title is required'); cy.contains('Proposal Abstract is required'); From be3c6f8e242e2b4c4ae54189189f6a7229361582 Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Thu, 11 Sep 2025 14:30:55 +0200 Subject: [PATCH 12/17] debug: see if test runs now --- apps/e2e/cypress/e2e/proposals.cy.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/e2e/cypress/e2e/proposals.cy.ts b/apps/e2e/cypress/e2e/proposals.cy.ts index 6bd76e3833..9db2a432f7 100644 --- a/apps/e2e/cypress/e2e/proposals.cy.ts +++ b/apps/e2e/cypress/e2e/proposals.cy.ts @@ -1087,12 +1087,9 @@ context('Proposal tests', () => { cy.window().then((win) => { win.location.href = 'about:blank'; }); - cy.resetDB(); - cy.updateFeature({ - action: FeatureUpdateAction.DISABLE, - featureIds: [FeatureId.EMAIL_INVITE_LEGACY], - }); + cy.getAndStoreFeaturesEnabled(); + cy.resetDB(); cy.createTemplate({ name: 'default esi template', groupId: TemplateGroupId.PROPOSAL_ESI, From 72765b1b971873856d18fdf76aaad3afd1780442 Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Fri, 12 Sep 2025 16:59:12 +0200 Subject: [PATCH 13/17] refactor: simplify isSameParticipants function using keyOf for comparison --- .../proposal/ParticipantSelector.tsx | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/apps/frontend/src/components/proposal/ParticipantSelector.tsx b/apps/frontend/src/components/proposal/ParticipantSelector.tsx index 4720f04359..5a8432aa70 100644 --- a/apps/frontend/src/components/proposal/ParticipantSelector.tsx +++ b/apps/frontend/src/components/proposal/ParticipantSelector.tsx @@ -31,22 +31,11 @@ import NoOptionsText from './NoOptionsText'; type UserOrEmail = BasicUserDetails | ValidEmailAddress; -const isSameParticipants = (a: UserOrEmail[], b: UserOrEmail[]): boolean => { - if (a.length !== b.length) { - return false; - } - - return a.every((value, index) => { - if (isValidEmail(value) && isValidEmail(b[index])) { - return value.toLowerCase() === b[index].toLowerCase(); - } - if (!isValidEmail(value) && !isValidEmail(b[index])) { - return value.id === (b[index] as BasicUserDetails).id; - } +const keyOf = (u: UserOrEmail) => + typeof u === 'string' ? `email:${u.toLowerCase()}` : `id:${String(u.id)}`; - return false; - }); -}; +const isSameParticipants = (a: UserOrEmail[], b: UserOrEmail[]): boolean => + a.length === b.length && a.every((v, i) => keyOf(v) === keyOf(b[i])); interface ParticipantSelectorProps { modalOpen: boolean; From 1521c3800015e6c1510007dd7bedbea139c813cd Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Fri, 3 Oct 2025 11:23:58 +0200 Subject: [PATCH 14/17] fix: correct JSON formatting in lint-staged configuration --- .lintstagedrc.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.lintstagedrc.json b/.lintstagedrc.json index c24519ee6e..067e6d7045 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -1,4 +1,4 @@ -{ +{ "apps/e2e/**/*.{js,ts,jsx,tsx}": ["npm run lint:e2e"], "apps/frontend/**/*.{js,ts,jsx,tsx}": ["npm run lint:frontend"], "apps/backend/**/*.{js,ts,jsx,tsx}": ["npm run lint:backend"] From 183508e81291d7d286a80217c9d60ea1694c7dc8 Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Fri, 3 Oct 2025 14:08:22 +0200 Subject: [PATCH 15/17] fix: hidding new ParticipantSelector behind EMAIL_LEGACY FE --- .../proposal/ProposalParticipantLegacy.tsx | 79 +++++++++++++++++++ .../QuestionaryComponentProposalBasis.tsx | 36 +++++++-- 2 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 apps/frontend/src/components/proposal/ProposalParticipantLegacy.tsx diff --git a/apps/frontend/src/components/proposal/ProposalParticipantLegacy.tsx b/apps/frontend/src/components/proposal/ProposalParticipantLegacy.tsx new file mode 100644 index 0000000000..7cb8832af1 --- /dev/null +++ b/apps/frontend/src/components/proposal/ProposalParticipantLegacy.tsx @@ -0,0 +1,79 @@ +import EditIcon from '@mui/icons-material/Edit'; +import Box from '@mui/material/Box'; +import FormControl from '@mui/material/FormControl'; +import IconButton from '@mui/material/IconButton'; +import { SxProps, Theme } from '@mui/material/styles'; +import TextField from '@mui/material/TextField'; +import Tooltip from '@mui/material/Tooltip'; +import React, { useState } from 'react'; + +import { BasicUserDetails, UserRole } from 'generated/sdk'; +import { BasicUserData } from 'hooks/user/useUserData'; +import { getFullUserNameWithInstitution } from 'utils/user'; + +import ParticipantModal from './ParticipantModal'; + +export default function ProposalParticipantLegacy(props: { + principalInvestigator: BasicUserData | null | undefined; + setPrincipalInvestigator: (user: BasicUserDetails) => void; + sx?: SxProps; + loadingPrincipalInvestigator?: boolean; +}) { + const [isPickerOpen, setIsPickerOpen] = useState(false); + + return ( + + { + setIsPickerOpen(false); + }} + selectedUsers={ + !!props.principalInvestigator ? [props.principalInvestigator?.id] : [] + } + addParticipants={(users: BasicUserDetails[]) => { + props.setPrincipalInvestigator(users[0]); + setIsPickerOpen(false); + }} + participant={true} + /> + + + + + + setIsPickerOpen(true)} + sx={(theme) => ({ + padding: '5px', + marginLeft: theme.spacing(1), + })} + disabled={props.loadingPrincipalInvestigator} + > + + + + + + + ); +} diff --git a/apps/frontend/src/components/questionary/questionaryComponents/ProposalBasis/QuestionaryComponentProposalBasis.tsx b/apps/frontend/src/components/questionary/questionaryComponents/ProposalBasis/QuestionaryComponentProposalBasis.tsx index 2c79375630..e1b237611f 100644 --- a/apps/frontend/src/components/questionary/questionaryComponents/ProposalBasis/QuestionaryComponentProposalBasis.tsx +++ b/apps/frontend/src/components/questionary/questionaryComponents/ProposalBasis/QuestionaryComponentProposalBasis.tsx @@ -2,7 +2,7 @@ import Box from '@mui/material/Box'; import InputAdornment from '@mui/material/InputAdornment'; import { useTheme } from '@mui/material/styles'; import { Field } from 'formik'; -import React, { ChangeEvent, useContext, useState } from 'react'; +import React, { ChangeEvent, useContext, useEffect, useState } from 'react'; import ErrorMessage from 'components/common/ErrorMessage'; import TextField from 'components/common/FormikUITextField'; @@ -11,12 +11,15 @@ import CoProposers from 'components/proposal/CoProposers'; import { BasicComponentProps } from 'components/proposal/IBasicComponentProps'; import PrincipalInvestigator from 'components/proposal/PrincipalInvestigator'; import { ProposalContextType } from 'components/proposal/ProposalContainer'; +import ProposalParticipantLegacy from 'components/proposal/ProposalParticipantLegacy'; import { createMissingContextErrorMessage, QuestionaryContext, } from 'components/questionary/QuestionaryContext'; -import { BasicUserDetails, Invite } from 'generated/sdk'; +import { FeatureContext } from 'context/FeatureContextProvider'; +import { BasicUserDetails, FeatureId, Invite } from 'generated/sdk'; import { SubmitActionDependencyContainer } from 'hooks/questionary/useSubmitActions'; +import { useBasicUserData } from 'hooks/user/useUserData'; import { ProposalSubmissionState } from 'models/questionary/proposal/ProposalSubmissionState'; const TextFieldNoSubmit = withPreventSubmit(TextField); @@ -38,12 +41,24 @@ function QuestionaryComponentProposalBasis(props: BasicComponentProps) { const [localAbstract, setLocalAbstract] = useState(state?.proposal.abstract); const [hasInvalidChars, setHasInvalidChars] = useState(false); const [textLen, setTextLen] = useState(state?.proposal.abstract ?? 0); + const { featuresMap } = useContext(FeatureContext); + const isLegacyInviteFlow = featuresMap.get( + FeatureId.EMAIL_INVITE_LEGACY + )?.isEnabled; if (!state || !dispatch) { throw new Error(createMissingContextErrorMessage()); } const { proposer, users } = state.proposal; + const { loading, userData } = useBasicUserData(state?.proposal.proposer?.id); + const [piData, setPIData] = useState(null); + + useEffect(() => { + if (userData !== null) { + setPIData(userData); + } + }, [userData]); const coProposersChanged = (users: BasicUserDetails[]) => { formikProps.setFieldValue( @@ -73,6 +88,7 @@ function QuestionaryComponentProposalBasis(props: BasicComponentProps) { }, }); + setPIData(user); // Remove the new PI from the co-proposers list (if present) and add the old PI (if present) to the co-proposers list coProposersChanged( users @@ -157,9 +173,19 @@ function QuestionaryComponentProposalBasis(props: BasicComponentProps) { > {counter} - + {isLegacyInviteFlow ? ( + + ) : ( + + )} + Date: Mon, 6 Oct 2025 22:50:52 +0200 Subject: [PATCH 16/17] fix: update invite handling to use allowInviteByEmail prop consistently --- apps/frontend/src/components/common/UserManagementTable.tsx | 4 ++-- apps/frontend/src/components/proposal/CoProposers.tsx | 2 +- .../frontend/src/components/proposal/ParticipantSelector.tsx | 5 ++--- .../src/components/proposal/PrincipalInvestigator.tsx | 2 +- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/frontend/src/components/common/UserManagementTable.tsx b/apps/frontend/src/components/common/UserManagementTable.tsx index a755f4abe8..b8fb64e5ef 100644 --- a/apps/frontend/src/components/common/UserManagementTable.tsx +++ b/apps/frontend/src/components/common/UserManagementTable.tsx @@ -23,7 +23,7 @@ export type UserManagementTableProps = { users: BasicUserDetails[]; /** Function for setting up the users. */ setUsers: (users: BasicUserDetails[]) => void; - invites: Invite[]; + invites?: Invite[]; setInvites?: (invites: Invite[]) => void; sx?: SxProps; title: string; @@ -42,7 +42,7 @@ export type UserManagementTableProps = { const UserManagementTable = ({ users, setUsers, - invites, + invites = [], setInvites, sx, title, diff --git a/apps/frontend/src/components/proposal/CoProposers.tsx b/apps/frontend/src/components/proposal/CoProposers.tsx index 2cbfa8afdc..881b9336fa 100644 --- a/apps/frontend/src/components/proposal/CoProposers.tsx +++ b/apps/frontend/src/components/proposal/CoProposers.tsx @@ -45,7 +45,7 @@ const CoProposers = ({ // https://github.com/mbrn/material-table/issues/666 users={JSON.parse(JSON.stringify(users))} invites={coProposerInvites} - allowEmailInvites={true} + allowInviteByEmail={true} /> ); }; diff --git a/apps/frontend/src/components/proposal/ParticipantSelector.tsx b/apps/frontend/src/components/proposal/ParticipantSelector.tsx index 9e6878138c..d19dbc0587 100644 --- a/apps/frontend/src/components/proposal/ParticipantSelector.tsx +++ b/apps/frontend/src/components/proposal/ParticipantSelector.tsx @@ -70,11 +70,10 @@ function ParticipantSelector({ onAddParticipants, excludeUserIds, excludeEmails, - allowInviteByEmail, + allowInviteByEmail = false, confirm, preset = [], multiple = true, - allowEmailInvites = false, }: ParticipantSelectorProps & WithConfirmProps) { // multiple selection enabled by default const api = useDataApi(); @@ -377,7 +376,7 @@ function ParticipantSelector({ - allowEmailInvites + allowInviteByEmail ? addValidEmailToSelection(email) : undefined } diff --git a/apps/frontend/src/components/proposal/PrincipalInvestigator.tsx b/apps/frontend/src/components/proposal/PrincipalInvestigator.tsx index 27fc8a762a..8a6922f76e 100644 --- a/apps/frontend/src/components/proposal/PrincipalInvestigator.tsx +++ b/apps/frontend/src/components/proposal/PrincipalInvestigator.tsx @@ -46,7 +46,7 @@ export default function PrincipalInvestigator( excludeUserIds={[users.map((u) => u.id)].flat()} preset={proposer ? [proposer] : []} multiple={false} - allowEmailInvites={false} + allowInviteByEmail={false} /> )} From cb11eb66b97eeffa91387861650e64c5a227539a Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Sun, 12 Oct 2025 15:01:41 +0200 Subject: [PATCH 17/17] test: revert test until new flow activated by all facilities --- apps/e2e/cypress/e2e/proposals.cy.ts | 31 ++++++++++++---------------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/apps/e2e/cypress/e2e/proposals.cy.ts b/apps/e2e/cypress/e2e/proposals.cy.ts index 85ff41ab8b..a9d0b870b1 100644 --- a/apps/e2e/cypress/e2e/proposals.cy.ts +++ b/apps/e2e/cypress/e2e/proposals.cy.ts @@ -3,7 +3,6 @@ import { AllocationTimeUnits, DataType, FeatureId, - FeatureUpdateAction, ProposalEndStatus, SettingsId, TemplateCategoryId, @@ -118,10 +117,6 @@ context('Proposal tests', () => { }); cy.resetDB(); - cy.updateFeature({ - action: FeatureUpdateAction.DISABLE, - featureIds: [FeatureId.EMAIL_INVITE_LEGACY], - }); cy.getAndStoreFeaturesEnabled(); cy.createTemplate({ name: 'default esi template', @@ -237,15 +232,14 @@ context('Proposal tests', () => { cy.finishedLoading(); - cy.get('[data-cy="invite-user-autocomplete"] input[type="text"]') - .type('{backspace}') - .type(initialDBData.users.user2.email); + cy.get('[data-cy=email]').type('ben@inbox.com'); - cy.get('[role="presentation"]') - .contains(initialDBData.users.user2.lastName) - .click(); + cy.get('[data-cy=findUser]').click(); - cy.get('[data-testid="AddIcon"]').click(); + cy.contains('Benjamin') + .parent() + .find("[aria-label='Select user']") + .click(); cy.get('[data-cy="save-and-continue-button"]').focus().click(); @@ -1165,15 +1159,16 @@ context('Proposal tests', () => { cy.finishedLoading(); - cy.get('[data-cy="invite-user-autocomplete"] input[type="text"]') - .type('{backspace}') - .type(initialDBData.users.user2.email); + cy.get('[data-cy=email]').type('ben@inbox.com'); - cy.get('[role="presentation"]') - .contains(initialDBData.users.user2.lastName) + cy.get('[data-cy=findUser]').click(); + + cy.contains('Benjamin') + .parent() + .find("[aria-label='Select user']") .click(); - cy.get('[data-testid="AddIcon"]').click(); + cy.get('[data-cy="save-and-continue-button"]').focus().click(); cy.contains('Proposal Title is required'); cy.contains('Proposal Abstract is required');