Compare commits

..
Author SHA1 Message Date
Thomas Trompette 9154f7a4c2 Use datetime design 2026-04-01 14:29:15 +02:00
Thomas Trompette 1ce64f5028 Fix workflow dates 2026-03-31 17:03:55 +02:00
176e81cd76 fix: clear navigation stack immediately in goBackFromSidePanel (#19153)
Fixes: #19152 

## Summary
goBackFromSidePanel returned early without writing the new (empty) stack
to the store, leaving stale navigation state. And it caused
openRecordInSidePanel to think the record was already open and skip
reopening.
<img width="587" height="405" alt="image"
src="https://github.com/user-attachments/assets/eb36f4c6-43ca-4c08-891a-c592ff673837"
/>

## Before


https://github.com/user-attachments/assets/03d1e24a-6d1e-4efc-93c1-72be0bc31a89


## After
When close the side panel with Escape, now it can be opened by clicking
arrow button again.




https://github.com/user-attachments/assets/ae700d41-4f81-4d1a-af9a-f97f31f489a6

---------

Co-authored-by: Devessier <baptiste@devessier.fr>
2026-03-31 11:29:03 +00:00
Charles BochetandGitHub 1ce4da5b67 Fix upgrade commands 2 (#19157) 2026-03-31 12:47:01 +02:00
ef66d6b337 i18n - translations (#19158)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-31 12:23:19 +02:00
bcca5d0002 fix: show disabled file chip when file no longer exists in timeline (#19045)
Fixes: #18943 
Follow-up pr: #19001

## Summary:                                                             
- Timeline activity shows file upload history, but deleted files had no
signed URL and were still rendered as clickable — clicking did nothing
- grab the fileId from properties.diff.after, look it up in the current
record's files field: if present, use live signed URL; if absent, mark
as deleted
- Deleted file chips show line-through label, not-allowed cursor, and
"File no longer exists" tooltip on hover
  


https://github.com/user-attachments/assets/5df6a675-0003-4fd1-ad57-a07e4338923f

---------

Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
2026-03-31 10:07:19 +00:00
WeikoandGitHub e0630b8653 Fix TransactionNotStartedError (#19155)
## Context
Checked in the codebase where we are trying to rollback a non-active
transaction.


packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts
seemed to be the only place where it happens.

We could enforce this with a lint rule in the future 🤔
2026-03-31 10:02:50 +00:00
Paul RastoinandGitHub 61a27984e8 0.8.0.canary.7 bump (#19150)
Already published on npm
2026-03-31 09:50:28 +02:00
99 changed files with 681 additions and 430 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "0.8.0-canary.6",
"version": "0.8.0-canary.7",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-client-sdk",
"version": "0.8.0-canary.6",
"version": "0.8.0-canary.7",
"sideEffects": false,
"license": "AGPL-3.0",
"scripts": {
@@ -6892,6 +6892,12 @@ msgstr ""
msgid "File label"
msgstr "Lêeretiket"
#. js-lingui-id: QWWgBD
#. placeholder {0}: file.label
#: src/modules/ui/field/display/components/FileChip.tsx
msgid "File no longer exists - {0}"
msgstr ""
#. js-lingui-id: ncBVqE
#: src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts
msgid "File upload failed"
@@ -6892,6 +6892,12 @@ msgstr ""
msgid "File label"
msgstr "تسمية الملف"
#. js-lingui-id: QWWgBD
#. placeholder {0}: file.label
#: src/modules/ui/field/display/components/FileChip.tsx
msgid "File no longer exists - {0}"
msgstr ""
#. js-lingui-id: ncBVqE
#: src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts
msgid "File upload failed"
@@ -6892,6 +6892,12 @@ msgstr ""
msgid "File label"
msgstr "Etiqueta del fitxer"
#. js-lingui-id: QWWgBD
#. placeholder {0}: file.label
#: src/modules/ui/field/display/components/FileChip.tsx
msgid "File no longer exists - {0}"
msgstr ""
#. js-lingui-id: ncBVqE
#: src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts
msgid "File upload failed"
@@ -6892,6 +6892,12 @@ msgstr ""
msgid "File label"
msgstr "Štítek souboru"
#. js-lingui-id: QWWgBD
#. placeholder {0}: file.label
#: src/modules/ui/field/display/components/FileChip.tsx
msgid "File no longer exists - {0}"
msgstr ""
#. js-lingui-id: ncBVqE
#: src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts
msgid "File upload failed"
@@ -6892,6 +6892,12 @@ msgstr ""
msgid "File label"
msgstr "Fil-etiket"
#. js-lingui-id: QWWgBD
#. placeholder {0}: file.label
#: src/modules/ui/field/display/components/FileChip.tsx
msgid "File no longer exists - {0}"
msgstr ""
#. js-lingui-id: ncBVqE
#: src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts
msgid "File upload failed"
@@ -6892,6 +6892,12 @@ msgstr ""
msgid "File label"
msgstr "Dateibezeichnung"
#. js-lingui-id: QWWgBD
#. placeholder {0}: file.label
#: src/modules/ui/field/display/components/FileChip.tsx
msgid "File no longer exists - {0}"
msgstr ""
#. js-lingui-id: ncBVqE
#: src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts
msgid "File upload failed"
@@ -6892,6 +6892,12 @@ msgstr ""
msgid "File label"
msgstr "Ετικέτα αρχείου"
#. js-lingui-id: QWWgBD
#. placeholder {0}: file.label
#: src/modules/ui/field/display/components/FileChip.tsx
msgid "File no longer exists - {0}"
msgstr ""
#. js-lingui-id: ncBVqE
#: src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts
msgid "File upload failed"
+6
View File
@@ -6887,6 +6887,12 @@ msgstr "File field not found for attachment object"
msgid "File label"
msgstr "File label"
#. js-lingui-id: QWWgBD
#. placeholder {0}: file.label
#: src/modules/ui/field/display/components/FileChip.tsx
msgid "File no longer exists - {0}"
msgstr "File no longer exists - {0}"
#. js-lingui-id: ncBVqE
#: src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts
msgid "File upload failed"
@@ -6892,6 +6892,12 @@ msgstr ""
msgid "File label"
msgstr "Etiqueta del archivo"
#. js-lingui-id: QWWgBD
#. placeholder {0}: file.label
#: src/modules/ui/field/display/components/FileChip.tsx
msgid "File no longer exists - {0}"
msgstr ""
#. js-lingui-id: ncBVqE
#: src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts
msgid "File upload failed"
@@ -6892,6 +6892,12 @@ msgstr ""
msgid "File label"
msgstr "Tiedoston otsikko"
#. js-lingui-id: QWWgBD
#. placeholder {0}: file.label
#: src/modules/ui/field/display/components/FileChip.tsx
msgid "File no longer exists - {0}"
msgstr ""
#. js-lingui-id: ncBVqE
#: src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts
msgid "File upload failed"
@@ -6892,6 +6892,12 @@ msgstr ""
msgid "File label"
msgstr "Étiquette du fichier"
#. js-lingui-id: QWWgBD
#. placeholder {0}: file.label
#: src/modules/ui/field/display/components/FileChip.tsx
msgid "File no longer exists - {0}"
msgstr ""
#. js-lingui-id: ncBVqE
#: src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts
msgid "File upload failed"
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 one or more lines are too long
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 one or more lines are too long
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 one or more lines are too long
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 one or more lines are too long
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 one or more lines are too long
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 one or more lines are too long
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 one or more lines are too long
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 one or more lines are too long
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 one or more lines are too long
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 one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -6892,6 +6892,12 @@ msgstr ""
msgid "File label"
msgstr "תווית קובץ"
#. js-lingui-id: QWWgBD
#. placeholder {0}: file.label
#: src/modules/ui/field/display/components/FileChip.tsx
msgid "File no longer exists - {0}"
msgstr ""
#. js-lingui-id: ncBVqE
#: src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts
msgid "File upload failed"
@@ -6892,6 +6892,12 @@ msgstr "Fájlmező nem található a melléklet objektumhoz"
msgid "File label"
msgstr "Fájlcímke"
#. js-lingui-id: QWWgBD
#. placeholder {0}: file.label
#: src/modules/ui/field/display/components/FileChip.tsx
msgid "File no longer exists - {0}"
msgstr ""
#. js-lingui-id: ncBVqE
#: src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts
msgid "File upload failed"
@@ -6892,6 +6892,12 @@ msgstr ""
msgid "File label"
msgstr "Etichetta del file"
#. js-lingui-id: QWWgBD
#. placeholder {0}: file.label
#: src/modules/ui/field/display/components/FileChip.tsx
msgid "File no longer exists - {0}"
msgstr ""
#. js-lingui-id: ncBVqE
#: src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts
msgid "File upload failed"
@@ -6892,6 +6892,12 @@ msgstr ""
msgid "File label"
msgstr "ファイルのラベル"
#. js-lingui-id: QWWgBD
#. placeholder {0}: file.label
#: src/modules/ui/field/display/components/FileChip.tsx
msgid "File no longer exists - {0}"
msgstr ""
#. js-lingui-id: ncBVqE
#: src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts
msgid "File upload failed"
@@ -6892,6 +6892,12 @@ msgstr ""
msgid "File label"
msgstr "파일 라벨"
#. js-lingui-id: QWWgBD
#. placeholder {0}: file.label
#: src/modules/ui/field/display/components/FileChip.tsx
msgid "File no longer exists - {0}"
msgstr ""
#. js-lingui-id: ncBVqE
#: src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts
msgid "File upload failed"
@@ -6892,6 +6892,12 @@ msgstr ""
msgid "File label"
msgstr "Bestandslabel"
#. js-lingui-id: QWWgBD
#. placeholder {0}: file.label
#: src/modules/ui/field/display/components/FileChip.tsx
msgid "File no longer exists - {0}"
msgstr ""
#. js-lingui-id: ncBVqE
#: src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts
msgid "File upload failed"
@@ -6892,6 +6892,12 @@ msgstr ""
msgid "File label"
msgstr "Etikett for fil"
#. js-lingui-id: QWWgBD
#. placeholder {0}: file.label
#: src/modules/ui/field/display/components/FileChip.tsx
msgid "File no longer exists - {0}"
msgstr ""
#. js-lingui-id: ncBVqE
#: src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts
msgid "File upload failed"
@@ -6892,6 +6892,12 @@ msgstr ""
msgid "File label"
msgstr "Etykieta pliku"
#. js-lingui-id: QWWgBD
#. placeholder {0}: file.label
#: src/modules/ui/field/display/components/FileChip.tsx
msgid "File no longer exists - {0}"
msgstr ""
#. js-lingui-id: ncBVqE
#: src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts
msgid "File upload failed"
@@ -6887,6 +6887,12 @@ msgstr ""
msgid "File label"
msgstr ""
#. js-lingui-id: QWWgBD
#. placeholder {0}: file.label
#: src/modules/ui/field/display/components/FileChip.tsx
msgid "File no longer exists - {0}"
msgstr ""
#. js-lingui-id: ncBVqE
#: src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts
msgid "File upload failed"
@@ -6892,6 +6892,12 @@ msgstr ""
msgid "File label"
msgstr "Rótulo do arquivo"
#. js-lingui-id: QWWgBD
#. placeholder {0}: file.label
#: src/modules/ui/field/display/components/FileChip.tsx
msgid "File no longer exists - {0}"
msgstr ""
#. js-lingui-id: ncBVqE
#: src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts
msgid "File upload failed"
@@ -6892,6 +6892,12 @@ msgstr ""
msgid "File label"
msgstr "Rótulo do arquivo"
#. js-lingui-id: QWWgBD
#. placeholder {0}: file.label
#: src/modules/ui/field/display/components/FileChip.tsx
msgid "File no longer exists - {0}"
msgstr ""
#. js-lingui-id: ncBVqE
#: src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts
msgid "File upload failed"
@@ -6892,6 +6892,12 @@ msgstr ""
msgid "File label"
msgstr "Eticheta fișierului"
#. js-lingui-id: QWWgBD
#. placeholder {0}: file.label
#: src/modules/ui/field/display/components/FileChip.tsx
msgid "File no longer exists - {0}"
msgstr ""
#. js-lingui-id: ncBVqE
#: src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts
msgid "File upload failed"
Binary file not shown.
@@ -6892,6 +6892,12 @@ msgstr ""
msgid "File label"
msgstr "Ознака датотеке"
#. js-lingui-id: QWWgBD
#. placeholder {0}: file.label
#: src/modules/ui/field/display/components/FileChip.tsx
msgid "File no longer exists - {0}"
msgstr ""
#. js-lingui-id: ncBVqE
#: src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts
msgid "File upload failed"
@@ -6892,6 +6892,12 @@ msgstr ""
msgid "File label"
msgstr "Filens etikett"
#. js-lingui-id: QWWgBD
#. placeholder {0}: file.label
#: src/modules/ui/field/display/components/FileChip.tsx
msgid "File no longer exists - {0}"
msgstr ""
#. js-lingui-id: ncBVqE
#: src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts
msgid "File upload failed"
@@ -6892,6 +6892,12 @@ msgstr ""
msgid "File label"
msgstr "Dosya etiketi"
#. js-lingui-id: QWWgBD
#. placeholder {0}: file.label
#: src/modules/ui/field/display/components/FileChip.tsx
msgid "File no longer exists - {0}"
msgstr ""
#. js-lingui-id: ncBVqE
#: src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts
msgid "File upload failed"
@@ -6892,6 +6892,12 @@ msgstr ""
msgid "File label"
msgstr "Мітка файлу"
#. js-lingui-id: QWWgBD
#. placeholder {0}: file.label
#: src/modules/ui/field/display/components/FileChip.tsx
msgid "File no longer exists - {0}"
msgstr ""
#. js-lingui-id: ncBVqE
#: src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts
msgid "File upload failed"
@@ -6892,6 +6892,12 @@ msgstr ""
msgid "File label"
msgstr "Nhãn tệp"
#. js-lingui-id: QWWgBD
#. placeholder {0}: file.label
#: src/modules/ui/field/display/components/FileChip.tsx
msgid "File no longer exists - {0}"
msgstr ""
#. js-lingui-id: ncBVqE
#: src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts
msgid "File upload failed"
@@ -6892,6 +6892,12 @@ msgstr ""
msgid "File label"
msgstr "文件标签"
#. js-lingui-id: QWWgBD
#. placeholder {0}: file.label
#: src/modules/ui/field/display/components/FileChip.tsx
msgid "File no longer exists - {0}"
msgstr ""
#. js-lingui-id: ncBVqE
#: src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts
msgid "File upload failed"
@@ -6892,6 +6892,12 @@ msgstr ""
msgid "File label"
msgstr "檔案標籤"
#. js-lingui-id: QWWgBD
#. placeholder {0}: file.label
#: src/modules/ui/field/display/components/FileChip.tsx
msgid "File no longer exists - {0}"
msgstr ""
#. js-lingui-id: ncBVqE
#: src/modules/object-record/record-field/ui/meta-types/hooks/useUploadFilesFieldFile.ts
msgid "File upload failed"
@@ -1,9 +1,13 @@
import { useEffect } from 'react';
import { useContext, useEffect } from 'react';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { TimelineActivityContext } from '@/activities/timeline-activities/contexts/TimelineActivityContext';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type FieldFilesValue } from '@/object-record/record-field/ui/types/FieldMetadata';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { useSetAtomFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAtomFamilyState';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
export const EventFieldDiffValueEffect = ({
@@ -22,13 +26,39 @@ export const EventFieldDiffValueEffect = ({
diffArtificialRecordStoreId,
);
const { recordId } = useContext(TimelineActivityContext);
const recordStore = useAtomFamilyStateValue(recordStoreFamilyState, recordId);
useEffect(() => {
if (!isDefined(diffRecord)) return;
let fieldValue = diffRecord;
if (
fieldMetadataItem.type === FieldMetadataType.FILES &&
isDefined(recordStore) &&
Array.isArray(diffRecord)
) {
const currentFiles = Array.isArray(recordStore[fieldMetadataItem.name])
? (recordStore[fieldMetadataItem.name] as FieldFilesValue[])
: [];
const currentFileMap = new Map(
currentFiles.map((file) => [file.fileId, file]),
);
fieldValue = (diffRecord as FieldFilesValue[]).map((file) => {
const currentFile = currentFileMap.get(file.fileId);
if (isDefined(currentFile)) {
return { ...file, url: currentFile.url };
}
return { ...file, isDeleted: true, url: undefined };
});
}
const forgedObjectRecord = {
__typename: mainObjectMetadataItem.nameSingular,
id: diffArtificialRecordStoreId,
[fieldMetadataItem.name]: diffRecord,
[fieldMetadataItem.name]: fieldValue,
};
setRecordStore(forgedObjectRecord);
@@ -36,8 +66,10 @@ export const EventFieldDiffValueEffect = ({
diffRecord,
diffArtificialRecordStoreId,
fieldMetadataItem.name,
fieldMetadataItem.type,
mainObjectMetadataItem.nameSingular,
setRecordStore,
recordStore,
]);
return <></>;
@@ -56,29 +56,29 @@ import { FieldMetadataType } from 'twenty-shared/types';
import { type JsonValue } from 'type-fest';
type FormFieldInputProps = {
field: Pick<FieldDefinition<FieldMetadata>, 'label' | 'metadata' | 'type'>;
defaultValue: JsonValue;
error?: string;
field: Pick<FieldDefinition<FieldMetadata>, 'label' | 'metadata' | 'type'>;
onChange: (value: JsonValue) => void;
onClear?: () => void;
VariablePicker?: VariablePickerComponent;
readonly?: boolean;
placeholder?: string;
error?: string;
onError?: (error: string | undefined) => void;
placeholder?: string;
readonly?: boolean;
timeZone?: string;
VariablePicker?: VariablePickerComponent;
};
export const FormFieldInput = ({
field,
defaultValue,
error,
field,
onChange,
onClear,
VariablePicker,
readonly,
placeholder,
error,
onError,
placeholder,
readonly,
timeZone,
VariablePicker,
}: FormFieldInputProps) => {
return isFieldNumber(field) || field.type === FieldMetadataType.NUMERIC ? (
<FormNumberFieldInput
@@ -168,21 +168,23 @@ export const FormFieldInput = ({
/>
) : isFieldDate(field) ? (
<FormDateFieldInput
label={field.label}
defaultValue={defaultValue as string | undefined}
error={error}
label={field.label}
onChange={onChange}
VariablePicker={VariablePicker}
readonly={readonly}
onError={onError}
placeholder={placeholder}
readonly={readonly}
VariablePicker={VariablePicker}
/>
) : isFieldDateTime(field) ? (
<FormDateTimeFieldInput
label={field.label}
defaultValue={defaultValue as string | undefined}
label={field.label}
onChange={onChange}
VariablePicker={VariablePicker}
readonly={readonly}
timeZone={timeZone}
VariablePicker={VariablePicker}
/>
) : isFieldMultiSelect(field) ? (
<FormMultiSelectFieldInput
@@ -1,17 +1,16 @@
import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat';
import { FormFieldInputContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputContainer';
import { FormFieldInputInnerContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputInnerContainer';
import { FormFieldInputRowContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputRowContainer';
import { VariableChipStandalone } from '@/object-record/record-field/ui/form-types/components/VariableChipStandalone';
import { type VariablePickerComponent } from '@/object-record/record-field/ui/form-types/types/VariablePickerComponent';
import { InputHint } from '@/ui/input/components/InputHint';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { DatePicker } from '@/ui/input/components/internal/date/components/DatePicker';
import { DatePickerInput } from '@/ui/input/components/internal/date/components/DatePickerInput';
import {
MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID,
MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID,
} from '@/ui/input/components/internal/date/components/DateTimePicker';
import { useParseDateInputStringToPlainDate } from '@/ui/input/components/internal/date/hooks/useParseDateInputStringToPlainDate';
import { useParsePlainDateToDateInputString } from '@/ui/input/components/internal/date/hooks/useParsePlainDateToDateInputString';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
@@ -20,25 +19,21 @@ import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useLis
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import { styled } from '@linaria/react';
import { isNonEmptyString } from '@sniptt/guards';
import {
useId,
useRef,
useState,
type ChangeEvent,
type KeyboardEvent,
} from 'react';
import { useId, useRef, useState } from 'react';
import { Key } from 'ts-key-enum';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { type Nullable } from 'twenty-ui/utilities';
import { getDateFormatStringForDatePickerInputMask } from '~/utils/date-utils';
const StyledInputContainerWrapper = styled.div`
display: grid;
flex: 1 1 auto;
grid-template-columns: 1fr;
grid-template-rows: 1fr 0;
min-width: 0;
overflow: visible;
position: relative;
width: 100%;
`;
const StyledDateInputAbsoluteContainer = styled.div`
@@ -46,31 +41,11 @@ const StyledDateInputAbsoluteContainer = styled.div`
top: ${themeCssVariables.spacing[1]};
`;
const StyledDateInput = styled.input<{ hasError?: boolean }>`
background-color: transparent;
border: none;
color: ${({ hasError }) =>
hasError
? themeCssVariables.color.red
: themeCssVariables.font.color.primary};
color: ${themeCssVariables.font.color.primary};
font-family: ${themeCssVariables.font.family};
font-size: inherit;
font-weight: inherit;
outline: none;
&::placeholder,
&::-webkit-input-placeholder {
color: ${themeCssVariables.font.color.light};
font-family: ${themeCssVariables.font.family};
font-weight: ${themeCssVariables.font.weight.medium};
}
&:disabled {
color: ${themeCssVariables.font.color.tertiary};
}
padding: ${themeCssVariables.spacing[0]} ${themeCssVariables.spacing[2]};
const StyledDateInputTextContainer = styled.div`
align-items: center;
display: flex;
min-width: 0;
width: 100%;
`;
const StyledDateInputContainer = styled.div`
@@ -90,29 +65,31 @@ type DraftValue =
};
type FormDateFieldInputProps = {
label?: string;
defaultValue: string | undefined;
error?: string;
label?: string;
onChange: (value: string | null) => void;
onError?: (error: string | undefined) => void;
placeholder?: string;
VariablePicker?: VariablePickerComponent;
readonly?: boolean;
VariablePicker?: VariablePickerComponent;
};
export const FormDateFieldInput = ({
label,
defaultValue,
error: errorFromProps,
label,
onChange,
VariablePicker,
onError,
placeholder: _placeholder,
readonly,
placeholder,
VariablePicker,
}: FormDateFieldInputProps) => {
const instanceId = useId();
const { dateFormat } = useDateTimeFormat();
const { parsePlainDateToDateInputString } =
useParsePlainDateToDateInputString();
const { parseDateInputStringToPlainDate } =
useParseDateInputStringToPlainDate();
const [errorMessage, setErrorMessage] = useState<string | undefined>(
undefined,
);
const [draftValue, setDraftValue] = useState<DraftValue>(
isStandaloneVariableString(defaultValue)
@@ -139,12 +116,6 @@ export const FormDateFieldInput = ({
const datePickerWrapperRef = useRef<HTMLDivElement>(null);
const [inputDate, setInputDate] = useState(
isDefined(draftValueAsDate) && !isStandaloneVariableString(defaultValue)
? parsePlainDateToDateInputString(draftValueAsDate)
: '',
);
const persistDate = (newDate: Nullable<string>) => {
if (!isDefined(newDate)) {
onChange(null);
@@ -159,11 +130,6 @@ export const FormDateFieldInput = ({
const displayDatePicker =
draftValue.type === 'static' && draftValue.mode === 'edit';
const defaultPlaceHolder =
getDateFormatStringForDatePickerInputMask(dateFormat);
const placeholderToDisplay = placeholder ?? defaultPlaceHolder;
useListenClickOutside({
refs: [datePickerWrapperRef],
listenerId: 'FormDateTimeFieldInputBase',
@@ -182,16 +148,15 @@ export const FormDateFieldInput = ({
});
const handlePickerChange = (newDate: Nullable<string>) => {
setErrorMessage(undefined);
onError?.(undefined);
setDraftValue({
type: 'static',
mode: 'edit',
value: newDate ?? null,
});
setInputDate(
isDefined(newDate) ? parsePlainDateToDateInputString(newDate) : '',
);
setPickerDate(newDate);
persistDate(newDate);
@@ -216,6 +181,9 @@ export const FormDateFieldInput = ({
};
const handlePickerClear = () => {
setErrorMessage(undefined);
onError?.(undefined);
setDraftValue({
type: 'static',
value: null,
@@ -224,12 +192,13 @@ export const FormDateFieldInput = ({
setPickerDate(null);
setInputDate('');
persistDate(null);
};
const handlePickerMouseSelect = (newDate: Nullable<string>) => {
setErrorMessage(undefined);
onError?.(undefined);
setDraftValue({
type: 'static',
value: newDate ?? null,
@@ -238,10 +207,6 @@ export const FormDateFieldInput = ({
setPickerDate(newDate);
setInputDate(
isDefined(newDate) ? parsePlainDateToDateInputString(newDate) : '',
);
persistDate(newDate);
};
@@ -253,55 +218,42 @@ export const FormDateFieldInput = ({
});
};
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
setInputDate(event.target.value);
};
const handleMaskedDateChange = (newPlainDate: string | null) => {
setErrorMessage(undefined);
onError?.(undefined);
const handleInputKeydown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key !== 'Enter') {
return;
}
const inputDateTime = inputDate.trim();
if (inputDateTime === '') {
if (!isDefined(newPlainDate)) {
handlePickerClear();
return;
}
const parsedInputPlainDate = parseDateInputStringToPlainDate(inputDateTime);
if (!isDefined(parsedInputPlainDate)) {
return;
}
let validatedDate = parsedInputPlainDate;
setDraftValue({
type: 'static',
value: validatedDate,
mode: 'edit',
value: newPlainDate,
});
setPickerDate(validatedDate);
setPickerDate(newPlainDate);
setInputDate(parsePlainDateToDateInputString(validatedDate));
persistDate(validatedDate);
persistDate(newPlainDate);
};
const handleVariableTagInsert = (variableName: string) => {
setErrorMessage(undefined);
onError?.(undefined);
setDraftValue({
type: 'variable',
value: variableName,
});
setInputDate('');
onChange(variableName);
};
const handleUnlinkVariable = () => {
setErrorMessage(undefined);
onError?.(undefined);
setDraftValue({
type: 'static',
value: null,
@@ -313,6 +265,8 @@ export const FormDateFieldInput = ({
onChange(null);
};
const error = errorMessage ?? errorFromProps;
useHotkeysOnFocusedElement({
keys: [Key.Escape],
callback: handlePickerEscape,
@@ -332,16 +286,15 @@ export const FormDateFieldInput = ({
>
{draftValue.type === 'static' ? (
<>
<StyledDateInput
type="text"
placeholder={placeholderToDisplay}
value={inputDate}
onFocus={handleInputFocus}
onChange={handleInputChange}
onKeyDown={handleInputKeydown}
disabled={readonly}
/>
<StyledDateInputTextContainer>
<DatePickerInput
date={pickerDate ?? null}
hasError={isDefined(error)}
onChange={handleMaskedDateChange}
onFocus={handleInputFocus}
readonly={readonly}
/>
</StyledDateInputTextContainer>
{draftValue.mode === 'edit' ? (
<StyledDateInputContainer>
<StyledDateInputAbsoluteContainer>
@@ -377,6 +330,8 @@ export const FormDateFieldInput = ({
/>
) : null}
</FormFieldInputRowContainer>
{error ? <InputHint danger>{error}</InputHint> : null}
</FormFieldInputContainer>
);
};
@@ -28,10 +28,13 @@ import { type Nullable } from 'twenty-ui/utilities';
const StyledInputContainerWrapper = styled.div`
display: grid;
flex: 1 1 auto;
grid-template-columns: 1fr;
grid-template-rows: 1fr 0;
min-width: 0;
overflow: visible;
position: relative;
width: 100%;
`;
const StyledDateInputAbsoluteContainer = styled.div`
@@ -42,6 +45,8 @@ const StyledDateInputAbsoluteContainer = styled.div`
const StyledDateInputTextContainer = styled.div`
align-items: center;
display: flex;
min-width: 0;
width: 100%;
`;
const StyledDateInputContainer = styled.div`
@@ -61,22 +66,22 @@ type DraftValue =
};
type FormDateTimeFieldInputProps = {
label?: string;
defaultValue: string | undefined;
label?: string;
onChange: (value: string | null) => void;
placeholder?: string;
VariablePicker?: VariablePickerComponent;
readonly?: boolean;
timeZone?: string;
VariablePicker?: VariablePickerComponent;
};
export const FormDateTimeFieldInput = ({
label,
defaultValue,
label,
onChange,
VariablePicker,
readonly,
timeZone,
VariablePicker,
}: FormDateTimeFieldInputProps) => {
const instanceId = useId();
@@ -259,6 +264,7 @@ export const FormDateTimeFieldInput = ({
<StyledDateInputTextContainer>
<DateTimePickerInput
date={dateValue}
fullWidth
onChange={handleInputChange}
onFocus={handleInputFocus}
readonly={readonly}
@@ -3,6 +3,7 @@ import { styled } from '@linaria/react';
const StyledFormFieldInputContainer = styled.div`
display: flex;
flex-direction: column;
min-width: 0;
width: 100%;
`;
@@ -23,8 +23,10 @@ const StyledFormFieldInputInnerContainer = styled.div<
border-bottom-left-radius: ${themeCssVariables.border.radius.sm};
border-bottom-right-radius: ${({ multiline, hasRightElement }) =>
multiline || !hasRightElement ? themeCssVariables.border.radius.sm : '0'};
border-right: ${({ multiline, hasRightElement }) =>
multiline || !hasRightElement ? 'auto' : 'none'};
border-right: ${({ hasRightElement }) =>
hasRightElement
? 'none'
: `1px solid ${themeCssVariables.border.color.medium}`};
border-top-left-radius: ${themeCssVariables.border.radius.sm};
border-top-right-radius: ${({ multiline, hasRightElement }) =>
multiline || !hasRightElement ? themeCssVariables.border.radius.sm : '0'};
@@ -9,14 +9,16 @@ const StyledFormFieldInputRowContainer = styled.div<{
display: flex;
flex-direction: row;
height: ${({ multiline }) => (multiline ? 'auto' : '32px')};
line-height: ${({ multiline }) =>
multiline ? `${LINE_HEIGHT}px` : 'normal'};
max-height: ${({ multiline, maxHeight }) =>
multiline ? `${maxHeight ?? 5 * LINE_HEIGHT}px` : 'none'};
min-height: ${({ multiline }) =>
multiline ? `${3 * LINE_HEIGHT}px` : 'auto'};
min-width: 0;
position: relative;
width: 100%;
`;
export const FormFieldInputRowContainer = StyledFormFieldInputRowContainer;
@@ -335,4 +335,5 @@ export type FieldFilesValue = {
extension?: string;
url?: string;
fileCategory?: FileCategory;
isDeleted?: boolean;
};
@@ -100,10 +100,13 @@ describe('useSidePanelHistory', () => {
act(() => {
result.current.commandMenuHistory.goBackFromSidePanel();
result.current.sidePanelCloseAnimationCompleteCleanup.sidePanelCloseAnimationCompleteCleanup();
});
expect(jotaiStore.get(sidePanelNavigationStackState.atom)).toEqual([]);
act(() => {
result.current.sidePanelCloseAnimationCompleteCleanup.sidePanelCloseAnimationCompleteCleanup();
});
expect(jotaiStore.get(sidePanelPageState.atom)).toBe(
SidePanelPages.CommandMenuDisplay,
);
@@ -115,6 +118,34 @@ describe('useSidePanelHistory', () => {
expect(jotaiStore.get(isSidePanelOpenedState.atom)).toBe(false);
});
it('should clear navigation stack immediately when closeSidePanelMenu is called', () => {
const { result } = renderHooks();
act(() => {
result.current.commandMenu.navigateSidePanelMenu({
page: SidePanelPages.ViewRecord,
pageTitle: 'Company',
pageIcon: IconList,
pageId: '1',
});
});
expect(jotaiStore.get(sidePanelNavigationStackState.atom)).toEqual([
{
page: SidePanelPages.ViewRecord,
pageTitle: 'Company',
pageIcon: IconList,
pageId: '1',
},
]);
act(() => {
result.current.commandMenu.closeSidePanelMenu();
});
expect(jotaiStore.get(sidePanelNavigationStackState.atom)).toEqual([]);
});
it('should navigate to a page in history', () => {
const { result } = renderHooks();
@@ -67,6 +67,8 @@ export const useSidePanelHistory = () => {
const newNavigationStack = currentNavigationStack.slice(0, -1);
const lastNavigationStackItem = newNavigationStack.at(-1);
store.set(sidePanelNavigationStackState.atom, newNavigationStack);
if (!isDefined(lastNavigationStackItem)) {
closeSidePanelMenu();
return;
@@ -80,8 +82,6 @@ export const useSidePanelHistory = () => {
instanceId: lastNavigationStackItem.pageId,
});
store.set(sidePanelNavigationStackState.atom, newNavigationStack);
store.set(hasUserSelectedSidePanelListItemState.atom, false);
}, [cleanupCurrentPage, closeSidePanelMenu, store]);
@@ -3,6 +3,7 @@ import { selectedNavigationMenuItemIdInEditModeState } from '@/navigation-menu-i
import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId';
import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
import { isSidePanelClosingState } from '@/side-panel/states/isSidePanelClosingState';
import { sidePanelNavigationStackState } from '@/side-panel/states/sidePanelNavigationStackState';
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
import { sidePanelSearchObjectFilterState } from '@/side-panel/states/sidePanelSearchObjectFilterState';
import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
@@ -28,6 +29,7 @@ export const useSidePanelMenu = () => {
const isSidePanelOpened = store.get(isSidePanelOpenedState.atom);
if (isSidePanelOpened) {
store.set(sidePanelNavigationStackState.atom, []);
store.set(isSidePanelOpenedState.atom, false);
store.set(isSidePanelClosingState.atom, true);
closeAnyOpenDropdown();
@@ -30,9 +30,13 @@ import { IconLogin2, IconLogout, IconStepInto } from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledContainer = styled.div`
align-items: stretch;
box-sizing: border-box;
display: flex;
flex-direction: column;
height: 100%;
min-width: 0;
width: 100%;
`;
const StyledTabListContainer = styled.div`
@@ -1,4 +1,5 @@
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { FileIcon } from '@/file/components/FileIcon';
import { type FieldFilesValue } from '@/object-record/record-field/ui/types/FieldMetadata';
@@ -24,7 +25,8 @@ export const FileChip = ({
onClick,
forceDisableClick,
}: FileChipProps) => {
const isClickable = forceDisableClick !== true;
const isDeleted = file.isDeleted === true;
const isClickable = forceDisableClick !== true && !isDeleted;
const handleMouseDown = (event: React.MouseEvent): void => {
if (!isClickable) {
@@ -45,17 +47,24 @@ export const FileChip = ({
);
return (
<StyledClickableContainer
clickable={isClickable}
onMouseDown={handleMouseDown}
>
<Chip
label={file.label ?? ''}
maxWidth={MAX_WIDTH}
leftComponent={fileIcon}
variant={ChipVariant.Highlighted}
<>
<StyledClickableContainer
clickable={isClickable}
/>
</StyledClickableContainer>
onMouseDown={handleMouseDown}
>
<Chip
label={file.label}
alwaysShowTooltip={isDeleted}
tooltipLabel={
isDeleted ? t`File no longer exists - ${file.label}` : undefined
}
disabled={isDeleted}
maxWidth={MAX_WIDTH}
leftComponent={fileIcon}
variant={isDeleted ? ChipVariant.Static : ChipVariant.Highlighted}
clickable={isClickable}
/>
</StyledClickableContainer>
</>
);
};
@@ -22,6 +22,7 @@ const StyledInputContainer = styled.div`
border-top-right-radius: ${themeCssVariables.border.radius.md};
display: flex;
height: ${themeCssVariables.spacing[8]};
min-width: 0;
width: 100%;
`;
@@ -32,19 +33,34 @@ const StyledInput = styled.input<{ hasError?: boolean }>`
hasError
? themeCssVariables.color.red
: themeCssVariables.font.color.primary};
flex: 1 1 auto;
font-size: ${themeCssVariables.font.size.md};
font-weight: 500;
min-width: 0;
outline: none;
padding: 4px 8px 4px 8px;
width: 100%;
&:disabled {
color: ${themeCssVariables.font.color.tertiary};
}
`;
type DatePickerInputProps = {
onChange?: (date: string | null) => void;
date: string | null;
hasError?: boolean;
onChange?: (date: string | null) => void;
onFocus?: () => void;
readonly?: boolean;
};
export const DatePickerInput = ({ date, onChange }: DatePickerInputProps) => {
export const DatePickerInput = ({
date,
hasError,
onChange,
onFocus,
readonly,
}: DatePickerInputProps) => {
const { dateFormat } = useDateTimeFormat();
const [internalDate, setInternalDate] = useState(date);
@@ -71,15 +87,17 @@ export const DatePickerInput = ({ date, onChange }: DatePickerInputProps) => {
? (parsePlainDateToDateInputString(internalDate) ?? undefined)
: undefined;
const { ref, setValue, value } = useIMask(
const { ref, setValue } = useIMask(
{
mask: Date,
pattern,
blocks,
min: MIN_DATE,
max: MAX_DATE,
format: (date: any) =>
isDefined(date) ? parseIMaskJSDateIMaskDateInputString(date) : '',
format: (dateValue: unknown) =>
isDefined(dateValue) && dateValue instanceof Date
? parseIMaskJSDateIMaskDateInputString(dateValue)
: '',
parse: parseIMaskDateInputStringToJSDate,
lazy: false,
autofix: true,
@@ -89,25 +107,35 @@ export const DatePickerInput = ({ date, onChange }: DatePickerInputProps) => {
onComplete: (newValue) => {
const parsedDate = parseDateInputStringToPlainDate(newValue);
onChange?.(parsedDate);
onChange?.(isDefined(parsedDate) ? parsedDate : null);
},
},
);
useEffect(() => {
if (isDefined(date) && internalDate !== date) {
setInternalDate(date);
if (date === internalDate) {
return;
}
setInternalDate(date);
if (isDefined(date)) {
setValue(parsePlainDateToDateInputString(date));
} else {
setValue('');
}
}, [date, internalDate, parsePlainDateToDateInputString, setValue]);
const shouldDisplayReadOnly = readonly === true;
return (
<StyledInputContainer>
<StyledInput
disabled={shouldDisplayReadOnly}
hasError={hasError}
type="text"
ref={ref as any}
value={value}
onChange={() => {}} // Prevent React warning
onFocus={!shouldDisplayReadOnly ? onFocus : undefined}
/>
</StyledInputContainer>
);
@@ -20,30 +20,33 @@ import { isDefined } from 'twenty-shared/utils';
import { isDifferentZonedDateTime } from '~/utils/dates/isDifferentZonedDateTime';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledInputContainer = styled.div`
const StyledInputContainer = styled.div<{ fullWidth?: boolean }>`
align-items: center;
border-top-left-radius: ${themeCssVariables.border.radius.md};
border-top-right-radius: ${themeCssVariables.border.radius.md};
display: flex;
height: ${themeCssVariables.spacing[8]};
min-width: ${({ fullWidth }) => (fullWidth === true ? '0' : 'auto')};
width: 100%;
`;
const StyledInput = styled.input<{ hasError?: boolean }>`
const StyledInput = styled.input<{ fullWidth?: boolean; hasError?: boolean }>`
background: transparent;
border: none;
color: ${themeCssVariables.font.color.primary};
flex: ${({ fullWidth }) => (fullWidth === true ? '1 1 auto' : 'none')};
font-size: ${themeCssVariables.font.size.md};
font-weight: 500;
min-width: ${({ fullWidth }) => (fullWidth === true ? '0' : 'auto')};
outline: none;
padding-left: ${themeCssVariables.spacing[2]};
width: 140px;
width: ${({ fullWidth }) => (fullWidth === true ? '100%' : '140px')};
`;
type DateTimePickerInputProps = {
onChange?: (date: Temporal.ZonedDateTime | null) => void;
date: Temporal.ZonedDateTime | null;
fullWidth?: boolean;
onChange?: (date: Temporal.ZonedDateTime | null) => void;
onFocus?: () => void;
readonly?: boolean;
timeZone?: string;
@@ -51,6 +54,7 @@ type DateTimePickerInputProps = {
export const DateTimePickerInput = ({
date,
fullWidth = false,
onChange,
onFocus,
readonly,
@@ -167,9 +171,10 @@ export const DateTimePickerInput = ({
internalDate?.toInstant() ?? Temporal.Now.instant();
return (
<StyledInputContainer>
<StyledInputContainer fullWidth={fullWidth}>
<StyledInput
disabled={shouldDisplayReadOnly}
fullWidth={fullWidth}
type="text"
ref={ref as any}
onFocus={!shouldDisplayReadOnly ? onFocus : undefined}
@@ -13,17 +13,20 @@ const StyledWorkflowStepBody = styled.div<{
gridTemplateRows?: string;
}>`
background: ${themeCssVariables.background.primary};
box-sizing: border-box;
display: ${({ display }) => display ?? 'flex'};
flex: 1 1 auto;
flex-direction: column;
grid-template-rows: ${({ gridTemplateRows }) => gridTemplateRows ?? 'none'};
height: 100%;
min-width: 0;
overflow: ${({ overflow }) => overflow ?? 'hidden scroll'};
padding-block: ${({ paddingBlock }) =>
paddingBlock ?? themeCssVariables.spacing[4]};
padding-inline: ${({ paddingInline }) =>
paddingInline ?? themeCssVariables.spacing[3]};
row-gap: ${({ rowGap }) => rowGap ?? themeCssVariables.spacing[4]};
width: 100%;
`;
export const WorkflowStepBody = ({
@@ -17,8 +17,8 @@ import { getDefaultFormFieldSettings } from '@/workflow/workflow-steps/workflow-
import { styled } from '@linaria/react';
import { type OnDragEndResponder } from '@hello-pangea/dnd';
import { useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useContext, useEffect, useState } from 'react';
import { isNonEmptyString } from '@sniptt/guards';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
@@ -83,6 +83,8 @@ const StyledTrashButtonContainer = styled.div`
const StyledFormFieldInputContainerWrapper = styled.div`
grid-area: input;
min-width: 0;
width: 100%;
`;
const StyledOpenedSettingsContainer = styled.div`
@@ -93,11 +95,13 @@ const StyledFieldContainer = styled.div<{
readonly?: boolean;
}>`
align-items: center;
align-self: stretch;
background: transparent;
border: none;
cursor: ${({ readonly }) => (readonly ? 'default' : 'pointer')};
display: flex;
font-family: inherit;
min-height: 0;
padding-left: ${themeCssVariables.spacing[2]};
padding-right: ${themeCssVariables.spacing[2]};
@@ -116,10 +120,19 @@ const StyledPlaceholderContainer = styled.div`
width: 100%;
`;
const StyledAddFieldButtonContainer = styled.div`
padding-left: ${themeCssVariables.spacing[7]};
padding-right: ${themeCssVariables.spacing[7]};
const StyledAddFieldButtonOuterGrid = styled.div`
column-gap: ${themeCssVariables.spacing[1]};
display: grid;
grid-template-columns: 24px 1fr 24px;
min-width: 0;
padding-top: ${themeCssVariables.spacing[2]};
width: 100%;
`;
const StyledAddFieldButtonGridCenter = styled.div`
grid-column: 2;
min-width: 0;
width: 100%;
`;
const StyledAddFieldButtonContentContainer = styled.div`
@@ -323,8 +336,7 @@ export const WorkflowEditActionFormBuilder = ({
>
<StyledPlaceholderContainer>
<FormFieldPlaceholder>
{isDefined(field.placeholder) &&
isNonEmptyString(field.placeholder)
{isNonEmptyString(field.placeholder)
? field.placeholder
: getDefaultFormFieldSettings(field.type)
.placeholder}
@@ -391,47 +403,49 @@ export const WorkflowEditActionFormBuilder = ({
/>
{!actionOptions.readonly && (
<StyledAddFieldButtonContainer>
<FormFieldInputContainer>
<FormFieldInputRowContainer>
<FormFieldInputInnerContainer
formFieldInputInstanceId="add-field-button"
hasRightElement={false}
onClick={() => {
const { label, name } = getDefaultFormFieldSettings(
FieldMetadataType.TEXT,
);
<StyledAddFieldButtonOuterGrid>
<StyledAddFieldButtonGridCenter>
<FormFieldInputContainer>
<FormFieldInputRowContainer>
<FormFieldInputInnerContainer
formFieldInputInstanceId="add-field-button"
hasRightElement={false}
onClick={() => {
const { label, name } = getDefaultFormFieldSettings(
FieldMetadataType.TEXT,
);
const newField: WorkflowFormActionField = {
id: v4(),
name,
type: FieldMetadataType.TEXT,
label,
};
const newField: WorkflowFormActionField = {
id: v4(),
name,
type: FieldMetadataType.TEXT,
label,
};
setFormData([...formData, newField]);
setFormData([...formData, newField]);
actionOptions.onActionUpdate({
...action,
settings: {
...action.settings,
input: [...action.settings.input, newField],
},
});
actionOptions.onActionUpdate({
...action,
settings: {
...action.settings,
input: [...action.settings.input, newField],
},
});
setSelectedField(newField.id);
}}
>
<StyledFieldContainer>
<StyledAddFieldButtonContentContainer>
<IconPlus size={theme.icon.size.sm} />
{t`Add Field`}
</StyledAddFieldButtonContentContainer>
</StyledFieldContainer>
</FormFieldInputInnerContainer>
</FormFieldInputRowContainer>
</FormFieldInputContainer>
</StyledAddFieldButtonContainer>
setSelectedField(newField.id);
}}
>
<StyledFieldContainer>
<StyledAddFieldButtonContentContainer>
<IconPlus size={theme.icon.size.sm} />
{t`Add Field`}
</StyledAddFieldButtonContentContainer>
</StyledFieldContainer>
</FormFieldInputInnerContainer>
</FormFieldInputRowContainer>
</FormFieldInputContainer>
</StyledAddFieldButtonGridCenter>
</StyledAddFieldButtonOuterGrid>
)}
</WorkflowStepBody>
{!actionOptions.readonly && <WorkflowStepFooter stepId={action.id} />}
@@ -1,22 +1,44 @@
import { WorkflowStepCmdEnterButton } from '@/workflow/workflow-steps/components/WorkflowStepCmdEnterButton';
import { useSidePanelHistory } from '@/side-panel/hooks/useSidePanelHistory';
import { FormFieldInput } from '@/object-record/record-field/ui/components/FormFieldInput';
import { FormSingleRecordPicker } from '@/object-record/record-field/ui/form-types/components/FormSingleRecordPicker';
import { type FieldMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
import { useSidePanelHistory } from '@/side-panel/hooks/useSidePanelHistory';
import { SidePanelFooter } from '@/ui/layout/side-panel/components/SidePanelFooter';
import { useWorkflowRunIdOrThrow } from '@/workflow/hooks/useWorkflowRunIdOrThrow';
import { type WorkflowFormAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepCmdEnterButton } from '@/workflow/workflow-steps/components/WorkflowStepCmdEnterButton';
import { useUpdateWorkflowRunStep } from '@/workflow/workflow-steps/hooks/useUpdateWorkflowRunStep';
import { WorkflowFormFieldInput } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowFormFieldInput';
import { useSubmitFormStep } from '@/workflow/workflow-steps/workflow-actions/form-action/hooks/useSubmitFormStep';
import { type WorkflowFormActionField } from '@/workflow/workflow-steps/workflow-actions/form-action/types/WorkflowFormActionField';
import { getDefaultFormFieldSettings } from '@/workflow/workflow-steps/workflow-actions/form-action/utils/getDefaultFormFieldSettings';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useEffect, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useDebouncedCallback } from 'use-debounce';
const StyledWorkflowFormFillerRoot = styled.div`
box-sizing: border-box;
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
min-width: 0;
width: 100%;
`;
const StyledWorkflowFormFillerFields = styled.div`
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: ${themeCssVariables.spacing[4]};
min-width: 0;
width: 100%;
`;
export type WorkflowEditActionFormFillerProps = {
action: WorkflowFormAction;
actionOptions: {
@@ -99,86 +121,89 @@ export const WorkflowEditActionFormFiller = ({
}, [saveAction]);
return (
<>
<StyledWorkflowFormFillerRoot>
<WorkflowStepBody>
{formData.map((field) => {
if (field.type === 'RECORD') {
const objectNameSingular = field.settings?.objectName;
<StyledWorkflowFormFillerFields>
{formData.map((field) => {
if (field.type === 'RECORD') {
const objectNameSingular = field.settings?.objectName;
if (!isDefined(objectNameSingular)) {
return null;
if (!isDefined(objectNameSingular)) {
return null;
}
const recordId = field.value?.id;
return (
<FormSingleRecordPicker
key={field.id}
label={field.label}
defaultValue={recordId}
onChange={(recordId) => {
onFieldUpdate({
fieldId: field.id,
value: {
id: recordId,
},
});
}}
objectNameSingulars={[objectNameSingular]}
disabled={actionOptions.readonly}
/>
);
}
const recordId = field.value?.id;
if (field.type === 'SELECT' || field.type === 'MULTI_SELECT') {
const selectedFieldId = field.settings?.selectedFieldId;
return (
<FormSingleRecordPicker
key={field.id}
label={field.label}
defaultValue={recordId}
onChange={(recordId) => {
onFieldUpdate({
fieldId: field.id,
value: {
id: recordId,
},
});
}}
objectNameSingulars={[objectNameSingular]}
disabled={actionOptions.readonly}
/>
);
}
if (!isDefined(selectedFieldId)) {
return null;
}
if (field.type === 'SELECT' || field.type === 'MULTI_SELECT') {
const selectedFieldId = field.settings?.selectedFieldId;
if (!isDefined(selectedFieldId)) {
return null;
return (
<WorkflowFormFieldInput
key={field.id}
fieldMetadataId={selectedFieldId}
defaultValue={field.value}
readonly={actionOptions.readonly}
onChange={(value) => {
onFieldUpdate({
fieldId: field.id,
value,
});
}}
/>
);
}
return (
<WorkflowFormFieldInput
<FormFieldInput
key={field.id}
fieldMetadataId={selectedFieldId}
defaultValue={field.value}
readonly={actionOptions.readonly}
field={{
label: field.label,
type: field.type,
metadata: {} as FieldMetadata,
}}
onChange={(value) => {
onFieldUpdate({
fieldId: field.id,
value,
});
}}
onError={(error) => {
setError(error);
}}
placeholder={
isNonEmptyString(field.placeholder)
? field.placeholder
: getDefaultFormFieldSettings(field.type).placeholder
}
readonly={actionOptions.readonly}
/>
);
}
return (
<FormFieldInput
key={field.id}
field={{
label: field.label,
type: field.type,
metadata: {} as FieldMetadata,
}}
onChange={(value) => {
onFieldUpdate({
fieldId: field.id,
value,
});
}}
defaultValue={field.value}
readonly={actionOptions.readonly}
placeholder={
field.placeholder ??
getDefaultFormFieldSettings(field.type).placeholder
}
onError={(error) => {
setError(error);
}}
/>
);
})}
})}
</StyledWorkflowFormFillerFields>
</WorkflowStepBody>
{!actionOptions.readonly && (
<SidePanelFooter
@@ -191,6 +216,6 @@ export const WorkflowEditActionFormFiller = ({
]}
/>
)}
</>
</StyledWorkflowFormFillerRoot>
);
};
@@ -69,7 +69,6 @@ const mockAction: WorkflowFormAction = {
name: 'date',
label: 'Date',
type: FieldMetadataType.DATE,
placeholder: 'mm/dd/yyyy',
settings: {},
},
],
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-sdk",
"version": "0.8.0-canary.6",
"version": "0.8.0-canary.7",
"main": "dist/index.cjs",
"module": "dist/index.mjs",
"types": "dist/sdk/index.d.ts",
@@ -3,7 +3,6 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { BackfillCommandMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-command-menu-items.command';
import { BackfillNavigationMenuItemTypeCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-navigation-menu-item-type.command';
import { BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-page-layouts-and-fields-widget-view-fields.command';
import { BackfillSelectFieldOptionIdsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-select-field-option-ids.command';
import { DeleteOrphanNavigationMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-delete-orphan-navigation-menu-items.command';
import { IdentifyFieldPermissionMetadataCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-identify-field-permission-metadata.command';
@@ -66,7 +65,6 @@ import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-commo
MakeFieldPermissionUniversalIdentifierAndApplicationIdNotNullableMigrationCommand,
BackfillCommandMenuItemsCommand,
BackfillNavigationMenuItemTypeCommand,
BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand,
BackfillSelectFieldOptionIdsCommand,
DeleteOrphanNavigationMenuItemsCommand,
SeedCliApplicationRegistrationCommand,
@@ -84,7 +82,6 @@ import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-commo
MakeFieldPermissionUniversalIdentifierAndApplicationIdNotNullableMigrationCommand,
BackfillCommandMenuItemsCommand,
BackfillNavigationMenuItemTypeCommand,
BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand,
BackfillSelectFieldOptionIdsCommand,
DeleteOrphanNavigationMenuItemsCommand,
SeedCliApplicationRegistrationCommand,
@@ -87,7 +87,7 @@ const isRelationTargetAvailable = (
};
@Command({
name: 'upgrade:1-20:backfill-page-layouts-and-fields-widget-view-fields',
name: 'upgrade:1-21:backfill-page-layouts-and-fields-widget-view-fields',
description:
'Backfill RECORD_PAGE page layouts, sync FIELDS_WIDGET view fields, create FIELD widgets, and enable IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED',
})
@@ -594,7 +594,7 @@ export class BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand extends ActiveO
}
}
// Build object ID objectMetadata map
// Build object ID -> objectMetadata map
const objectById = new Map<string, FlatObjectMetadata>();
for (const obj of Object.values(
@@ -605,7 +605,7 @@ export class BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand extends ActiveO
}
}
// Build object ID RECORD_PAGE layout map
// Build object ID -> RECORD_PAGE layout map
const recordPageLayoutByObjectId = new Map<
string,
{ id: string; universalIdentifier: string }
@@ -626,7 +626,7 @@ export class BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand extends ActiveO
}
}
// Build pageLayoutId home tab map
// Build pageLayoutId -> home tab map
const homeTabByPageLayoutId = new Map<
string,
{
@@ -655,7 +655,7 @@ export class BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand extends ActiveO
// Group fields by objectMetadataId
const fieldsByObjectId = new Map<string, FlatFieldMetadata[]>();
// Map morphId all field IDs sharing that morphId (for dedup)
// Map morphId -> all field IDs sharing that morphId (for dedup)
const fieldIdsByMorphId = new Map<string, string[]>();
for (const field of Object.values(
@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-backfill-page-layouts-and-fields-widget-view-fields.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
@Module({
imports: [
TypeOrmModule.forFeature([WorkspaceEntity]),
DataSourceModule,
WorkspaceCacheModule,
ApplicationModule,
WorkspaceMigrationModule,
FeatureFlagModule,
],
providers: [BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand],
exports: [BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand],
})
export class V1_21_UpgradeVersionCommandModule {}
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { V1_20_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-20/1-20-upgrade-version-command.module';
import { V1_21_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-21/1-21-upgrade-version-command.module';
import { UpgradeCommand } from 'src/database/commands/upgrade-version-command/upgrade.command';
import { CoreEngineVersionModule } from 'src/engine/core-engine-version/core-engine-version.module';
import { CoreMigrationRunnerModule } from 'src/database/commands/core-migration-runner/core-migration-runner.module';
@@ -13,6 +14,7 @@ import { WorkspaceVersionModule } from 'src/engine/workspace-manager/workspace-v
imports: [
TypeOrmModule.forFeature([WorkspaceEntity]),
V1_20_UpgradeVersionCommandModule,
V1_21_UpgradeVersionCommandModule,
DataSourceModule,
CoreEngineVersionModule,
CoreMigrationRunnerModule,
@@ -12,7 +12,7 @@ import {
import { CoreMigrationRunnerService } from 'src/database/commands/core-migration-runner/services/core-migration-runner.service';
import { BackfillCommandMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-command-menu-items.command';
import { BackfillNavigationMenuItemTypeCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-navigation-menu-item-type.command';
import { BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-page-layouts-and-fields-widget-view-fields.command';
import { BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-backfill-page-layouts-and-fields-widget-view-fields.command';
import { IdentifyFieldPermissionMetadataCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-identify-field-permission-metadata.command';
import { BackfillSelectFieldOptionIdsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-select-field-option-ids.command';
import { DeleteOrphanNavigationMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-delete-orphan-navigation-menu-items.command';
@@ -60,13 +60,15 @@ export class UpgradeCommand extends UpgradeCommandRunner {
protected readonly backfillNavigationMenuItemTypeCommand: BackfillNavigationMenuItemTypeCommand,
protected readonly backfillCommandMenuItemsCommand: BackfillCommandMenuItemsCommand,
protected readonly deleteOrphanNavigationMenuItemsCommand: DeleteOrphanNavigationMenuItemsCommand,
protected readonly backfillPageLayoutsAndFieldsWidgetViewFieldsCommand: BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand,
protected readonly seedCliApplicationRegistrationCommand: SeedCliApplicationRegistrationCommand,
protected readonly migrateRichTextToTextCommand: MigrateRichTextToTextCommand,
protected readonly migrateMessagingInfrastructureToMetadataCommand: MigrateMessagingInfrastructureToMetadataCommand,
protected readonly backfillSelectFieldOptionIdsCommand: BackfillSelectFieldOptionIdsCommand,
protected readonly updateStandardIndexViewNamesCommand: UpdateStandardIndexViewNamesCommand,
protected readonly makeWorkflowSearchableCommand: MakeWorkflowSearchableCommand,
// 1.21 Commands
protected readonly backfillPageLayoutsAndFieldsWidgetViewFieldsCommand: BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand,
) {
super(
workspaceRepository,
@@ -92,7 +94,6 @@ export class UpgradeCommand extends UpgradeCommandRunner {
this.migrateRichTextToTextCommand,
this.deleteOrphanNavigationMenuItemsCommand,
this.backfillCommandMenuItemsCommand,
this.backfillPageLayoutsAndFieldsWidgetViewFieldsCommand,
this.seedCliApplicationRegistrationCommand,
this.migrateMessagingInfrastructureToMetadataCommand,
this.backfillSelectFieldOptionIdsCommand,
@@ -100,9 +101,14 @@ export class UpgradeCommand extends UpgradeCommandRunner {
this.makeWorkflowSearchableCommand,
];
const commands_1210: VersionCommands = [
this.backfillPageLayoutsAndFieldsWidgetViewFieldsCommand,
];
this.allCommands = {
'1.19.0': [],
'1.20.0': commands_1200,
'1.21.0': commands_1210,
};
}
@@ -14,6 +14,9 @@ export const makePermissionFlagUniversalIdentifierAndApplicationIdNotNullQueries
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_da8ffd3c24b4a819430a861067" ON "core"."permissionFlag" ("workspaceId", "universalIdentifier")`,
);
await queryRunner.query(
`ALTER TABLE "core"."permissionFlag" DROP CONSTRAINT IF EXISTS "FK_b26a9d39a88d0e72373c677c6c5"`,
);
await queryRunner.query(
`ALTER TABLE "core"."permissionFlag" ADD CONSTRAINT "FK_b26a9d39a88d0e72373c677c6c5" FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
@@ -14,6 +14,9 @@ export const makeObjectPermissionUniversalIdentifierAndApplicationIdNotNullQueri
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_c5ea53618b32558fe24e495f21" ON "core"."objectPermission" ("workspaceId", "universalIdentifier")`,
);
await queryRunner.query(
`ALTER TABLE "core"."objectPermission" DROP CONSTRAINT IF EXISTS "FK_f2ecee1066fd43800dbc85f87e4"`,
);
await queryRunner.query(
`ALTER TABLE "core"."objectPermission" ADD CONSTRAINT "FK_f2ecee1066fd43800dbc85f87e4" FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
@@ -14,6 +14,9 @@ export const makeFieldPermissionUniversalIdentifierAndApplicationIdNotNullQuerie
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_0dedb90c717e179ef653c512b9" ON "core"."fieldPermission" ("workspaceId", "universalIdentifier")`,
);
await queryRunner.query(
`ALTER TABLE "core"."fieldPermission" DROP CONSTRAINT IF EXISTS "FK_71cc60c4a1c9f8a7c434d91d38c"`,
);
await queryRunner.query(
`ALTER TABLE "core"."fieldPermission" ADD CONSTRAINT "FK_71cc60c4a1c9f8a7c434d91d38c" FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
@@ -7,6 +7,9 @@ export const makeNavigationMenuItemTypeNotNullQueries = async (
`ALTER TABLE "core"."navigationMenuItem" ALTER COLUMN "type" SET NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "core"."navigationMenuItem" DROP CONSTRAINT IF EXISTS "CHK_navigation_menu_item_type_fields"`,
);
await queryRunner.query(
`ALTER TABLE "core"."navigationMenuItem" ADD CONSTRAINT "CHK_navigation_menu_item_type_fields" CHECK (
("type" = 'FOLDER')
@@ -1,4 +1,8 @@
export const UPGRADE_COMMAND_SUPPORTED_VERSIONS = ['1.19.0', '1.20.0'] as const;
export const UPGRADE_COMMAND_SUPPORTED_VERSIONS = [
'1.19.0',
'1.20.0',
'1.21.0',
] as const;
export type UpgradeCommandVersion =
(typeof UPGRADE_COMMAND_SUPPORTED_VERSIONS)[number];
@@ -9,7 +9,6 @@ import { Repository, type DataSource, type QueryRunner } from 'typeorm';
import { v4 } from 'uuid';
import { USER_SIGNUP_EVENT_NAME } from 'src/engine/api/graphql/workspace-query-runner/constants/user-signup-event-name.constants';
import { MAX_WORKSPACES_WITHOUT_ENTERPRISE_KEY } from 'src/engine/core-modules/auth/constants/max-workspaces-without-enterprise-key.constants';
import { type AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import {
@@ -21,6 +20,7 @@ import {
compareHash,
hashPassword,
} from 'src/engine/core-modules/auth/auth.util';
import { MAX_WORKSPACES_WITHOUT_ENTERPRISE_KEY } from 'src/engine/core-modules/auth/constants/max-workspaces-without-enterprise-key.constants';
import {
type AuthProviderWithPasswordType,
type ExistingUserOrPartialUserWithPicture,
@@ -593,16 +593,18 @@ export class SignInUpService {
);
await queryRunner.commitTransaction();
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
'flatApplicationMaps',
]);
return { user, workspace };
} catch (error) {
await queryRunner.rollbackTransaction();
if (queryRunner.isTransactionActive) {
await queryRunner.rollbackTransaction();
}
throw error;
} finally {
await queryRunner.release();
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
'flatApplicationMaps',
]);
}
}
@@ -1,21 +1,14 @@
import { Readable } from 'stream';
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { S3Driver } from 'src/engine/core-modules/file-storage/drivers/s3.driver';
import { FileStorageExceptionCode } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
const mockSend = jest.fn();
jest.mock('@aws-sdk/client-s3', () => {
const actual = jest.requireActual('@aws-sdk/client-s3');
return {
...actual,
S3: jest.fn().mockImplementation(() => ({
send: mockSend,
})),
S3: jest.fn().mockImplementation(() => ({})),
};
});
@@ -23,76 +16,6 @@ jest.mock('@aws-sdk/s3-request-presigner', () => ({
getSignedUrl: jest.fn(),
}));
describe('S3Driver.readFile', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should retry once on ExpiredToken and succeed', async () => {
const expiredError = new Error('The provided token has expired.');
Object.defineProperty(expiredError, 'name', { value: 'ExpiredToken' });
const readableBody = Readable.from(Buffer.from('file-content'));
mockSend
.mockRejectedValueOnce(expiredError)
.mockResolvedValueOnce({ Body: readableBody });
const driver = new S3Driver({
bucketName: 'test-bucket',
region: 'us-east-1',
});
const result = await driver.readFile({ filePath: 'some/file.js' });
expect(result).toBeInstanceOf(Readable);
expect(mockSend).toHaveBeenCalledTimes(2);
});
it('should throw NoSuchKey as FileStorageException without retrying', async () => {
const noSuchKeyError = new Error('Not found');
Object.defineProperty(noSuchKeyError, 'name', { value: 'NoSuchKey' });
mockSend.mockRejectedValueOnce(noSuchKeyError);
const driver = new S3Driver({
bucketName: 'test-bucket',
region: 'us-east-1',
});
await expect(
driver.readFile({ filePath: 'missing/file.js' }),
).rejects.toMatchObject({
code: FileStorageExceptionCode.FILE_NOT_FOUND,
});
expect(mockSend).toHaveBeenCalledTimes(1);
});
it('should throw non-retryable errors immediately', async () => {
const accessDeniedError = new Error('Access denied');
Object.defineProperty(accessDeniedError, 'name', {
value: 'AccessDenied',
});
mockSend.mockRejectedValueOnce(accessDeniedError);
const driver = new S3Driver({
bucketName: 'test-bucket',
region: 'us-east-1',
});
await expect(
driver.readFile({ filePath: 'some/file.js' }),
).rejects.toThrow('Access denied');
expect(mockSend).toHaveBeenCalledTimes(1);
});
});
describe('S3Driver.getPresignedUrl', () => {
afterEach(() => {
jest.clearAllMocks();
@@ -37,14 +37,6 @@ export interface S3DriverOptions extends S3ClientConfig {
presignEndpoint?: string;
}
// AWS error names that indicate expired/invalid temporary credentials
// and are recoverable by retrying (which triggers credential refresh)
const RETRYABLE_CREDENTIAL_ERROR_NAMES = [
'ExpiredToken',
'ExpiredTokenException',
'RequestExpired',
];
export class S3Driver implements StorageDriver {
private s3Client: S3;
private presignClient: S3 | undefined;
@@ -79,10 +71,6 @@ export class S3Driver implements StorageDriver {
return this.s3Client;
}
private isRetryableCredentialError(error: { name?: string }): boolean {
return RETRYABLE_CREDENTIAL_ERROR_NAMES.includes(error.name ?? '');
}
async readFile(params: { filePath: string }): Promise<Readable> {
const command = new GetObjectCommand({
Key: params.filePath,
@@ -105,25 +93,6 @@ export class S3Driver implements StorageDriver {
);
}
if (this.isRetryableCredentialError(error)) {
this.logger.warn(
`S3 credential error (${error.name}), retrying once to trigger credential refresh`,
);
const retryCommand = new GetObjectCommand({
Key: params.filePath,
Bucket: this.bucketName,
});
const file = await this.s3Client.send(retryCommand);
if (!file || !file.Body || !(file.Body instanceof Readable)) {
throw new Error('Unable to get file stream');
}
return Readable.from(file.Body);
}
throw error;
}
}
@@ -29,6 +29,8 @@ export type ChipProps = {
disabled?: boolean;
clickable?: boolean;
label: string;
tooltipLabel?: string;
alwaysShowTooltip?: boolean;
isLabelHidden?: boolean;
isBold?: boolean;
maxWidth?: number;
@@ -172,6 +174,8 @@ const renderRightComponent = (
export const Chip = ({
size = ChipSize.Small,
label,
tooltipLabel,
alwaysShowTooltip = false,
isLabelHidden = false,
isBold = false,
disabled = false,
@@ -200,7 +204,12 @@ export const Chip = ({
>
{leftComponent}
{!isLabelHidden && isDefined(label) && isNonEmptyString(label) ? (
<OverflowingTextWithTooltip size={size} text={label} />
<OverflowingTextWithTooltip
size={size}
text={label}
tooltipContent={tooltipLabel}
alwaysShowTooltip={alwaysShowTooltip}
/>
) : !forceEmptyText && !isLabelHidden ? (
<StyledDiv>{emptyLabel}</StyledDiv>
) : (
@@ -3,8 +3,8 @@ import { type ReactNode, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from '@ui/theme-constants';
import { isDefined } from 'twenty-shared/utils';
import { AppTooltip, TooltipDelay } from './AppTooltip';
const spacing4 = themeCssVariables.spacing[4];
@@ -64,6 +64,7 @@ type OverflowingTextWithTooltipProps = {
isTooltipMultiline?: boolean;
displayedMaxRows?: number;
tooltipDelay?: TooltipDelay;
alwaysShowTooltip?: boolean;
} & (
| {
text: string | null | undefined;
@@ -82,6 +83,7 @@ export const OverflowingTextWithTooltip = ({
displayedMaxRows,
tooltipContent,
tooltipDelay = TooltipDelay.mediumDelay,
alwaysShowTooltip = false,
}: OverflowingTextWithTooltipProps) => {
const textElementId = `title-id-${+new Date()}`;
@@ -146,7 +148,7 @@ export const OverflowingTextWithTooltip = ({
)}
{shouldRenderTooltip &&
isTitleOverflowing &&
(isTitleOverflowing || alwaysShowTooltip) &&
isDefined(tooltipText) &&
createPortal(
<div onClick={handleTooltipClick}>