Compare commits

..
174 Commits
Author SHA1 Message Date
Charles Bochet 571e9ba4e5 0.34.1 release branch 2024-12-13 16:50:39 +01:00
Charles BochetandGitHub 7e67b1c5a6 Enforce system wide sso providers (#9058)
We have recently introduced the possibility to specify workspace
specific auth providers.
I'm:
- introducing system wide auth providers (provided by clientConfig)
- making sure workspace specific auth providers belong to system wide
auth providers set
2024-12-13 16:38:04 +01:00
Charles BochetandGitHub 57869d3c8c Fix broken chips on timelin eActivity on show pages (#9057)
We were using RecordIndexContext in FieldDisplays components which is
wrong as the FieldDisplays could be used in locations not providing this
context (not being indexes pages).

Instead, we are passing it within FieldContext which the context that
will always be there for FieldDisplays and we need indexes to fill this
FieldContext with their RecordIndexContext value when needed
2024-12-13 15:34:00 +01:00
nitinandGitHub 257834ea71 In navigation drawer, cursor should not be drag on hover and some enhancements (#8975)
as per the title- on hover, the draggable items had a grab cursor.
Couldn't change it to a pointer, because then onClick would have a weird
behavior of grab which causes lag onClick.
2024-12-13 14:37:46 +01:00
Charles BochetandGitHub 9579f22bc2 Move settings data model refreshMetadata to sync calls (#9046)
In this PR, I'm
- removing setting up the isAppWaitingForFreshMetadata boolean state in
PageChangeEffect navigate (not robust) to some precise synchronous
places, improving the control we have on when the app considers it's
ready to be rendered based on fresh metadata
- fixing tests
2024-12-13 13:14:10 +01:00
martmullandGitHub b10d831371 8726 workflow add a test button in workflow code step (#9016)
- add test button to workflow code step
- add test tab to workflow code step


https://github.com/user-attachments/assets/e180a827-7321-49a2-8026-88490c557da2



![image](https://github.com/user-attachments/assets/cacbd756-de3f-4141-a84c-8e1853f6556b)

![image](https://github.com/user-attachments/assets/ee170d81-8a22-4178-bd6d-11a0e8c73365)
2024-12-13 10:16:29 +00:00
07aaf0801c fix: can't properly drag and drop in empty record group (#9039)
Fix #8968 

Fix issue with drag and drop when record group are empty.
Happy to discuss the implementation, as it's limited to the
`@hello-pangea/dnd` api.

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2024-12-13 09:37:23 +01:00
MarieandGitHub d56c815897 [Fix] isLabelSyncedWithName should be nullable (#9028)
isLabelSyncedWithName should be nullable for fieldMetadata, as it is for
objectMetadata.

+ Adding missing validation on label and name sync in
fieldMetadataService for creation and update
+ adding metadata tests
2024-12-12 18:25:40 +01:00
Thomas TrompetteandGitHub 2990d23411 Fix composite field creation (#9044)
- composite field need to be formatted before being saved
- repository.create() does not do it. So we simply lose the composite
fields on the way
- save() does it directly and doing create() before does not change
anything
2024-12-12 16:34:56 +00:00
77c2961912 Fast follows on 0.34 (#9034)
Co-authored-by: Weiko <corentin@twenty.com>
2024-12-12 15:46:48 +00:00
MarieandGitHub 05cd0d1803 [Aggregate queries for table views - #2] Add aggregate queries footer for simple views (#9025)
In this PR, we are introducing aggregate queries on table views, behind
a feature flag.
This does not work with view groups yet, nor with views that have
records until the bottom. (both will be tackled next)
2024-12-12 13:38:58 +01:00
Jérémy MandGitHub 5f2a39d9e9 fix: group by sort options should have a checkmark when selected (#9038)
Fix #8966 

This PR is adding a checkmark beside the selected sort option in the
`Options` dropdown menu.

<img width="225" alt="Screenshot 2024-12-12 at 12 03 18 PM"
src="https://github.com/user-attachments/assets/81783bc0-5aea-4f0d-9327-c70f3e70edb6"
/>
2024-12-12 11:18:53 +00:00
Jérémy MandGitHub d7da73f0ec feat: record group add new (#8925)
Fix #8757

This PR is adding the Add new button on view groups.
Also this PR fix an issue where the pending record can be draggable, and
is causing error.

<img width="1119" alt="Screenshot 2024-12-10 at 4 24 43 PM"
src="https://github.com/user-attachments/assets/4fd01e99-c85e-4a06-a733-cbf3cc32957d">

It also start to issues with the way we're using Context.
We're initializing pretty much all Context like this:

```typescript
export const RecordTableContext = createContext<RecordTableContextProps>(
   {} as RecordTableContextProps,
 );
```

This is causing issues when by mistake we use the context like this
outside the Provider hierarchy:

```typescript
const context = useContext(RecordTableContext);
```

This is going to fail silently, and all the context variables become
undefined...

To fix this I've introduced an util called `createRequiredContext`, this
one is returning an array containing the provider and the hook to
retrieve the context.
The context is initialized to undefined inside this utility, this way we
can check if the value has been initialized with the provider to check
if we're inside it. It'll throw an error if this one is used outside the
provider.
The return values are properly typed, so `undefined` is not added to the
value of the Context.

I'll create a followup ticket to use this new utility function, if
that's ok and replace it everywhere in the codebase.
We can also consider adding a eslint rule to warn about the use of
`createContext` directly.
2024-12-12 11:50:13 +01:00
BOHEUSandGitHub 182ebb6394 Playwright tests - stage 1 - login with email and password test (#8988)
Scenario:
https://github.com/twentyhq/twenty/issues/8469#issuecomment-2471420099

To launch this test, `yarn playwright test --project Authentication`
must be used in `packages/twenty-e2e-testing` directory (for some reason
when launching this test from IDE, be Webstorm or VSCode, it won't fetch
the data from .env)
2024-12-12 11:05:25 +01:00
bce5be85a3 add info to customer table and stripe customer data (#9004)
Solves (https://github.com/twentyhq/private-issues/issues/194)

**TLDR**

Updates the billingCustomer table data using stripe webhooks event, also
updates the customer's metadata in stripe, in order to contain the
workspaceId associated to this customer.

**In order to test**
Billing:

- Set IS_BILLING_ENABLED to true
- Add your BILLING_STRIPE_SECRET and BILLING_STRIPE_API_KEY
- Add your BILLING_STRIPE_BASE_PLAN_PRODUCT_ID (use the one in testMode
> Base Plan)
- 

Authenticate with your account in the stripe CLI
Run the command: stripe listen --forward-to
http://localhost:3000/billing/webhooks

Run the twenty workker

Authenticate yourself on the app choose a plan and run the app normally.
In stripe and in posgress the customer table data should be added.

**Next steps**
Learn more about integrations tests and implement some for this PR.

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
2024-12-12 08:00:39 +01:00
WeikoandGitHub c776179ecc start 0.40.0 canary (#9029) 2024-12-11 19:23:51 +01:00
Charles BochetandGitHub 90c26643a8 Fix race condition while loading metadata on sign in (#9027) 2024-12-11 18:56:02 +01:00
khudditeandGitHub 183fd877c4 Add a confirmation modal for relation object deletion (#8818)
Fixes #8698 

1. Summary
We decided to add a confirmation modal for the relation object deletion.
It's gonna a bit of safety to the user interactions because this action
can be disruptive even though it can be restored.

2. Solution
Used `createPortal` function to address the issue where the vertical
scrollbar shows over the modal. Added a logic that displays a
confirmation modal for deletion in [this
file](https://github.com/twentyhq/twenty/blob/d284419d66cf52e418d6622b9635334486b51040/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsListItem.tsx).
I can update the text(title, description, and CTA) as necessary based on
the feedback.

_**However, I observed an issue that the deleted object still shows up
under the list until hard-refresh. I figured that can be addressed as a
separate issue.**_

3. Recording


https://github.com/user-attachments/assets/1a64b702-a915-49f3-a226-2c2d5af8a1d7
2024-12-11 18:23:27 +01:00
07de458fda fix:Hide Scrollbar before Scroll (#8896)
Fix for the issue #8847 Hide Scrollbar before Scroll.


https://github.com/user-attachments/assets/27dda89f-e3f6-4c72-bcc5-8c7e10d3c823

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: ehconitin <nitinkoche03@gmail.com>
2024-12-11 22:44:46 +05:30
Thomas TrompetteandGitHub 4d9facb9bd Add address composite form field (#9022)
- Create FormCountrySelectInput using the existing FormSelectFieldInput
- Create AddressFormFieldInput component
- Fix FormSelectFieldInput dropdown + add leftIcon

<img width="554" alt="Capture d’écran 2024-12-11 à 15 56 32"
src="https://github.com/user-attachments/assets/c3019f29-af76-44e1-96bd-a0c6283674e1"
/>
2024-12-11 16:57:42 +00:00
Raphaël BosiandGitHub 2c4a77a7b7 Refactor RecordShowActionMenu to use contextual actions (#9023)
Closes #8736
2024-12-11 15:24:02 +00:00
224b6d1334 Fix login issue (#9012)
Co-authored-by: Weiko <corentin@twenty.com>
2024-12-11 15:42:53 +01:00
Raphaël BosiandGitHub ad04040ea6 8976 modify mobile page header (#9020)
Closes #8976
2024-12-11 15:06:09 +01:00
6067852c1c fix(chip): update padding for transparent variant (#7164) (#8823)
This PR addresses the issue described in #7164, where the Chip
component's padding for the Transparent variant was not correctly
aligned. The following changes have been made:

The padding-left for the Transparent variant is now set to 2px to
improve alignment.
Other chip variants retain the default horizontal padding values.

These changes ensure that the Transparent chip variant is rendered
consistently with the rest of the design, as described in #7164.

See screenshots below for before and after comparison:
Before:

![image](https://github.com/user-attachments/assets/7749e393-3a9b-4367-a6f9-cb32a9897e7d)

![image](https://github.com/user-attachments/assets/2ad80002-f867-4bf2-b27c-29392b008ed9)

After:
![Screenshot 2024-12-01 at 4 02
08 AM](https://github.com/user-attachments/assets/71fb19a1-00fb-4ea3-989f-185c92221d5e)

![image](https://github.com/user-attachments/assets/7cc93c5e-d42b-4ada-bd27-178836b660b4)

Please review @Bonapara and let me know if you have any feedback or
concerns.

---------

Co-authored-by: guillim <guigloo@msn.com>
2024-12-11 14:52:55 +01:00
GuillimandGitHub 841b1f0e11 Thomas request (#9017)
Design request
2024-12-11 13:50:33 +01:00
Raphaël BosiandGitHub 5c60a5511e 8929 move recordindexactionmenu menu inside the recordindexpageheader (#9007)
Closes #8929 

Users with the feature flag `IS_PAGE_HEADER_V2_ENABLED` set to false
will still have the old behavior with the action bar.

To test the PR, test with and without the feature flag.
2024-12-11 11:51:33 +01:00
GuillimandGitHub 89f6f32243 Title overflow (#9009)
fix #8803
2024-12-11 11:37:08 +01:00
c1fff908fe Fix:Hide deleted filter component when reset button is clicked (#8880)
Resolve: #8874 

Problem :
When we are on the deleted records page and use the filter, if no
records are found, we see the no deleted recordName message along with a
button to remove the deleted filter. However, if we reset and filter
again, and still don't find any records, this message and button for the
deleted filter continue to display.
Solution: 
I noticed that the component RecordTableEmptyStateSoftDelete has this
button, and its visibility is controlled by the function
toggleSoftDeleteFilterState. If the state is true, the button appears;
if it's false, it doesn't. Therefore, we just need to call this function
when the reset button is clicked and set the state to false.
![All-Peopleand1morepage-Personal-MicrosoftEdge2024-12-0421-04-12-ezgif
com-video-to-gif-converter](https://github.com/user-attachments/assets/68e524ff-2902-4a25-a361-3bb8e1220ff8)

---------

Co-authored-by: bosiraphael <raphael.bosi@gmail.com>
2024-12-11 10:06:23 +00:00
MarieandGitHub 8ecf07f112 [Aggregate queries for table views - #1] Introduce aggregateOperationForViewFieldState (#9010)
Introducing aggregateOperationForViewFieldState to add a state storing
the aggregate operation for each view field
2024-12-10 18:03:38 +00:00
Thomas TrompetteandGitHub b6e02b630d Add full name composite field (#9008)
- Add composite field
- Add test
- Fix search variable dropdown scroll
<img width="548" alt="Capture d’écran 2024-12-10 à 16 52 43"
src="https://github.com/user-attachments/assets/c337f0c3-8a70-401a-abd0-7206489ba73e">
2024-12-10 16:29:55 +00:00
WeikoandGitHub 96d56f8883 Fix removeBillingFKWithCore migration location (#9006)
removeBillingFKWithCore migration is in the wrong folder and is not
executed as intended.
Moving to billing folder to fix that and to be only run in billing mode
2024-12-10 16:33:52 +01:00
Lucas BordeauandGitHub 013eda4a32 Fixed dropdown bugs on 0.34 (#9000)
- New task dropdown wasn't using the proper dropdown id
- Action menu triggered by context menu (right click) on table cell was
listening in edit mode.
2024-12-10 16:01:03 +01:00
WeikoandGitHub b0595e452a Fix add-subdomain-to-workspace command (#9005)
## Context
Fix add-subdomain-to-workspace command not included in global module
also fixing the command regex logic that was not generating subdomain
properly
2024-12-10 15:32:13 +01:00
Raphaël BosiandGitHub 90d47a8fca Fix favorite button (#9001)
Closes #8977
2024-12-10 15:20:37 +01:00
GuillimandGitHub afd3252cbe Settings options (#8996)
- [x] text change

![image](https://github.com/user-attachments/assets/c7304052-18ad-4870-a5ad-5ea73aedcad1)

- [x] CSS for settings cards
Current :

![image](https://github.com/user-attachments/assets/8fd9b3cc-c3df-47a0-9ccb-1a0417f076f2)
Expected :  

![image](https://github.com/user-attachments/assets/8a4e7f17-8ee8-4664-b8b0-5b2857568570)
2024-12-10 13:23:00 +00:00
Félix MalfaitandGitHub 0a8960c2ed Fix broken workspace deletion (#9002)
Fixes #8985
2024-12-10 14:13:58 +01:00
Raphaël BosiandGitHub f0dbeea704 Remove buttons for activity empty state (#8999)
Before:
<img width="482" alt="Capture d’écran 2024-12-10 à 11 17 53"
src="https://github.com/user-attachments/assets/3641e902-10b9-4fbf-8383-d6e5d5b2de79">

After:
<img width="453" alt="Capture d’écran 2024-12-10 à 11 33 01"
src="https://github.com/user-attachments/assets/9f949933-0740-4c91-abf3-2c1817a68968">
2024-12-10 11:09:40 +00:00
Raphaël BosiandGitHub c553361f25 Fix RecordDetailSectionHeader spacing (#8997)
Before:
<img width="339" alt="Capture d’écran 2024-12-10 à 11 02 30"
src="https://github.com/user-attachments/assets/4fd1d598-0efe-4ee9-931e-dd307af21c95">

After:
<img width="345" alt="Capture d’écran 2024-12-10 à 11 02 19"
src="https://github.com/user-attachments/assets/fb72a661-ac66-43c7-906c-9020171dbf3b">
2024-12-10 11:16:45 +01:00
Raphaël BosiandGitHub d70987653b 8964 add feature flag is page header v2 enabled (#8995)
Closes #8964
2024-12-10 10:56:28 +01:00
Thomas des FrancsandGitHub 228b7386ea API & Webhooks doc quick fix (#8994)
Quick fix for https://github.com/twentyhq/twenty/issues/8990
2024-12-10 10:39:38 +01:00
Pradeep KaswanandGitHub 49c3250519 update: Replace reactflow with @xyflow/react and Update Dependencies (#8940)
## Description:

This pull request includes the following changes:

1. **Dependency Update**:
   - Added `@xyflow/react` as a dependency.
- Replaced imports from `reactflow` with `@xyflow/react` in multiple
components.

2. **Dependency Removal**:
- Removed `reactflow` dependency and its related code from the project.

3. **Type Definitions Update**:
- Updated type definitions for nodes and edges to align with the new
library.

4. **Code Refactoring**:
- Minor refactoring for improved code clarity and consistency in the
following components:
     - `SettingsDataModelOverview`
     - `SettingsDataModelOverviewEffect`
     - `SettingsDataModelOverviewField`
     - `SettingsDataModelOverviewObject`

5. **Dependency Files Update**:
- Updated `package.json` and `yarn.lock` to reflect the changes in
dependencies.

## Additional Notes:
- These changes ensure that the project is now using the `@xyflow/react`
library instead of `reactflow`.
- The refactoring improves code readability and maintains consistency
across the components.
- Please review the changes and provide any feedback or suggestions.

## Testing:
- The changes have been tested locally and verified to work as expected.

## Checklist:
- [x] Added `@xyflow/react` as a dependency.
- [x] Replaced `reactflow` imports with `@xyflow/react`.
- [x] Removed `reactflow` dependency.
- [x] Updated type definitions for nodes and edges.
- [x] Refactored components for improved clarity and consistency.
- [x] Updated `package.json` and `yarn.lock`.
- [x] Tested the changes locally.

## Related Issue
Fixes #6662
2024-12-10 10:21:01 +01:00
28ff5610f0 better label for toggle input that will improve UX (#8982)
Answering the ticket : Increase the clickable area of toggles to parent
container

# Change in Labels

In order to solve this ticket, we change the way we handle the labels VS
the inputs. The use of `htmlFor` and setting up the appropriate id for
the toggle solves the "blank click"
<img width="239" alt="Screenshot 2024-12-09 at 17 43 53"
src="https://github.com/user-attachments/assets/19eab5fc-8393-496d-b713-57e51c620bf1">

fix #8953

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
2024-12-10 09:41:04 +01:00
nitinandGitHub 0a61550f43 View picker minor change - favorite fast followups (#8987)
Clicking "Add to favorite" should add the record as a favorite. It
shouldn't open the menu on the first click; it should only open on the
second click when the record is already a favorite.
2024-12-10 09:39:04 +01:00
f4ef51fbe5 Fix : Small fixes for the options menu (#8983)
Fix #8789 : fixed options menu with current view hook to fetch the view
name

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
2024-12-10 09:19:46 +01:00
0654d814c6 Added Button Shortcut Style Enhancements + Additional Storybook Tests (#8947)
Resolves Issue #4871 (or enhances)

**Additions**
- **Customized Styling for Button Shortcuts:** Previously, button
shortcuts/separator styling was standardized. We added customized
styling for optional button shortcuts based on button accents and
variants.
- **Enhanced Storybook Catalogs:** We modified ShortcutCatalog in
Button.stories.tsx to display button shortcuts across styles and various
inputs.

![Screenshot 2024-12-07 at 9 16
35 PM](https://github.com/user-attachments/assets/f56ec269-d5d4-45d7-b490-e5632371f9b2)
![Screenshot 2024-12-07 at 9 16
30 PM](https://github.com/user-attachments/assets/04c3b7a9-a0bb-49fc-ae30-6e488f9658dd)
![Screenshot 2024-12-07 at 9 16
22 PM](https://github.com/user-attachments/assets/a22847c2-f5da-4a10-9a0d-f07389e1751e)

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
2024-12-10 08:27:11 +01:00
azaurus1andGitHub bb5cc7e30c Update PG_DATABASE_URL to match Service name in k8s manifests (#8989)
### Issue Summary
The `service` name in the Kubernetes manifest for the PostgreSQL
database is defined as `twentycrm-db`. However, the `DSN` configuration
in the Server and Worker `deployments` incorrectly references
`twenty-db`. This mismatch causes the pods to fail during initialization
and crash on boot.

### Fix Description
- Updated the `DSN` values in both the Server and Worker `deployments`
to correctly reference `twentycrm-db`, ensuring alignment with the
database `service` name.
- Verified the deployments to ensure the pods start without issues after
the fix.

### Testing
- Deployed the updated manifests in a development environment.
- Confirmed that both Server and Worker pods initialize correctly and
connect to the database service as expected.
2024-12-10 08:21:38 +01:00
23015de996 Add migration ci check (#8867)
Fixes #8865

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
2024-12-09 17:15:24 +01:00
GuillimandGitHub 310bba758b fix (#8963)
fixing 

![image](https://github.com/user-attachments/assets/96841950-17a9-4e71-b09e-f505c40a736a)
2024-12-09 13:44:19 +00:00
nitinandGitHub 8d469b9bc3 Swap dark placeholders (#8956)
closes #8853
2024-12-09 14:21:10 +01:00
Raphaël BosiandGitHub 387f5259a7 8928 refactor page header buttons (#8952)
Closes #8928 

<img width="1296" alt="Capture d’écran 2024-12-09 à 10 26 37"
src="https://github.com/user-attachments/assets/f33202b0-9c11-48da-8daa-e867d62a1803">
<img width="1296" alt="Capture d’écran 2024-12-09 à 10 26 54"
src="https://github.com/user-attachments/assets/a94f89d4-ca12-403f-bfcb-12168a82f77c">
2024-12-09 10:55:33 +01:00
nitinandGitHub ef0ae2568d View picker dropdown position fix (#8945)
as per title
2024-12-07 18:26:41 +01:00
Lucas BordeauandGitHub 1749793bc1 More fixes (#8931)
Fixes https://github.com/twentyhq/twenty/issues/8888
2024-12-07 16:49:53 +01:00
Antoine MoreauxandGitHub 2524d64687 Feat/improve error management in core module (#8933)
## Summary
This Pull Request introduces a custom validator for checking forbidden
words in workspaces and refines how exceptions are handled within the
workspace module.

- Introduced `ForbiddenWords` custom class validator for validating
forbidden words against specific fields in `UpdateWorkspaceInput`.
- Added `EnvironmentService` usage in `WorkspaceService` to check
default subdomains.
- New file `workspaceGraphqlApiExceptionHandler` to handle GraphQL API
exceptions with specific error mappings.
- Expanded `WorkspaceExceptionCode` with `SUBDOMAIN_ALREADY_TAKEN`.
- Added new unit tests for validating forbidden words and exception
handler behavior.
2024-12-07 16:48:53 +01:00
Charles BochetandGitHub 99f53a5030 Fix broken image urls in Settings > Profile and Invite To Workspace Email (#8942)
Fixes #8601

We had 3 implementations of getImageAbsoluteURI: in twenty-front, in
twenty-ui and in twenty-emails. I was able to remove the one in
twenty-front but I could not remove it from twenty-emails as this is a
standalone for now. The vision is to introduce shared utils in a
twenty-shared package
2024-12-07 14:57:32 +01:00
Charles BochetandGitHub ab8ad46685 Fix bug reoder on table in view groups mode (#8894)
Fixes #8403 

Added comments on the PR to explain changes
2024-12-06 19:19:27 +01:00
f36555bdc0 Fix infinite loading on field settings (#8938)
We were experiencing infinite loading on field settings pages (creation
of new field), due to the fact that the component was being rendered on
and on and on.
This was due to useGetCurrentUserQuery calls outside of the update
function, causing renders in cascade. We also had an issue with the
component being unmounted too often.

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2024-12-06 18:46:06 +01:00
b2be2fb398 Fix advanced settings tool icon alignment (#8935)
Followup of @bosiraphael's design

<img width="333" alt="Screenshot 2024-12-06 at 17 25 45"
src="https://github.com/user-attachments/assets/b744a7a6-99cb-4d4c-b9da-df0661536208">
<img width="614" alt="Screenshot 2024-12-06 at 17 26 19"
src="https://github.com/user-attachments/assets/e9f31fed-66b2-4ed7-a5cd-97a9bc526752">

---------

Co-authored-by: bosiraphael <raphael.bosi@gmail.com>
2024-12-06 18:07:37 +01:00
Antoine MoreauxandGitHub 9bbb963a08 refactor(settings): nest support section within workspace (#8937)
Refactor the settings structure to include the support section within
the workspace feature when multi-workspace is enabled. This update
provides a more consistent user interface by grouping related settings,
thereby enhancing the manageability of workspace-specific actions.

Fix #8914
2024-12-06 17:58:46 +01:00
Antoine MoreauxandGitHub 6285186ead doc(tips): add tips to inform on authenticate methods settings (#8936)
Fix #8879
2024-12-06 17:54:47 +01:00
GuillimandGitHub 4208bd2b83 Settings Icon CSS update (#8934)
Current :

![image](https://github.com/user-attachments/assets/985e4f1b-6379-43ee-9311-25b455d8f5c0)

Expected : 
<img width="594" alt="Screenshot 2024-12-06 at 17 01 45"
src="https://github.com/user-attachments/assets/a26a5415-ecd2-4111-a093-ae049b734925">


Design Request from @Bonapara 😸
2024-12-06 17:47:28 +01:00
Raphaël BosiandGitHub 9c67a109af Fix SingleRecordSelectMenuItemsWithSearch (#8932)
- Fix all fields being selected when opening dropdown
- Fix not being able to set back to no selection when a record is
selected and there is only one record to select

Before:



https://github.com/user-attachments/assets/31185a05-7e1a-4550-aa2a-591683934224


After:


https://github.com/user-attachments/assets/e3cd388b-44de-42e2-970b-b4d4f31253bc
2024-12-06 17:41:58 +01:00
Thomas TrompetteandGitHub e1a0259154 Split record crud actions (#8930)
Having a global record crud action adds complex logic.
We decided to split those actions. I only kept a common folder / module
in backend.

⚠️ this may break existing workflows if these were using previous
actions!
2024-12-06 16:58:57 +01:00
229a93e41a Fix advanced settings icon tool and right bar alignment (#8910)
Before:
<img width="253" alt="Capture d’écran 2024-12-05 à 18 27 58"
src="https://github.com/user-attachments/assets/73f943d9-d5be-4f0c-8b65-a2c0a8420b80">


After:
<img width="233" alt="Capture d’écran 2024-12-05 à 18 27 45"
src="https://github.com/user-attachments/assets/e3992655-fd1f-4748-91e7-3f4e3a2a5fda">

Co-authored-by: Weiko <corentin@twenty.com>
2024-12-06 15:37:46 +00:00
Lucas BordeauandGitHub a9cb20f317 Refactor and fixes dropdown bugs (#8807)
Fixes https://github.com/twentyhq/twenty/issues/8788
Fixes https://github.com/twentyhq/twenty/issues/8793
Fixes https://github.com/twentyhq/twenty/issues/8791
Fixes https://github.com/twentyhq/twenty/issues/8890
Fixes https://github.com/twentyhq/twenty/issues/8893

- [x] Also : 

Icon buttons under dropdown are visible without blur : 

![Capture d’écran du 2024-11-29
15-09-53](https://github.com/user-attachments/assets/f563333d-4e43-4ded-acc7-62e116004ed9)

- [x] Also : 

<img width="237" alt="image"
src="https://github.com/user-attachments/assets/e4c70936-beff-4481-89cb-0a32a36e0ee2">

- [x] Also : 

<img width="335" alt="image"
src="https://github.com/user-attachments/assets/5be60395-6baf-49eb-8d40-197add049e20">

- [x] Also : 

<img width="287" alt="image"
src="https://github.com/user-attachments/assets/a317561f-7986-4d70-a1c0-deee4f4e268a">

- Button create new without padding
- Container is expanding

- [x] Also : 

<img width="303" alt="image"
src="https://github.com/user-attachments/assets/09f8a27f-91db-4191-acdc-aaaeedaf6da5">

- [x] Also : 

<img width="133" alt="image"
src="https://github.com/user-attachments/assets/fe17b32e-f7a4-46c4-8040-239eaf8198e8">

Font is cut at bottom ?

- [x] Also : 

<img width="385" alt="image"
src="https://github.com/user-attachments/assets/7bab2092-2936-4112-a2ee-d32d6737e304">

The component should flip and not resize in this situation

- [x] Also : 

<img width="244" alt="image"
src="https://github.com/user-attachments/assets/5384f49a-71f9-4638-a60c-158cc8c83f81">

- [x] Also : 


![image](https://github.com/user-attachments/assets/9cd1f43a-df59-401e-9a41-bdb8e93ebe58)
2024-12-06 14:27:48 +00:00
14b7bcf262 Fix phone not reactive (#8918)
Fixin reactivity on the multi items phone component (add/delete/edit
phones)

<img width="325" alt="Screenshot 2024-12-06 at 11 03 34"
src="https://github.com/user-attachments/assets/96ddaf82-19ae-4a09-aa45-04caad2f0af1">

---------

Co-authored-by: khuddite <khuddite@gmail.com>
Co-authored-by: Weiko <deniaud.corentin@gmail.com>
2024-12-06 14:45:14 +01:00
36fb14179b feat(workspace): Add subdomain availability check (#8906)
Implemented a feature to check the availability of subdomains when
updating workspace settings. This includes a new mutation,
`isSubdomainAvailable`, to validate subdomain availability through
GraphQL. The frontend now verifies if a subdomain is available to
prevent duplicates during updates.

---------

Co-authored-by: Weiko <corentin@twenty.com>
2024-12-06 13:28:30 +00:00
Thomas TrompetteandGitHub 5c565345ae Add delete record action (#8908)
<img width="1272" alt="Capture d’écran 2024-12-05 à 18 04 19"
src="https://github.com/user-attachments/assets/dcbcc761-2f6d-4b6d-9e10-0f5b25d12c39">

- Adding new action
- Adding tests
- Moving into action folder
2024-12-06 13:02:17 +01:00
Antoine MoreauxandGitHub ab22fd560a fix(): Add default name to workspace in MultiWorkspaceDropdownButton (#8924)
Fix #8923
2024-12-06 12:23:02 +01:00
martmullandGitHub 2b3b073570 8725 workflow avoid serverless function autosave errors (#8916)
See issue #8725 
- Build function asynchronously using a job
- prevent useless builds
- run promises simultaneously

Todo:
- fix outputSchema computing
2024-12-06 10:13:12 +00:00
Raphaël BosiandGitHub a8867fd090 Remove advanced settings wrapper from security page (#8912)
Before:
Without advanced settings toggled:
<img width="1512" alt="Capture d’écran 2024-12-05 à 19 12 53"
src="https://github.com/user-attachments/assets/efe550e2-5b7f-483f-9726-254acefb2872">

With advanced settings toggled:
<img width="1512" alt="Capture d’écran 2024-12-05 à 19 13 01"
src="https://github.com/user-attachments/assets/2fab6843-5be1-4d5b-83c4-94c22c85d4d3">

After:
Without advanced settings toggled:
<img width="1512" alt="Capture d’écran 2024-12-05 à 19 12 34"
src="https://github.com/user-attachments/assets/72d87281-3822-42d7-816b-403724052678">

With advanced settings toggled:
<img width="1512" alt="Capture d’écran 2024-12-05 à 19 12 26"
src="https://github.com/user-attachments/assets/34c87be4-d528-41a8-b269-e274939a75c7">
2024-12-06 00:17:47 +01:00
50af41f170 Remove duplicate plus sign for phone numbers (#8871)
Fixes #8838 

1. Summary
It seems [this PR](https://github.com/twentyhq/twenty/pull/8614) caused
the issue. We added a plus sign on front-end when the
[callingCode](https://github.com/twentyhq/twenty/blob/c735026f6c4f011e82e80713f1554b5cdb4fda5b/packages/twenty-front/src/modules/ui/field/display/components/PhonesDisplay.tsx#L70)
retrieved from back-end already has a plus sign.

2. Solution
@guillim Please let me know if I missed a case where the plus sign is
not there for the `callingCode`. If so, I think we should check whether
or not `callingCode` has a leading plus sign on front-end before adding
it. For now, I just removed the code that appends a plus sign on
front-end.

3. Screenshots

![localhost_3001_objects_people_view=f4aee583-9d0c-4961-a6e1-fd66bc51dfd5](https://github.com/user-attachments/assets/9fe8b361-47b9-4e3f-82b7-570713cf430c)

---------

Co-authored-by: guillim <guigloo@msn.com>
Co-authored-by: Guillim <guillim@users.noreply.github.com>
Co-authored-by: Weiko <deniaud.corentin@gmail.com>
2024-12-05 22:41:14 +01:00
GuillimandGitHub 4623b6127b Dropdown in dropdown (#8911)
Dropdown in dropdown bug
while still fixing scroll issue 
fix #8716
2024-12-05 19:07:19 +01:00
f60ce384c6 CSV importing and exporting fixes (#8824)
Fixes issue https://github.com/twentyhq/twenty/issues/5793 (and
duplicate https://github.com/twentyhq/twenty/issues/8822)

- Fix importing multi-select and array fields.
- Fix exporting and importing RAW_JSON fields.

---------

Co-authored-by: ad-elias <elias@autodiligence.com>
2024-12-05 18:44:53 +01:00
WeikoandGitHub 815e5dfa16 Fix migration folder (#8909)
## Context
Common migrations now live in a different directory however the PR that
added that behavior was not up to date with the few migrations that were
merged recently.

This PR moves the migrations to the correct folder so they can be
executed properly.

## Test
reset-db

Before
<img width="1505" alt="Screenshot 2024-12-05 at 18 14 13"
src="https://github.com/user-attachments/assets/6cabf094-805a-4101-aeb2-3678497c2947">
After
<img width="1200" alt="Screenshot 2024-12-05 at 18 15 40"
src="https://github.com/user-attachments/assets/3ae59af5-d615-48e0-bfde-9467770acc27">
2024-12-05 18:19:22 +01:00
Raphaël BosiandGitHub c23896c5dc Fix dropdown overflow (#8907)
Fixes:
<img width="267" alt="Capture d’écran 2024-12-03 à 14 59 24"
src="https://github.com/user-attachments/assets/73278208-25d2-4225-a632-eb37cc69fcd6">
2024-12-05 18:12:59 +01:00
Raphaël BosiandGitHub de56c01206 8733 refactor gmailhandleerrorservice (#8901)
Closes #8733 
- Refactor `GmailHandleErrorService`
- Add tests and mocks for the errors
2024-12-05 17:57:57 +01:00
Antoine MoreauxandGitHub 680366e998 fix(): validate subdomain (#8902)
Fix #8876
2024-12-05 17:30:23 +01:00
Antoine MoreauxandGitHub f4c5d03b98 fix(): redirect loop + signout (#8899) 2024-12-05 16:24:24 +01:00
11d244194f Add billing tables (#8772)
Beforehand, the name of the branch is not representative of the work
that has been done in this PR

**TLDR:**

Solves https://github.com/twentyhq/private-issues/issues/192
Add 3 tables BillingCustomer, BillingProduct and BillingPrice and
BillingMeter to core, inspired by the Stripe implementation. Separates
migration, between common and billing on order to not populate the db of
the self-hosting instances with unused tables.

**In order to test:**

Run the command:
npx nx typeorm -- migration:run -d
src/database/typeorm/core/core.datasource.ts


**Considerations:**

I only put the information we should use right now in the Billing
module, for instance columns like meter or agreggation formula where
omitted in the creation of the tables.
These columns and other ones who fall on the same spectrum will be added
as we need them.

If you want to add more information to the table, I'll leave some
utility links down bellow:

- BillingPrices: https://docs.stripe.com/api/prices/object
- BillingCustomer: https://docs.stripe.com/api/customers/object
- BillingProduct:  https://docs.stripe.com/api/products/object

**Next Steps**

Use the Stripe Webhook in order to update the tables accordingly

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
2024-12-05 16:17:35 +01:00
WeikoandGitHub c993f2de0b Fix user email unique constraint (#8898)
## Context
Fix wrong unique index on user email as we don't want an index on
email/deletedAt but a partial on a where condition on deletedAt. This
should enforce email unicity excluding the ones that have a deletedAt

## Test
Run
```sql
SELECT email, COUNT(*) as duplicate_count
FROM core."user"
WHERE "deletedAt" IS NULL
GROUP BY email
HAVING COUNT(*) > 1
ORDER BY duplicate_count DESC;
```
to check duplicates before running the migration
2024-12-05 15:54:12 +01:00
MarieandGitHub 26ff344f56 Refetch aggregate queries on record creation/update/deletion of record (#8885)
Closes #8755.
Refetching the aggregate queries on an object following creation,
update, deletion of a record.
2024-12-05 15:23:54 +01:00
GuillimandGitHub 9ed9b4746a Settings Form Select refacto (#8875)
fixes #8751
2024-12-05 13:44:24 +00:00
GuillimandGitHub 6c78cac908 Fix wrapping text side effects (#8895)
- ellipsis on the letter level instead of work level
- summaryCard title line-height diffferent from the record line-height
2024-12-05 14:43:16 +01:00
martmullandGitHub 455e548bea 8839 workflow follow up code step (#8856)
- add readonly mode
- fix falsy stepOutput computation
2024-12-05 13:26:28 +00:00
Antoine MoreauxandGitHub 081ecbcfaf review(front): refacto url-manager (#8861)
Replace https://github.com/twentyhq/twenty/pull/8855
2024-12-05 11:47:51 +01:00
7ab00a4c82 feat: record group fetch more (#8868)
Fix #8756 

This PR is adding a load more button when we're grouping by a field in
table view.
Only 8 records are printed at first, and a click on `Load more` is
needed to show more records.

<img width="1347" alt="Screenshot 2024-12-04 at 11 54 15 AM"
src="https://github.com/user-attachments/assets/4ad6ad4f-8de9-424d-b7b6-5f82f28c53df">
<img width="1352" alt="Screenshot 2024-12-04 at 11 54 23 AM"
src="https://github.com/user-attachments/assets/2a94b4ac-7285-4ba2-9cff-d2f653e36302">

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2024-12-05 10:51:56 +01:00
Thomas des FrancsandGitHub b686e0b2e6 Update BackgroundDark.ts (#8883)
Fixes #8877

Improved Contrast for better modal visibility

### After
![CleanShot 2024-12-05 at 09 39
59](https://github.com/user-attachments/assets/1a8723d3-fe2b-488f-9e45-7f396af0cbb4)

### Before
![CleanShot 2024-12-05 at 09 39
05](https://github.com/user-attachments/assets/b2882e37-265b-42db-8bed-d32e756cc737)
2024-12-05 10:50:34 +01:00
Thomas TrompetteandGitHub 36e4357bb1 Select full record in variable dropdown (#8851)
Output schema is now separated in two sections:
- object, that gather all informations on the selectable object
- fields, that display object fields in a record context, or simply the
available fields from the previous steps

The dropdown variable has now a new mode:
- if objectNameSingularToSelect is defined, it goes into an object mode.
Only objects of the right type will be shown
- if not set, it will use the already existing mode, to select a field

When an object is selected, it actually set the id of the object



https://github.com/user-attachments/assets/1c95f8fd-10f0-4c1c-aeb7-c7d847e89536
2024-12-05 09:48:34 +00:00
Antoine MoreauxandGitHub 33e69805cb review(): from PR #8656 (#8870) 2024-12-05 10:46:13 +01:00
Baptiste DevessierandGitHub 99caab1412 Select all fields even those not loaded yet when doing Ctrl+A or Cmd+A on Record Table (#8849)
Solves
https://github.com/twentyhq/twenty/pull/8282#issuecomment-2508641480
2024-12-05 06:11:48 +01:00
02f34211b5 Updated email invitation logic to include sender details in the From … (#8858)
Updated email invitation logic to include sender details in the From
field.

Please feel free to provide comments so that we can make adjustments as
early as possible if needed


Fixes #7001

---------

Co-authored-by: Your Name <your@email.address>
Co-authored-by: Félix Malfait <felix@twenty.com>
2024-12-05 05:52:35 +01:00
Raphaël BosiandGitHub 078e23154a Update messaging and calendar doc (#8869)
Update messaging and calendar doc
2024-12-04 17:30:50 +01:00
Jérémy MandGitHub 2fa5fb7de7 fix: record group issues (#8854)
This PR is fixing the following issues with record groups:

- [x] [Backend] - Only update view groups when a field is edited if this
one already has view groups
- [x] [Backend] - Editing a Select field metadata option brake view
groups
- [x] [Frontend] - Changing the group by field from one to another brake
record group and doesn't remove the previous ones
- [x] [Frontend & Backend] - Mark `kanbanFieldMetadataId` as deprecated
in favour of `viewGroups.fieldMetadataId`

Also the following has been checked:

- [x] Properly displayed a table with only one view groups
- [x] Properly displayed a table without view groups
2024-12-04 16:03:25 +01:00
Baptiste DevessierandGitHub 9142bdfb92 Add Select form field (#8815)
Closes https://github.com/twentyhq/twenty/pull/8815

I took inspiration from existing parts of the codebase. Please, see the
comments I left below.

Remaining questions:

- I'm not sure about the best way to handle hotkey scopes in the
components easily



https://github.com/user-attachments/assets/7a6dd144-d528-4f68-97cd-c9181f3954f9
2024-12-04 15:39:14 +01:00
GuillimandGitHub 2c0d3e93d2 Error 500 (#8845) 2024-12-04 14:03:06 +00:00
WeikoandGitHub c735026f6c Fix seed-demo command (#8866)
## Context
Fix seed demo command since we added a subdomain non-nullable column we
needed to update the command to fill that info for the demo workspace
2024-12-04 11:24:48 +01:00
Raphaël BosiandGitHub 743169a260 Fix message participant assignation bug (#8857)
Closes #8846
2024-12-03 23:27:08 +01:00
2fc247cb21 Display and update aggregate queries in kanban views (#8833)
Closes #8752, #8753, #8754

Implements usage of aggregate queries in kanban views.

https://github.com/user-attachments/assets/732590ca-2785-4c57-82d5-d999a2279e92

TO DO

1. write tests + storybook
2. Fix values displayed should have the same format as defined in number
fields + Fix display for amountMicros

---------

Co-authored-by: Weiko <corentin@twenty.com>
2024-12-03 22:46:57 +01:00
WeikoandGitHub 5e891a135b Fix CI (#8862) 2024-12-03 21:58:43 +01:00
Antoine MoreauxandGitHub 7943141d03 feat(*): allow to select auth providers + add multiworkspace with subdomain management (#8656)
## Summary
Add support for multi-workspace feature and adjust configurations and
states accordingly.
- Introduced new state isMultiWorkspaceEnabledState.
- Updated ClientConfigProviderEffect component to handle
multi-workspace.
- Modified GraphQL schema and queries to include multi-workspace related
configurations.
- Adjusted server environment variables and their respective
documentation to support multi-workspace toggle.
- Updated server-side logic to handle new multi-workspace configurations
and conditions.
2024-12-03 19:06:28 +01:00
nitinandGitHub 9a65e80566 Remove the heart icon button to add the view as a favorite from the top bar (#8769)
closes #8546 

@Bonapara please check the behaviour, if this is what you were looking
for! ;)
2024-12-03 13:49:00 +01:00
WeikoandGitHub 3c7805c6d0 Add field isLabelSyncedWithName (#8829)
## Context
The recent addition of object renaming introduced issues with enum
names. Enum names should follow the pattern
`${schemaName}.${tableName}_${columnName}_enum`. To address this, and to
allow users to customize the API name (which is included in the enum
name, columnName), this PR implements behavior similar to object
renaming by introducing a `isLabelSyncedWithName` boolean.

<img width="624" alt="Screenshot 2024-12-02 at 11 58 49"
src="https://github.com/user-attachments/assets/690fb71c-83f0-4922-80c0-946c92dacc30">
<img width="596" alt="Screenshot 2024-12-02 at 11 58 39"
src="https://github.com/user-attachments/assets/af9a0037-7cf5-40c3-9ed5-d51b340c8087">
2024-12-03 13:22:12 +01:00
GuillimandGitHub 7e4277fbe4 Better errors handling (#8835)
- [ ] Catch this specific `500` error
- [ ] Make sure catched `500` errors are sent to sentry for the Cloud
version
- [ ] Hide the option to sync email with google in this situation where
the according env var is missing
- [x] Add Worskpace information to all catched errors for better
debugging

fix #8607
2024-12-03 12:16:38 +01:00
32194a88b3 Implement contextual actions for the workflows (#8814)
Implemented the following actions for the workflows:
- Test Workflow
- Discard Draft
- Activate Draft
- Activate Workflow Last Published Version
- Deactivate Workflow
- See Workflow Executions
- See Workflow Active Version
- See Workflow Versions History

And the following actions for the workflow versions:
- Use As Draft
- See Workflow Versions History
- See Workflow Executions

Video example:


https://github.com/user-attachments/assets/016956a6-6f2e-4de5-9605-d6e14526cbb3

A few of these actions are links to the relations of the workflow object
(link to a filtered table). To generalize this behavior, I will create
an hook named `useSeeRelationsActionSingleRecordAction` to automatically
generate links to a showPage if the relation is a Many To One or to a
filtered table if the relation is a One to Many for all the record
types.
I will also create actions which will allow to create a relation.

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2024-12-03 12:09:51 +01:00
Antoine MoreauxandGitHub 7b2d9894f3 fix(environment): Make SSL variables optional (#8843)
Previously, SSL_KEY_PATH and SSL_CERT_PATH were required if the
SERVER_URL started with 'https'. This change makes these variables
optional, allowing greater flexibility in dealing with SSL
configurations. This update helps in scenarios where SSL paths are
managed differently or not needed.
2024-12-03 11:20:24 +01:00
martmullandGitHub d0ff1ffd5f 8723 workflow add editor in serverless function code step (#8805)
- create a serverless function when creating a new workflow code step
- add code editor in workflow code step
- move workflowVersion steps management from frontend to backend
  - add a custom resolver for workflow-version management
  - fix optimistic rendering on frontend
- fix css
- delete serverless function when deleting workflow code step

TODO
- Don't update serverlessFunction if no code change
- Factorize what can be between crud trigger and crud step
- Publish serverless version when activating workflow
- delete serverless functions when deleting workflow or workflowVersion
- fix optimistic rendering for code updates
- Unify CRUD types

<img width="1279" alt="image"
src="https://github.com/user-attachments/assets/3d97ee9f-4b96-4abc-9d36-5c0280058be4">
2024-12-03 08:41:13 +00:00
Baptiste DevessierandGitHub 9d7632cb4f Update best-practices-front.mdx (#8836) 2024-12-02 17:26:31 +01:00
Nathaniel BroughandGitHub 64500d07d3 Use builtin number validation (#8819)
The regex approach doesn't work great for the many different number
formats. Even a simple decimal failed e.g. '1.1' was considered invalid.
I've switched this over to use javascripts builtin conversion and
validation. This now supports other formats e.g. '-1.0e15' would now be
considered valid.

Closes: #8820
2024-12-02 15:03:49 +01:00
b6701a81e1 Prefill workspace invitation email (#7174) (#8826)
Prefill workspace invitation email, fixes #7174

---------

Co-authored-by: ad-elias <elias@autodiligence.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Antoine Moreaux <moreaux.antoine@gmail.com>
2024-12-02 14:22:53 +01:00
2b0f67191a toggle Field on label between singular and plural based on relation type (#8817)
#7683 

![labelPlural](https://github.com/user-attachments/assets/3e620d7e-dd51-4e4e-a9ba-289f2685ddf3)

![labelSingular](https://github.com/user-attachments/assets/84739ac5-29b4-48c8-8a71-3f8f2816641b)
Hello,
I’ve implemented the logic for dynamically toggling the Field on label
between singular and plural based on the relation type selected by the
user. Here's an overview of the changes:

Added a variable selectedRelationType to store the user’s selected
relation type.
Based on this variable, I determine whether to use labelPlural or
labelSingular from the selectedObjectMetadataItem.
Please review my changes and let me know if there's anything that needs
improvement .

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
2024-12-02 14:14:21 +01:00
0527bc296e Default address country 🗺️ & Phone prefix ☎️ (#8614)
# Default address 🗺️ country & Phone ☎️ country

We add the ability to add a Default address country and a default Phone
country for fields in the Data model.

fix #8081

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2024-12-02 12:34:05 +00:00
Félix MalfaitandGitHub 39a9cd0d51 Fix Make Postgres on Docker (#8830)
Fixes #8630
2024-12-02 09:58:03 +01:00
Charles BochetandGitHub 69f052e3ad Fix activity target picker (#8812)
This PR is fixing the Task/Note Target picker.

## Issue

A few weeks ago, we have simplified the recordPicker logic. During this
refacto, we stopped making sure the selected records were fetched.
However, the optimistic update of the activity supposed that current
activityTargets are present in the selection in the picker in order to
filter out the removed items.

## Fix

In case we don't find the record in the picker selection, we don't
filter it out (it means the user cannot have removed it)

## Next step

@ijreilly I think we should put it back, as the selected records do not
appear on top anymore
2024-11-30 10:48:43 +01:00
Thomas TrompetteandGitHub b542b43878 Add record picker with variables (#8813)
- Add update actions
- Create a folder for workflow actions
- Add a SingleRecordPicker with variables handler



https://github.com/user-attachments/assets/9fd57ce1-1b8d-424a-8aa1-69468d684fa1
2024-11-29 20:33:45 +01:00
Félix MalfaitandGitHub 29eb9fe77b Don't show summary/fields on workflow record pages (#8804)
Hiding the left column on workflow pages


This raises additional questions: how do we edit the title then?
Probably need a significant refacto of PageHeader


<img width="1094" alt="Screenshot 2024-11-29 at 10 01 08"
src="https://github.com/user-attachments/assets/b98d55cb-5097-4e46-bac5-5570d9ca0ad8">
2024-11-29 18:11:44 +01:00
Félix MalfaitandGitHub ebc381f009 Small text improvement to number field type (#8816)
Micro improvement to the number field type text
2024-11-29 18:06:00 +01:00
nitinandGitHub a7bbc9e00d Render IconFolderOpen when folder is open (#8811)
closes #8809 


https://github.com/user-attachments/assets/519e03ea-ae5e-4e4b-b03f-cf60d61dea49
2024-11-29 16:13:22 +01:00
27eae53d0a Updated website docs and /twenty-server/.env (#8801)
Fix for the issue Incorrect Database Connection String in .env File
#8741

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
2024-11-29 14:12:09 +01:00
Baptiste DevessierandGitHub 0136be65d1 Fix delete trigger node (#8806) 2024-11-29 13:58:23 +01:00
Jérémy MandGitHub a2d55a8694 feat: table record group (#8781)
Fix #8401 #8402

This PR is only taking care or displaying properly the record group on
the table.
Record-reorder within group has also been prepared.
Start of collapsible animation has been done, but not working for now.

<img width="1381" alt="Screenshot 2024-11-28 at 2 52 07 PM"
src="https://github.com/user-attachments/assets/514bb3e6-3475-4c47-a91c-64f7d20bbe73">
2024-11-29 13:04:27 +01:00
Raphaël BosiandGitHub 05149feb00 Do not mount the command menu when the user is logged out (#8808)
Fixes two bugs:
- The command menu contains hooks which were trying to execute queries
even when the user was logged out
- The command menu could be opened with the command + K shortcut even
when the user was logged out
2024-11-29 12:19:58 +01:00
c878f09d72 Fix token expiration error loop (#6731) (#8802)
Fixes issue https://github.com/twentyhq/twenty/issues/6731

**Problem:** After access token token expires, scrolling down the
`RecordTable` component will put it to an infinite loop of trying to
fetch records and printing errors on every iteration.

**Solution:** Disable fetching until component remount if a `FORBIDDEN`
error is encountered.

---------

Co-authored-by: ad-elias <elias@autodiligence.com>
2024-11-29 10:59:19 +01:00
Thomas TrompetteandGitHub 83223eeae3 Upgrade relation picker (#8795)
- Rename all parts using the name "relation" to "record" when component
is only selecting record
- Remove the use of scope states in folder
- Rename entities to records

This PR prepares the use of the record picker in workflows
2024-11-28 18:08:39 +01:00
Baptiste DevessierandGitHub d73dc1a728 Create form field number (#8634)
- Refactor VariableTagInput to have a reusable low-level TipTap editor
- Create three primitive form fields:
  - Text
  - Number
  - Boolean

## Notes

- We should automatically recognize the placeholder to use for every
FormFieldInput, as it's done for FieldInputs.

## Design decisions

Our main challenge was for variables and inputs to be able to
communicate between each other. We chose an API that adds some
duplication but remains simple and doesn't rely on "hacks" to work.
Common styles are centralized.

## Demo

"Workflow" mode with variables:

![CleanShot 2024-11-26 at 10 43
25@2x](https://github.com/user-attachments/assets/cc17098a-ca27-4f97-b86a-bf88593e53db)

FormFieldInput mode, without variables:

![CleanShot 2024-11-26 at 10 44
26@2x](https://github.com/user-attachments/assets/fec07c36-5944-4a1d-a863-516fd77c8f55)

Behavior difference between fields that can contain variables and static
content, and inputs that can have either a variable value or a static
value:

![CleanShot 2024-11-26 at 10 47
13@2x](https://github.com/user-attachments/assets/1e562cd8-c362-46d0-b438-481215159da9)
2024-11-28 17:03:24 +00:00
Thomas TrompetteandGitHub 3573d89c3c Add a few tests on workflow hooks (#8800)
As title
2024-11-28 16:54:41 +00:00
Baptiste DevessierandGitHub 38b83f0866 Write more tests (#8799) 2024-11-28 17:43:15 +01:00
Raphaël BosiandGitHub b857d45182 Add ongoing stale crons commands to doc (#8776)
Add ongoing stale crons commands to doc
2024-11-28 17:26:07 +01:00
Raphaël BosiandGitHub 541bbb93cb Fix: Put workflow run actions behind feature flag (#8798)
Fix: Put workflow run actions behind feature flag
2024-11-28 17:21:46 +01:00
Raphaël BosiandGitHub 33159e29b7 Put workflow run actions behind feature flag (#8796)
Put workflow run actions behind feature flag
2024-11-28 16:03:34 +01:00
WeikoandGitHub 75f5afb968 Rename enum types when tables are renamed (#8794)
## Context
Enum are named after this pattern
`${schema}_${tableName}_${columnName}_enum` however now that we allowed
table name update, we need to make sure their enums are renamed as well.
2024-11-28 15:39:20 +01:00
Félix MalfaitandGitHub fbe042db3b Calendar event cursor flashes (#8792)
Fixes #4692 

This is not the most beautiful fix (I think the right fix is to add 1px
somewhere), but this issue is old and deserved to be closed quickly
without spending too much time as this will likely be refactored and is
not very important
2024-11-28 15:05:59 +01:00
041b1f21bf line breaks in tooltips & text (#8783)
This feature goal is to :

1 - clean variables not used any longer

2 - add line breaks for tooltips and text wrapped

<img width="329" alt="Screenshot 2024-11-27 at 18 37 18"
src="https://github.com/user-attachments/assets/b3d7ed76-df30-4377-9d73-08d55c0f5c49">
<img width="468" alt="Screenshot 2024-11-27 at 18 37 31"
src="https://github.com/user-attachments/assets/48ef343e-6071-4a89-a73f-fb271f8284c0">


Request From @Bonapara

---------

Co-authored-by: guillim <guillaume@twenty.com>
2024-11-28 14:30:23 +01:00
Charles Bochet 8aab063da3 Demo new onclick behavior 2024-11-28 14:17:01 +01:00
Jérémy MandGitHub 812ed6ed69 feat: record board component state refactor (#8779)
Fix #8758 

This PR is migrating the recoil component state from v1 to v2 for board.
It also now share some states and logics between board and table,
further can be done later.
Lastly this PR fix an issue since the PR #8613 that was treating
no-value as a normal record-group.
2024-11-28 13:44:21 +01:00
e96ad9a1f2 Admin panel init (#8742)
WIP
Related issues - 
#7090 
#8547 
Master issue - 
#4499

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
2024-11-28 13:43:11 +01:00
abe9185f48 Devenv setup via devenvious (#8774)
This PR adds a devenv template based on [devenv.sh](https://devenv.sh/),
this is a nice to have for devs that use nix, direnv, and devenv.
It provides a quick and easy way to ensure that all packages that are
needed to start up the dev environment are installed.
I've initialized using devenvious, which is a @tennox creation: [source
on gitlab](https://gitlab.com/txlab/dx/devenvious).

I acknowledge that this "clutters" the root dir with some less common
files, but they are harmless for those that don't use the tools, and
helpful for those that do... a bit like .vscode and .idea folders.

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
2024-11-28 11:33:22 +01:00
nitinandGitHub 525a2c2bed minor-fix: console errors (#8782) 2024-11-28 11:09:00 +01:00
WeikoandGitHub c9fd194695 Fix index renaming (#8771)
Fixes https://github.com/twentyhq/twenty/issues/8760

## Context
Index names are based on table names and column names, which means
renaming an object (or a field) should also trigger a recompute of index
names. As of today it raises a bug where you can't create an object with
a name that was previously used.

I also took the occasion to refactor a bit the part where we create and
update (after renaming) relations. Basically the only relations we want
to affect are standard relations so I've aligned the logic with
sync-metadata which uses standardId as a source of truth to simplify the
code.

Note: We don't create index for custom relations
Next step should be to do that and update that code to update the index
name as well. Also note that we need to update the sync-metadata logic
for that as well
2024-11-28 10:21:03 +01:00
Antoine MoreauxandGitHub 2fab2266d5 feat(front): improve logo component (#8720) 2024-11-27 19:26:45 +01:00
3ad1113173 fix: scroll dropdown listing in hidden fields (#8738)
Fixes: #8716 

[Screencast from 2024-11-25
22-06-24.webm](https://github.com/user-attachments/assets/35bd66cc-942f-4903-abda-0d67a75b6582)

---------

Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
2024-11-27 18:06:11 +01:00
Raphaël BosiandGitHub a9cb1e9b0d Refactor actions (#8761)
Closes #8737 
- Refactored actions by creating hooks to add the possibility to
register actions programatically.
- Small fixes from #8610 review
- Fixed shortcuts display inside the command menu
- Removed `actionMenuEntriesComponentState` and introduced
`actionMenuEntriesComponentSelector`
2024-11-27 15:08:27 +01:00
Raphaël BosiandGitHub 0d6a6ec678 8764 incorrect email sorting (#8768)
Closes #8764
2024-11-27 10:11:57 +01:00
MarieandGitHub 149ce680d5 [Fix] Custom object icon update (#8762) 2024-11-26 18:03:19 +01:00
315938215e Fix Table text wrapping (#8683)
As discovered during the last release, text fields in the table were
wrapped. This PR fixes this unwanted behaviour

Current :
<img width="1077" alt="Screenshot 2024-11-22 at 14 17 42"
src="https://github.com/user-attachments/assets/080c5b1f-b793-46de-8733-9c69a4eb6b3b">

Wanted : 
One line ellipsed
<img width="244" alt="Screenshot 2024-11-22 at 14 20 46"
src="https://github.com/user-attachments/assets/c1d32859-4ffe-42e3-bfed-66db20c8c0c7">

---------

Co-authored-by: guillim <guillaume@twenty.com>
2024-11-26 17:58:05 +01:00
KhudditeandGitHub dfb966d47e Treat no value view group as normal & enable hide/dnd for no value (#8613)
Fixes #8591 

1. Summary
We disabled hide/dnd(drag-and-drop) options for `No value` view group
intentionally in the first implementation. We want to change it to
behave like normal view groups, so enable hide/dnd for `No value` view
group as well.

2. Solution
I have removed the code that filters the `No value` group out of view
groups, so `No value` is stored in the same array as other view groups.
I have removed the `No value` flag check for `Hide` button on the
hamburger menu of the Kanban header. I had to update the code in
`packages/twenty-front/src/modules/views/utils/mapViewGroupsToRecordGroupDefinitions.ts`
because it was ignoring the visibility flag of the `No value` view group
and set it always to true. Also, it was always putting the `No value`
group last ignoring the previous position.
>**_I am not 100% confident in the changes I made in
`packages/twenty-front/src/modules/views/utils/mapViewGroupsToRecordGroupDefinitions.ts`.
I'd like to have a review from someone more familiar with that part._**

3. Recording

https://github.com/user-attachments/assets/e135e22e-6e3a-4f94-a898-aafc03bba060
2024-11-26 17:47:42 +01:00
Raphaël BosiandGitHub 12467d67c8 Remove pg-boss from doc and .env.example (#8766)
Remove pg-boss from doc and .env.example
2024-11-26 17:10:05 +01:00
Antoine MoreauxandGitHub eb39288583 feat(server): allow to use ssl on server (#8722) 2024-11-26 15:30:51 +00:00
Raphaël BosiandGitHub 08f163592b Gmail error handling fixes (#8732)
Gmail error handling fixes
2024-11-26 11:22:03 +01:00
WeikoandGitHub 182f9b12da Fix labelIdentifierFieldMetadata creation for custom objects (#8729) 2024-11-26 11:19:19 +01:00
WeikoandGitHub 88e63262cd Set missing labelIdentifier to custom objects (#8750)
## Context
Following https://github.com/twentyhq/twenty/pull/8729

This command backfills missing labelIdentifier for custom objects
2024-11-26 11:03:27 +01:00
MarieandGitHub 4c413d4802 Fix custom object renaming (#8746)
Currently when renaming an object, we execute
```
await this.fieldMetadataRepository
                    .findOneByOrFail({
                      name: existingObjectMetadata.nameSingular,
                      label: existingObjectMetadata.labelSingular,
                      objectMetadataId: relatedObject.id,
                      workspaceId: workspaceId,
                    })
```
to find the standard relation fields. 
This would throw an error if the label solely was update beforehand
without updating the name too: in that case we will not have migrated
the label of the standard relation fields (which is maybe a mistake?
@Weiko wdyt?).
Let's remove it.
2024-11-26 11:01:12 +01:00
nitinandGitHub a026cde46a Fix folder deletion confirmation modal (#8744)
Scrollwrapper `navigationDrawer` is interfering with this particular
modal rendering
2024-11-26 10:58:07 +01:00
MarieandGitHub d85279c55e Fix update of custom object icon (#8730)
Icon update was not triggering a save due to missing onBlur prop drill
2024-11-26 10:56:57 +01:00
eea2885cbd Fix: open filter from column (#8747)
Column filter button (image below) was broken for all filter types, this
PR fixes it.

<img width="1053" alt="broken-filter-button"
src="https://github.com/user-attachments/assets/febd10a8-f360-4245-ba06-ef847c79fde1">

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
2024-11-26 10:52:45 +01:00
WeikoandGitHub 64b8fd544c Fix mutations with camelCase table names (#8740)
## Context
Some mutations are not working properly, workspaceMember soft deletion
for example. workspaceMember being a camelCase table name, it's probably
not propagated properly to pgql (which needs double quote for the table
name to keep it as camelCase)

I didn't have time to dig too much but if the `where` is before
`softDelete`, the query is `WHERE workspaceMember.id = $1` while if it's
after, the query becomes `WHERE id = $1`.
Probably due to the fact that once you call delete/softDelete/update,
the standard builder (SelectQueryBuilder) becomes a
DeleteQueryBuilder/etc... and filters are not handled the same way.
2024-11-26 10:47:13 +01:00
WeikoandGitHub a16b0d233e add delete view fields without views command (#8728)
## Context
We recently added a command to ensure uniqueness on the viewId column in
the viewField table. This created some issues for some old workspaces
that had viewFields with an empty viewId.
This command should get rid of those and set the column as non-nullable.
Also updating the onDelete action accordingly and set one missing for
FavoriteFolder
2024-11-26 10:40:17 +01:00
Félix MalfaitandGitHub 9f2e774113 Fix Error field type rich text (#8739)
fix #8445

It seems linked to commandBar search where we filter tasks/notes by body
with ilike.
2024-11-26 10:11:41 +01:00
2e75fae3ad Allow standard field default value and settings editing (#7104) (#8559)
Co-authored-by: Marie Stoppa <marie.stoppa@essec.edu>
2024-11-26 10:03:48 +01:00
MarieandGitHub 7bde2006c5 Add integration tests for /metadata + fix relation deletion (#8706)
In this PR

1. Add integration tests for /metadata (master issue:
https://github.com/twentyhq/twenty/issues/8719)
2. Fix relation deletion: index on "from" object was not deleted,
impeding creation of a new relation between the same two objects A and B
after relation between A and B was deleted
2024-11-26 10:00:36 +01:00
Harsh SinghandGitHub 49526937fa fix: navigation drawer clip while making folder in favorites (#8619) 2024-11-25 19:49:22 +00:00
2e2b27912b Website - Readme update (#8712)
### Readme ideas for better getting started

Website update proposal to give an easier way to get started on the
Local Setup

---------

Co-authored-by: guillim <guillaume@twenty.com>
2024-11-25 17:26:39 +01:00
8c7d1f923d Updated docs for smtp4dev connection (#8713)
Taken from [Discord #help
ticket](https://discord.com/channels/1130383047699738754/1309967928640147476)

---------

Co-authored-by: martmull <martmull@hotmail.fr>
2024-11-25 10:29:39 +00:00
c3d96b1dd8 Docs troubleshooting page (#8312)
Related to #8296

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
2024-11-24 22:26:54 +01:00
Félix MalfaitandGitHub 37970c08a9 Fix 1-click install tag version (#8709)
Followup of #8689
2024-11-24 19:08:04 +01:00
shubham yadavandGitHub 3f1683f8b9 Update 1-click install script to use /tags endpoint instead of /release (#8689)
Updated the API endpoint from
https://api.github.com/repos/twentyhq/twenty/releases/latest to
https://api.github.com/repos/twentyhq/twenty/tags.
Related Issue  #8679
2024-11-24 18:55:14 +01:00
Charles BochetandGitHub 08852dd1b1 Fix enumMigration not working on long fieldNames (#8708)
## Issue

Some users were facing issues while updating SELECT or MULTISELECT
options:

```
Error executing migration QueryFailedError: column "undefined" does not exist
    at PostgresQueryRunner.query (/app/node_modules/typeorm/driver/postgres/PostgresQueryRunner.js:219:19)
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    at async WorkspaceMigrationEnumService.migrateEnumValues (/app/packages/twenty-server/dist/src/engine/workspace-manager/workspace-migration-runner/services/workspace-migration-enum.service.js:101:13)
    at async WorkspaceMigrationEnumService.alterEnum (/app/packages/twenty-server/dist/src/engine/workspace-manager/workspace-migration-runner/services/workspace-migration-enum.service.js:54:9)
    at async WorkspaceMigrationRunnerService.alterColumn (/app/packages/twenty-server/dist/src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service.js:254:13)
    at async WorkspaceMigrationRunnerService.handleColumnChanges (/app/packages/twenty-server/dist/src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service.js:202:21)
    at async WorkspaceMigrationRunnerService.handleTableChanges (/app/packages/twenty-server/dist/src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service.js:94:25)
    at async WorkspaceMigrationRunnerService.executeMigrationFromPendingMigrations (/app/packages/twenty-server/dist/src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service.js:60:17)
    at async WorkspaceExecutePendingMigrationsCommand.run (/app/packages/twenty-server/dist/src/engine/workspace-manager/workspace-migration-runner/commands/workspace-execute-pending-migrations.command.js:24:9)
    at async Command.<anonymous> (/app/node_modules/nest-commander/src/command-runner.service.js:162:24) {
  query: '\n' +
    '        UPDATE "workspace_7i7bwawp7keh3gel1g69jropc"."_funnelInProductsRu"\n' +
    '        SET "reasonForStageTransition" = undefined\n' +
    "        WHERE id='2af8db61-5f75-46de-8b1a-97e312937e06'\n" +
    '      ',
  parameters: undefined,
  driverError: error: column "undefined" does not exist
```

## Root cause

Internally, while migrating enum values, we are doing:
```
const values = await queryRunner.query(
      `SELECT id, "${oldColumnName}" FROM "${schemaName}"."${tableName}"`,
    );

 let val = value[oldColumnName];
```

oldColumnName being: `${columnDefinition.columnName}_old_${v4()}`;

However, TypeORM only supports 63 bits identifiers:
https://github.com/typeorm/typeorm/issues/3118, but silently truncate
the identifer so if oldColumnName gets larger than 63 characters,
`value[oldColumnName]` is undefined.

## Fix

We only need a temporary and unique temporary name, no need to take into
account the previous columnName here
2024-11-24 18:40:23 +01:00
a19349bdae fix: table deselect doesn't work with few selected records (#8692)
Fixes: #8665 

[Screencast from 2024-11-23
00-33-14.webm](https://github.com/user-attachments/assets/e3b86955-4e12-4d10-9c13-ee60d7695cb3)

Co-authored-by: Félix Malfait <felix@twenty.com>
2024-11-24 17:59:49 +01:00
nitinandGitHub 00791c3cd5 Email invite design improvements (#8681)
closes #7140 

![image](https://github.com/user-attachments/assets/d3a31a49-8b37-4456-98e3-a16fbccb3786)
2024-11-24 17:39:08 +01:00
ad-eliasandGitHub bad7ad464b Add boolean filtering (#7190) (#8700)
<img width="956" alt="filter-icp-true"
src="https://github.com/user-attachments/assets/fc5fe18d-c7b6-463d-9ce7-8e5facb7352f">

Link to issue: https://github.com/twentyhq/twenty/issues/7190
2024-11-24 09:43:44 +01:00
Balaji KrishnamurthyandGitHub fd8e0d04a2 Fix array edit option (#8697)
Closes #8578 
There was no case to handle a FieldMetadataType.Array and thus an error
was thrown every time the edit button was clicked on an Array type.
It has been added now with an explicit break statement.
2024-11-23 13:13:15 +01:00
WeikoandGitHub 96b5d01ff5 Fix mutations with custom objects (#8688) 2024-11-22 17:54:50 +01:00
Thomas TrompetteandGitHub 5ec6cb0e6f Make workflow step name editable (#8677)
- Use TextInput in header title
- add onTitleChange prop
- rename field name instead of label

To fix :
- padding right on title comes from current TextInput component. It
needs to be refactored


https://github.com/user-attachments/assets/535cd6d3-866b-4a61-9c5d-cdbe7710396a
2024-11-22 15:25:01 +00:00
Antoine MoreauxandGitHub 4d8445a34a refactor(workspaces): Rename SEED_TWENTY_WORKSPACE_ID to SEED_ACME_WO… (#8611)
…RKSPACE_ID

The Pull Request modifies seed data by replacing
SEED_TWENTY_WORKSPACE_ID with SEED_ACME_WORKSPACE_ID across several
files.
- Updated SEED_TWENTY_WORKSPACE_ID to SEED_ACME_WORKSPACE_ID.
- Modified relevant import paths and seeds involving workspace data.
- Changes affect seeding processes for workspace members, user
workspaces, and general workspace details.
2024-11-22 16:03:18 +01:00
35f2d7a004 Add entitlement table and sso stripe feature (#8608)
**TLDR**


Added Billing Entitlement table, based on stripe
customer.ActiveEntitlements webhook event. In this table it has a key
value pair with each key being the stripe feature lookup key and the
value a boolean. We use this table in order to see if SSO or other
feaures are enabled by workspace.

**In order to test: twenty-server**


Billing:

- Set IS_BILLING_ENABLED to true
- Add your BILLING_STRIPE_SECRET and BILLING_STRIPE_API_KEY
- Add your BILLING_STRIPE_BASE_PLAN_PRODUCT_ID (use the one in testMode
> Base Plan)

Auth:

- Set AUTH_SSO_ENABLED to true
- Set your ACCESS_TOKEN_SECRET, LOGIN_TOKEN_SECRET, REFRESH_TOKEN_SECRET
and FILE_TOKEN_SECRET
- Set IS_SSO_ENABLED feature flag to true

Stripe Webhook: 

- Authenticate with your account in the stripe CLI
- Run the command: stripe listen --forward-to
http://localhost:3000/billing/webhooks

Migration:

- npx nx typeorm -- migration:run -d
src/database/typeorm/core/core.datasource.ts

**In order to test: twenty site**

- Buy a subscription (you can use the card 4242...42 with expiration
date later in the future)
- Go to SSO and create an OICD subscription
- Change the value in the entitlement table in order to put it in false
- An error should occur saying that the current workspace has no
entitlement

**Considerations**

The data from the Entitlement table is updated based on the stripe
webhook responses, and we use the customerActiveEntitlemet response to
update the info on the table, however this event doesnt have the
metadata containing the workspaceId. Because we cannot control at wich
order the webhook send events, we force a server error if the
entitlements are updated before the BillingSubscription. Stripe resends
the event based on a exponential backoff (for more info see
https://docs.stripe.com/webhooks#retries ) because we are in test mode
Stripe retries three times over a few hours. So if the
BillingEntitlement is not updated it is completely normal and it will be
updated when stripe resends the event.

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
2024-11-22 15:32:48 +01:00
1394 changed files with 36463 additions and 14310 deletions
+25 -4
View File
@@ -21,7 +21,7 @@ jobs:
image: twentycrm/twenty-postgres-spilo
env:
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: twenty
PGPASSWORD_SUPERUSER: postgres
ALLOW_NOSSL: "true"
SPILO_PROVIDER: "local"
ports:
@@ -73,11 +73,32 @@ jobs:
- name: Server / Create DB
if: steps.changed-files.outputs.any_changed == 'true'
run: |
PGPASSWORD=twenty psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=twenty psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
npx nx run twenty-server:database:init:prod
npx nx run twenty-server:database:migrate:prod
- name: Worker / Run
if: steps.changed-files.outputs.any_changed == 'true'
run: npx nx run twenty-server:worker:ci
- name: Server / Check for Pending Migrations
if: steps.changed-files.outputs.any_changed == 'true'
run: |
METADATA_MIGRATION_OUTPUT=$(npx nx run twenty-server:typeorm migration:generate metadata-migration-check -d src/database/typeorm/metadata/metadata.datasource.ts || true)
CORE_MIGRATION_OUTPUT=$(npx nx run twenty-server:typeorm migration:generate core-migration-check -d src/database/typeorm/core/core.datasource.ts || true)
METADATA_MIGRATION_FILE=$(ls packages/twenty-server/*metadata-migration-check.ts 2>/dev/null || echo "")
CORE_MIGRATION_FILE=$(ls packages/twenty-server/*core-migration-check.ts 2>/dev/null || echo "")
if [ -n "$METADATA_MIGRATION_FILE" ] || [ -n "$CORE_MIGRATION_FILE" ]; then
echo "::error::Unexpected migration files were generated. Please create a proper migration manually."
echo "$METADATA_MIGRATION_OUTPUT"
echo "$CORE_MIGRATION_OUTPUT"
rm -f packages/twenty-server/*metadata-migration-check.ts packages/twenty-server/*core-migration-check.ts
exit 1
fi
server-test:
timeout-minutes: 30
@@ -124,7 +145,7 @@ jobs:
image: twentycrm/twenty-postgres-spilo
env:
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: twenty
PGPASSWORD_SUPERUSER: postgres
ALLOW_NOSSL: "true"
SPILO_PROVIDER: "local"
ports:
+4 -4
View File
@@ -20,7 +20,7 @@ jobs:
image: twentycrm/twenty-postgres-spilo
env:
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: twenty
PGPASSWORD_SUPERUSER: postgres
ALLOW_NOSSL: "true"
SPILO_PROVIDER: "local"
ports:
@@ -46,18 +46,18 @@ jobs:
- name: Server / Create DB
if: steps.changed-files.outputs.any_changed == 'true'
run: PGPASSWORD=twenty psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
run: PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
- name: Website / Run migrations
if: steps.changed-files.outputs.changed == 'true'
run: npx nx database:migrate twenty-website
env:
DATABASE_PG_URL: postgres://postgres:twenty@localhost:5432/default
DATABASE_PG_URL: postgres://postgres:postgres@localhost:5432/default
- name: Website / Build Website
if: steps.changed-files.outputs.changed == 'true'
run: npx nx build twenty-website
env:
DATABASE_PG_URL: postgres://postgres:twenty@localhost:5432/default
DATABASE_PG_URL: postgres://postgres:postgres@localhost:5432/default
- name: Mark as VALID
if: steps.changed-files.outputs.changed != 'true' # If no changes, mark as valid
+10
View File
@@ -30,3 +30,13 @@ storybook-static
test-results/
dump.rdb
.tinyb
.notes
/data/
/.devenv/
/.direnv/
/.pre-commit-config.yaml
/.envrc
/devenv.nix
/flake.lock
/flake.nix
+3 -3
View File
@@ -2,17 +2,17 @@ postgres-on-docker:
docker run -d \
--name twenty_pg \
-e PGUSER_SUPERUSER=postgres \
-e PGPASSWORD_SUPERUSER=twenty \
-e PGPASSWORD_SUPERUSER=postgres \
-e ALLOW_NOSSL=true \
-v twenty_db_data:/home/postgres/pgdata \
-p 5432:5432 \
twentycrm/twenty-postgres-spilo:latest
@echo "Waiting for PostgreSQL to be ready..."
@until PGPASSWORD=twenty psql -h localhost -p 5432 -U postgres -d postgres \
@until docker exec twenty_pg psql -U postgres -d postgres \
-c 'SELECT pg_is_in_recovery();' 2>/dev/null | grep -q 'f'; do \
sleep 1; \
done
PGPASSWORD=twenty psql -h localhost -p 5432 -U postgres -d postgres \
docker exec twenty_pg psql -U postgres -d postgres \
-c "CREATE DATABASE \"default\" WITH OWNER postgres;" \
-c "CREATE DATABASE \"test\" WITH OWNER postgres;"
+1 -1
View File
@@ -112,7 +112,7 @@
"outputs": ["{projectRoot}/{options.output-dir}"],
"options": {
"cwd": "{projectRoot}",
"command": "VITE_DISABLE_ESLINT_CHECKER=true storybook build",
"command": "VITE_DISABLE_TYPESCRIPT_CHECKER=true VITE_DISABLE_ESLINT_CHECKER=true storybook build",
"output-dir": "storybook-static",
"config-dir": ".storybook"
}
+3 -3
View File
@@ -60,6 +60,7 @@
"@types/nodemailer": "^6.4.14",
"@types/passport-microsoft": "^1.0.3",
"@wyw-in-js/vite": "^0.5.3",
"@xyflow/react": "^12.3.5",
"add": "^2.0.6",
"addressparser": "^1.0.1",
"afterframe": "^1.0.2",
@@ -70,7 +71,7 @@
"bcrypt": "^5.1.1",
"better-sqlite3": "^9.2.2",
"body-parser": "^1.20.2",
"bullmq": "^4.14.0",
"bullmq": "^4.15.0",
"bytes": "^3.1.2",
"class-transformer": "^0.5.1",
"clsx": "^2.1.1",
@@ -168,7 +169,6 @@
"react-router-dom": "^6.4.4",
"react-textarea-autosize": "^8.4.1",
"react-tooltip": "^5.13.1",
"reactflow": "^11.11.3",
"recoil": "^0.7.7",
"rehype-slug": "^6.0.0",
"remark-behead": "^3.1.0",
@@ -179,7 +179,7 @@
"semver": "^7.5.4",
"sharp": "^0.32.1",
"slash": "^5.1.0",
"stripe": "^14.17.0",
"stripe": "^17.3.1",
"ts-key-enum": "^2.0.12",
"tslib": "^2.3.0",
"tsup": "^8.2.4",
+3
View File
@@ -7,6 +7,9 @@ PG_DATABASE_HOST=db:5432
REDIS_URL=redis://redis:6379
SERVER_URL=http://localhost:3000
FRONT_DOMAIN=localhost
FRONT_PORT=3000
FRONT_PROTOCOL=http
# Use openssl rand -base64 32 for each secret
# APP_SECRET=replace_me_with_a_random_string
+3 -6
View File
@@ -22,9 +22,8 @@ services:
- "3000:3000"
environment:
PORT: 3000
PG_DATABASE_URL: postgres://${PGUSER_SUPERUSER:-postgres}:${PGPASSWORD_SUPERUSER:-twenty}@${PG_DATABASE_HOST:-db:5432}/default
PG_DATABASE_URL: postgres://${PGUSER_SUPERUSER:-postgres}:${PGPASSWORD_SUPERUSER:-postgres}@${PG_DATABASE_HOST:-db:5432}/default
SERVER_URL: ${SERVER_URL}
FRONT_BASE_URL: ${FRONT_BASE_URL:-$SERVER_URL}
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
ENABLE_DB_MIGRATIONS: "true"
@@ -52,11 +51,9 @@ services:
image: twentycrm/twenty:${TAG}
command: ["yarn", "worker:prod"]
environment:
PG_DATABASE_URL: postgres://${PGUSER_SUPERUSER:-postgres}:${PGPASSWORD_SUPERUSER:-twenty}@${PG_DATABASE_HOST:-db:5432}/default
PG_DATABASE_URL: postgres://${PGUSER_SUPERUSER:-postgres}:${PGPASSWORD_SUPERUSER:-postgres}@${PG_DATABASE_HOST:-db:5432}/default
SERVER_URL: ${SERVER_URL}
FRONT_BASE_URL: ${FRONT_BASE_URL:-$SERVER_URL}
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
ENABLE_DB_MIGRATIONS: "false" # it already runs on the server
STORAGE_TYPE: ${STORAGE_TYPE}
@@ -78,7 +75,7 @@ services:
- db-data:/home/postgres/pgdata
environment:
PGUSER_SUPERUSER: ${PGUSER_SUPERUSER:-postgres}
PGPASSWORD_SUPERUSER: ${PGPASSWORD_SUPERUSER:-twenty}
PGPASSWORD_SUPERUSER: ${PGPASSWORD_SUPERUSER:-postgres}
ALLOW_NOSSL: "true"
SPILO_PROVIDER: "local"
healthcheck:
@@ -33,7 +33,7 @@ spec:
- name: PGUSER_SUPERUSER
value: "postgres"
- name: PGPASSWORD_SUPERUSER
value: "twenty"
value: "postgres"
ports:
- containerPort: 5432
name: tcp
@@ -37,10 +37,8 @@ spec:
value: 3000
- name: SERVER_URL
value: "https://crm.example.com:443"
- name: FRONT_BASE_URL
value: "https://crm.example.com:443"
- name: "PG_DATABASE_URL"
value: "postgres://postgres:twenty@twenty-db.twentycrm.svc.cluster.local/default"
value: "postgres://postgres:postgres@twentycrm-db.twentycrm.svc.cluster.local/default"
- name: "REDIS_URL"
value: "redis://twentycrm-redis.twentycrm.svc.cluster.local:6379"
- name: ENABLE_DB_MIGRATIONS
@@ -28,10 +28,8 @@ spec:
env:
- name: SERVER_URL
value: "https://crm.example.com:443"
- name: FRONT_BASE_URL
value: "https://crm.example.com:443"
- name: PG_DATABASE_URL
value: "postgres://postgres:twenty@twenty-db.twentycrm.svc.cluster.local/default"
value: "postgres://postgres:postgres@twentycrm-db.twentycrm.svc.cluster.local/default"
- name: ENABLE_DB_MIGRATIONS
value: "false" # it already runs on the server
- name: STORAGE_TYPE
@@ -51,11 +51,6 @@ resource "kubernetes_deployment" "twentycrm_server" {
value = var.twentycrm_app_hostname
}
env {
name = "FRONT_BASE_URL"
value = var.twentycrm_app_hostname
}
env {
name = "PG_DATABASE_URL"
value = "postgres://twenty:${var.twentycrm_pgdb_admin_password}@${kubernetes_service.twentycrm_db.metadata.0.name}.${kubernetes_namespace.twentycrm.metadata.0.name}.svc.cluster.local/default"
@@ -43,11 +43,6 @@ resource "kubernetes_deployment" "twentycrm_worker" {
value = var.twentycrm_app_hostname
}
env {
name = "FRONT_BASE_URL"
value = var.twentycrm_app_hostname
}
env {
name = "PG_DATABASE_URL"
value = "postgres://twenty:${var.twentycrm_pgdb_admin_password}@${kubernetes_service.twentycrm_db.metadata.0.name}.${kubernetes_namespace.twentycrm.metadata.0.name}.svc.cluster.local/default"
+6 -1
View File
@@ -1,4 +1,9 @@
pull_version=${VERSION:-$(curl -s https://api.github.com/repos/twentyhq/twenty/releases/latest | grep '"tag_name":' | cut -d '"' -f 4)}
pull_version=${VERSION:-$(curl -s https://api.github.com/repos/twentyhq/twenty/tags | grep '"name":' | head -n 1 | cut -d '"' -f 4)}
if [[ -z "$pull_version" ]]; then
echo "Error: Unable to fetch the latest version tag. Please check your network connection or the GitHub API response."
exit 1
fi
pull_branch=${BRANCH:-$pull_version}
version_num=${pull_version#v}
+1 -1
View File
@@ -44,7 +44,7 @@ function on_exit {
trap on_exit EXIT
# Use environment variables VERSION and BRANCH, with defaults if not set
version=${VERSION:-$(curl -s https://api.github.com/repos/twentyhq/twenty/releases/latest | grep '"tag_name":' | cut -d '"' -f 4)}
version=${VERSION:-$(curl -s https://api.github.com/repos/twentyhq/twenty/tags | grep '"name":' | head -n 1 | cut -d '"' -f 4)}
branch=${BRANCH:-$version}
echo "🚀 Using version $version and branch $branch"
+1 -1
View File
@@ -1,5 +1,5 @@
# Note that provide always without trailing forward slash to have expected behaviour
FRONTEND_BASE_URL=http://localhost:3001
FRONTEND_BASE_URL=http://app.localhost:3001
CI_DEFAULT_BASE_URL=https://demo.twenty.com
DEFAULT_LOGIN=tim@apple.dev
NEW_WORKSPACE_LOGIN=test@apple.dev
@@ -3,8 +3,7 @@ import path from 'path';
export const envVariables = (variables: string) => {
let payload = `
PG_DATABASE_URL=postgres://postgres:twenty@localhost:5432/default
FRONT_BASE_URL=http://localhost:3001
PG_DATABASE_URL=postgres://postgres:postgres@localhost:5432/default
ACCESS_TOKEN_SECRET=replace_me_with_a_random_string_access
LOGIN_TOKEN_SECRET=replace_me_with_a_random_string_login
REFRESH_TOKEN_SECRET=replace_me_with_a_random_string_refresh
@@ -10,7 +10,7 @@ export class LoginPage {
private readonly forgotPasswordButton: Locator;
private readonly passwordField: Locator;
private readonly revealPasswordButton: Locator;
private readonly signInButton: Locator;
readonly signInButton: Locator;
private readonly signUpButton: Locator;
private readonly previewImageButton: Locator;
private readonly uploadImageButton: Locator;
@@ -16,12 +16,12 @@ export default defineConfig({
fullyParallel: true, // false only for specific tests, overwritten in specific projects or global setups of projects
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined, // undefined = amount of projects * amount of tests
workers: 1, // 1 worker = 1 test at the time, tests can't be parallelized
timeout: 30 * 1000, // timeout can be changed
use: {
baseURL: process.env.CI
? process.env.CI_DEFAULT_BASE_URL
: (process.env.FRONTEND_BASE_URL ?? 'http://localhost:3001'),
: (process.env.FRONTEND_BASE_URL ?? 'http://app.localhost:3001'),
trace: 'retain-on-failure', // trace takes EVERYTHING from page source, records every single step, should be used only when normal debugging won't work
screenshot: 'on', // either 'on' here or in different method in modules, if 'on' all screenshots are overwritten each time the test is run
headless: true, // instead of changing it to false, run 'yarn test:e2e:debug' or 'yarn test:e2e:ui'
@@ -56,6 +56,10 @@ export default defineConfig({
},
dependencies: ['Login setup'],
},
{
name: 'Authentication',
testMatch: /authentication\/.*\.spec\.ts/,
},
//{
// name: 'webkit',
@@ -0,0 +1,17 @@
import { test as base, expect } from '../../lib/fixtures/screenshot';
import { LoginPage } from '../../lib/pom/loginPage';
// fixture
const test = base.extend<{ loginPage: LoginPage }>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
});
test('Check login with email', async ({ loginPage }) => {
await loginPage.typeEmail(process.env.DEFAULT_LOGIN);
await loginPage.clickContinueButton();
await loginPage.typePassword(process.env.DEFAULT_PASSWORD);
await loginPage.clickSignInButton();
await expect(loginPage.signInButton).not.toBeVisible();
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-emails",
"version": "0.33.5",
"version": "0.34.1",
"description": "",
"author": "",
"private": true,
@@ -18,8 +18,9 @@ type SendInviteLinkEmailProps = {
sender: {
email: string;
firstName: string;
lastName: string;
};
serverUrl?: string;
serverUrl: string;
};
export const SendInviteLinkEmail = ({
@@ -28,14 +29,17 @@ export const SendInviteLinkEmail = ({
sender,
serverUrl,
}: SendInviteLinkEmailProps) => {
const workspaceLogo = getImageAbsoluteURI(workspace.logo, serverUrl);
const workspaceLogo = workspace.logo
? getImageAbsoluteURI(workspace.logo, serverUrl)
: null;
return (
<BaseEmail width={333}>
<Title value="Join your team on Twenty" />
<MainText>
{capitalize(sender.firstName)} (
<Link
href={sender.email}
href={`mailto:${sender.email}`}
value={sender.email}
color={emailTheme.font.colors.blue}
/>
@@ -1,16 +1,9 @@
export const getImageAbsoluteURI = (
imageUrl?: string | null,
serverUrl?: string,
) => {
if (!imageUrl) {
return null;
}
if (imageUrl?.startsWith('https:')) {
export const getImageAbsoluteURI = (imageUrl: string, serverUrl: string) => {
if (imageUrl.startsWith('https:') || imageUrl.startsWith('http:')) {
return imageUrl;
}
return serverUrl?.endsWith('/')
return serverUrl.endsWith('/')
? `${serverUrl.substring(0, serverUrl.length - 1)}/files/${imageUrl}`
: `${serverUrl || ''}/files/${imageUrl}`;
: `${serverUrl}/files/${imageUrl}`;
};
+1
View File
@@ -7,5 +7,6 @@ GENERATE_SOURCEMAP=false
# VITE_DISABLE_TYPESCRIPT_CHECKER=true
# VITE_DISABLE_ESLINT_CHECKER=true
# VITE_ENABLE_SSL=false
# VITE_HOST=localhost.com
# SSL_KEY_PATH="./certs/your-cert.key"
# SSL_CERT_PATH="./certs/your-cert.crt"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-front",
"version": "0.33.5",
"version": "0.34.1",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -69,7 +69,7 @@
"test": {},
"storybook:build": {
"options": {
"env": { "NODE_OPTIONS": "--max_old_space_size=6500" }
"env": { "NODE_OPTIONS": "--max_old_space_size=8000" }
},
"configurations": {
"docs": { "env": { "STORYBOOK_SCOPE": "ui-docs" } },
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -234,17 +234,6 @@ const testCases = [
{ loc: AppPath.DevelopersCatchAll, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: AppPath.InviteTeam },
{ loc: AppPath.DevelopersCatchAll, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: undefined },
{ loc: AppPath.Impersonate, isLoggedIn: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: AppPath.PlanRequired },
{ loc: AppPath.Impersonate, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' },
{ loc: AppPath.Impersonate, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' },
{ loc: AppPath.Impersonate, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.PastDue, onboardingStatus: OnboardingStatus.Completed, res: undefined },
{ loc: AppPath.Impersonate, isLoggedIn: false, subscriptionStatus: undefined, onboardingStatus: undefined, res: AppPath.SignInUp },
{ loc: AppPath.Impersonate, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.WorkspaceActivation, res: AppPath.CreateWorkspace },
{ loc: AppPath.Impersonate, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.ProfileCreation, res: AppPath.CreateProfile },
{ loc: AppPath.Impersonate, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.SyncEmail, res: AppPath.SyncEmails },
{ loc: AppPath.Impersonate, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: AppPath.InviteTeam },
{ loc: AppPath.Impersonate, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: undefined },
{ loc: AppPath.Authorize, isLoggedIn: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: AppPath.PlanRequired },
{ loc: AppPath.Authorize, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' },
{ loc: AppPath.Authorize, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' },
@@ -1,14 +1,6 @@
import React, { Ref, RefCallback } from 'react';
import { isFunction } from '@sniptt/guards';
import { Ref, RefCallback } from 'react';
import { combineRefs } from '~/utils/combineRefs';
export const useCombinedRefs =
<T>(...refs: (Ref<T> | undefined)[]): RefCallback<T> =>
(node: T) => {
for (const ref of refs) {
if (isFunction(ref)) {
ref(node);
} else if (ref !== null && ref !== undefined) {
(ref as React.MutableRefObject<T | null>).current = node;
}
}
};
export const useCombinedRefs = <T>(
...refs: (Ref<T> | undefined)[]
): RefCallback<T> => combineRefs<T>(...refs);
@@ -1,32 +0,0 @@
import { useEffect, useState } from 'react';
import { useDebouncedCallback } from 'use-debounce';
export const usePreventOverlapCallback = (
callback: () => Promise<void>,
wait?: number,
) => {
const [isRunning, setIsRunning] = useState(false);
const [pendingRun, setPendingRun] = useState(false);
const handleCallback = async () => {
if (isRunning) {
setPendingRun(true);
return;
}
setIsRunning(true);
try {
await callback();
} finally {
setIsRunning(false);
}
};
useEffect(() => {
if (!isRunning && pendingRun) {
setPendingRun(false);
callback();
}
}, [callback, isRunning, pendingRun, setPendingRun]);
return useDebouncedCallback(handleCallback, wait);
};
@@ -0,0 +1,31 @@
import styled from '@emotion/styled';
import { BACKGROUND_LIGHT, GRAY_SCALE } from 'twenty-ui';
import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
const StyledRightDrawerContainer = styled.div`
display: flex;
flex-direction: column;
width: 100%;
padding: ${({ theme }) => theme.spacing(4)};
`;
const StyledSkeletonLoader = () => {
return (
<SkeletonTheme
baseColor={GRAY_SCALE.gray15}
highlightColor={BACKGROUND_LIGHT.transparent.lighter}
borderRadius={4}
>
<Skeleton height={SKELETON_LOADER_HEIGHT_SIZES.standard.m} width={140} />
</SkeletonTheme>
);
};
export const RightDrawerSkeletonLoader = () => {
return (
<StyledRightDrawerContainer>
<StyledSkeletonLoader />
</StyledRightDrawerContainer>
);
};
@@ -1,8 +0,0 @@
import { WorkflowRunActionEffect } from '@/action-menu/actions/global-actions/workflow-run-actions/components/WorkflowRunActionEffect';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
export const GlobalActionMenuEntriesSetter = () => {
const isWorkflowEnabled = useIsFeatureEnabled('IS_WORKFLOW_ENABLED');
return <>{isWorkflowEnabled && <WorkflowRunActionEffect />}</>;
};
@@ -1,75 +0,0 @@
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useAllActiveWorkflowVersions } from '@/workflow/hooks/useAllActiveWorkflowVersions';
import { useRunWorkflowVersion } from '@/workflow/hooks/useRunWorkflowVersion';
import { useTheme } from '@emotion/react';
import { useEffect } from 'react';
import { IconSettingsAutomation } from 'twenty-ui';
import { capitalize } from '~/utils/string/capitalize';
export const WorkflowRunActionEffect = () => {
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
const { records: activeWorkflowVersions } = useAllActiveWorkflowVersions({
triggerType: 'MANUAL',
});
const { runWorkflowVersion } = useRunWorkflowVersion();
const { enqueueSnackBar } = useSnackBar();
const theme = useTheme();
useEffect(() => {
for (const [
index,
activeWorkflowVersion,
] of activeWorkflowVersions.entries()) {
addActionMenuEntry({
type: ActionMenuEntryType.WorkflowRun,
key: `workflow-run-${activeWorkflowVersion.id}`,
scope: ActionMenuEntryScope.Global,
label: capitalize(activeWorkflowVersion.workflow.name),
position: index,
Icon: IconSettingsAutomation,
onClick: async () => {
await runWorkflowVersion({
workflowVersionId: activeWorkflowVersion.id,
});
enqueueSnackBar('', {
variant: SnackBarVariant.Success,
title: `${capitalize(activeWorkflowVersion.workflow.name)} starting...`,
icon: (
<IconSettingsAutomation
size={16}
color={theme.snackBar.success.color}
/>
),
});
},
});
}
return () => {
for (const activeWorkflowVersion of activeWorkflowVersions) {
removeActionMenuEntry(`workflow-run-${activeWorkflowVersion.id}`);
}
};
}, [
activeWorkflowVersions,
addActionMenuEntry,
enqueueSnackBar,
removeActionMenuEntry,
runWorkflowVersion,
theme.snackBar.success.color,
]);
return null;
};
@@ -1,32 +1,30 @@
import { DeleteRecordsActionEffect } from '@/action-menu/actions/record-actions/components/DeleteRecordsActionEffect';
import { ExportRecordsActionEffect } from '@/action-menu/actions/record-actions/components/ExportRecordsActionEffect';
import { ManageFavoritesActionEffect } from '@/action-menu/actions/record-actions/components/ManageFavoritesActionEffect';
import { WorkflowRunRecordActionEffect } from '@/action-menu/actions/record-actions/workflow-run-record-actions/components/WorkflowRunRecordActionEffect';
import { MultipleRecordsActionMenuEntrySetterEffect } from '@/action-menu/actions/record-actions/multiple-records/components/MultipleRecordsActionMenuEntrySetterEffect';
import { NoSelectionActionMenuEntrySetterEffect } from '@/action-menu/actions/record-actions/no-selection/components/NoSelectionActionMenuEntrySetterEffect';
import { SingleRecordActionMenuEntrySetter } from '@/action-menu/actions/record-actions/single-record/components/SingleRecordActionMenuEntrySetter';
import { WorkflowRunRecordActionMenuEntrySetterEffect } from '@/action-menu/actions/record-actions/workflow-run-record-actions/components/WorkflowRunRecordActionMenuEntrySetter';
import { contextStoreCurrentObjectMetadataIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdComponentState';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
const noSelectionRecordActionEffects = [ExportRecordsActionEffect];
const singleRecordActionEffects = [
ManageFavoritesActionEffect,
DeleteRecordsActionEffect,
];
const multipleRecordActionEffects = [
ExportRecordsActionEffect,
DeleteRecordsActionEffect,
];
export const RecordActionMenuEntriesSetter = () => {
const contextStoreCurrentObjectMetadataId = useRecoilComponentValueV2(
contextStoreCurrentObjectMetadataIdComponentState,
);
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
if (!isDefined(contextStoreCurrentObjectMetadataId)) {
const objectMetadataItem = objectMetadataItems.find(
(item) => item.id === contextStoreCurrentObjectMetadataId,
);
if (
!isDefined(contextStoreCurrentObjectMetadataId) ||
!isDefined(objectMetadataItem)
) {
return null;
}
@@ -44,30 +42,36 @@ const ActionEffects = ({
objectId: objectMetadataItemId,
});
const contextStoreNumberOfSelectedRecords = useRecoilComponentValueV2(
contextStoreNumberOfSelectedRecordsComponentState,
const contextStoreTargetedRecordsRule = useRecoilComponentValueV2(
contextStoreTargetedRecordsRuleComponentState,
);
const isWorkflowEnabled = useIsFeatureEnabled('IS_WORKFLOW_ENABLED');
const actions =
contextStoreNumberOfSelectedRecords === 0
? noSelectionRecordActionEffects
: contextStoreNumberOfSelectedRecords === 1
? singleRecordActionEffects
: multipleRecordActionEffects;
return (
<>
{actions.map((ActionEffect, index) => (
<ActionEffect
key={index}
position={index}
objectMetadataItem={objectMetadataItem}
/>
))}
{contextStoreNumberOfSelectedRecords === 1 && isWorkflowEnabled && (
<WorkflowRunRecordActionEffect
{contextStoreTargetedRecordsRule.mode === 'selection' &&
contextStoreTargetedRecordsRule.selectedRecordIds.length === 0 && (
<NoSelectionActionMenuEntrySetterEffect
objectMetadataItem={objectMetadataItem}
/>
)}
{contextStoreTargetedRecordsRule.mode === 'selection' &&
contextStoreTargetedRecordsRule.selectedRecordIds.length === 1 && (
<>
<SingleRecordActionMenuEntrySetter
objectMetadataItem={objectMetadataItem}
/>
{isWorkflowEnabled && (
<WorkflowRunRecordActionMenuEntrySetterEffect
objectMetadataItem={objectMetadataItem}
/>
)}
</>
)}
{(contextStoreTargetedRecordsRule.mode === 'exclusion' ||
contextStoreTargetedRecordsRule.selectedRecordIds.length > 1) && (
<MultipleRecordsActionMenuEntrySetterEffect
objectMetadataItem={objectMetadataItem}
/>
)}
@@ -0,0 +1,24 @@
import { useMultipleRecordsActions } from '@/action-menu/actions/record-actions/multiple-records/hooks/useMultipleRecordsActions';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useEffect } from 'react';
export const MultipleRecordsActionMenuEntrySetterEffect = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const { registerMultipleRecordsActions, unregisterMultipleRecordsActions } =
useMultipleRecordsActions({
objectMetadataItem,
});
useEffect(() => {
registerMultipleRecordsActions();
return () => {
unregisterMultipleRecordsActions();
};
}, [registerMultipleRecordsActions, unregisterMultipleRecordsActions]);
return null;
};
@@ -0,0 +1,132 @@
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { expect } from '@storybook/test';
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import { useDeleteMultipleRecordsAction } from '../useDeleteMultipleRecordsAction';
jest.mock('@/object-record/hooks/useDeleteManyRecords', () => ({
useDeleteManyRecords: () => ({
deleteManyRecords: jest.fn(),
}),
}));
jest.mock('@/favorites/hooks/useDeleteFavorite', () => ({
useDeleteFavorite: () => ({
deleteFavorite: jest.fn(),
}),
}));
jest.mock('@/favorites/hooks/useFavorites', () => ({
useFavorites: () => ({
sortedFavorites: [],
}),
}));
jest.mock('@/object-record/record-table/hooks/useRecordTable', () => ({
useRecordTable: () => ({
resetTableRowSelection: jest.fn(),
}),
}));
const companyMockObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'company',
)!;
const JestMetadataAndApolloMocksWrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: [],
onInitializeRecoilSnapshot: ({ set }) => {
set(
contextStoreNumberOfSelectedRecordsComponentState.atomFamily({
instanceId: '1',
}),
3,
);
},
});
describe('useDeleteMultipleRecordsAction', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<JestMetadataAndApolloMocksWrapper>
<ContextStoreComponentInstanceContext.Provider
value={{
instanceId: '1',
}}
>
<ActionMenuComponentInstanceContext.Provider
value={{
instanceId: '1',
}}
>
{children}
</ActionMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
</JestMetadataAndApolloMocksWrapper>
);
it('should register delete action', () => {
const { result } = renderHook(
() => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return {
actionMenuEntries,
useDeleteMultipleRecordsAction: useDeleteMultipleRecordsAction({
objectMetadataItem: companyMockObjectMetadataItem,
}),
};
},
{ wrapper },
);
act(() => {
result.current.useDeleteMultipleRecordsAction.registerDeleteMultipleRecordsAction(
{ position: 1 },
);
});
expect(result.current.actionMenuEntries.size).toBe(1);
expect(
result.current.actionMenuEntries.get('delete-multiple-records'),
).toBeDefined();
expect(
result.current.actionMenuEntries.get('delete-multiple-records')?.position,
).toBe(1);
});
it('should unregister delete action', () => {
const { result } = renderHook(
() => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return {
actionMenuEntries,
useDeleteMultipleRecordsAction: useDeleteMultipleRecordsAction({
objectMetadataItem: companyMockObjectMetadataItem,
}),
};
},
{ wrapper },
);
act(() => {
result.current.useDeleteMultipleRecordsAction.registerDeleteMultipleRecordsAction(
{ position: 1 },
);
});
expect(result.current.actionMenuEntries.size).toBe(1);
act(() => {
result.current.useDeleteMultipleRecordsAction.unregisterDeleteMultipleRecordsAction();
});
expect(result.current.actionMenuEntries.size).toBe(0);
});
});
@@ -0,0 +1,105 @@
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { expect } from '@storybook/test';
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { JestObjectMetadataItemSetter } from '~/testing/jest/JestObjectMetadataItemSetter';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import { useExportMultipleRecordsAction } from '../useExportMultipleRecordsAction';
const companyMockObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'company',
)!;
const JestMetadataAndApolloMocksWrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: [],
});
describe('useExportMultipleRecordsAction', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<JestMetadataAndApolloMocksWrapper>
<JestObjectMetadataItemSetter>
<ContextStoreComponentInstanceContext.Provider
value={{
instanceId: '1',
}}
>
<ActionMenuComponentInstanceContext.Provider
value={{
instanceId: '1',
}}
>
{children}
</ActionMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
</JestObjectMetadataItemSetter>
</JestMetadataAndApolloMocksWrapper>
);
it('should register export multiple records action', () => {
const { result } = renderHook(
() => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return {
actionMenuEntries,
useExportMultipleRecordsAction: useExportMultipleRecordsAction({
objectMetadataItem: companyMockObjectMetadataItem,
}),
};
},
{ wrapper },
);
act(() => {
result.current.useExportMultipleRecordsAction.registerExportMultipleRecordsAction(
{ position: 1 },
);
});
expect(result.current.actionMenuEntries.size).toBe(1);
expect(
result.current.actionMenuEntries.get('export-multiple-records'),
).toBeDefined();
expect(
result.current.actionMenuEntries.get('export-multiple-records')?.position,
).toBe(1);
});
it('should unregister export multiple records action', () => {
const { result } = renderHook(
() => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return {
actionMenuEntries,
useExportMultipleRecordsAction: useExportMultipleRecordsAction({
objectMetadataItem: companyMockObjectMetadataItem,
}),
};
},
{ wrapper },
);
act(() => {
result.current.useExportMultipleRecordsAction.registerExportMultipleRecordsAction(
{ position: 1 },
);
});
expect(result.current.actionMenuEntries.size).toBe(1);
act(() => {
result.current.useExportMultipleRecordsAction.unregisterExportMultipleRecordsAction();
});
expect(result.current.actionMenuEntries.size).toBe(0);
});
});
@@ -18,14 +18,12 @@ import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTabl
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useCallback, useContext, useEffect, useState } from 'react';
import { useCallback, useContext, useState } from 'react';
import { IconTrash, isDefined } from 'twenty-ui';
export const DeleteRecordsActionEffect = ({
position,
export const useDeleteMultipleRecordsAction = ({
objectMetadataItem,
}: {
position: number;
objectMetadataItem: ObjectMetadataItem;
}) => {
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
@@ -106,12 +104,16 @@ export const DeleteRecordsActionEffect = ({
const { isInRightDrawer, onActionExecutedCallback } =
useContext(ActionMenuContext);
useEffect(() => {
const registerDeleteMultipleRecordsAction = ({
position,
}: {
position: number;
}) => {
if (canDelete) {
addActionMenuEntry({
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: 'delete',
key: 'delete-multiple-records',
label: 'Delete',
position,
Icon: IconTrash,
@@ -124,16 +126,8 @@ export const DeleteRecordsActionEffect = ({
<ConfirmationModal
isOpen={isDeleteRecordsModalOpen}
setIsOpen={setIsDeleteRecordsModalOpen}
title={`Delete ${contextStoreNumberOfSelectedRecords} ${
contextStoreNumberOfSelectedRecords === 1 ? `record` : 'records'
}`}
subtitle={`Are you sure you want to delete ${
contextStoreNumberOfSelectedRecords === 1
? 'this record'
: 'these records'
}? ${
contextStoreNumberOfSelectedRecords === 1 ? 'It' : 'They'
} can be recovered from the Options menu.`}
title={'Delete Records'}
subtitle={`Are you sure you want to delete these records? They can be recovered from the Options menu.`}
onConfirmClick={() => {
handleDeleteClick();
onActionExecutedCallback?.();
@@ -141,31 +135,19 @@ export const DeleteRecordsActionEffect = ({
closeRightDrawer();
}
}}
deleteButtonText={`Delete ${
contextStoreNumberOfSelectedRecords > 1 ? 'Records' : 'Record'
}`}
deleteButtonText={'Delete Records'}
/>
),
});
} else {
removeActionMenuEntry('delete');
}
};
return () => {
removeActionMenuEntry('delete');
};
}, [
addActionMenuEntry,
canDelete,
closeRightDrawer,
contextStoreNumberOfSelectedRecords,
handleDeleteClick,
isDeleteRecordsModalOpen,
isInRightDrawer,
onActionExecutedCallback,
position,
removeActionMenuEntry,
]);
const unregisterDeleteMultipleRecordsAction = () => {
removeActionMenuEntry('delete-multiple-records');
};
return null;
return {
registerDeleteMultipleRecordsAction,
unregisterDeleteMultipleRecordsAction,
};
};
@@ -1,7 +1,5 @@
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { IconDatabaseExport } from 'twenty-ui';
import {
@@ -12,19 +10,13 @@ import {
displayedExportProgress,
useExportRecords,
} from '@/object-record/record-index/export/hooks/useExportRecords';
import { useEffect } from 'react';
export const ExportRecordsActionEffect = ({
position,
export const useExportMultipleRecordsAction = ({
objectMetadataItem,
}: {
position: number;
objectMetadataItem: ObjectMetadataItem;
}) => {
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
const contextStoreNumberOfSelectedRecords = useRecoilComponentValueV2(
contextStoreNumberOfSelectedRecordsComponentState,
);
const { progress, download } = useExportRecords({
delayMs: 100,
@@ -33,32 +25,29 @@ export const ExportRecordsActionEffect = ({
filename: `${objectMetadataItem.nameSingular}.csv`,
});
useEffect(() => {
const registerExportMultipleRecordsAction = ({
position,
}: {
position: number;
}) => {
addActionMenuEntry({
type: ActionMenuEntryType.Standard,
scope:
contextStoreNumberOfSelectedRecords > 0
? ActionMenuEntryScope.RecordSelection
: ActionMenuEntryScope.Global,
key: 'export',
scope: ActionMenuEntryScope.RecordSelection,
key: 'export-multiple-records',
position,
label: displayedExportProgress(progress),
Icon: IconDatabaseExport,
accent: 'default',
onClick: () => download(),
});
};
return () => {
removeActionMenuEntry('export');
};
}, [
contextStoreNumberOfSelectedRecords,
download,
progress,
addActionMenuEntry,
removeActionMenuEntry,
position,
]);
const unregisterExportMultipleRecordsAction = () => {
removeActionMenuEntry('export-multiple-records');
};
return null;
return {
registerExportMultipleRecordsAction,
unregisterExportMultipleRecordsAction,
};
};
@@ -0,0 +1,38 @@
import { useDeleteMultipleRecordsAction } from '@/action-menu/actions/record-actions/multiple-records/hooks/useDeleteMultipleRecordsAction';
import { useExportMultipleRecordsAction } from '@/action-menu/actions/record-actions/multiple-records/hooks/useExportMultipleRecordsAction';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
export const useMultipleRecordsActions = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const {
registerDeleteMultipleRecordsAction,
unregisterDeleteMultipleRecordsAction,
} = useDeleteMultipleRecordsAction({
objectMetadataItem,
});
const {
registerExportMultipleRecordsAction,
unregisterExportMultipleRecordsAction,
} = useExportMultipleRecordsAction({
objectMetadataItem,
});
const registerMultipleRecordsActions = () => {
registerDeleteMultipleRecordsAction({ position: 1 });
registerExportMultipleRecordsAction({ position: 2 });
};
const unregisterMultipleRecordsActions = () => {
unregisterDeleteMultipleRecordsAction();
unregisterExportMultipleRecordsAction();
};
return {
registerMultipleRecordsActions,
unregisterMultipleRecordsActions,
};
};
@@ -0,0 +1,26 @@
import { useNoSelectionRecordActions } from '@/action-menu/actions/record-actions/no-selection/hooks/useNoSelectionRecordActions';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useEffect } from 'react';
export const NoSelectionActionMenuEntrySetterEffect = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const {
registerNoSelectionRecordActions,
unregisterNoSelectionRecordActions,
} = useNoSelectionRecordActions({
objectMetadataItem,
});
useEffect(() => {
registerNoSelectionRecordActions();
return () => {
unregisterNoSelectionRecordActions();
};
}, [registerNoSelectionRecordActions, unregisterNoSelectionRecordActions]);
return null;
};
@@ -0,0 +1,108 @@
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { expect } from '@storybook/test';
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { JestObjectMetadataItemSetter } from '~/testing/jest/JestObjectMetadataItemSetter';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import { useExportViewNoSelectionRecordAction } from '@/action-menu/actions/record-actions/no-selection/hooks/useExportViewNoSelectionRecordAction';
const companyMockObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'company',
)!;
const JestMetadataAndApolloMocksWrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: [],
});
describe('useExportViewNoSelectionRecordAction', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<JestMetadataAndApolloMocksWrapper>
<JestObjectMetadataItemSetter>
<ContextStoreComponentInstanceContext.Provider
value={{
instanceId: '1',
}}
>
<ActionMenuComponentInstanceContext.Provider
value={{
instanceId: '1',
}}
>
{children}
</ActionMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
</JestObjectMetadataItemSetter>
</JestMetadataAndApolloMocksWrapper>
);
it('should register export view action', () => {
const { result } = renderHook(
() => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return {
actionMenuEntries,
useExportViewNoSelectionRecordAction:
useExportViewNoSelectionRecordAction({
objectMetadataItem: companyMockObjectMetadataItem,
}),
};
},
{ wrapper },
);
act(() => {
result.current.useExportViewNoSelectionRecordAction.registerExportViewNoSelectionRecordsAction(
{ position: 1 },
);
});
expect(result.current.actionMenuEntries.size).toBe(1);
expect(
result.current.actionMenuEntries.get('export-view-no-selection'),
).toBeDefined();
expect(
result.current.actionMenuEntries.get('export-view-no-selection')
?.position,
).toBe(1);
});
it('should unregister export view action', () => {
const { result } = renderHook(
() => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return {
actionMenuEntries,
useExportViewNoSelectionRecordAction:
useExportViewNoSelectionRecordAction({
objectMetadataItem: companyMockObjectMetadataItem,
}),
};
},
{ wrapper },
);
act(() => {
result.current.useExportViewNoSelectionRecordAction.registerExportViewNoSelectionRecordsAction(
{ position: 1 },
);
});
expect(result.current.actionMenuEntries.size).toBe(1);
act(() => {
result.current.useExportViewNoSelectionRecordAction.unregisterExportViewNoSelectionRecordsAction();
});
expect(result.current.actionMenuEntries.size).toBe(0);
});
});
@@ -0,0 +1,53 @@
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { IconDatabaseExport } from 'twenty-ui';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import {
displayedExportProgress,
useExportRecords,
} from '@/object-record/record-index/export/hooks/useExportRecords';
export const useExportViewNoSelectionRecordAction = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
const { progress, download } = useExportRecords({
delayMs: 100,
objectMetadataItem,
recordIndexId: objectMetadataItem.namePlural,
filename: `${objectMetadataItem.nameSingular}.csv`,
});
const registerExportViewNoSelectionRecordsAction = ({
position,
}: {
position: number;
}) => {
addActionMenuEntry({
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.Global,
key: 'export-view-no-selection',
position,
label: displayedExportProgress(progress),
Icon: IconDatabaseExport,
accent: 'default',
onClick: () => download(),
});
};
const unregisterExportViewNoSelectionRecordsAction = () => {
removeActionMenuEntry('export-view-no-selection');
};
return {
registerExportViewNoSelectionRecordsAction,
unregisterExportViewNoSelectionRecordsAction,
};
};
@@ -0,0 +1,28 @@
import { useExportViewNoSelectionRecordAction } from '@/action-menu/actions/record-actions/no-selection/hooks/useExportViewNoSelectionRecordAction';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
export const useNoSelectionRecordActions = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const {
registerExportViewNoSelectionRecordsAction,
unregisterExportViewNoSelectionRecordsAction,
} = useExportViewNoSelectionRecordAction({
objectMetadataItem,
});
const registerNoSelectionRecordActions = () => {
registerExportViewNoSelectionRecordsAction({ position: 1 });
};
const unregisterNoSelectionRecordActions = () => {
unregisterExportViewNoSelectionRecordsAction();
};
return {
registerNoSelectionRecordActions,
unregisterNoSelectionRecordActions,
};
};
@@ -0,0 +1,26 @@
import { SingleRecordActionMenuEntrySetterEffect } from '@/action-menu/actions/record-actions/single-record/components/SingleRecordActionMenuEntrySetterEffect';
import { WorkflowSingleRecordActionMenuEntrySetterEffect } from '@/action-menu/actions/record-actions/single-record/workflow-actions/components/WorkflowSingleRecordActionMenuEntrySetterEffect';
import { WorkflowVersionsSingleRecordActionMenuEntrySetterEffect } from '@/action-menu/actions/record-actions/single-record/workflow-version-actions/components/WorkflowVersionsSingleRecordActionMenuEntrySetterEffect';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
export const SingleRecordActionMenuEntrySetter = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
return (
<>
<SingleRecordActionMenuEntrySetterEffect
objectMetadataItem={objectMetadataItem}
/>
{objectMetadataItem.nameSingular === CoreObjectNameSingular.Workflow && (
<WorkflowSingleRecordActionMenuEntrySetterEffect />
)}
{objectMetadataItem.nameSingular ===
CoreObjectNameSingular.WorkflowVersion && (
<WorkflowVersionsSingleRecordActionMenuEntrySetterEffect />
)}
</>
);
};
@@ -0,0 +1,24 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useEffect } from 'react';
import { useSingleRecordActions } from '../hooks/useSingleRecordActions';
export const SingleRecordActionMenuEntrySetterEffect = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const { registerSingleRecordActions, unregisterSingleRecordActions } =
useSingleRecordActions({
objectMetadataItem,
});
useEffect(() => {
registerSingleRecordActions();
return () => {
unregisterSingleRecordActions();
};
}, [registerSingleRecordActions, unregisterSingleRecordActions]);
return null;
};
@@ -0,0 +1 @@
export const NUMBER_OF_STANDARD_SINGLE_RECORD_ACTIONS_ON_ALL_OBJECTS = 2;
@@ -0,0 +1,121 @@
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { expect } from '@storybook/test';
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { RecoilRoot } from 'recoil';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import { useDeleteSingleRecordAction } from '../useDeleteSingleRecordAction';
jest.mock('@/object-record/hooks/useDeleteOneRecord', () => ({
useDeleteOneRecord: () => ({
deleteOneRecord: jest.fn(),
}),
}));
jest.mock('@/favorites/hooks/useDeleteFavorite', () => ({
useDeleteFavorite: () => ({
deleteFavorite: jest.fn(),
}),
}));
jest.mock('@/favorites/hooks/useFavorites', () => ({
useFavorites: () => ({
sortedFavorites: [],
}),
}));
jest.mock('@/object-record/record-table/hooks/useRecordTable', () => ({
useRecordTable: () => ({
resetTableRowSelection: jest.fn(),
}),
}));
const companyMockObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'company',
)!;
describe('useDeleteSingleRecordAction', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<RecoilRoot>
<ContextStoreComponentInstanceContext.Provider
value={{
instanceId: '1',
}}
>
<ActionMenuComponentInstanceContext.Provider
value={{
instanceId: '1',
}}
>
{children}
</ActionMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
</RecoilRoot>
);
it('should register delete action', () => {
const { result } = renderHook(
() => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return {
actionMenuEntries,
useDeleteSingleRecordAction: useDeleteSingleRecordAction({
recordId: 'record1',
objectMetadataItem: companyMockObjectMetadataItem,
}),
};
},
{ wrapper },
);
act(() => {
result.current.useDeleteSingleRecordAction.registerDeleteSingleRecordAction(
{ position: 1 },
);
});
expect(result.current.actionMenuEntries.size).toBe(1);
expect(
result.current.actionMenuEntries.get('delete-single-record'),
).toBeDefined();
expect(
result.current.actionMenuEntries.get('delete-single-record')?.position,
).toBe(1);
});
it('should unregister delete action', () => {
const { result } = renderHook(
() => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return {
actionMenuEntries,
useDeleteSingleRecordAction: useDeleteSingleRecordAction({
recordId: 'record1',
objectMetadataItem: companyMockObjectMetadataItem,
}),
};
},
{ wrapper },
);
act(() => {
result.current.useDeleteSingleRecordAction.registerDeleteSingleRecordAction(
{ position: 1 },
);
});
expect(result.current.actionMenuEntries.size).toBe(1);
act(() => {
result.current.useDeleteSingleRecordAction.unregisterDeleteSingleRecordAction();
});
expect(result.current.actionMenuEntries.size).toBe(0);
});
});
@@ -0,0 +1,108 @@
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { expect } from '@storybook/test';
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { JestObjectMetadataItemSetter } from '~/testing/jest/JestObjectMetadataItemSetter';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import { useManageFavoritesSingleRecordAction } from '../useManageFavoritesSingleRecordAction';
const companyMockObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'company',
)!;
const JestMetadataAndApolloMocksWrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: [],
});
describe('useManageFavoritesSingleRecordAction', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<JestMetadataAndApolloMocksWrapper>
<JestObjectMetadataItemSetter>
<ContextStoreComponentInstanceContext.Provider
value={{
instanceId: '1',
}}
>
<ActionMenuComponentInstanceContext.Provider
value={{
instanceId: '1',
}}
>
{children}
</ActionMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
</JestObjectMetadataItemSetter>
</JestMetadataAndApolloMocksWrapper>
);
it('should register manage favorites action', () => {
const { result } = renderHook(
() => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return {
actionMenuEntries,
useManageFavoritesSingleRecordAction:
useManageFavoritesSingleRecordAction({
recordId: 'record1',
objectMetadataItem: companyMockObjectMetadataItem,
}),
};
},
{ wrapper },
);
act(() => {
result.current.useManageFavoritesSingleRecordAction.registerManageFavoritesSingleRecordAction(
{ position: 1 },
);
});
expect(result.current.actionMenuEntries.size).toBe(1);
expect(
result.current.actionMenuEntries.get('manage-favorites-single-record'),
).toBeDefined();
expect(
result.current.actionMenuEntries.get('manage-favorites-single-record')
?.position,
).toBe(1);
});
it('should unregister manage favorites action', () => {
const { result } = renderHook(
() => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return {
actionMenuEntries,
useManageFavoritesSingleRecordAction:
useManageFavoritesSingleRecordAction({
recordId: 'record1',
objectMetadataItem: companyMockObjectMetadataItem,
}),
};
},
{ wrapper },
);
act(() => {
result.current.useManageFavoritesSingleRecordAction.registerManageFavoritesSingleRecordAction(
{ position: 1 },
);
});
act(() => {
result.current.useManageFavoritesSingleRecordAction.unregisterManageFavoritesSingleRecordAction();
});
expect(result.current.actionMenuEntries.size).toBe(0);
});
});
@@ -0,0 +1,117 @@
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
import { useCallback, useContext, useState } from 'react';
import { IconTrash, isDefined } from 'twenty-ui';
export const useDeleteSingleRecordAction = ({
recordId,
objectMetadataItem,
}: {
recordId: string;
objectMetadataItem: ObjectMetadataItem;
}) => {
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
const [isDeleteRecordsModalOpen, setIsDeleteRecordsModalOpen] =
useState(false);
const { resetTableRowSelection } = useRecordTable({
recordTableId: objectMetadataItem.namePlural,
});
const { deleteOneRecord } = useDeleteOneRecord({
objectNameSingular: objectMetadataItem.nameSingular,
});
const { sortedFavorites: favorites } = useFavorites();
const { deleteFavorite } = useDeleteFavorite();
const { closeRightDrawer } = useRightDrawer();
const handleDeleteClick = useCallback(async () => {
resetTableRowSelection();
const foundFavorite = favorites?.find(
(favorite) => favorite.recordId === recordId,
);
if (isDefined(foundFavorite)) {
deleteFavorite(foundFavorite.id);
}
await deleteOneRecord(recordId);
}, [
deleteFavorite,
deleteOneRecord,
favorites,
resetTableRowSelection,
recordId,
]);
const isRemoteObject = objectMetadataItem.isRemote;
const { isInRightDrawer, onActionExecutedCallback } =
useContext(ActionMenuContext);
const registerDeleteSingleRecordAction = ({
position,
}: {
position: number;
}) => {
if (isRemoteObject) {
return;
}
addActionMenuEntry({
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: 'delete-single-record',
label: 'Delete',
position,
Icon: IconTrash,
accent: 'danger',
isPinned: true,
onClick: () => {
setIsDeleteRecordsModalOpen(true);
},
ConfirmationModal: (
<ConfirmationModal
isOpen={isDeleteRecordsModalOpen}
setIsOpen={setIsDeleteRecordsModalOpen}
title={'Delete Record'}
subtitle={
'Are you sure you want to delete this record? It can be recovered from the Options menu.'
}
onConfirmClick={() => {
handleDeleteClick();
onActionExecutedCallback?.();
if (isInRightDrawer) {
closeRightDrawer();
}
}}
deleteButtonText={'Delete Record'}
/>
),
});
};
const unregisterDeleteSingleRecordAction = () => {
removeActionMenuEntry('delete-single-record');
};
return {
registerDeleteSingleRecordAction,
unregisterDeleteSingleRecordAction,
};
};
@@ -3,52 +3,47 @@ import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useCreateFavorite } from '@/favorites/hooks/useCreateFavorite';
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useEffect } from 'react';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useRecoilValue } from 'recoil';
import { IconHeart, IconHeartOff, isDefined } from 'twenty-ui';
export const ManageFavoritesActionEffect = ({
position,
export const useManageFavoritesSingleRecordAction = ({
recordId,
objectMetadataItem,
}: {
position: number;
recordId: string;
objectMetadataItem: ObjectMetadataItem;
}) => {
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
const contextStoreTargetedRecordsRule = useRecoilComponentValueV2(
contextStoreTargetedRecordsRuleComponentState,
);
const { sortedFavorites: favorites } = useFavorites();
const { createFavorite } = useCreateFavorite();
const { deleteFavorite } = useDeleteFavorite();
const selectedRecordId =
contextStoreTargetedRecordsRule.mode === 'selection'
? contextStoreTargetedRecordsRule.selectedRecordIds[0]
: undefined;
const selectedRecord = useRecoilValue(
recordStoreFamilyState(selectedRecordId ?? ''),
);
const selectedRecord = useRecoilValue(recordStoreFamilyState(recordId));
const foundFavorite = favorites?.find(
(favorite) => favorite.recordId === selectedRecordId,
(favorite) => favorite.recordId === recordId,
);
const isFavorite = !!selectedRecordId && !!foundFavorite;
const isFavorite = !!foundFavorite;
useEffect(() => {
const isPageHeaderV2Enabled = useIsFeatureEnabled(
'IS_PAGE_HEADER_V2_ENABLED',
);
const registerManageFavoritesSingleRecordAction = ({
position,
}: {
position: number;
}) => {
if (!isDefined(objectMetadataItem) || objectMetadataItem.isRemote) {
return;
}
@@ -56,7 +51,8 @@ export const ManageFavoritesActionEffect = ({
addActionMenuEntry({
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: 'manage-favorites',
key: 'manage-favorites-single-record',
isPinned: isPageHeaderV2Enabled,
label: isFavorite ? 'Remove from favorites' : 'Add to favorites',
position,
Icon: isFavorite ? IconHeartOff : IconHeart,
@@ -68,21 +64,14 @@ export const ManageFavoritesActionEffect = ({
}
},
});
};
return () => {
removeActionMenuEntry('manage-favorites');
};
}, [
addActionMenuEntry,
createFavorite,
deleteFavorite,
foundFavorite?.id,
isFavorite,
objectMetadataItem,
position,
removeActionMenuEntry,
selectedRecord,
]);
const unregisterManageFavoritesSingleRecordAction = () => {
removeActionMenuEntry('manage-favorites-single-record');
};
return null;
return {
registerManageFavoritesSingleRecordAction,
unregisterManageFavoritesSingleRecordAction,
};
};
@@ -0,0 +1,56 @@
import { useDeleteSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useDeleteSingleRecordAction';
import { useManageFavoritesSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useManageFavoritesSingleRecordAction';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { isDefined } from 'twenty-ui';
export const useSingleRecordActions = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const contextStoreTargetedRecordsRule = useRecoilComponentValueV2(
contextStoreTargetedRecordsRuleComponentState,
);
const selectedRecordId =
contextStoreTargetedRecordsRule.mode === 'selection'
? contextStoreTargetedRecordsRule.selectedRecordIds[0]
: undefined;
if (!isDefined(selectedRecordId)) {
throw new Error('Selected record ID is required');
}
const {
registerManageFavoritesSingleRecordAction,
unregisterManageFavoritesSingleRecordAction,
} = useManageFavoritesSingleRecordAction({
recordId: selectedRecordId,
objectMetadataItem,
});
const {
registerDeleteSingleRecordAction,
unregisterDeleteSingleRecordAction,
} = useDeleteSingleRecordAction({
recordId: selectedRecordId,
objectMetadataItem,
});
const registerSingleRecordActions = () => {
registerManageFavoritesSingleRecordAction({ position: 1 });
registerDeleteSingleRecordAction({ position: 2 });
};
const unregisterSingleRecordActions = () => {
unregisterManageFavoritesSingleRecordAction();
unregisterDeleteSingleRecordAction();
};
return {
registerSingleRecordActions,
unregisterSingleRecordActions,
};
};
@@ -0,0 +1,21 @@
import { NUMBER_OF_STANDARD_SINGLE_RECORD_ACTIONS_ON_ALL_OBJECTS } from '@/action-menu/actions/record-actions/single-record/constants/NumberOfStandardSingleRecordActionsOnAllObjects';
import { useEffect } from 'react';
import { useWorkflowSingleRecordActions } from '../hooks/useWorkflowSingleRecordActions';
export const WorkflowSingleRecordActionMenuEntrySetterEffect = () => {
const { registerSingleRecordActions, unregisterSingleRecordActions } =
useWorkflowSingleRecordActions();
useEffect(() => {
registerSingleRecordActions({
startPosition:
NUMBER_OF_STANDARD_SINGLE_RECORD_ACTIONS_ON_ALL_OBJECTS + 1,
});
return () => {
unregisterSingleRecordActions();
};
}, [registerSingleRecordActions, unregisterSingleRecordActions]);
return null;
};
@@ -0,0 +1,123 @@
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { expect } from '@storybook/test';
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { JestObjectMetadataItemSetter } from '~/testing/jest/JestObjectMetadataItemSetter';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { useActivateWorkflowDraftWorkflowSingleRecordAction } from '../useActivateWorkflowDraftWorkflowSingleRecordAction';
const JestMetadataAndApolloMocksWrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: [],
});
jest.mock('@/workflow/hooks/useWorkflowWithCurrentVersion', () => ({
useWorkflowWithCurrentVersion: () => ({
id: 'workflowId',
currentVersion: {
id: 'currentVersionId',
trigger: 'trigger',
status: 'DRAFT',
steps: [
{
id: 'stepId1',
},
{
id: 'stepId2',
},
],
},
}),
}));
describe('useActivateWorkflowDraftWorkflowSingleRecordAction', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<JestMetadataAndApolloMocksWrapper>
<JestObjectMetadataItemSetter>
<ContextStoreComponentInstanceContext.Provider
value={{
instanceId: '1',
}}
>
<ActionMenuComponentInstanceContext.Provider
value={{
instanceId: '1',
}}
>
{children}
</ActionMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
</JestObjectMetadataItemSetter>
</JestMetadataAndApolloMocksWrapper>
);
it('should register activate workflow draft workflow action', () => {
const { result } = renderHook(
() => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return {
actionMenuEntries,
useActivateWorkflowDraftWorkflowSingleRecordAction:
useActivateWorkflowDraftWorkflowSingleRecordAction({
workflowId: 'workflowId',
}),
};
},
{ wrapper },
);
act(() => {
result.current.useActivateWorkflowDraftWorkflowSingleRecordAction.registerActivateWorkflowDraftWorkflowSingleRecordAction(
{ position: 1 },
);
});
expect(result.current.actionMenuEntries.size).toBe(1);
expect(
result.current.actionMenuEntries.get(
'activate-workflow-draft-single-record',
),
).toBeDefined();
expect(
result.current.actionMenuEntries.get(
'activate-workflow-draft-single-record',
)?.position,
).toBe(1);
});
it('should unregister activate workflow draft workflow action', () => {
const { result } = renderHook(
() => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return {
actionMenuEntries,
useActivateWorkflowDraftWorkflowSingleRecordAction:
useActivateWorkflowDraftWorkflowSingleRecordAction({
workflowId: 'workflow1',
}),
};
},
{ wrapper },
);
act(() => {
result.current.useActivateWorkflowDraftWorkflowSingleRecordAction.registerActivateWorkflowDraftWorkflowSingleRecordAction(
{ position: 1 },
);
});
act(() => {
result.current.useActivateWorkflowDraftWorkflowSingleRecordAction.unregisterActivateWorkflowDraftWorkflowSingleRecordAction();
});
expect(result.current.actionMenuEntries.size).toBe(0);
});
});
@@ -0,0 +1,124 @@
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { expect } from '@storybook/test';
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { JestObjectMetadataItemSetter } from '~/testing/jest/JestObjectMetadataItemSetter';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { useActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction } from '../useActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction';
const JestMetadataAndApolloMocksWrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: [],
});
jest.mock('@/workflow/hooks/useWorkflowWithCurrentVersion', () => ({
useWorkflowWithCurrentVersion: () => ({
id: 'workflowId',
currentVersion: {
id: 'currentVersionId',
trigger: 'trigger',
status: 'DEACTIVATED',
steps: [
{
id: 'stepId1',
},
{
id: 'stepId2',
},
],
},
lastPublishedVersionId: 'lastPublishedVersionId',
}),
}));
describe('useActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<JestMetadataAndApolloMocksWrapper>
<JestObjectMetadataItemSetter>
<ContextStoreComponentInstanceContext.Provider
value={{
instanceId: '1',
}}
>
<ActionMenuComponentInstanceContext.Provider
value={{
instanceId: '1',
}}
>
{children}
</ActionMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
</JestObjectMetadataItemSetter>
</JestMetadataAndApolloMocksWrapper>
);
it('should register activate workflow last published version workflow action', () => {
const { result } = renderHook(
() => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return {
actionMenuEntries,
useActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction:
useActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction({
workflowId: 'workflowId',
}),
};
},
{ wrapper },
);
act(() => {
result.current.useActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction.registerActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction(
{ position: 1 },
);
});
expect(result.current.actionMenuEntries.size).toBe(1);
expect(
result.current.actionMenuEntries.get(
'activate-workflow-last-published-version-single-record',
),
).toBeDefined();
expect(
result.current.actionMenuEntries.get(
'activate-workflow-last-published-version-single-record',
)?.position,
).toBe(1);
});
it('should unregister activate workflow last published version workflow action', () => {
const { result } = renderHook(
() => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return {
actionMenuEntries,
useActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction:
useActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction({
workflowId: 'workflow1',
}),
};
},
{ wrapper },
);
act(() => {
result.current.useActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction.registerActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction(
{ position: 1 },
);
});
act(() => {
result.current.useActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction.unregisterActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction();
});
expect(result.current.actionMenuEntries.size).toBe(0);
});
});
@@ -0,0 +1,113 @@
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { expect } from '@storybook/test';
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { JestObjectMetadataItemSetter } from '~/testing/jest/JestObjectMetadataItemSetter';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { useDeactivateWorkflowWorkflowSingleRecordAction } from '../useDeactivateWorkflowWorkflowSingleRecordAction';
const JestMetadataAndApolloMocksWrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: [],
});
jest.mock('@/workflow/hooks/useWorkflowWithCurrentVersion', () => ({
useWorkflowWithCurrentVersion: () => ({
id: 'workflowId',
currentVersion: {
id: 'currentVersionId',
trigger: 'trigger',
status: 'ACTIVE',
},
lastPublishedVersionId: 'lastPublishedVersionId',
}),
}));
describe('useDeactivateWorkflowWorkflowSingleRecordAction', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<JestMetadataAndApolloMocksWrapper>
<JestObjectMetadataItemSetter>
<ContextStoreComponentInstanceContext.Provider
value={{
instanceId: '1',
}}
>
<ActionMenuComponentInstanceContext.Provider
value={{
instanceId: '1',
}}
>
{children}
</ActionMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
</JestObjectMetadataItemSetter>
</JestMetadataAndApolloMocksWrapper>
);
it('should register activate workflow last published version workflow action', () => {
const { result } = renderHook(
() => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return {
actionMenuEntries,
useDeactivateWorkflowWorkflowSingleRecordAction:
useDeactivateWorkflowWorkflowSingleRecordAction({
workflowId: 'workflowId',
}),
};
},
{ wrapper },
);
act(() => {
result.current.useDeactivateWorkflowWorkflowSingleRecordAction.registerDeactivateWorkflowWorkflowSingleRecordAction(
{ position: 1 },
);
});
expect(result.current.actionMenuEntries.size).toBe(1);
expect(
result.current.actionMenuEntries.get('deactivate-workflow-single-record'),
).toBeDefined();
expect(
result.current.actionMenuEntries.get('deactivate-workflow-single-record')
?.position,
).toBe(1);
});
it('should unregister deactivate workflow workflow action', () => {
const { result } = renderHook(
() => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return {
actionMenuEntries,
useDeactivateWorkflowWorkflowSingleRecordAction:
useDeactivateWorkflowWorkflowSingleRecordAction({
workflowId: 'workflow1',
}),
};
},
{ wrapper },
);
act(() => {
result.current.useDeactivateWorkflowWorkflowSingleRecordAction.registerDeactivateWorkflowWorkflowSingleRecordAction(
{ position: 1 },
);
});
act(() => {
result.current.useDeactivateWorkflowWorkflowSingleRecordAction.unregisterDeactivateWorkflowWorkflowSingleRecordAction();
});
expect(result.current.actionMenuEntries.size).toBe(0);
});
});
@@ -0,0 +1,128 @@
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { expect } from '@storybook/test';
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { JestObjectMetadataItemSetter } from '~/testing/jest/JestObjectMetadataItemSetter';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { useDiscardDraftWorkflowSingleRecordAction } from '../useDiscardDraftWorkflowSingleRecordAction';
const JestMetadataAndApolloMocksWrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: [],
});
jest.mock('@/workflow/hooks/useWorkflowWithCurrentVersion', () => ({
useWorkflowWithCurrentVersion: () => ({
id: 'workflowId',
currentVersion: {
id: 'currentVersionId',
trigger: 'trigger',
status: 'DRAFT',
},
lastPublishedVersionId: 'lastPublishedVersionId',
versions: [
{
id: 'currentVersionId',
trigger: 'trigger',
status: 'DRAFT',
},
{
id: 'lastPublishedVersionId',
trigger: 'trigger',
status: 'ACTIVE',
},
],
}),
}));
describe('useDiscardDraftWorkflowSingleRecordAction', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<JestMetadataAndApolloMocksWrapper>
<JestObjectMetadataItemSetter>
<ContextStoreComponentInstanceContext.Provider
value={{
instanceId: '1',
}}
>
<ActionMenuComponentInstanceContext.Provider
value={{
instanceId: '1',
}}
>
{children}
</ActionMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
</JestObjectMetadataItemSetter>
</JestMetadataAndApolloMocksWrapper>
);
it('should register discard workflow draft workflow action', () => {
const { result } = renderHook(
() => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return {
actionMenuEntries,
useDiscardDraftWorkflowSingleRecordAction:
useDiscardDraftWorkflowSingleRecordAction({
workflowId: 'workflowId',
}),
};
},
{ wrapper },
);
act(() => {
result.current.useDiscardDraftWorkflowSingleRecordAction.registerDiscardDraftWorkflowSingleRecordAction(
{ position: 1 },
);
});
expect(result.current.actionMenuEntries.size).toBe(1);
expect(
result.current.actionMenuEntries.get(
'discard-workflow-draft-single-record',
),
).toBeDefined();
expect(
result.current.actionMenuEntries.get(
'discard-workflow-draft-single-record',
)?.position,
).toBe(1);
});
it('should unregister deactivate workflow workflow action', () => {
const { result } = renderHook(
() => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return {
actionMenuEntries,
useDiscardDraftWorkflowSingleRecordAction:
useDiscardDraftWorkflowSingleRecordAction({
workflowId: 'workflow1',
}),
};
},
{ wrapper },
);
act(() => {
result.current.useDiscardDraftWorkflowSingleRecordAction.registerDiscardDraftWorkflowSingleRecordAction(
{ position: 1 },
);
});
act(() => {
result.current.useDiscardDraftWorkflowSingleRecordAction.unregisterDiscardDraftWorkflowSingleRecordAction();
});
expect(result.current.actionMenuEntries.size).toBe(0);
});
});
@@ -0,0 +1,64 @@
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { useActivateWorkflowVersion } from '@/workflow/hooks/useActivateWorkflowVersion';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { IconPower, isDefined } from 'twenty-ui';
export const useActivateWorkflowDraftWorkflowSingleRecordAction = ({
workflowId,
}: {
workflowId: string;
}) => {
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
const { activateWorkflowVersion } = useActivateWorkflowVersion();
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(workflowId);
const registerActivateWorkflowDraftWorkflowSingleRecordAction = ({
position,
}: {
position: number;
}) => {
if (
!isDefined(workflowWithCurrentVersion?.currentVersion?.trigger) ||
!isDefined(workflowWithCurrentVersion.currentVersion?.steps)
) {
return;
}
const isDraft =
workflowWithCurrentVersion.currentVersion.status === 'DRAFT';
if (!isDraft) {
return;
}
addActionMenuEntry({
key: 'activate-workflow-draft-single-record',
label: 'Activate Draft',
position,
Icon: IconPower,
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
onClick: () => {
activateWorkflowVersion({
workflowVersionId: workflowWithCurrentVersion.currentVersion.id,
workflowId: workflowWithCurrentVersion.id,
});
},
});
};
const unregisterActivateWorkflowDraftWorkflowSingleRecordAction = () => {
removeActionMenuEntry('activate-workflow-draft-single-record');
};
return {
registerActivateWorkflowDraftWorkflowSingleRecordAction,
unregisterActivateWorkflowDraftWorkflowSingleRecordAction,
};
};
@@ -0,0 +1,61 @@
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { useActivateWorkflowVersion } from '@/workflow/hooks/useActivateWorkflowVersion';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { IconPower, isDefined } from 'twenty-ui';
export const useActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction =
({ workflowId }: { workflowId: string }) => {
const { addActionMenuEntry, removeActionMenuEntry } =
useActionMenuEntries();
const { activateWorkflowVersion } = useActivateWorkflowVersion();
const workflowWithCurrentVersion =
useWorkflowWithCurrentVersion(workflowId);
const registerActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction =
({ position }: { position: number }) => {
if (
!isDefined(workflowWithCurrentVersion) ||
!isDefined(workflowWithCurrentVersion.currentVersion.trigger) ||
!isDefined(workflowWithCurrentVersion.lastPublishedVersionId) ||
workflowWithCurrentVersion.currentVersion.status === 'ACTIVE' ||
!isDefined(workflowWithCurrentVersion.currentVersion?.steps) ||
workflowWithCurrentVersion.currentVersion?.steps.length === 0
) {
return;
}
addActionMenuEntry({
key: 'activate-workflow-last-published-version-single-record',
label: 'Activate last published version',
position,
Icon: IconPower,
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
onClick: () => {
activateWorkflowVersion({
workflowVersionId:
workflowWithCurrentVersion.lastPublishedVersionId,
workflowId: workflowWithCurrentVersion.id,
});
},
});
};
const unregisterActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction =
() => {
removeActionMenuEntry(
'activate-workflow-last-published-version-single-record',
);
};
return {
registerActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction,
unregisterActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction,
};
};
@@ -0,0 +1,55 @@
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { useDeactivateWorkflowVersion } from '@/workflow/hooks/useDeactivateWorkflowVersion';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { IconPlayerPause, isDefined } from 'twenty-ui';
export const useDeactivateWorkflowWorkflowSingleRecordAction = ({
workflowId,
}: {
workflowId: string;
}) => {
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
const { deactivateWorkflowVersion } = useDeactivateWorkflowVersion();
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(workflowId);
const isWorkflowActive =
isDefined(workflowWithCurrentVersion) &&
workflowWithCurrentVersion.currentVersion.status === 'ACTIVE';
const registerDeactivateWorkflowWorkflowSingleRecordAction = ({
position,
}: {
position: number;
}) => {
if (!isDefined(workflowWithCurrentVersion) || !isWorkflowActive) {
return;
}
addActionMenuEntry({
key: 'deactivate-workflow-single-record',
label: 'Deactivate Workflow',
position,
Icon: IconPlayerPause,
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
onClick: () => {
deactivateWorkflowVersion(workflowWithCurrentVersion.currentVersion.id);
},
});
};
const unregisterDeactivateWorkflowWorkflowSingleRecordAction = () => {
removeActionMenuEntry('deactivate-workflow-single-record');
};
return {
registerDeactivateWorkflowWorkflowSingleRecordAction,
unregisterDeactivateWorkflowWorkflowSingleRecordAction,
};
};
@@ -0,0 +1,63 @@
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { useDeleteOneWorkflowVersion } from '@/workflow/hooks/useDeleteOneWorkflowVersion';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { IconTrash, isDefined } from 'twenty-ui';
export const useDiscardDraftWorkflowSingleRecordAction = ({
workflowId,
}: {
workflowId: string;
}) => {
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
const { deleteOneWorkflowVersion } = useDeleteOneWorkflowVersion();
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(workflowId);
const registerDiscardDraftWorkflowSingleRecordAction = ({
position,
}: {
position: number;
}) => {
if (
!isDefined(workflowWithCurrentVersion) ||
workflowWithCurrentVersion.versions.length < 2
) {
return;
}
const isDraft =
workflowWithCurrentVersion.currentVersion.status === 'DRAFT';
if (!isDraft) {
return;
}
addActionMenuEntry({
key: 'discard-workflow-draft-single-record',
label: 'Discard Draft',
position,
Icon: IconTrash,
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
onClick: () => {
deleteOneWorkflowVersion({
workflowVersionId: workflowWithCurrentVersion.currentVersion.id,
});
},
});
};
const unregisterDiscardDraftWorkflowSingleRecordAction = () => {
removeActionMenuEntry('discard-workflow-draft-single-record');
};
return {
registerDiscardDraftWorkflowSingleRecordAction,
unregisterDiscardDraftWorkflowSingleRecordAction,
};
};
@@ -0,0 +1,60 @@
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useActiveWorkflowVersion } from '@/workflow/hooks/useActiveWorkflowVersion';
import { useNavigate } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { IconHistory, isDefined } from 'twenty-ui';
export const useSeeWorkflowActiveVersionWorkflowSingleRecordAction = ({
workflowId,
}: {
workflowId: string;
}) => {
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
const workflow = useRecoilValue(recordStoreFamilyState(workflowId));
const isDraft = workflow?.statuses?.includes('DRAFT');
const workflowActiveVersion = useActiveWorkflowVersion(workflowId);
const navigate = useNavigate();
const registerSeeWorkflowActiveVersionWorkflowSingleRecordAction = ({
position,
}: {
position: number;
}) => {
if (!isDefined(workflowActiveVersion) || !isDraft) {
return;
}
addActionMenuEntry({
key: 'see-workflow-active-version-single-record',
label: 'See active version',
position,
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
Icon: IconHistory,
onClick: () => {
navigate(
`/object/${CoreObjectNameSingular.WorkflowVersion}/${workflowActiveVersion.id}`,
);
},
});
};
const unregisterSeeWorkflowActiveVersionWorkflowSingleRecordAction = () => {
removeActionMenuEntry('see-workflow-active-version-single-record');
};
return {
registerSeeWorkflowActiveVersionWorkflowSingleRecordAction,
unregisterSeeWorkflowActiveVersionWorkflowSingleRecordAction,
};
};
@@ -0,0 +1,66 @@
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { FilterQueryParams } from '@/views/hooks/internal/useViewFromQueryParams';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import qs from 'qs';
import { useNavigate } from 'react-router-dom';
import { IconHistoryToggle, isDefined } from 'twenty-ui';
export const useSeeWorkflowRunsWorkflowSingleRecordAction = ({
workflowId,
}: {
workflowId: string;
}) => {
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(workflowId);
const navigate = useNavigate();
const registerSeeWorkflowRunsWorkflowSingleRecordAction = ({
position,
}: {
position: number;
}) => {
if (!isDefined(workflowWithCurrentVersion)) {
return;
}
const filterQueryParams: FilterQueryParams = {
filter: {
workflow: {
[ViewFilterOperand.Is]: [workflowWithCurrentVersion.id],
},
},
};
const filterLinkHref = `/objects/${CoreObjectNamePlural.WorkflowRun}?${qs.stringify(
filterQueryParams,
)}`;
addActionMenuEntry({
key: 'see-workflow-runs-single-record',
label: 'See runs',
position,
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
Icon: IconHistoryToggle,
onClick: () => {
navigate(filterLinkHref);
},
});
};
const unregisterSeeWorkflowRunsWorkflowSingleRecordAction = () => {
removeActionMenuEntry('see-workflow-runs-single-record');
};
return {
registerSeeWorkflowRunsWorkflowSingleRecordAction,
unregisterSeeWorkflowRunsWorkflowSingleRecordAction,
};
};
@@ -0,0 +1,66 @@
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { FilterQueryParams } from '@/views/hooks/internal/useViewFromQueryParams';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import qs from 'qs';
import { useNavigate } from 'react-router-dom';
import { IconHistory, isDefined } from 'twenty-ui';
export const useSeeWorkflowVersionsHistoryWorkflowSingleRecordAction = ({
workflowId,
}: {
workflowId: string;
}) => {
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(workflowId);
const navigate = useNavigate();
const registerSeeWorkflowVersionsHistoryWorkflowSingleRecordAction = ({
position,
}: {
position: number;
}) => {
if (!isDefined(workflowWithCurrentVersion)) {
return;
}
const filterQueryParams: FilterQueryParams = {
filter: {
workflow: {
[ViewFilterOperand.Is]: [workflowWithCurrentVersion.id],
},
},
};
const filterLinkHref = `/objects/${CoreObjectNamePlural.WorkflowVersion}?${qs.stringify(
filterQueryParams,
)}`;
addActionMenuEntry({
key: 'see-workflow-versions-history-single-record',
label: 'See versions history',
position,
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
Icon: IconHistory,
onClick: () => {
navigate(filterLinkHref);
},
});
};
const unregisterSeeWorkflowVersionsHistoryWorkflowSingleRecordAction = () => {
removeActionMenuEntry('see-workflow-versions-history-single-record');
};
return {
registerSeeWorkflowVersionsHistoryWorkflowSingleRecordAction,
unregisterSeeWorkflowVersionsHistoryWorkflowSingleRecordAction,
};
};
@@ -0,0 +1,60 @@
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { useRunWorkflowVersion } from '@/workflow/hooks/useRunWorkflowVersion';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { IconPlayerPlay, isDefined } from 'twenty-ui';
export const useTestWorkflowWorkflowSingleRecordAction = ({
workflowId,
}: {
workflowId: string;
}) => {
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(workflowId);
const { runWorkflowVersion } = useRunWorkflowVersion();
const registerTestWorkflowWorkflowSingleRecordAction = ({
position,
}: {
position: number;
}) => {
if (
!isDefined(workflowWithCurrentVersion?.currentVersion?.trigger) ||
workflowWithCurrentVersion.currentVersion.trigger.type !== 'MANUAL' ||
isDefined(
workflowWithCurrentVersion.currentVersion.trigger.settings.objectType,
)
) {
return;
}
addActionMenuEntry({
key: 'test-workflow-single-record',
label: 'Test workflow',
position,
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
Icon: IconPlayerPlay,
onClick: () => {
runWorkflowVersion({
workflowVersionId: workflowWithCurrentVersion.currentVersion.id,
workflowName: workflowWithCurrentVersion.name,
});
},
});
};
const unregisterTestWorkflowWorkflowSingleRecordAction = () => {
removeActionMenuEntry('test-workflow-single-record');
};
return {
registerTestWorkflowWorkflowSingleRecordAction,
unregisterTestWorkflowWorkflowSingleRecordAction,
};
};
@@ -0,0 +1,127 @@
import { useActivateWorkflowDraftWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useActivateWorkflowDraftWorkflowSingleRecordAction';
import { useActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction';
import { useDeactivateWorkflowWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useDeactivateWorkflowWorkflowSingleRecordAction';
import { useDiscardDraftWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useDiscardDraftWorkflowSingleRecordAction';
import { useSeeWorkflowActiveVersionWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useSeeWorkflowActiveVersionWorkflowSingleRecordAction';
import { useSeeWorkflowRunsWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useSeeWorkflowRunsWorkflowSingleRecordAction';
import { useSeeWorkflowVersionsHistoryWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useSeeWorkflowVersionsHistoryWorkflowSingleRecordAction';
import { useTestWorkflowWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useTestWorkflowWorkflowSingleRecordAction';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { isDefined } from 'twenty-ui';
export const useWorkflowSingleRecordActions = () => {
const contextStoreTargetedRecordsRule = useRecoilComponentValueV2(
contextStoreTargetedRecordsRuleComponentState,
);
const selectedRecordId =
contextStoreTargetedRecordsRule.mode === 'selection'
? contextStoreTargetedRecordsRule.selectedRecordIds?.[0]
: undefined;
if (!isDefined(selectedRecordId)) {
throw new Error('Selected record ID is required');
}
const {
registerTestWorkflowWorkflowSingleRecordAction,
unregisterTestWorkflowWorkflowSingleRecordAction,
} = useTestWorkflowWorkflowSingleRecordAction({
workflowId: selectedRecordId,
});
const {
registerActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction,
unregisterActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction,
} = useActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction({
workflowId: selectedRecordId,
});
const {
registerDeactivateWorkflowWorkflowSingleRecordAction,
unregisterDeactivateWorkflowWorkflowSingleRecordAction,
} = useDeactivateWorkflowWorkflowSingleRecordAction({
workflowId: selectedRecordId,
});
const {
registerSeeWorkflowRunsWorkflowSingleRecordAction,
unregisterSeeWorkflowRunsWorkflowSingleRecordAction,
} = useSeeWorkflowRunsWorkflowSingleRecordAction({
workflowId: selectedRecordId,
});
const {
registerSeeWorkflowVersionsHistoryWorkflowSingleRecordAction,
unregisterSeeWorkflowVersionsHistoryWorkflowSingleRecordAction,
} = useSeeWorkflowVersionsHistoryWorkflowSingleRecordAction({
workflowId: selectedRecordId,
});
const {
registerSeeWorkflowActiveVersionWorkflowSingleRecordAction,
unregisterSeeWorkflowActiveVersionWorkflowSingleRecordAction,
} = useSeeWorkflowActiveVersionWorkflowSingleRecordAction({
workflowId: selectedRecordId,
});
const {
registerActivateWorkflowDraftWorkflowSingleRecordAction,
unregisterActivateWorkflowDraftWorkflowSingleRecordAction,
} = useActivateWorkflowDraftWorkflowSingleRecordAction({
workflowId: selectedRecordId,
});
const {
registerDiscardDraftWorkflowSingleRecordAction,
unregisterDiscardDraftWorkflowSingleRecordAction,
} = useDiscardDraftWorkflowSingleRecordAction({
workflowId: selectedRecordId,
});
const registerSingleRecordActions = ({
startPosition,
}: {
startPosition: number;
}) => {
registerTestWorkflowWorkflowSingleRecordAction({ position: startPosition });
registerDiscardDraftWorkflowSingleRecordAction({
position: startPosition + 1,
});
registerActivateWorkflowDraftWorkflowSingleRecordAction({
position: startPosition + 2,
});
registerActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction({
position: startPosition + 3,
});
registerDeactivateWorkflowWorkflowSingleRecordAction({
position: startPosition + 4,
});
registerSeeWorkflowRunsWorkflowSingleRecordAction({
position: startPosition + 5,
});
registerSeeWorkflowActiveVersionWorkflowSingleRecordAction({
position: startPosition + 6,
});
registerSeeWorkflowVersionsHistoryWorkflowSingleRecordAction({
position: startPosition + 7,
});
};
const unregisterSingleRecordActions = () => {
unregisterTestWorkflowWorkflowSingleRecordAction();
unregisterActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction();
unregisterDiscardDraftWorkflowSingleRecordAction();
unregisterActivateWorkflowDraftWorkflowSingleRecordAction();
unregisterDeactivateWorkflowWorkflowSingleRecordAction();
unregisterSeeWorkflowRunsWorkflowSingleRecordAction();
unregisterSeeWorkflowActiveVersionWorkflowSingleRecordAction();
unregisterSeeWorkflowVersionsHistoryWorkflowSingleRecordAction();
};
return {
registerSingleRecordActions,
unregisterSingleRecordActions,
};
};
@@ -0,0 +1,21 @@
import { NUMBER_OF_STANDARD_SINGLE_RECORD_ACTIONS_ON_ALL_OBJECTS } from '@/action-menu/actions/record-actions/single-record/constants/NumberOfStandardSingleRecordActionsOnAllObjects';
import { useWorkflowVersionsSingleRecordActions } from '@/action-menu/actions/record-actions/single-record/workflow-version-actions/hooks/useWorkflowVersionsSingleRecordActions';
import { useEffect } from 'react';
export const WorkflowVersionsSingleRecordActionMenuEntrySetterEffect = () => {
const { registerSingleRecordActions, unregisterSingleRecordActions } =
useWorkflowVersionsSingleRecordActions();
useEffect(() => {
registerSingleRecordActions({
startPosition:
NUMBER_OF_STANDARD_SINGLE_RECORD_ACTIONS_ON_ALL_OBJECTS + 1,
});
return () => {
unregisterSingleRecordActions();
};
}, [registerSingleRecordActions, unregisterSingleRecordActions]);
return null;
};
@@ -0,0 +1,78 @@
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { FilterQueryParams } from '@/views/hooks/internal/useViewFromQueryParams';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import qs from 'qs';
import { useNavigate } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { IconHistoryToggle, isDefined } from 'twenty-ui';
export const useSeeWorkflowExecutionsWorkflowVersionSingleRecordAction = ({
workflowVersionId,
}: {
workflowVersionId: string;
}) => {
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
const workflowVersion = useRecoilValue(
recordStoreFamilyState(workflowVersionId),
);
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(
workflowVersion?.workflow.id,
);
const navigate = useNavigate();
const registerSeeWorkflowExecutionsWorkflowVersionSingleRecordAction = ({
position,
}: {
position: number;
}) => {
if (!isDefined(workflowWithCurrentVersion)) {
return;
}
const filterQueryParams: FilterQueryParams = {
filter: {
workflow: {
[ViewFilterOperand.Is]: [workflowWithCurrentVersion.id],
},
workflowVersion: {
[ViewFilterOperand.Is]: [workflowVersionId],
},
},
};
const filterLinkHref = `/objects/${CoreObjectNamePlural.WorkflowRun}?${qs.stringify(
filterQueryParams,
)}`;
addActionMenuEntry({
key: 'see-workflow-executions-single-record',
label: 'See executions',
position,
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
Icon: IconHistoryToggle,
onClick: () => {
navigate(filterLinkHref);
},
});
};
const unregisterSeeWorkflowExecutionsWorkflowVersionSingleRecordAction =
() => {
removeActionMenuEntry('see-workflow-executions-single-record');
};
return {
registerSeeWorkflowExecutionsWorkflowVersionSingleRecordAction,
unregisterSeeWorkflowExecutionsWorkflowVersionSingleRecordAction,
};
};
@@ -0,0 +1,27 @@
import { useSeeWorkflowVersionsHistoryWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useSeeWorkflowVersionsHistoryWorkflowSingleRecordAction';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useRecoilValue } from 'recoil';
export const useSeeWorkflowVersionsHistoryWorkflowVersionSingleRecordAction = ({
workflowVersionId,
}: {
workflowVersionId: string;
}) => {
const workflowVersion = useRecoilValue(
recordStoreFamilyState(workflowVersionId),
);
const {
registerSeeWorkflowVersionsHistoryWorkflowSingleRecordAction:
registerSeeWorkflowVersionsHistoryWorkflowVersionSingleRecordAction,
unregisterSeeWorkflowVersionsHistoryWorkflowSingleRecordAction:
unregisterSeeWorkflowVersionsHistoryWorkflowVersionSingleRecordAction,
} = useSeeWorkflowVersionsHistoryWorkflowSingleRecordAction({
workflowId: workflowVersion?.workflow.id,
});
return {
registerSeeWorkflowVersionsHistoryWorkflowVersionSingleRecordAction,
unregisterSeeWorkflowVersionsHistoryWorkflowVersionSingleRecordAction,
};
};
@@ -0,0 +1,92 @@
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { OverrideWorkflowDraftConfirmationModal } from '@/workflow/components/OverrideWorkflowDraftConfirmationModal';
import { useCreateNewWorkflowVersion } from '@/workflow/hooks/useCreateNewWorkflowVersion';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { openOverrideWorkflowDraftConfirmationModalState } from '@/workflow/states/openOverrideWorkflowDraftConfirmationModalState';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { IconPencil, isDefined } from 'twenty-ui';
export const useUseAsDraftWorkflowVersionSingleRecordAction = ({
workflowVersionId,
}: {
workflowVersionId: string;
}) => {
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
const workflowVersion = useRecoilValue(
recordStoreFamilyState(workflowVersionId),
);
const workflow = useWorkflowWithCurrentVersion(
workflowVersion?.workflow?.id ?? '',
);
const { createNewWorkflowVersion } = useCreateNewWorkflowVersion();
const setOpenOverrideWorkflowDraftConfirmationModal = useSetRecoilState(
openOverrideWorkflowDraftConfirmationModalState,
);
const registerUseAsDraftWorkflowVersionSingleRecordAction = ({
position,
}: {
position: number;
}) => {
if (
!isDefined(workflowVersion) ||
!isDefined(workflow) ||
!isDefined(workflow.statuses) ||
workflowVersion.status === 'DRAFT'
) {
return;
}
const hasAlreadyDraftVersion = workflow.statuses.includes('DRAFT');
addActionMenuEntry({
key: 'use-workflow-version-as-draft-single-record',
label: 'Use as draft',
position,
Icon: IconPencil,
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
onClick: async () => {
if (hasAlreadyDraftVersion) {
setOpenOverrideWorkflowDraftConfirmationModal(true);
} else {
await createNewWorkflowVersion({
workflowId: workflowVersion.workflow.id,
name: `v${workflow.versions.length + 1}`,
status: 'DRAFT',
trigger: workflowVersion.trigger,
steps: workflowVersion.steps,
});
}
},
ConfirmationModal: (
<OverrideWorkflowDraftConfirmationModal
draftWorkflowVersionId={workflow?.currentVersion?.id ?? ''}
workflowId={workflow?.id ?? ''}
workflowVersionUpdateInput={{
steps: workflowVersion.steps,
trigger: workflowVersion.trigger,
}}
/>
),
});
};
const unregisterUseAsDraftWorkflowVersionSingleRecordAction = () => {
removeActionMenuEntry('use-workflow-version-as-draft-single-record');
};
return {
registerUseAsDraftWorkflowVersionSingleRecordAction,
unregisterUseAsDraftWorkflowVersionSingleRecordAction,
};
};
@@ -0,0 +1,70 @@
import { useSeeWorkflowExecutionsWorkflowVersionSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-version-actions/hooks/useSeeWorkflowExecutionsWorkflowVersionSingleRecordAction';
import { useSeeWorkflowVersionsHistoryWorkflowVersionSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-version-actions/hooks/useSeeWorkflowVersionsHistoryWorkflowVersionSingleRecordAction';
import { useUseAsDraftWorkflowVersionSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-version-actions/hooks/useUseAsDraftWorkflowVersionSingleRecordAction';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { isDefined } from 'twenty-ui';
export const useWorkflowVersionsSingleRecordActions = () => {
const contextStoreTargetedRecordsRule = useRecoilComponentValueV2(
contextStoreTargetedRecordsRuleComponentState,
);
const selectedRecordId =
contextStoreTargetedRecordsRule.mode === 'selection'
? contextStoreTargetedRecordsRule.selectedRecordIds?.[0]
: undefined;
if (!isDefined(selectedRecordId)) {
throw new Error('Selected record ID is required');
}
const {
registerSeeWorkflowVersionsHistoryWorkflowVersionSingleRecordAction,
unregisterSeeWorkflowVersionsHistoryWorkflowVersionSingleRecordAction,
} = useSeeWorkflowVersionsHistoryWorkflowVersionSingleRecordAction({
workflowVersionId: selectedRecordId,
});
const {
registerUseAsDraftWorkflowVersionSingleRecordAction,
unregisterUseAsDraftWorkflowVersionSingleRecordAction,
} = useUseAsDraftWorkflowVersionSingleRecordAction({
workflowVersionId: selectedRecordId,
});
const {
registerSeeWorkflowExecutionsWorkflowVersionSingleRecordAction,
unregisterSeeWorkflowExecutionsWorkflowVersionSingleRecordAction,
} = useSeeWorkflowExecutionsWorkflowVersionSingleRecordAction({
workflowVersionId: selectedRecordId,
});
const registerSingleRecordActions = ({
startPosition,
}: {
startPosition: number;
}) => {
registerUseAsDraftWorkflowVersionSingleRecordAction({
position: startPosition,
});
registerSeeWorkflowVersionsHistoryWorkflowVersionSingleRecordAction({
position: startPosition + 1,
});
registerSeeWorkflowExecutionsWorkflowVersionSingleRecordAction({
position: startPosition + 2,
});
};
const unregisterSingleRecordActions = () => {
unregisterUseAsDraftWorkflowVersionSingleRecordAction();
unregisterSeeWorkflowVersionsHistoryWorkflowVersionSingleRecordAction();
unregisterSeeWorkflowExecutionsWorkflowVersionSingleRecordAction();
};
return {
registerSingleRecordActions,
unregisterSingleRecordActions,
};
};
@@ -0,0 +1,26 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useEffect } from 'react';
import { useWorkflowRunRecordActions } from '../hooks/useWorkflowRunRecordActions';
export const WorkflowRunRecordActionMenuEntrySetterEffect = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const {
registerWorkflowRunRecordActions,
unregisterWorkflowRunRecordActions,
} = useWorkflowRunRecordActions({
objectMetadataItem,
});
useEffect(() => {
registerWorkflowRunRecordActions();
return () => {
unregisterWorkflowRunRecordActions();
};
}, [registerWorkflowRunRecordActions, unregisterWorkflowRunRecordActions]);
return null;
};
@@ -6,19 +6,15 @@ import {
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useAllActiveWorkflowVersions } from '@/workflow/hooks/useAllActiveWorkflowVersions';
import { useRunWorkflowVersion } from '@/workflow/hooks/useRunWorkflowVersion';
import { useTheme } from '@emotion/react';
import { useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { IconSettingsAutomation, isDefined } from 'twenty-ui';
import { capitalize } from '~/utils/string/capitalize';
export const WorkflowRunRecordActionEffect = ({
export const useWorkflowRunRecordActions = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
@@ -34,8 +30,12 @@ export const WorkflowRunRecordActionEffect = ({
? contextStoreTargetedRecordsRule.selectedRecordIds[0]
: undefined;
if (!isDefined(selectedRecordId)) {
throw new Error('Selected record ID is required');
}
const selectedRecord = useRecoilValue(
recordStoreFamilyState(selectedRecordId ?? ''),
recordStoreFamilyState(selectedRecordId),
);
const { records: activeWorkflowVersions } = useAllActiveWorkflowVersions({
@@ -45,11 +45,7 @@ export const WorkflowRunRecordActionEffect = ({
const { runWorkflowVersion } = useRunWorkflowVersion();
const { enqueueSnackBar } = useSnackBar();
const theme = useTheme();
useEffect(() => {
const registerWorkflowRunRecordActions = () => {
if (!isDefined(objectMetadataItem) || objectMetadataItem.isRemote) {
return;
}
@@ -58,11 +54,15 @@ export const WorkflowRunRecordActionEffect = ({
index,
activeWorkflowVersion,
] of activeWorkflowVersions.entries()) {
if (!isDefined(activeWorkflowVersion.workflow)) {
continue;
}
const name = capitalize(activeWorkflowVersion.workflow.name);
addActionMenuEntry({
type: ActionMenuEntryType.WorkflowRun,
key: `workflow-run-${activeWorkflowVersion.id}`,
scope: ActionMenuEntryScope.RecordSelection,
label: capitalize(activeWorkflowVersion.workflow.name),
label: name,
position: index,
Icon: IconSettingsAutomation,
onClick: async () => {
@@ -72,38 +72,22 @@ export const WorkflowRunRecordActionEffect = ({
await runWorkflowVersion({
workflowVersionId: activeWorkflowVersion.id,
workflowName: name,
payload: selectedRecord,
});
enqueueSnackBar('', {
variant: SnackBarVariant.Success,
title: `${capitalize(activeWorkflowVersion.workflow.name)} starting...`,
icon: (
<IconSettingsAutomation
size={16}
color={theme.snackBar.success.color}
/>
),
});
},
});
}
};
return () => {
for (const activeWorkflowVersion of activeWorkflowVersions) {
removeActionMenuEntry(`workflow-run-${activeWorkflowVersion.id}`);
}
};
}, [
activeWorkflowVersions,
addActionMenuEntry,
enqueueSnackBar,
objectMetadataItem,
removeActionMenuEntry,
runWorkflowVersion,
selectedRecord,
theme.snackBar.success.color,
]);
const unregisterWorkflowRunRecordActions = () => {
for (const activeWorkflowVersion of activeWorkflowVersions) {
removeActionMenuEntry(`workflow-run-${activeWorkflowVersion.id}`);
}
};
return null;
return {
registerWorkflowRunRecordActions,
unregisterWorkflowRunRecordActions,
};
};
@@ -0,0 +1,17 @@
import { useRecordAgnosticActions } from '@/action-menu/actions/record-agnostic-actions/hooks/useRecordAgnosticActions';
import { useEffect } from 'react';
export const RecordAgnosticActionsSetterEffect = () => {
const { registerRecordAgnosticActions, unregisterRecordAgnosticActions } =
useRecordAgnosticActions();
useEffect(() => {
registerRecordAgnosticActions();
return () => {
unregisterRecordAgnosticActions();
};
}, [registerRecordAgnosticActions, unregisterRecordAgnosticActions]);
return null;
};
@@ -0,0 +1,23 @@
import { useWorkflowRunActions } from '@/action-menu/actions/record-agnostic-actions/workflow-run-actions/hooks/useWorkflowRunActions';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
export const useRecordAgnosticActions = () => {
const isWorkflowEnabled = useIsFeatureEnabled('IS_WORKFLOW_ENABLED');
const { addWorkflowRunActions, removeWorkflowRunActions } =
useWorkflowRunActions();
const registerRecordAgnosticActions = () => {
if (isWorkflowEnabled) {
addWorkflowRunActions();
}
};
const unregisterRecordAgnosticActions = () => {
if (isWorkflowEnabled) {
removeWorkflowRunActions();
}
};
return { registerRecordAgnosticActions, unregisterRecordAgnosticActions };
};
@@ -0,0 +1,63 @@
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { useAllActiveWorkflowVersions } from '@/workflow/hooks/useAllActiveWorkflowVersions';
import { useRunWorkflowVersion } from '@/workflow/hooks/useRunWorkflowVersion';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { IconSettingsAutomation, isDefined } from 'twenty-ui';
import { capitalize } from '~/utils/string/capitalize';
export const useWorkflowRunActions = () => {
const isWorkflowEnabled = useIsFeatureEnabled('IS_WORKFLOW_ENABLED');
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
const { records: activeWorkflowVersions } = useAllActiveWorkflowVersions({
triggerType: 'MANUAL',
});
const { runWorkflowVersion } = useRunWorkflowVersion();
const addWorkflowRunActions = () => {
if (!isWorkflowEnabled) {
return;
}
for (const [
index,
activeWorkflowVersion,
] of activeWorkflowVersions.entries()) {
if (!isDefined(activeWorkflowVersion.workflow)) {
continue;
}
const name = capitalize(activeWorkflowVersion.workflow.name);
addActionMenuEntry({
type: ActionMenuEntryType.WorkflowRun,
key: `workflow-run-${activeWorkflowVersion.id}`,
scope: ActionMenuEntryScope.Global,
label: name,
position: index,
Icon: IconSettingsAutomation,
onClick: async () => {
await runWorkflowVersion({
workflowVersionId: activeWorkflowVersion.id,
workflowName: name,
});
},
});
}
};
const removeWorkflowRunActions = () => {
for (const activeWorkflowVersion of activeWorkflowVersions) {
removeActionMenuEntry(`workflow-run-${activeWorkflowVersion.id}`);
}
};
return { addWorkflowRunActions, removeWorkflowRunActions };
};
@@ -0,0 +1,30 @@
import { Button } from 'twenty-ui';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { Key } from 'ts-key-enum';
import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope';
export const CmdEnterActionButton = ({
title,
onClick,
}: {
title: string;
onClick: () => void;
}) => {
useScopedHotkeys(
[`${Key.Control}+${Key.Enter}`, `${Key.Meta}+${Key.Enter}`],
() => onClick(),
RightDrawerHotkeyScope.RightDrawer,
[onClick],
);
return (
<Button
title={title}
variant="primary"
accent="blue"
size="medium"
onClick={onClick}
shortcut={'⌘⏎'}
/>
);
};
@@ -1,19 +1,30 @@
import { GlobalActionMenuEntriesSetter } from '@/action-menu/actions/global-actions/components/GlobalActionMenuEntriesSetter';
import { RecordActionMenuEntriesSetter } from '@/action-menu/actions/record-actions/components/RecordActionMenuEntriesSetter';
import { RecordAgnosticActionsSetterEffect } from '@/action-menu/actions/record-agnostic-actions/components/RecordAgnosticActionsSetterEffect';
import { ActionMenuConfirmationModals } from '@/action-menu/components/ActionMenuConfirmationModals';
import { RecordIndexActionMenuBar } from '@/action-menu/components/RecordIndexActionMenuBar';
import { RecordIndexActionMenuButtons } from '@/action-menu/components/RecordIndexActionMenuButtons';
import { RecordIndexActionMenuDropdown } from '@/action-menu/components/RecordIndexActionMenuDropdown';
import { RecordIndexActionMenuEffect } from '@/action-menu/components/RecordIndexActionMenuEffect';
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { contextStoreCurrentObjectMetadataIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useIsMobile } from 'twenty-ui';
export const RecordIndexActionMenu = () => {
const contextStoreCurrentObjectMetadataId = useRecoilComponentValueV2(
contextStoreCurrentObjectMetadataIdComponentState,
);
const isWorkflowEnabled = useIsFeatureEnabled('IS_WORKFLOW_ENABLED');
const isPageHeaderV2Enabled = useIsFeatureEnabled(
'IS_PAGE_HEADER_V2_ENABLED',
);
const isMobile = useIsMobile();
return (
<>
{contextStoreCurrentObjectMetadataId && (
@@ -23,12 +34,16 @@ export const RecordIndexActionMenu = () => {
onActionExecutedCallback: () => {},
}}
>
<RecordIndexActionMenuBar />
{isPageHeaderV2Enabled ? (
<>{!isMobile && <RecordIndexActionMenuButtons />}</>
) : (
<RecordIndexActionMenuBar />
)}
<RecordIndexActionMenuDropdown />
<ActionMenuConfirmationModals />
<RecordIndexActionMenuEffect />
<RecordActionMenuEntriesSetter />
<GlobalActionMenuEntriesSetter />
{isWorkflowEnabled && <RecordAgnosticActionsSetterEffect />}
</ActionMenuContext.Provider>
)}
</>
@@ -1,5 +1,3 @@
import styled from '@emotion/styled';
import { RecordIndexActionMenuBarAllActionsButton } from '@/action-menu/components/RecordIndexActionMenuBarAllActionsButton';
import { RecordIndexActionMenuBarEntry } from '@/action-menu/components/RecordIndexActionMenuBarEntry';
import { actionMenuEntriesComponentSelector } from '@/action-menu/states/actionMenuEntriesComponentSelector';
@@ -10,6 +8,7 @@ import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-sto
import { BottomBar } from '@/ui/layout/bottom-bar/components/BottomBar';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import styled from '@emotion/styled';
const StyledLabel = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
@@ -33,7 +32,6 @@ export const RecordIndexActionMenuBar = () => {
);
const pinnedEntries = actionMenuEntries.filter((entry) => entry.isPinned);
if (contextStoreNumberOfSelectedRecords === 0) {
return null;
}
@@ -1,8 +1,7 @@
import { ActionMenuEntry } from '@/action-menu/types/ActionMenuEntry';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { ActionMenuEntry } from '@/action-menu/types/ActionMenuEntry';
type RecordIndexActionMenuBarEntryProps = {
entry: ActionMenuEntry;
};
@@ -13,11 +12,9 @@ const StyledButton = styled.div`
cursor: pointer;
display: flex;
justify-content: center;
padding: ${({ theme }) => theme.spacing(2)};
transition: background ${({ theme }) => theme.animation.duration.fast} ease;
user-select: none;
&:hover {
background: ${({ theme }) => theme.background.tertiary};
}
@@ -32,6 +29,7 @@ export const RecordIndexActionMenuBarEntry = ({
entry,
}: RecordIndexActionMenuBarEntryProps) => {
const theme = useTheme();
return (
<StyledButton onClick={() => entry.onClick?.()}>
{entry.Icon && <entry.Icon size={theme.icon.size.md} />}
@@ -0,0 +1,37 @@
import { actionMenuEntriesComponentSelector } from '@/action-menu/states/actionMenuEntriesComponentSelector';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { Button } from 'twenty-ui';
export const RecordIndexActionMenuButtons = () => {
const contextStoreNumberOfSelectedRecords = useRecoilComponentValueV2(
contextStoreNumberOfSelectedRecordsComponentState,
);
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentSelector,
);
const pinnedEntries = actionMenuEntries.filter((entry) => entry.isPinned);
if (contextStoreNumberOfSelectedRecords === 0) {
return null;
}
return (
<>
{pinnedEntries.map((entry, index) => (
<Button
key={index}
Icon={entry.Icon}
size="small"
variant="secondary"
accent="default"
title={entry.label}
onClick={() => entry.onClick?.()}
ariaLabel={entry.label}
/>
))}
</>
);
};
@@ -21,10 +21,6 @@ type StyledContainerProps = {
const StyledContainerActionMenuDropdown = styled.div<StyledContainerProps>`
align-items: flex-start;
background: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.light};
border-radius: ${({ theme }) => theme.border.radius.md};
box-shadow: ${({ theme }) => theme.boxShadow.strong};
display: flex;
flex-direction: column;
@@ -33,7 +29,8 @@ const StyledContainerActionMenuDropdown = styled.div<StyledContainerProps>`
top: ${(props) => `${props.position.y}px`};
transform: translateX(-50%);
width: auto;
width: 0;
height: 0;
`;
export const RecordIndexActionMenuDropdown = () => {
@@ -84,6 +81,7 @@ export const RecordIndexActionMenuDropdown = () => {
))}
</DropdownMenuItemsContainer>
}
avoidPortal
/>
</StyledContainerActionMenuDropdown>
);
@@ -1,12 +1,14 @@
import { GlobalActionMenuEntriesSetter } from '@/action-menu/actions/global-actions/components/GlobalActionMenuEntriesSetter';
import { RecordActionMenuEntriesSetter } from '@/action-menu/actions/record-actions/components/RecordActionMenuEntriesSetter';
import { RecordAgnosticActionsSetterEffect } from '@/action-menu/actions/record-agnostic-actions/components/RecordAgnosticActionsSetterEffect';
import { ActionMenuConfirmationModals } from '@/action-menu/components/ActionMenuConfirmationModals';
import { RecordShowActionMenuButtons } from '@/action-menu/components/RecordShowActionMenuButtons';
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { contextStoreCurrentObjectMetadataIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdComponentState';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { RecordShowPageBaseHeader } from '~/pages/object-record/RecordShowPageBaseHeader';
export const RecordShowActionMenu = ({
@@ -26,6 +28,12 @@ export const RecordShowActionMenu = ({
contextStoreCurrentObjectMetadataIdComponentState,
);
const isWorkflowEnabled = useIsFeatureEnabled('IS_WORKFLOW_ENABLED');
const isPageHeaderV2Enabled = useIsFeatureEnabled(
'IS_PAGE_HEADER_V2_ENABLED',
);
// TODO: refactor RecordShowPageBaseHeader to use the context store
return (
@@ -37,18 +45,22 @@ export const RecordShowActionMenu = ({
onActionExecutedCallback: () => {},
}}
>
<RecordShowPageBaseHeader
{...{
isFavorite,
record,
objectMetadataItem,
objectNameSingular,
handleFavoriteButtonClick,
}}
/>
{isPageHeaderV2Enabled ? (
<RecordShowActionMenuButtons />
) : (
<RecordShowPageBaseHeader
{...{
isFavorite,
record,
objectMetadataItem,
objectNameSingular,
handleFavoriteButtonClick,
}}
/>
)}
<ActionMenuConfirmationModals />
<RecordActionMenuEntriesSetter />
<GlobalActionMenuEntriesSetter />
{isWorkflowEnabled && <RecordAgnosticActionsSetterEffect />}
</ActionMenuContext.Provider>
)}
</>
@@ -0,0 +1,33 @@
import { actionMenuEntriesComponentSelector } from '@/action-menu/states/actionMenuEntriesComponentSelector';
import { PageHeaderOpenCommandMenuButton } from '@/ui/layout/page-header/components/PageHeaderOpenCommandMenuButton';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { Button, useIsMobile } from 'twenty-ui';
export const RecordShowActionMenuButtons = () => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentSelector,
);
const pinnedEntries = actionMenuEntries.filter((entry) => entry.isPinned);
const isMobile = useIsMobile();
return (
<>
{!isMobile &&
pinnedEntries.map((entry, index) => (
<Button
key={index}
Icon={entry.Icon}
size="small"
variant="secondary"
accent="default"
title={entry.label}
onClick={() => entry.onClick?.()}
ariaLabel={entry.label}
/>
))}
<PageHeaderOpenCommandMenuButton key="more" />
</>
);
};
@@ -1,17 +1,20 @@
import { GlobalActionMenuEntriesSetter } from '@/action-menu/actions/global-actions/components/GlobalActionMenuEntriesSetter';
import { RecordActionMenuEntriesSetter } from '@/action-menu/actions/record-actions/components/RecordActionMenuEntriesSetter';
import { RecordAgnosticActionsSetterEffect } from '@/action-menu/actions/record-agnostic-actions/components/RecordAgnosticActionsSetterEffect';
import { ActionMenuConfirmationModals } from '@/action-menu/components/ActionMenuConfirmationModals';
import { RightDrawerActionMenuDropdown } from '@/action-menu/components/RightDrawerActionMenuDropdown';
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { contextStoreCurrentObjectMetadataIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
export const RecordShowRightDrawerActionMenu = () => {
const contextStoreCurrentObjectMetadataId = useRecoilComponentValueV2(
contextStoreCurrentObjectMetadataIdComponentState,
);
const isWorkflowEnabled = useIsFeatureEnabled('IS_WORKFLOW_ENABLED');
return (
<>
{contextStoreCurrentObjectMetadataId && (
@@ -24,7 +27,7 @@ export const RecordShowRightDrawerActionMenu = () => {
<RightDrawerActionMenuDropdown />
<ActionMenuConfirmationModals />
<RecordActionMenuEntriesSetter />
<GlobalActionMenuEntriesSetter />
{isWorkflowEnabled && <RecordAgnosticActionsSetterEffect />}
</ActionMenuContext.Provider>
)}
</>
@@ -1,7 +1,3 @@
import { expect, jest } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react';
import { RecoilRoot } from 'recoil';
import { RecordIndexActionMenuBar } from '@/action-menu/components/RecordIndexActionMenuBar';
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
@@ -11,11 +7,14 @@ import {
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { getActionBarIdFromActionMenuId } from '@/action-menu/utils/getActionBarIdFromActionMenuId';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { isBottomBarOpenedComponentState } from '@/ui/layout/bottom-bar/states/isBottomBarOpenedComponentState';
import { expect, jest } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react';
import { userEvent, waitFor, within } from '@storybook/test';
import { RecoilRoot } from 'recoil';
import { IconTrash, RouterDecorator } from 'twenty-ui';
const deleteMock = jest.fn();
@@ -40,16 +39,13 @@ const meta: Meta<typeof RecordIndexActionMenuBar> = {
selectedRecordIds: ['1', '2', '3'],
},
);
set(
contextStoreNumberOfSelectedRecordsComponentState.atomFamily({
instanceId: 'story-action-menu',
}),
3,
);
const map = new Map<string, ActionMenuEntry>();
map.set('delete', {
isPinned: true,
scope: ActionMenuEntryScope.RecordSelection,
@@ -60,14 +56,12 @@ const meta: Meta<typeof RecordIndexActionMenuBar> = {
Icon: IconTrash,
onClick: deleteMock,
});
set(
actionMenuEntriesComponentState.atomFamily({
instanceId: 'story-action-menu',
}),
map,
);
set(
isBottomBarOpenedComponentState.atomFamily({
instanceId: getActionBarIdFromActionMenuId('story-action-menu'),
@@ -117,10 +111,8 @@ export const WithButtonClicks: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const deleteButton = await canvas.findByText('Delete');
await userEvent.click(deleteButton);
await waitFor(() => {
expect(deleteMock).toHaveBeenCalled();
});
@@ -6,7 +6,6 @@ import {
import { expect, jest } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/testing-library';
import { ComponentDecorator, IconCheckbox, IconTrash } from 'twenty-ui';
const meta: Meta<typeof RecordIndexActionMenuBarEntry> = {
@@ -14,7 +13,6 @@ const meta: Meta<typeof RecordIndexActionMenuBarEntry> = {
component: RecordIndexActionMenuBarEntry,
decorators: [ComponentDecorator],
};
export default meta;
type Story = StoryObj<typeof RecordIndexActionMenuBarEntry>;
@@ -14,7 +14,12 @@ import {
} from '@/action-menu/types/ActionMenuEntry';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState';
import { IconCheckbox, IconHeart, IconTrash } from 'twenty-ui';
import {
IconCheckbox,
IconHeart,
IconTrash,
getCanvasElementForDropdownTesting,
} from 'twenty-ui';
const deleteMock = jest.fn();
const markAsDoneMock = jest.fn();
@@ -107,7 +112,9 @@ export const WithInteractions: Story = {
args: {
actionMenuId: 'story',
},
play: async ({ canvasElement }) => {
play: async () => {
const canvasElement = getCanvasElementForDropdownTesting();
const canvas = within(canvasElement);
const deleteButton = await canvas.findByText('Delete');
@@ -15,6 +15,7 @@ import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/s
import { userEvent, waitFor, within } from '@storybook/test';
import {
ComponentDecorator,
getCanvasElementForDropdownTesting,
IconFileExport,
IconHeart,
IconTrash,
@@ -117,8 +118,8 @@ export const WithButtonClicks: Story = {
args: {
actionMenuId: 'story-action-menu',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
play: async () => {
const canvas = within(getCanvasElementForDropdownTesting());
let actionButton = await canvas.findByText('Actions');
await userEvent.click(actionButton);
@@ -20,6 +20,13 @@ type CalendarCurrentEventCursorProps = {
calendarEvent: TimelineCalendarEvent;
};
const StyledDot = styled(motion.div)`
background-color: ${({ theme }) => theme.font.color.danger};
border-radius: 1px;
height: ${({ theme }) => theme.spacing(1)};
width: ${({ theme }) => theme.spacing(1)};
`;
const StyledCurrentEventCursor = styled(motion.div)`
align-items: center;
background-color: ${({ theme }) => theme.font.color.danger};
@@ -30,15 +37,6 @@ const StyledCurrentEventCursor = styled(motion.div)`
right: 0;
border-radius: ${({ theme }) => theme.border.radius.sm};
transform: translateY(-50%);
&::before {
background-color: ${({ theme }) => theme.font.color.danger};
border-radius: 1px;
content: '';
display: block;
height: ${({ theme }) => theme.spacing(1)};
width: ${({ theme }) => theme.spacing(1)};
}
`;
export const CalendarCurrentEventCursor = ({
@@ -166,7 +164,18 @@ export const CalendarCurrentEventCursor = ({
transition={{
duration: theme.animation.duration.normal,
}}
/>
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
delay: theme.animation.duration.normal,
duration: theme.animation.duration.normal,
}}
>
<StyledDot />
</motion.div>
</StyledCurrentEventCursor>
)}
</AnimatePresence>
);
@@ -29,7 +29,7 @@ import { recordStoreFamilyState } from '@/object-record/record-store/states/reco
import { ActivityTargetInlineCellEditModeMultiRecordsEffect } from '@/object-record/relation-picker/components/ActivityTargetInlineCellEditModeMultiRecordsEffect';
import { ActivityTargetInlineCellEditModeMultiRecordsSearchFilterEffect } from '@/object-record/relation-picker/components/ActivityTargetInlineCellEditModeMultiRecordsSearchFilterEffect';
import { MultiRecordSelect } from '@/object-record/relation-picker/components/MultiRecordSelect';
import { RelationPickerScope } from '@/object-record/relation-picker/scopes/RelationPickerScope';
import { RecordPickerComponentInstanceContext } from '@/object-record/relation-picker/states/contexts/RecordPickerComponentInstanceContext';
import { prefillRecord } from '@/object-record/utils/prefillRecord';
const StyledSelectContainer = styled.div`
@@ -52,7 +52,7 @@ export const ActivityTargetInlineCellEditMode = ({
activityObjectNameSingular,
}: ActivityTargetInlineCellEditModeProps) => {
const [isActivityInCreateMode] = useRecoilState(isActivityInCreateModeState);
const relationPickerScopeId = `relation-picker-${activity.id}`;
const recordPickerInstanceId = `record-picker-${activity.id}`;
const selectedTargetObjectIds = activityTargetWithTargetRecords.map(
(activityTarget) => ({
@@ -101,7 +101,7 @@ export const ActivityTargetInlineCellEditMode = ({
const recordSelectedInMultiSelect = snapshot
.getLoadable(
objectRecordMultiSelectComponentFamilyState({
scopeId: relationPickerScopeId,
scopeId: recordPickerInstanceId,
familyKey: activityTarget.targetObject.id,
}),
)
@@ -126,7 +126,7 @@ export const ActivityTargetInlineCellEditMode = ({
[
activityTargetWithTargetRecords,
closeEditableField,
relationPickerScopeId,
recordPickerInstanceId,
setActivityFromStore,
],
);
@@ -144,7 +144,7 @@ export const ActivityTargetInlineCellEditMode = ({
const previouslyCheckedRecordsIds = snapshot
.getLoadable(
objectRecordMultiSelectCheckedRecordsIdsComponentState({
scopeId: relationPickerScopeId,
scopeId: recordPickerInstanceId,
}),
)
.getValue();
@@ -155,7 +155,7 @@ export const ActivityTargetInlineCellEditMode = ({
const record = snapshot
.getLoadable(
objectRecordMultiSelectComponentFamilyState({
scopeId: relationPickerScopeId,
scopeId: recordPickerInstanceId,
familyKey: recordId,
}),
)
@@ -169,7 +169,7 @@ export const ActivityTargetInlineCellEditMode = ({
set(
objectRecordMultiSelectCheckedRecordsIdsComponentState({
scopeId: relationPickerScopeId,
scopeId: recordPickerInstanceId,
}),
(prev) => [...prev, recordId],
);
@@ -239,7 +239,7 @@ export const ActivityTargetInlineCellEditMode = ({
set(
objectRecordMultiSelectCheckedRecordsIdsComponentState({
scopeId: relationPickerScopeId,
scopeId: recordPickerInstanceId,
}),
previouslyCheckedRecordsIds.filter((id) => id !== recordId),
);
@@ -275,7 +275,7 @@ export const ActivityTargetInlineCellEditMode = ({
deleteManyActivityTargets,
isActivityInCreateMode,
objectMetadataItemActivityTarget,
relationPickerScopeId,
recordPickerInstanceId,
upsertActivity,
activityObjectNameSingular,
],
@@ -283,7 +283,9 @@ export const ActivityTargetInlineCellEditMode = ({
return (
<StyledSelectContainer>
<RelationPickerScope relationPickerScopeId={relationPickerScopeId}>
<RecordPickerComponentInstanceContext.Provider
value={{ instanceId: recordPickerInstanceId }}
>
<ActivityTargetObjectRecordEffect
activityTargetWithTargetRecords={activityTargetWithTargetRecords}
/>
@@ -292,7 +294,7 @@ export const ActivityTargetInlineCellEditMode = ({
/>
<ActivityTargetInlineCellEditModeMultiRecordsSearchFilterEffect />
<MultiRecordSelect onSubmit={handleSubmit} onChange={handleChange} />
</RelationPickerScope>
</RecordPickerComponentInstanceContext.Provider>
</StyledSelectContainer>
);
};
@@ -1,5 +1,4 @@
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import {
AnimatedPlaceholder,
AnimatedPlaceholderEmptyContainer,
@@ -43,8 +42,7 @@ export const TaskGroups = ({ targetableObjects }: TaskGroupsProps) => {
activityObjectNameSingular: CoreObjectNameSingular.Task,
});
const { activeTabIdState } = useTabList(TASKS_TAB_LIST_COMPONENT_ID);
const activeTabId = useRecoilValue(activeTabIdState);
const { activeTabId } = useTabList(TASKS_TAB_LIST_COMPONENT_ID);
const isLoading =
(activeTabId !== 'done' && tasksLoading) ||
@@ -3,7 +3,6 @@ import styled from '@emotion/styled';
import { CustomResolverFetchMoreLoader } from '@/activities/components/CustomResolverFetchMoreLoader';
import { SkeletonLoader } from '@/activities/components/SkeletonLoader';
import { EventList } from '@/activities/timeline-activities/components/EventList';
import { TimelineCreateButtonGroup } from '@/activities/timeline-activities/components/TimelineCreateButtonGroup';
import { useTimelineActivities } from '@/activities/timeline-activities/hooks/useTimelineActivities';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
@@ -41,10 +40,8 @@ const StyledMainContainer = styled.div`
export const TimelineActivities = ({
targetableObject,
isInRightDrawer = false,
}: {
targetableObject: ActivityTargetableObject;
isInRightDrawer?: boolean;
}) => {
const { timelineActivities, loading, fetchMoreRecords } =
useTimelineActivities(targetableObject);
@@ -65,13 +62,12 @@ export const TimelineActivities = ({
<AnimatedPlaceholder type="emptyTimeline" />
<AnimatedPlaceholderEmptyTextContainer>
<AnimatedPlaceholderEmptyTitle>
Add your first Activity
No activity yet
</AnimatedPlaceholderEmptyTitle>
<AnimatedPlaceholderEmptySubTitle>
There are no activities associated with this record.{' '}
There is no activity associated with this record.
</AnimatedPlaceholderEmptySubTitle>
</AnimatedPlaceholderEmptyTextContainer>
<TimelineCreateButtonGroup isInRightDrawer={isInRightDrawer} />
</AnimatedPlaceholderEmptyContainer>
);
}
@@ -1,45 +0,0 @@
import { TAB_LIST_COMPONENT_ID } from '@/ui/layout/show-page/components/ShowPageSubContainer';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
import { useSetRecoilState } from 'recoil';
import {
Button,
ButtonGroup,
IconCheckbox,
IconNotes,
IconPaperclip,
} from 'twenty-ui';
export const TimelineCreateButtonGroup = ({
isInRightDrawer = false,
}: {
isInRightDrawer?: boolean;
}) => {
const { activeTabIdState } = useTabList(
`${TAB_LIST_COMPONENT_ID}-${isInRightDrawer}`,
);
const setActiveTabId = useSetRecoilState(activeTabIdState);
return (
<ButtonGroup variant={'secondary'}>
<Button
Icon={IconNotes}
title="Note"
onClick={() => {
setActiveTabId('notes');
}}
/>
<Button
Icon={IconCheckbox}
title="Task"
onClick={() => {
setActiveTabId('tasks');
}}
/>
<Button
Icon={IconPaperclip}
title="File"
onClick={() => setActiveTabId('files')}
/>
</ButtonGroup>
);
};
@@ -1,4 +1,5 @@
import { useCreateAppRouter } from '@/app/hooks/useCreateAppRouter';
import { currentUserState } from '@/auth/states/currentUserState';
import { billingState } from '@/client-config/states/billingState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { RouterProvider } from 'react-router-dom';
@@ -16,6 +17,10 @@ export const AppRouter = () => {
const isBillingPageEnabled =
billing?.isBillingEnabled && !isFreeAccessEnabled;
const currentUser = useRecoilValue(currentUserState);
const isAdminPageEnabled = currentUser?.canImpersonate;
return (
<RouterProvider
router={useCreateAppRouter(
@@ -23,6 +28,7 @@ export const AppRouter = () => {
isCRMMigrationEnabled,
isServerlessFunctionSettingsEnabled,
isSSOEnabled,
isAdminPageEnabled,
)}
/>
);
@@ -8,6 +8,7 @@ import { ClientConfigProvider } from '@/client-config/components/ClientConfigPro
import { ClientConfigProviderEffect } from '@/client-config/components/ClientConfigProviderEffect';
import { PromiseRejectionEffect } from '@/error-handler/components/PromiseRejectionEffect';
import { ApolloMetadataClientProvider } from '@/object-metadata/components/ApolloMetadataClientProvider';
import { ObjectMetadataItemsGater } from '@/object-metadata/components/ObjectMetadataItemsGater';
import { ObjectMetadataItemsProvider } from '@/object-metadata/components/ObjectMetadataItemsProvider';
import { PrefetchDataProvider } from '@/prefetch/components/PrefetchDataProvider';
import { DialogManager } from '@/ui/feedback/dialog-manager/components/DialogManager';
@@ -15,9 +16,11 @@ import { DialogManagerScope } from '@/ui/feedback/dialog-manager/scopes/DialogMa
import { SnackBarProvider } from '@/ui/feedback/snack-bar-manager/components/SnackBarProvider';
import { UserThemeProviderEffect } from '@/ui/theme/components/AppThemeProvider';
import { BaseThemeProvider } from '@/ui/theme/components/BaseThemeProvider';
import { PageFavicon } from '@/ui/utilities/page-favicon/components/PageFavicon';
import { PageTitle } from '@/ui/utilities/page-title/components/PageTitle';
import { UserProvider } from '@/users/components/UserProvider';
import { UserProviderEffect } from '@/users/components/UserProviderEffect';
import { WorkspaceProviderEffect } from '@/workspace/components/WorkspaceProviderEffect';
import { StrictMode } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { getPageTitleFromPath } from '~/utils/title-utils';
@@ -34,25 +37,29 @@ export const AppRouterProviders = () => {
<ChromeExtensionSidecarEffect />
<ChromeExtensionSidecarProvider>
<UserProviderEffect />
<WorkspaceProviderEffect />
<UserProvider>
<AuthProvider>
<ApolloMetadataClientProvider>
<ObjectMetadataItemsProvider>
<PrefetchDataProvider>
<UserThemeProviderEffect />
<SnackBarProvider>
<DialogManagerScope dialogManagerScopeId="dialog-manager">
<DialogManager>
<StrictMode>
<PromiseRejectionEffect />
<GotoHotkeysEffectsProvider />
<PageTitle title={pageTitle} />
<Outlet />
</StrictMode>
</DialogManager>
</DialogManagerScope>
</SnackBarProvider>
</PrefetchDataProvider>
<ObjectMetadataItemsGater>
<PrefetchDataProvider>
<UserThemeProviderEffect />
<SnackBarProvider>
<DialogManagerScope dialogManagerScopeId="dialog-manager">
<DialogManager>
<StrictMode>
<PromiseRejectionEffect />
<GotoHotkeysEffectsProvider />
<PageTitle title={pageTitle} />
<PageFavicon />
<Outlet />
</StrictMode>
</DialogManager>
</DialogManagerScope>
</SnackBarProvider>
</PrefetchDataProvider>
</ObjectMetadataItemsGater>
<PageChangeEffect />
</ObjectMetadataItemsProvider>
</ApolloMetadataClientProvider>

Some files were not shown because too many files have changed in this diff Show More