snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 3s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { SettingsColorSample } from 'src/front-components/components/SettingsColorSample';
import { StyledSettingsTextInput } from 'src/front-components/components/StyledSettingsTextInput';
const StyledAdornmentContainer = styled.div`
align-items: center;
background-color: ${() => themeCssVariables.background.transparent.light};
border: 1px solid ${() => themeCssVariables.border.color.medium};
border-bottom-left-radius: ${() => themeCssVariables.border.radius.md};
border-right: none;
border-top-left-radius: ${() => themeCssVariables.border.radius.md};
box-sizing: border-box;
display: flex;
height: 32px;
padding: ${() => themeCssVariables.spacing[2]};
`;
const StyledAdornedInput = styled(StyledSettingsTextInput)`
border-bottom-left-radius: 0;
border-left: none;
border-top-left-radius: 0;
`;
const StyledContainer = styled.div`
align-items: center;
display: flex;
width: 100%;
&:focus-within ${StyledAdornmentContainer} {
border-color: ${() => themeCssVariables.color.blue};
}
`;
type AdornedHexInputProps = {
id: string;
value: string;
swatchColor: string;
onChange: (value: string) => void;
onBlur?: () => void;
};
export const AdornedHexInput = ({
id,
value,
swatchColor,
onChange,
onBlur,
}: AdornedHexInputProps) => (
<StyledContainer>
<StyledAdornmentContainer>
<SettingsColorSample colorName="gray" color={swatchColor} />
</StyledAdornmentContainer>
<StyledAdornedInput
id={id}
type="text"
autoComplete="off"
placeholder="#1d1d1d"
value={value}
onChange={(event) => onChange(event.target.value)}
onBlur={onBlur}
/>
</StyledContainer>
);

View File

@@ -0,0 +1,80 @@
import 'twenty-ui/style.css';
import styled from '@emotion/styled';
import { useState } from 'react';
import {
useColorScheme,
useFrontComponentId,
} from 'twenty-sdk/front-component';
import { THEME_DARK, THEME_LIGHT } from 'twenty-ui/theme';
import {
ThemeContext,
themeCssVariables,
type ThemeType,
} from 'twenty-ui/theme-constants';
import { InCallSection } from 'src/front-components/components/InCallSection';
import { RecorderSection } from 'src/front-components/components/RecorderSection';
import { SchedulingSection } from 'src/front-components/components/SchedulingSection';
import { TranscriptionSection } from 'src/front-components/components/TranscriptionSection';
import { CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ROW } from 'src/front-components/constants/call-recorder-settings-layout.constant';
import { getApplicationVariableValue } from 'src/front-components/utils/get-application-variable-value.util';
const StyledContainer = styled.div`
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: ${() => themeCssVariables.spacing[8]};
width: 100%;
`;
const StyledPausedNotice = styled.div`
color: ${() => themeCssVariables.font.color.tertiary};
font-size: ${() => themeCssVariables.font.size.md};
line-height: ${() => themeCssVariables.text.lineHeight.md};
`;
export const CallRecorderSettings = () => {
const colorScheme = useColorScheme();
const frontComponentId = useFrontComponentId();
const [isSchedulingEnabled, setIsSchedulingEnabled] = useState(
() =>
getApplicationVariableValue(
CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ROW.variableKey,
) !== 'false',
);
// twenty-ui components read icon sizes off ThemeContext, and the context
// default resolves them to var() strings an SVG size attribute cannot use —
// menu check marks render at their intrinsic size without a real theme here.
return (
<ThemeContext.Provider
value={{
theme: (colorScheme === 'dark'
? THEME_DARK
: THEME_LIGHT) as unknown as ThemeType,
colorScheme,
}}
>
<StyledContainer>
<SchedulingSection
frontComponentId={frontComponentId}
isEnabled={isSchedulingEnabled}
onEnabledChange={setIsSchedulingEnabled}
/>
{isSchedulingEnabled ? (
<>
<RecorderSection frontComponentId={frontComponentId} />
<InCallSection frontComponentId={frontComponentId} />
<TranscriptionSection frontComponentId={frontComponentId} />
</>
) : (
<StyledPausedNotice>
Recording is paused. Turn it back on to schedule recorders and edit
how they behave. Your settings are kept.
</StyledPausedNotice>
)}
</StyledContainer>
</ThemeContext.Provider>
);
};

View File

@@ -0,0 +1,43 @@
import styled from '@emotion/styled';
import { type ReactNode } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledExternalContainer = styled.div`
align-items: flex-start;
box-sizing: border-box;
display: flex;
flex-direction: column;
height: fit-content;
min-height: 0;
padding: ${() => themeCssVariables.spacing[1]};
width: 100%;
`;
const StyledScrollableContainer = styled.div`
box-sizing: border-box;
display: flex;
min-height: 0;
overflow-y: auto;
scrollbar-color: ${() => themeCssVariables.border.color.medium} transparent;
scrollbar-width: thin;
width: 100%;
`;
const StyledInternalContainer = styled.div`
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
`;
export const DropdownMenuItemsContainer = ({
children,
}: {
children: ReactNode;
}) => (
<StyledExternalContainer>
<StyledScrollableContainer>
<StyledInternalContainer>{children}</StyledInternalContainer>
</StyledScrollableContainer>
</StyledExternalContainer>
);

View File

@@ -0,0 +1,52 @@
import styled from '@emotion/styled';
import { type ReactNode } from 'react';
import {
MenuItemLeftContent,
StyledMenuItemIconCheck,
StyledMenuItemSelect,
} from 'twenty-ui/navigation';
import { useTheme } from 'twenty-ui/theme-constants';
const StyledOptionContainer = styled.div`
cursor: pointer;
width: 100%;
`;
type DropdownMenuOptionProps = {
id: string;
text: string;
selected: boolean;
isActive: boolean;
LeftComponent?: ReactNode;
onSelect: () => void;
};
export const DropdownMenuOption = ({
id,
text,
selected,
isActive,
LeftComponent,
onSelect,
}: DropdownMenuOptionProps) => {
const theme = useTheme();
return (
<StyledOptionContainer
id={id}
role="option"
tabIndex={-1}
aria-selected={selected}
onClick={onSelect}
>
<StyledMenuItemSelect focused={isActive}>
<MenuItemLeftContent
LeftComponent={LeftComponent}
LeftIcon={null}
text={text}
/>
{selected && <StyledMenuItemIconCheck size={theme.icon.size.md} />}
</StyledMenuItemSelect>
</StyledOptionContainer>
);
};

View File

@@ -0,0 +1,10 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledDropdownMenuSeparator = styled.div`
background-color: ${() => themeCssVariables.border.color.light};
min-height: 1px;
width: 100%;
`;
export const DropdownMenuSeparator = () => <StyledDropdownMenuSeparator />;

View File

@@ -0,0 +1,113 @@
import { useId, useState } from 'react';
import { Section } from 'twenty-ui/layout';
import { H2Title } from 'twenty-ui/typography';
import { LabelledSettingsField } from 'src/front-components/components/LabelledSettingsField';
import { SettingsOptionCardContentToggle } from 'src/front-components/components/SettingsOptionCardContentToggle';
import { StyledDimmable } from 'src/front-components/components/StyledDimmable';
import { StyledSettingsCard } from 'src/front-components/components/StyledSettingsCard';
import { StyledSettingsSectionStack } from 'src/front-components/components/StyledSettingsSectionStack';
import { StyledSettingsTextArea } from 'src/front-components/components/StyledSettingsTextArea';
import { TimingCounterRow } from 'src/front-components/components/TimingCounterRow';
import {
CALL_RECORDER_RECORDING_NOTICE_MESSAGE_FIELD,
CALL_RECORDER_RECORDING_NOTICE_ROW,
CALL_RECORDER_TIMING_ROWS,
} from 'src/front-components/constants/call-recorder-settings-layout.constant';
import { useAutosaveApplicationVariable } from 'src/front-components/hooks/use-autosave-application-variable';
import { getApplicationVariableValue } from 'src/front-components/utils/get-application-variable-value.util';
import { truncateRecordingNoticeMessage } from 'src/logic-functions/utils/truncate-recording-notice-message.util';
type InCallSectionProps = {
frontComponentId: string;
};
export const InCallSection = ({ frontComponentId }: InCallSectionProps) => {
const noticeMessageInputId = useId();
const [isNoticeEnabled, setIsNoticeEnabled] = useState(
() =>
getApplicationVariableValue(
CALL_RECORDER_RECORDING_NOTICE_ROW.variableKey,
) === 'true',
);
const [noticeMessageValue, setNoticeMessageValue] = useState(() =>
getApplicationVariableValue(
CALL_RECORDER_RECORDING_NOTICE_MESSAGE_FIELD.variableKey,
),
);
const { saveImmediately: saveNoticeEnabledImmediately } =
useAutosaveApplicationVariable({
frontComponentId,
variableKey: CALL_RECORDER_RECORDING_NOTICE_ROW.variableKey,
});
const { saveDebounced: saveNoticeMessageDebounced } =
useAutosaveApplicationVariable({
frontComponentId,
variableKey: CALL_RECORDER_RECORDING_NOTICE_MESSAGE_FIELD.variableKey,
});
const handleNoticeEnabledChange = (checked: boolean) => {
const value = checked ? 'true' : 'false';
setIsNoticeEnabled(checked);
saveNoticeEnabledImmediately(value);
};
const handleNoticeMessageChange = (value: string) => {
const truncatedValue = truncateRecordingNoticeMessage(value);
setNoticeMessageValue(truncatedValue);
saveNoticeMessageDebounced(truncatedValue);
};
return (
<Section>
<H2Title
title="In the call"
description="What the recorder does while it is in your meetings."
/>
<StyledSettingsSectionStack>
<StyledSettingsCard>
{CALL_RECORDER_TIMING_ROWS.map((row) => (
<TimingCounterRow
key={row.variableKey}
frontComponentId={frontComponentId}
variableKey={row.variableKey}
title={row.title}
description={row.description}
Icon={row.Icon}
divider
persistedValue={getApplicationVariableValue(row.variableKey)}
/>
))}
<SettingsOptionCardContentToggle
Icon={CALL_RECORDER_RECORDING_NOTICE_ROW.Icon}
title={CALL_RECORDER_RECORDING_NOTICE_ROW.title}
description={CALL_RECORDER_RECORDING_NOTICE_ROW.description}
checked={isNoticeEnabled}
onChange={handleNoticeEnabledChange}
/>
</StyledSettingsCard>
<StyledDimmable $dimmed={!isNoticeEnabled}>
<LabelledSettingsField
label={CALL_RECORDER_RECORDING_NOTICE_MESSAGE_FIELD.label}
inputId={noticeMessageInputId}
hint={CALL_RECORDER_RECORDING_NOTICE_MESSAGE_FIELD.hint}
>
<StyledSettingsTextArea
id={noticeMessageInputId}
placeholder="Value"
disabled={!isNoticeEnabled}
value={noticeMessageValue}
onChange={(event) =>
handleNoticeMessageChange(event.target.value)
}
onBlur={() => saveNoticeMessageDebounced.flush()}
/>
</LabelledSettingsField>
</StyledDimmable>
</StyledSettingsSectionStack>
</Section>
);
};

View File

@@ -0,0 +1,52 @@
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { type ReactNode } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { StyledSettingsError } from 'src/front-components/components/StyledSettingsError';
const StyledField = styled.div`
display: flex;
flex-direction: column;
`;
// twenty-front hard-codes this size in FieldLabel.module.scss; there is no token.
const StyledLabel = styled.label`
color: ${() => themeCssVariables.font.color.primary};
font-family: ${() => themeCssVariables.font.family};
font-size: 11px;
font-weight: ${() => themeCssVariables.font.weight.semiBold};
margin-bottom: ${() => themeCssVariables.spacing[1]};
`;
const StyledHint = styled.span`
color: ${() => themeCssVariables.font.color.secondary};
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.sm};
margin-top: ${() => themeCssVariables.spacing[3]};
`;
type LabelledSettingsFieldProps = {
label: string;
inputId: string;
hint?: string;
errorMessage?: string;
children: ReactNode;
};
export const LabelledSettingsField = ({
label,
inputId,
hint,
errorMessage,
children,
}: LabelledSettingsFieldProps) => (
<StyledField>
<StyledLabel htmlFor={inputId}>{label}</StyledLabel>
{children}
{isNonEmptyString(errorMessage) && (
<StyledSettingsError>{errorMessage}</StyledSettingsError>
)}
{isNonEmptyString(hint) && <StyledHint>{hint}</StyledHint>}
</StyledField>
);

View File

@@ -0,0 +1,183 @@
import { isUndefined } from '@sniptt/guards';
import { useId, useState } from 'react';
import { Section } from 'twenty-ui/layout';
import { type ThemeColor } from 'twenty-ui/theme';
import { H2Title } from 'twenty-ui/typography';
import { AdornedHexInput } from 'src/front-components/components/AdornedHexInput';
import { LabelledSettingsField } from 'src/front-components/components/LabelledSettingsField';
import { SettingsOptionCardContentSelect } from 'src/front-components/components/SettingsOptionCardContentSelect';
import { SettingsOptionCardContentToggle } from 'src/front-components/components/SettingsOptionCardContentToggle';
import { StyledDimmable } from 'src/front-components/components/StyledDimmable';
import { StyledSettingsCard } from 'src/front-components/components/StyledSettingsCard';
import { StyledSettingsSectionStack } from 'src/front-components/components/StyledSettingsSectionStack';
import { StyledSettingsTextInput } from 'src/front-components/components/StyledSettingsTextInput';
import { TileBackgroundControl } from 'src/front-components/components/TileBackgroundControl';
import {
CALL_RECORDER_NAME_FIELD,
CALL_RECORDER_TILE_BACKGROUND_ROW,
CALL_RECORDER_USE_WORKSPACE_LOGO_ROW,
} from 'src/front-components/constants/call-recorder-settings-layout.constant';
import { THEME_COLOR_HEX } from 'src/front-components/constants/theme-color-hex.constant';
import { useAutosaveApplicationVariable } from 'src/front-components/hooks/use-autosave-application-variable';
import { getApplicationVariableValue } from 'src/front-components/utils/get-application-variable-value.util';
import { getNormalizedHexValue } from 'src/front-components/utils/get-normalized-hex-value.util';
import { getThemeColorFromHex } from 'src/front-components/utils/get-theme-color-from-hex.util';
import { normalizeHexColor } from 'src/front-components/utils/normalize-hex-color.util';
import { DEFAULT_CALL_RECORDER_BOT_IMAGE_BACKGROUND } from 'src/logic-functions/constants/default-call-recorder-bot-image-background';
type RecorderSectionProps = {
frontComponentId: string;
};
export const RecorderSection = ({ frontComponentId }: RecorderSectionProps) => {
const nameInputId = useId();
const hexInputId = useId();
const [nameValue, setNameValue] = useState(() =>
getApplicationVariableValue(CALL_RECORDER_NAME_FIELD.variableKey),
);
const [isWorkspaceLogoEnabled, setIsWorkspaceLogoEnabled] = useState(
() =>
getApplicationVariableValue(
CALL_RECORDER_USE_WORKSPACE_LOGO_ROW.variableKey,
) === 'true',
);
const [tileBackgroundValue, setTileBackgroundValue] = useState(() =>
getApplicationVariableValue(CALL_RECORDER_TILE_BACKGROUND_ROW.variableKey),
);
const [isCustomHexOverride, setIsCustomHexOverride] = useState<
boolean | undefined
>(undefined);
const { saveDebounced: saveNameDebounced } = useAutosaveApplicationVariable({
frontComponentId,
variableKey: CALL_RECORDER_NAME_FIELD.variableKey,
});
const { saveImmediately: saveWorkspaceLogoImmediately } =
useAutosaveApplicationVariable({
frontComponentId,
variableKey: CALL_RECORDER_USE_WORKSPACE_LOGO_ROW.variableKey,
});
const {
saveDebounced: saveTileBackgroundDebounced,
saveImmediately: saveTileBackgroundImmediately,
} = useAutosaveApplicationVariable({
frontComponentId,
variableKey: CALL_RECORDER_TILE_BACKGROUND_ROW.variableKey,
});
const selectedTileColor = getThemeColorFromHex(tileBackgroundValue);
const isCustomHexSelected =
isCustomHexOverride ?? isUndefined(selectedTileColor);
const swatchColor =
normalizeHexColor(tileBackgroundValue) ??
DEFAULT_CALL_RECORDER_BOT_IMAGE_BACKGROUND;
const handleNameChange = (value: string) => {
setNameValue(value);
saveNameDebounced(value);
};
const handleWorkspaceLogoChange = (checked: boolean) => {
const value = checked ? 'true' : 'false';
setIsWorkspaceLogoEnabled(checked);
saveWorkspaceLogoImmediately(value);
};
const handleSelectTileColor = (color: ThemeColor) => {
const value = THEME_COLOR_HEX[color];
setIsCustomHexOverride(false);
setTileBackgroundValue(value);
saveTileBackgroundImmediately(value);
};
const handleHexChange = (value: string) => {
setTileBackgroundValue(value);
const valueToSave = getNormalizedHexValue(value);
if (isUndefined(valueToSave)) {
saveTileBackgroundDebounced.cancel();
return;
}
saveTileBackgroundDebounced(valueToSave);
};
return (
<Section>
<H2Title
title="Recorder"
description="How the recorder appears when it joins your meetings."
/>
<StyledSettingsSectionStack>
<LabelledSettingsField
label={CALL_RECORDER_NAME_FIELD.label}
inputId={nameInputId}
hint={CALL_RECORDER_NAME_FIELD.hint}
>
<StyledSettingsTextInput
id={nameInputId}
type="text"
autoComplete="off"
placeholder="Value"
value={nameValue}
onChange={(event) => handleNameChange(event.target.value)}
onBlur={() => saveNameDebounced.flush()}
/>
</LabelledSettingsField>
<StyledSettingsCard>
<SettingsOptionCardContentToggle
Icon={CALL_RECORDER_USE_WORKSPACE_LOGO_ROW.Icon}
title={CALL_RECORDER_USE_WORKSPACE_LOGO_ROW.title}
description={CALL_RECORDER_USE_WORKSPACE_LOGO_ROW.description}
divider
checked={isWorkspaceLogoEnabled}
onChange={handleWorkspaceLogoChange}
/>
<SettingsOptionCardContentSelect
Icon={CALL_RECORDER_TILE_BACKGROUND_ROW.Icon}
title={CALL_RECORDER_TILE_BACKGROUND_ROW.title}
description={CALL_RECORDER_TILE_BACKGROUND_ROW.description}
isDimmed={!isWorkspaceLogoEnabled}
>
<TileBackgroundControl
swatchColor={swatchColor}
selectedColor={
isCustomHexSelected ? undefined : selectedTileColor
}
isCustomSelected={isCustomHexSelected}
disabled={!isWorkspaceLogoEnabled}
onSelectColor={handleSelectTileColor}
onSelectCustom={() => setIsCustomHexOverride(true)}
/>
</SettingsOptionCardContentSelect>
</StyledSettingsCard>
{isCustomHexSelected && (
<StyledDimmable $dimmed={!isWorkspaceLogoEnabled}>
<LabelledSettingsField
label="Custom hex"
inputId={hexInputId}
hint="Six-digit hex, for example #1d1d1d."
errorMessage={
isUndefined(getNormalizedHexValue(tileBackgroundValue))
? 'Invalid hex colour'
: undefined
}
>
<AdornedHexInput
id={hexInputId}
value={tileBackgroundValue}
swatchColor={swatchColor}
onChange={handleHexChange}
onBlur={() => saveTileBackgroundDebounced.flush()}
/>
</LabelledSettingsField>
</StyledDimmable>
)}
</StyledSettingsSectionStack>
</Section>
);
};

View File

@@ -0,0 +1,45 @@
import { Section } from 'twenty-ui/layout';
import { SettingsOptionCardContentToggle } from 'src/front-components/components/SettingsOptionCardContentToggle';
import { StyledSettingsCard } from 'src/front-components/components/StyledSettingsCard';
import { CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ROW } from 'src/front-components/constants/call-recorder-settings-layout.constant';
import { useAutosaveApplicationVariable } from 'src/front-components/hooks/use-autosave-application-variable';
import { requestCalendarBotSchedulingSync } from 'src/front-components/utils/request-calendar-bot-scheduling-sync.util';
type SchedulingSectionProps = {
frontComponentId: string;
isEnabled: boolean;
onEnabledChange: (isEnabled: boolean) => void;
};
export const SchedulingSection = ({
frontComponentId,
isEnabled,
onEnabledChange,
}: SchedulingSectionProps) => {
const { saveImmediately } = useAutosaveApplicationVariable({
frontComponentId,
variableKey: CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ROW.variableKey,
onSaveSuccess: () => requestCalendarBotSchedulingSync(),
onSaveError: (value) => onEnabledChange(value !== 'true'),
});
const handleChange = (checked: boolean) => {
onEnabledChange(checked);
saveImmediately(checked ? 'true' : 'false');
};
return (
<Section>
<StyledSettingsCard>
<SettingsOptionCardContentToggle
Icon={CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ROW.Icon}
title={CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ROW.title}
description={CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ROW.description}
checked={isEnabled}
onChange={handleChange}
/>
</StyledSettingsCard>
</Section>
);
};

View File

@@ -0,0 +1,14 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledHr = styled.hr`
border: none;
border-top: 1px solid ${() => themeCssVariables.border.color.light};
margin: 0px;
margin-left: ${() => themeCssVariables.spacing[4]};
margin-right: ${() => themeCssVariables.spacing[4]};
`;
export const Separator = () => {
return <StyledHr />;
};

View File

@@ -0,0 +1,42 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export const StyledSettingsCardContent = styled.div<{ $isDimmed?: boolean }>`
align-items: center;
background-color: ${() => themeCssVariables.background.secondary};
display: flex;
gap: ${() => themeCssVariables.spacing[3]};
opacity: ${({ $isDimmed }) => ($isDimmed ? 0.72 : 1)};
padding: ${() => themeCssVariables.spacing[4]};
`;
export const StyledSettingsCardIcon = styled.div`
align-items: center;
background-color: ${() => themeCssVariables.background.primary};
border: 2px solid ${() => themeCssVariables.border.color.light};
border-radius: ${() => themeCssVariables.border.radius.sm};
box-sizing: border-box;
display: flex;
height: ${() => themeCssVariables.spacing[8]};
justify-content: center;
min-width: ${() => themeCssVariables.spacing[8]};
width: ${() => themeCssVariables.spacing[8]};
`;
export const StyledSettingsCardTitle = styled.div`
color: ${() => themeCssVariables.font.color.primary};
font-weight: ${() => themeCssVariables.font.weight.medium};
margin-bottom: ${() => themeCssVariables.spacing[1]};
`;
export const StyledSettingsCardTextContainer = styled.div`
flex: 1;
min-width: 0;
`;
export const StyledSettingsCardDescription = styled.div`
color: ${() => themeCssVariables.font.color.secondary};
font-size: ${() => themeCssVariables.font.size.sm};
line-height: ${() => themeCssVariables.text.lineHeight.lg};
overflow: hidden;
`;

View File

@@ -0,0 +1,21 @@
import styled from '@emotion/styled';
import { ColorSample, type ColorSampleProps } from 'twenty-ui/data-display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
type SettingsColorSampleProps = Omit<ColorSampleProps, 'variant'>;
// The front-component style bridge does not preserve ColorSample's pill radius.
const StyledContainer = styled.span`
display: flex;
> div {
border-radius: ${() => themeCssVariables.border.radius.pill};
corner-shape: round;
}
`;
export const SettingsColorSample = (props: SettingsColorSampleProps) => (
<StyledContainer>
<ColorSample {...props} />
</StyledContainer>
);

View File

@@ -0,0 +1,147 @@
import styled from '@emotion/styled';
import { isUndefined } from '@sniptt/guards';
import { IconMinus, IconPlus } from 'twenty-ui/icon';
import { IconButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { StyledSettingsTextInput } from 'src/front-components/components/StyledSettingsTextInput';
const StyledCounterContainer = styled.div<{ $showButtons: boolean }>`
align-items: center;
display: flex;
gap: ${() => themeCssVariables.spacing[1]};
margin-left: auto;
width: ${({ $showButtons }) =>
$showButtons
? themeCssVariables.spacing[30]
: themeCssVariables.spacing[16]};
button svg {
pointer-events: none;
}
`;
const StyledTextInputContainer = styled.div`
width: ${() => themeCssVariables.spacing[16]};
input {
font-weight: ${() => themeCssVariables.font.weight.medium};
height: ${() => themeCssVariables.spacing[6]};
text-align: center;
width: ${() => themeCssVariables.spacing[16]};
}
`;
type SettingsCounterProps = {
value: string;
onChange: (value: string, changeType: 'input' | 'button') => void;
onBlur?: () => void;
minValue?: number;
maxValue?: number;
disabled?: boolean;
showButtons?: boolean;
inputId?: string;
errorMessageId?: string;
isInvalid?: boolean;
};
// twenty-front's SettingsCounter works on numbers and snaps invalid text back
// to minValue. The app keeps its own draft-string validation instead, so a
// half-typed value stays on screen with the Invalid number line beneath it.
export const SettingsCounter = ({
value,
onChange,
onBlur,
minValue = 0,
maxValue,
disabled = false,
showButtons = true,
inputId,
errorMessageId,
isInvalid = false,
}: SettingsCounterProps) => {
const parsedValue = Number(value.trim());
const isParsable = value.trim() !== '' && Number.isFinite(parsedValue);
const handleDecrementCounter = () => {
if (!isParsable) {
onChange(String(minValue), 'button');
return;
}
const nextValue = Math.max(parsedValue - 1, minValue);
if (nextValue !== parsedValue) {
onChange(String(nextValue), 'button');
}
};
const handleIncrementCounter = () => {
if (!isParsable) {
onChange(String(minValue), 'button');
return;
}
const nextValue = isUndefined(maxValue)
? parsedValue + 1
: Math.min(parsedValue + 1, maxValue);
if (nextValue !== parsedValue) {
onChange(String(nextValue), 'button');
}
};
const handleTextInputChange = (nextValue: string) => {
const nextParsedValue = Number(nextValue.trim());
if (
nextValue.trim() !== '' &&
Number.isFinite(nextParsedValue) &&
(nextParsedValue < minValue ||
(!isUndefined(maxValue) && nextParsedValue > maxValue))
) {
return;
}
onChange(nextValue, 'input');
};
return (
<StyledCounterContainer $showButtons={showButtons}>
{showButtons && (
<IconButton
size="small"
Icon={IconMinus}
variant="secondary"
onClick={handleDecrementCounter}
disabled={disabled}
ariaLabel="Decrease"
/>
)}
<StyledTextInputContainer>
<StyledSettingsTextInput
id={inputId}
type="text"
inputMode="decimal"
autoComplete="off"
value={value}
disabled={disabled}
aria-describedby={errorMessageId}
aria-invalid={isInvalid}
onChange={(event) => handleTextInputChange(event.target.value)}
onBlur={onBlur}
/>
</StyledTextInputContainer>
{showButtons && (
<IconButton
size="small"
Icon={IconPlus}
variant="secondary"
onClick={handleIncrementCounter}
disabled={disabled}
ariaLabel="Increase"
/>
)}
</StyledCounterContainer>
);
};

View File

@@ -0,0 +1,87 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type IconComponent } from 'twenty-ui/icon';
import { OverflowingTextWithTooltip } from 'twenty-ui/surfaces';
import { Separator } from 'src/front-components/components/Separator';
import {
StyledSettingsCardContent,
StyledSettingsCardDescription,
StyledSettingsCardIcon,
StyledSettingsCardTextContainer,
StyledSettingsCardTitle,
} from 'src/front-components/components/SettingsCardContentBase';
import { SettingsCounter } from 'src/front-components/components/SettingsCounter';
import { SettingsOptionIconCustomizer } from 'src/front-components/components/SettingsOptionIconCustomizer';
import { StyledSettingsError } from 'src/front-components/components/StyledSettingsError';
type SettingsOptionCardContentCounterProps = {
Icon?: IconComponent;
title: React.ReactNode;
description?: string;
divider?: boolean;
disabled?: boolean;
errorMessage?: string;
inputId?: string;
value: string;
onChange: (value: string, changeType: 'input' | 'button') => void;
onBlur?: () => void;
minValue?: number;
maxValue?: number;
};
export const SettingsOptionCardContentCounter = ({
Icon,
title,
description,
divider,
disabled = false,
errorMessage,
inputId,
value,
onChange,
onBlur,
minValue,
maxValue,
}: SettingsOptionCardContentCounterProps) => {
const hasError = isNonEmptyString(errorMessage);
const errorMessageId = hasError && inputId ? `${inputId}-error` : undefined;
return (
<>
<StyledSettingsCardContent>
{Icon && (
<StyledSettingsCardIcon>
<SettingsOptionIconCustomizer Icon={Icon} />
</StyledSettingsCardIcon>
)}
<StyledSettingsCardTextContainer>
<StyledSettingsCardTitle>
<label htmlFor={inputId}>{title}</label>
</StyledSettingsCardTitle>
{description && (
<StyledSettingsCardDescription>
<OverflowingTextWithTooltip text={description} />
</StyledSettingsCardDescription>
)}
{hasError && (
<StyledSettingsError id={errorMessageId} role="alert">
{errorMessage}
</StyledSettingsError>
)}
</StyledSettingsCardTextContainer>
<SettingsCounter
inputId={inputId}
value={value}
onChange={onChange}
onBlur={onBlur}
minValue={minValue}
maxValue={maxValue}
disabled={disabled}
errorMessageId={errorMessageId}
isInvalid={hasError}
/>
</StyledSettingsCardContent>
{divider && <Separator />}
</>
);
};

View File

@@ -0,0 +1,59 @@
import styled from '@emotion/styled';
import { type IconComponent } from 'twenty-ui/icon';
import { OverflowingTextWithTooltip } from 'twenty-ui/surfaces';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { Separator } from 'src/front-components/components/Separator';
import {
StyledSettingsCardContent,
StyledSettingsCardDescription,
StyledSettingsCardIcon,
StyledSettingsCardTextContainer,
StyledSettingsCardTitle,
} from 'src/front-components/components/SettingsCardContentBase';
import { SettingsOptionIconCustomizer } from 'src/front-components/components/SettingsOptionIconCustomizer';
const StyledSelectContainer = styled.div`
flex-shrink: 0;
justify-content: flex-end;
margin-left: auto;
max-width: ${() => themeCssVariables.spacing[30]};
`;
type SettingsOptionCardContentSelectProps = {
Icon?: IconComponent;
title: React.ReactNode;
description?: string;
divider?: boolean;
isDimmed?: boolean;
children?: React.ReactNode;
};
export const SettingsOptionCardContentSelect = ({
Icon,
title,
description,
divider,
isDimmed = false,
children,
}: SettingsOptionCardContentSelectProps) => (
<>
<StyledSettingsCardContent $isDimmed={isDimmed}>
{Icon && (
<StyledSettingsCardIcon>
<SettingsOptionIconCustomizer Icon={Icon} />
</StyledSettingsCardIcon>
)}
<StyledSettingsCardTextContainer>
<StyledSettingsCardTitle>{title}</StyledSettingsCardTitle>
{description && (
<StyledSettingsCardDescription>
<OverflowingTextWithTooltip text={description} />
</StyledSettingsCardDescription>
)}
</StyledSettingsCardTextContainer>
<StyledSelectContainer>{children}</StyledSelectContainer>
</StyledSettingsCardContent>
{divider && <Separator />}
</>
);

View File

@@ -0,0 +1,100 @@
import styled from '@emotion/styled';
import { useId } from 'react';
import { type IconComponent } from 'twenty-ui/icon';
import { OverflowingTextWithTooltip } from 'twenty-ui/surfaces';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { Separator } from 'src/front-components/components/Separator';
import {
StyledSettingsCardDescription,
StyledSettingsCardIcon,
StyledSettingsCardTextContainer,
StyledSettingsCardTitle,
} from 'src/front-components/components/SettingsCardContentBase';
import { SettingsOptionIconCustomizer } from 'src/front-components/components/SettingsOptionIconCustomizer';
import { Toggle } from 'src/front-components/components/Toggle';
const StyledSettingsCardToggleContent = styled.div<{ $disabled?: boolean }>`
align-items: center;
background-color: ${() => themeCssVariables.background.secondary};
cursor: ${({ $disabled }) => ($disabled ? 'default' : 'pointer')};
display: flex;
gap: ${() => themeCssVariables.spacing[3]};
padding: ${() => themeCssVariables.spacing[4]};
pointer-events: ${({ $disabled }) => ($disabled ? 'none' : 'auto')};
position: relative;
&:hover {
background: ${() => themeCssVariables.background.transparent.lighter};
}
`;
const StyledSettingsCardToggleButtonContainer = styled.span`
align-items: center;
display: flex;
flex-shrink: 0;
margin-left: auto;
`;
const StyledSettingsCardToggleCover = styled.span`
cursor: pointer;
inset: 0;
position: absolute;
`;
type SettingsOptionCardContentToggleProps = {
Icon?: IconComponent;
title: React.ReactNode;
description?: string;
divider?: boolean;
disabled?: boolean;
checked: boolean;
onChange: (checked: boolean) => void;
};
export const SettingsOptionCardContentToggle = ({
Icon,
title,
description,
divider,
disabled = false,
checked,
onChange,
}: SettingsOptionCardContentToggleProps) => {
const toggleId = useId();
return (
<>
<StyledSettingsCardToggleContent $disabled={disabled}>
{Icon && (
<StyledSettingsCardIcon>
<SettingsOptionIconCustomizer Icon={Icon} />
</StyledSettingsCardIcon>
)}
<StyledSettingsCardTextContainer>
<StyledSettingsCardTitle>
<label htmlFor={toggleId}>
{title}
<StyledSettingsCardToggleCover />
</label>
</StyledSettingsCardTitle>
{description && (
<StyledSettingsCardDescription>
<OverflowingTextWithTooltip text={description} />
</StyledSettingsCardDescription>
)}
</StyledSettingsCardTextContainer>
<StyledSettingsCardToggleButtonContainer>
<Toggle
id={toggleId}
checked={checked}
disabled={disabled}
toggleSize="small"
onChange={onChange}
/>
</StyledSettingsCardToggleButtonContainer>
</StyledSettingsCardToggleContent>
{divider && <Separator />}
</>
);
};

View File

@@ -0,0 +1,33 @@
import styled from '@emotion/styled';
import { type IconComponent } from 'twenty-ui/icon';
import { ICON } from 'twenty-ui/theme';
import { themeCssVariables } from 'twenty-ui/theme-constants';
// twenty-front reads these off ThemeContext, but a front component has no
// ThemeProvider: the context default resolves every value to a var() string,
// which an SVG size attribute cannot use. Sizes come from the numeric ICON
// constants and the colour is inherited through currentColor instead.
const StyledIconCustomizer = styled.div<{ $zoom: number; $rotate: number }>`
align-items: center;
color: ${() => themeCssVariables.IllustrationIcon.color.gray};
display: inline-flex;
justify-content: center;
pointer-events: none;
transform: ${({ $zoom, $rotate }) => `scale(${$zoom}) rotate(${$rotate}deg)`};
`;
type SettingsOptionIconCustomizerProps = {
Icon: IconComponent;
zoom?: number;
rotate?: number;
};
export const SettingsOptionIconCustomizer = ({
Icon,
zoom = 1,
rotate = -4,
}: SettingsOptionIconCustomizerProps) => (
<StyledIconCustomizer $zoom={zoom} $rotate={rotate}>
<Icon size={ICON.size.lg} stroke={ICON.stroke.md} />
</StyledIconCustomizer>
);

View File

@@ -0,0 +1,144 @@
import styled from '@emotion/styled';
import { isUndefined } from '@sniptt/guards';
import { type KeyboardEvent, type ReactNode, useRef } from 'react';
import { IconChevronDown } from 'twenty-ui/icon';
import { ICON } from 'twenty-ui/theme';
import { OverflowingTextWithTooltip } from 'twenty-ui/surfaces';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { type SettingsSelectNavigationKey } from 'src/front-components/utils/get-next-active-option-index.util';
const StyledControlContainer = styled.button<{
$hasAdornment: boolean;
}>`
appearance: none;
align-items: center;
background-color: ${() => themeCssVariables.background.transparent.light};
border: 1px solid ${() => themeCssVariables.border.color.medium};
border-radius: ${() => themeCssVariables.border.radius.md};
box-sizing: border-box;
color: ${() => themeCssVariables.font.color.primary};
cursor: pointer;
display: grid;
font: inherit;
gap: ${() => themeCssVariables.spacing[1]};
grid-template-columns: ${({ $hasAdornment }) =>
$hasAdornment ? 'auto 1fr auto' : '1fr auto'};
height: ${() => themeCssVariables.spacing[6]};
max-width: 100%;
padding: 0 ${() => themeCssVariables.spacing[2]};
text-align: left;
&:disabled {
color: ${() => themeCssVariables.font.color.secondary};
cursor: not-allowed;
}
`;
const StyledChevronWrapper = styled.div`
color: ${() => themeCssVariables.font.color.tertiary};
display: flex;
pointer-events: none;
`;
type SettingsSelectControlProps = {
label: string;
ariaLabel: string;
listboxId: string;
activeDescendantId: string | undefined;
adornment?: ReactNode;
disabled?: boolean;
isExpanded: boolean;
onNavigate: (key: SettingsSelectNavigationKey) => void;
onSelectActive: () => void;
onEscape: () => void;
onClick: () => void;
};
export const SettingsSelectControl = ({
label,
ariaLabel,
listboxId,
activeDescendantId,
adornment,
disabled = false,
isExpanded,
onNavigate,
onSelectActive,
onEscape,
onClick,
}: SettingsSelectControlProps) => {
const shouldIgnoreNextClickRef = useRef(false);
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === 'Tab' && isExpanded) {
onEscape();
return;
}
if (event.key === 'Escape' && isExpanded) {
onEscape();
return;
}
if (!isExpanded && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) {
event.preventDefault();
onClick();
return;
}
if (
isExpanded &&
(event.key === 'ArrowDown' ||
event.key === 'ArrowUp' ||
event.key === 'Home' ||
event.key === 'End')
) {
event.preventDefault();
onNavigate(event.key);
return;
}
if (isExpanded && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault();
shouldIgnoreNextClickRef.current = true;
onSelectActive();
}
};
const handleClick = () => {
if (shouldIgnoreNextClickRef.current) {
shouldIgnoreNextClickRef.current = false;
return;
}
onClick();
};
return (
<StyledControlContainer
type="button"
role="combobox"
$hasAdornment={!isUndefined(adornment)}
disabled={disabled}
aria-label={`${ariaLabel}: ${label}`}
aria-expanded={isExpanded}
aria-haspopup="listbox"
aria-controls={listboxId}
aria-activedescendant={isExpanded ? activeDescendantId : undefined}
onClick={handleClick}
onKeyDown={handleKeyDown}
>
{adornment}
<OverflowingTextWithTooltip text={label} />
<StyledChevronWrapper>
<IconChevronDown size={ICON.size.md} stroke={ICON.stroke.sm} />
</StyledChevronWrapper>
</StyledControlContainer>
);
};

View File

@@ -0,0 +1,65 @@
import styled from '@emotion/styled';
import { type ReactNode } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
SETTINGS_SELECT_MENU_BACKDROP_Z_INDEX,
SETTINGS_SELECT_MENU_GAP_PIXELS,
SETTINGS_SELECT_MENU_MAX_HEIGHT_PIXELS,
SETTINGS_SELECT_MENU_WIDTH_PIXELS,
SETTINGS_SELECT_MENU_Z_INDEX,
} from 'src/front-components/constants/settings-select-menu.constant';
// The sandbox cannot portal into the host document, so the menu opens inline
// below its control, and the backdrop stands in for the outside click the
// host page never forwards to the front component.
const StyledBackdrop = styled.div`
inset: 0;
position: fixed;
z-index: ${SETTINGS_SELECT_MENU_BACKDROP_Z_INDEX};
`;
const StyledMenu = styled.div`
background-color: ${() => themeCssVariables.background.primary};
border: 1px solid ${() => themeCssVariables.border.color.medium};
border-radius: ${() => themeCssVariables.border.radius.md};
box-shadow: ${() => themeCssVariables.boxShadow.strong};
box-sizing: border-box;
display: flex;
flex-direction: column;
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.md};
max-height: ${SETTINGS_SELECT_MENU_MAX_HEIGHT_PIXELS}px;
overflow: hidden;
position: absolute;
right: 0;
top: calc(100% + ${SETTINGS_SELECT_MENU_GAP_PIXELS}px);
width: ${SETTINGS_SELECT_MENU_WIDTH_PIXELS}px;
z-index: ${SETTINGS_SELECT_MENU_Z_INDEX};
`;
type SettingsSelectMenuProps = {
isOpen: boolean;
onClose: () => void;
children: ReactNode;
};
export const SettingsSelectMenu = ({
isOpen,
onClose,
children,
}: SettingsSelectMenuProps) => (
<>
<StyledBackdrop
aria-hidden={!isOpen}
style={{ display: isOpen ? 'block' : 'none' }}
onClick={onClose}
/>
<StyledMenu
aria-hidden={!isOpen}
style={{ display: isOpen ? 'flex' : 'none' }}
>
{children}
</StyledMenu>
</>
);

View File

@@ -0,0 +1,5 @@
import styled from '@emotion/styled';
export const StyledDimmable = styled.div<{ $dimmed: boolean }>`
opacity: ${({ $dimmed }) => ($dimmed ? 0.72 : 1)};
`;

View File

@@ -0,0 +1,23 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
// Mirrors twenty-ui's Card without its overflow: hidden, which would clip the
// select menus that open inline below their rows. The rows paint their own
// background, so the first and last ones take over the card's corners.
export const StyledSettingsCard = styled.div`
border: 1px solid ${() => themeCssVariables.border.color.medium};
border-radius: ${() => themeCssVariables.border.radius.md};
box-sizing: border-box;
color: ${() => themeCssVariables.font.color.secondary};
width: 100%;
& > :first-child {
border-top-left-radius: inherit;
border-top-right-radius: inherit;
}
& > :last-child {
border-bottom-left-radius: inherit;
border-bottom-right-radius: inherit;
}
`;

View File

@@ -0,0 +1,9 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export const StyledSettingsError = styled.div`
color: ${() => themeCssVariables.font.color.danger};
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.xs};
margin-top: ${() => themeCssVariables.spacing[1]};
`;

View File

@@ -0,0 +1,8 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export const StyledSettingsSectionStack = styled.div`
display: flex;
flex-direction: column;
gap: ${() => themeCssVariables.spacing[4]};
`;

View File

@@ -0,0 +1,5 @@
import styled from '@emotion/styled';
export const StyledSettingsSelectAnchor = styled.div`
position: relative;
`;

View File

@@ -0,0 +1,11 @@
import styled from '@emotion/styled';
import { getSettingsControlStyles } from 'src/front-components/utils/get-settings-control-styles.util';
export const StyledSettingsTextArea = styled.textarea`
display: block;
line-height: 1.5;
min-height: 96px;
resize: vertical;
${getSettingsControlStyles}
`;

View File

@@ -0,0 +1,11 @@
import styled from '@emotion/styled';
import { getSettingsControlStyles } from 'src/front-components/utils/get-settings-control-styles.util';
export const StyledSettingsTextInput = styled.input`
display: flex;
flex-grow: 1;
height: 32px;
text-overflow: ellipsis;
${getSettingsControlStyles}
`;

View File

@@ -0,0 +1,60 @@
import styled from '@emotion/styled';
import { DEFAULT_COLOR_LABELS } from 'twenty-ui/navigation';
import { MAIN_COLOR_NAMES, type ThemeColor } from 'twenty-ui/theme';
import { DropdownMenuItemsContainer } from 'src/front-components/components/DropdownMenuItemsContainer';
import { DropdownMenuOption } from 'src/front-components/components/DropdownMenuOption';
import { DropdownMenuSeparator } from 'src/front-components/components/DropdownMenuSeparator';
import { SettingsColorSample } from 'src/front-components/components/SettingsColorSample';
const StyledListbox = styled.div`
min-height: 0;
overflow-y: auto;
width: 100%;
`;
type ThemeColorPickerMenuProps = {
listboxId: string;
selectedColor: ThemeColor | undefined;
isCustomSelected: boolean;
activeOption: ThemeColor | 'custom';
getOptionId: (option: ThemeColor | 'custom') => string;
onSelectColor: (color: ThemeColor) => void;
onSelectCustom: () => void;
};
export const ThemeColorPickerMenu = ({
listboxId,
selectedColor,
isCustomSelected,
activeOption,
getOptionId,
onSelectColor,
onSelectCustom,
}: ThemeColorPickerMenuProps) => (
<StyledListbox id={listboxId} role="listbox" aria-label="Tile background">
<DropdownMenuItemsContainer>
{MAIN_COLOR_NAMES.map((colorName) => (
<DropdownMenuOption
key={colorName}
id={getOptionId(colorName)}
onSelect={() => onSelectColor(colorName)}
text={DEFAULT_COLOR_LABELS[colorName]}
selected={colorName === selectedColor}
isActive={colorName === activeOption}
LeftComponent={<SettingsColorSample colorName={colorName} />}
/>
))}
</DropdownMenuItemsContainer>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
<DropdownMenuOption
id={getOptionId('custom')}
text="Custom hex"
selected={isCustomSelected}
isActive={activeOption === 'custom'}
onSelect={onSelectCustom}
/>
</DropdownMenuItemsContainer>
</StyledListbox>
);

View File

@@ -0,0 +1,141 @@
import { isUndefined } from '@sniptt/guards';
import { useState } from 'react';
import { DEFAULT_COLOR_LABELS } from 'twenty-ui/navigation';
import { MAIN_COLOR_NAMES, type ThemeColor } from 'twenty-ui/theme';
import { isDefined } from 'twenty-ui/utilities';
import { SettingsSelectControl } from 'src/front-components/components/SettingsSelectControl';
import { SettingsSelectMenu } from 'src/front-components/components/SettingsSelectMenu';
import { SettingsColorSample } from 'src/front-components/components/SettingsColorSample';
import { StyledSettingsSelectAnchor } from 'src/front-components/components/StyledSettingsSelectAnchor';
import { ThemeColorPickerMenu } from 'src/front-components/components/ThemeColorPickerMenu';
import {
getNextActiveOptionIndex,
type SettingsSelectNavigationKey,
} from 'src/front-components/utils/get-next-active-option-index.util';
// The control is capped at 120px, where "Custom hex" ellipsises to "Custom …".
const CUSTOM_CONTROL_LABEL = 'Custom';
const CUSTOM_OPTION_VALUE = 'custom';
const FALLBACK_SWATCH_COLOR_NAME: ThemeColor = 'gray';
const TILE_BACKGROUND_LISTBOX_ID = 'call-recorder-tile-background';
type TileBackgroundOption = ThemeColor | typeof CUSTOM_OPTION_VALUE;
// Built lazily: the SDK manifest extractor loads this module with twenty-ui
// mocked, so module-scope iteration over MAIN_COLOR_NAMES would throw.
const getTileBackgroundOptions = (): TileBackgroundOption[] => [
...MAIN_COLOR_NAMES,
CUSTOM_OPTION_VALUE,
];
const getTileBackgroundOptionId = (option: TileBackgroundOption) =>
`${TILE_BACKGROUND_LISTBOX_ID}-option-${option}`;
type TileBackgroundControlProps = {
swatchColor: string;
selectedColor: ThemeColor | undefined;
isCustomSelected: boolean;
disabled: boolean;
onSelectColor: (color: ThemeColor) => void;
onSelectCustom: () => void;
};
export const TileBackgroundControl = ({
swatchColor,
selectedColor,
isCustomSelected,
disabled,
onSelectColor,
onSelectCustom,
}: TileBackgroundControlProps) => {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [activeOptionIndex, setActiveOptionIndex] = useState(0);
const tileBackgroundOptions = getTileBackgroundOptions();
const label = isUndefined(selectedColor)
? CUSTOM_CONTROL_LABEL
: (DEFAULT_COLOR_LABELS[selectedColor] ?? selectedColor);
const selectedOption: TileBackgroundOption = isCustomSelected
? CUSTOM_OPTION_VALUE
: (selectedColor ?? FALLBACK_SWATCH_COLOR_NAME);
const handleMenuToggle = () => {
if (!isMenuOpen) {
setActiveOptionIndex(
Math.max(tileBackgroundOptions.indexOf(selectedOption), 0),
);
}
setIsMenuOpen((isOpen) => !isOpen);
};
const handleMenuClose = () => setIsMenuOpen(false);
const handleNavigate = (key: SettingsSelectNavigationKey) => {
setActiveOptionIndex((currentIndex) =>
getNextActiveOptionIndex({
key,
currentIndex,
optionCount: tileBackgroundOptions.length,
}),
);
};
const handleSelectActive = () => {
const activeOption = tileBackgroundOptions[activeOptionIndex];
if (activeOption === CUSTOM_OPTION_VALUE) {
onSelectCustom();
} else if (isDefined(activeOption)) {
onSelectColor(activeOption);
}
handleMenuClose();
};
return (
<StyledSettingsSelectAnchor>
<SettingsSelectControl
label={label}
ariaLabel="Tile background"
disabled={disabled}
listboxId={TILE_BACKGROUND_LISTBOX_ID}
activeDescendantId={getTileBackgroundOptionId(
tileBackgroundOptions[activeOptionIndex] ?? CUSTOM_OPTION_VALUE,
)}
isExpanded={isMenuOpen}
onNavigate={handleNavigate}
onSelectActive={handleSelectActive}
onEscape={handleMenuClose}
adornment={
<SettingsColorSample
colorName={selectedColor ?? FALLBACK_SWATCH_COLOR_NAME}
color={isCustomSelected ? swatchColor : undefined}
/>
}
onClick={handleMenuToggle}
/>
<SettingsSelectMenu isOpen={isMenuOpen} onClose={handleMenuClose}>
<ThemeColorPickerMenu
listboxId={TILE_BACKGROUND_LISTBOX_ID}
selectedColor={selectedColor}
isCustomSelected={isCustomSelected}
activeOption={
tileBackgroundOptions[activeOptionIndex] ?? CUSTOM_OPTION_VALUE
}
getOptionId={getTileBackgroundOptionId}
onSelectColor={(color) => {
onSelectColor(color);
handleMenuClose();
}}
onSelectCustom={() => {
onSelectCustom();
handleMenuClose();
}}
/>
</SettingsSelectMenu>
</StyledSettingsSelectAnchor>
);
};

View File

@@ -0,0 +1,70 @@
import { isUndefined } from '@sniptt/guards';
import { useId, useState } from 'react';
import { type IconComponent } from 'twenty-ui/icon';
import { SettingsOptionCardContentCounter } from 'src/front-components/components/SettingsOptionCardContentCounter';
import { useAutosaveApplicationVariable } from 'src/front-components/hooks/use-autosave-application-variable';
import { getNormalizedNumberValue } from 'src/front-components/utils/get-normalized-number-value.util';
type TimingCounterRowProps = {
frontComponentId: string;
variableKey: string;
title: string;
description: string;
Icon: IconComponent;
divider: boolean;
persistedValue: string;
};
export const TimingCounterRow = ({
frontComponentId,
variableKey,
title,
description,
Icon,
divider,
persistedValue,
}: TimingCounterRowProps) => {
const inputId = useId();
const [inputValue, setInputValue] = useState(persistedValue);
const { saveDebounced, saveImmediately } = useAutosaveApplicationVariable({
frontComponentId,
variableKey,
});
const handleChange = (value: string, changeType: 'input' | 'button') => {
setInputValue(value);
const valueToSave = getNormalizedNumberValue(value);
if (isUndefined(valueToSave)) {
saveDebounced.cancel();
return;
}
if (changeType === 'button') {
saveImmediately(valueToSave);
return;
}
saveDebounced(valueToSave);
};
return (
<SettingsOptionCardContentCounter
Icon={Icon}
title={title}
description={description}
divider={divider}
inputId={inputId}
value={inputValue}
errorMessage={
isUndefined(getNormalizedNumberValue(inputValue))
? 'Invalid number'
: undefined
}
onChange={handleChange}
onBlur={() => saveDebounced.flush()}
/>
);
};

View File

@@ -0,0 +1,116 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
// twenty-ui's Toggle is a base-ui Switch whose click handler constructs a
// PointerEvent, which the front component sandbox does not provide. This is
// the same control as a plain button, styled after twenty-ui's Toggle. The
// host theme squircles every corner, so the capsule and thumb opt out.
export type ToggleSize = 'small' | 'medium';
type ToggleDimensions = {
width: number;
height: number;
thumbSize: number;
};
const TOGGLE_DIMENSIONS_BY_SIZE: Record<ToggleSize, ToggleDimensions> = {
small: { width: 24, height: 16, thumbSize: 12 },
medium: { width: 32, height: 20, thumbSize: 16 },
};
const THUMB_INSET_PIXELS = 2;
const getCheckedThumbOffsetPixels = ({
width,
thumbSize,
}: ToggleDimensions) => width - thumbSize - THUMB_INSET_PIXELS;
const StyledToggle = styled.button<{
$toggleSize: ToggleSize;
}>`
align-items: center;
appearance: none;
background-color: ${() => themeCssVariables.background.transparent.medium};
border: none;
border-radius: ${() => themeCssVariables.border.radius.pill};
corner-shape: round;
cursor: pointer;
display: flex;
flex-shrink: 0;
height: ${({ $toggleSize }) => TOGGLE_DIMENSIONS_BY_SIZE[$toggleSize].height}px;
padding: 0;
position: relative;
transition: background-color
calc(${() => themeCssVariables.animation.duration.normal} * 1s) ease;
width: ${({ $toggleSize }) => TOGGLE_DIMENSIONS_BY_SIZE[$toggleSize].width}px;
&[data-checked='true'] {
background-color: ${() => themeCssVariables.color.blue};
}
&:disabled {
cursor: default;
opacity: 0.5;
}
`;
const StyledThumb = styled.span<{
$toggleSize: ToggleSize;
}>`
background-color: ${() => themeCssVariables.background.primary};
border-radius: 50%;
corner-shape: round;
display: block;
height: ${({ $toggleSize }) =>
TOGGLE_DIMENSIONS_BY_SIZE[$toggleSize].thumbSize}px;
left: 0;
position: absolute;
top: 50%;
transform: translate(${THUMB_INSET_PIXELS}px, -50%);
transition: transform
calc(${() => themeCssVariables.animation.duration.normal} * 1s) ease;
width: ${({ $toggleSize }) =>
TOGGLE_DIMENSIONS_BY_SIZE[$toggleSize].thumbSize}px;
&[data-checked='true'] {
transform: translate(
${({ $toggleSize }) =>
getCheckedThumbOffsetPixels(
TOGGLE_DIMENSIONS_BY_SIZE[$toggleSize],
)}px,
-50%
);
}
`;
type ToggleProps = {
id?: string;
checked: boolean;
disabled?: boolean;
toggleSize?: ToggleSize;
onChange: (checked: boolean) => void;
};
export const Toggle = ({
id,
checked,
disabled = false,
toggleSize = 'medium',
onChange,
}: ToggleProps) => (
<StyledToggle
type="button"
role="switch"
id={id}
aria-checked={checked ? 'true' : 'false'}
data-checked={checked ? 'true' : 'false'}
disabled={disabled}
$toggleSize={toggleSize}
onClick={() => onChange(!checked)}
>
<StyledThumb
data-checked={checked ? 'true' : 'false'}
$toggleSize={toggleSize}
/>
</StyledToggle>
);

View File

@@ -0,0 +1,120 @@
import styled from '@emotion/styled';
import { useState } from 'react';
import { isDefined } from 'twenty-ui/utilities';
import { DropdownMenuItemsContainer } from 'src/front-components/components/DropdownMenuItemsContainer';
import { DropdownMenuOption } from 'src/front-components/components/DropdownMenuOption';
import { SettingsSelectControl } from 'src/front-components/components/SettingsSelectControl';
import { SettingsSelectMenu } from 'src/front-components/components/SettingsSelectMenu';
import { StyledSettingsSelectAnchor } from 'src/front-components/components/StyledSettingsSelectAnchor';
import { EMPTY_OPTION_LABEL } from 'src/front-components/constants/empty-option-label.constant';
import {
getNextActiveOptionIndex,
type SettingsSelectNavigationKey,
} from 'src/front-components/utils/get-next-active-option-index.util';
const StyledListbox = styled.div`
display: flex;
flex-direction: column;
min-height: 0;
width: 100%;
`;
const TRANSCRIPT_PROVIDER_LISTBOX_ID = 'call-recorder-transcript-provider';
const getTranscriptProviderOptionId = (index: number) =>
`${TRANSCRIPT_PROVIDER_LISTBOX_ID}-option-${index}`;
type TranscriptProviderControlProps = {
value: string;
options: { label: string; value: string }[];
onChange: (value: string) => void;
};
export const TranscriptProviderControl = ({
value,
options,
onChange,
}: TranscriptProviderControlProps) => {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [activeOptionIndex, setActiveOptionIndex] = useState(0);
const selectedOption = options.find((option) => option.value === value);
const selectableOptions = [
{ label: EMPTY_OPTION_LABEL, value: '' },
...options,
];
const selectedOptionIndex = Math.max(
selectableOptions.findIndex(
(selectableOption) => selectableOption.value === value,
),
0,
);
const handleMenuToggle = () => {
if (!isMenuOpen) {
setActiveOptionIndex(selectedOptionIndex);
}
setIsMenuOpen((isOpen) => !isOpen);
};
const handleMenuClose = () => setIsMenuOpen(false);
const handleNavigate = (key: SettingsSelectNavigationKey) => {
setActiveOptionIndex((currentIndex) =>
getNextActiveOptionIndex({
key,
currentIndex,
optionCount: selectableOptions.length,
}),
);
};
const handleSelectActive = () => {
const activeOption = selectableOptions[activeOptionIndex];
if (isDefined(activeOption)) {
onChange(activeOption.value);
handleMenuClose();
}
};
return (
<StyledSettingsSelectAnchor>
<SettingsSelectControl
label={selectedOption?.label ?? EMPTY_OPTION_LABEL}
ariaLabel="Transcript provider"
listboxId={TRANSCRIPT_PROVIDER_LISTBOX_ID}
activeDescendantId={getTranscriptProviderOptionId(activeOptionIndex)}
isExpanded={isMenuOpen}
onNavigate={handleNavigate}
onSelectActive={handleSelectActive}
onEscape={handleMenuClose}
onClick={handleMenuToggle}
/>
<SettingsSelectMenu isOpen={isMenuOpen} onClose={handleMenuClose}>
<StyledListbox
id={TRANSCRIPT_PROVIDER_LISTBOX_ID}
role="listbox"
aria-label="Transcript provider"
>
<DropdownMenuItemsContainer>
{selectableOptions.map((option, index) => (
<DropdownMenuOption
key={option.value}
id={getTranscriptProviderOptionId(index)}
text={option.label}
selected={option.value === value}
isActive={index === activeOptionIndex}
onSelect={() => {
onChange(option.value);
handleMenuClose();
}}
/>
))}
</DropdownMenuItemsContainer>
</StyledListbox>
</SettingsSelectMenu>
</StyledSettingsSelectAnchor>
);
};

View File

@@ -0,0 +1,133 @@
import { useId, useState } from 'react';
import { Section } from 'twenty-ui/layout';
import { H2Title } from 'twenty-ui/typography';
import { CALL_RECORDER_TRANSCRIPT_PROVIDER_OPTIONS } from 'src/constants/call-recorder-transcript-provider-options';
import { LabelledSettingsField } from 'src/front-components/components/LabelledSettingsField';
import { SettingsOptionCardContentSelect } from 'src/front-components/components/SettingsOptionCardContentSelect';
import { SettingsOptionCardContentToggle } from 'src/front-components/components/SettingsOptionCardContentToggle';
import { StyledDimmable } from 'src/front-components/components/StyledDimmable';
import { StyledSettingsCard } from 'src/front-components/components/StyledSettingsCard';
import { StyledSettingsSectionStack } from 'src/front-components/components/StyledSettingsSectionStack';
import { StyledSettingsTextArea } from 'src/front-components/components/StyledSettingsTextArea';
import { TranscriptProviderControl } from 'src/front-components/components/TranscriptProviderControl';
import {
CALL_RECORDER_SUMMARY_ENABLED_ROW,
CALL_RECORDER_SUMMARY_PROMPT_FIELD,
CALL_RECORDER_TRANSCRIPT_PROVIDER_ROW,
} from 'src/front-components/constants/call-recorder-settings-layout.constant';
import { useAutosaveApplicationVariable } from 'src/front-components/hooks/use-autosave-application-variable';
import { getApplicationVariableValue } from 'src/front-components/utils/get-application-variable-value.util';
import { serializeRichTextMarkdown } from 'src/front-components/utils/serialize-rich-text-markdown.util';
import { extractRichTextMarkdown } from 'src/logic-functions/utils/extract-rich-text-markdown.util';
type TranscriptionSectionProps = {
frontComponentId: string;
};
export const TranscriptionSection = ({
frontComponentId,
}: TranscriptionSectionProps) => {
const inputId = useId();
const [providerValue, setProviderValue] = useState(() =>
getApplicationVariableValue(
CALL_RECORDER_TRANSCRIPT_PROVIDER_ROW.variableKey,
),
);
const [isSummaryEnabled, setIsSummaryEnabled] = useState(
() =>
getApplicationVariableValue(
CALL_RECORDER_SUMMARY_ENABLED_ROW.variableKey,
) === 'true',
);
const [promptMarkdown, setPromptMarkdown] = useState(
() =>
extractRichTextMarkdown(
getApplicationVariableValue(
CALL_RECORDER_SUMMARY_PROMPT_FIELD.variableKey,
),
) ?? '',
);
const { saveImmediately: saveProviderImmediately } =
useAutosaveApplicationVariable({
frontComponentId,
variableKey: CALL_RECORDER_TRANSCRIPT_PROVIDER_ROW.variableKey,
});
const { saveImmediately: saveSummaryEnabledImmediately } =
useAutosaveApplicationVariable({
frontComponentId,
variableKey: CALL_RECORDER_SUMMARY_ENABLED_ROW.variableKey,
});
const { saveDebounced: savePromptDebounced } = useAutosaveApplicationVariable(
{
frontComponentId,
variableKey: CALL_RECORDER_SUMMARY_PROMPT_FIELD.variableKey,
},
);
const handleProviderChange = (value: string) => {
setProviderValue(value);
saveProviderImmediately(value);
};
const handleSummaryEnabledChange = (checked: boolean) => {
const value = checked ? 'true' : 'false';
setIsSummaryEnabled(checked);
saveSummaryEnabledImmediately(value);
};
const handlePromptChange = (value: string) => {
setPromptMarkdown(value);
savePromptDebounced(serializeRichTextMarkdown(value));
};
return (
<Section>
<H2Title
title="Transcription"
description="What happens to a recording once the call ends."
/>
<StyledSettingsSectionStack>
<StyledSettingsCard>
<SettingsOptionCardContentSelect
Icon={CALL_RECORDER_TRANSCRIPT_PROVIDER_ROW.Icon}
title={CALL_RECORDER_TRANSCRIPT_PROVIDER_ROW.title}
description={CALL_RECORDER_TRANSCRIPT_PROVIDER_ROW.description}
divider
>
<TranscriptProviderControl
value={providerValue}
options={CALL_RECORDER_TRANSCRIPT_PROVIDER_OPTIONS}
onChange={handleProviderChange}
/>
</SettingsOptionCardContentSelect>
<SettingsOptionCardContentToggle
Icon={CALL_RECORDER_SUMMARY_ENABLED_ROW.Icon}
title={CALL_RECORDER_SUMMARY_ENABLED_ROW.title}
description={CALL_RECORDER_SUMMARY_ENABLED_ROW.description}
checked={isSummaryEnabled}
onChange={handleSummaryEnabledChange}
/>
</StyledSettingsCard>
<StyledDimmable $dimmed={!isSummaryEnabled}>
<LabelledSettingsField
label={CALL_RECORDER_SUMMARY_PROMPT_FIELD.label}
inputId={inputId}
hint={CALL_RECORDER_SUMMARY_PROMPT_FIELD.hint}
>
<StyledSettingsTextArea
id={inputId}
placeholder="Value"
disabled={!isSummaryEnabled}
value={promptMarkdown}
onChange={(event) => handlePromptChange(event.target.value)}
onBlur={() => savePromptDebounced.flush()}
/>
</LabelledSettingsField>
</StyledDimmable>
</StyledSettingsSectionStack>
</Section>
);
};

View File

@@ -0,0 +1 @@
export const APPLICATION_VARIABLE_SAVE_DEBOUNCE_MILLISECONDS = 250;

View File

@@ -0,0 +1,139 @@
import {
IconCalendarEvent,
IconClockPlay,
IconColorSwatch,
IconDoorEnter,
IconHourglassHigh,
IconLanguage,
IconMessage,
IconPhoto,
IconPlayerStop,
IconSparkles,
type IconComponent,
} from 'twenty-ui/icon';
import { CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-additional-summary-prompt-env-var-name';
import { CALL_RECORDER_BOT_IMAGE_BACKGROUND_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-bot-image-background-env-var-name';
import { CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ENABLED_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-calendar-bot-scheduling-enabled-env-var-name';
import { CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-everyone-left-timeout-seconds-env-var-name';
import { CALL_RECORDER_JOIN_EARLY_MINUTES_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-join-early-minutes-env-var-name';
import { CALL_RECORDER_NAME_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-name-env-var-name';
import { CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-noone-joined-timeout-seconds-env-var-name';
import { CALL_RECORDER_RECORDING_NOTICE_ENABLED_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-recording-notice-enabled-env-var-name';
import { CALL_RECORDER_RECORDING_NOTICE_MAX_LENGTH } from 'src/logic-functions/constants/call-recorder-recording-notice-max-length';
import { CALL_RECORDER_RECORDING_NOTICE_MESSAGE_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-recording-notice-message-env-var-name';
import { CALL_RECORDER_SUMMARY_ENABLED_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-summary-enabled-env-var-name';
import { CALL_RECORDER_TRANSCRIPT_PROVIDER_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-transcript-provider-env-var-name';
import { CALL_RECORDER_USE_WORKSPACE_LOGO_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-use-workspace-logo-env-var-name';
import { CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/call-recorder-waiting-room-timeout-seconds-env-var-name';
export type CallRecorderCounterRow = {
variableKey: string;
title: string;
description: string;
Icon: IconComponent;
};
export const CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ROW = {
variableKey: CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ENABLED_ENV_VAR_NAME,
title: 'Record my calendar meetings',
description: 'Joins every meeting with a supported video link.',
Icon: IconCalendarEvent,
};
export const CALL_RECORDER_RECORDING_NOTICE_ROW = {
variableKey: CALL_RECORDER_RECORDING_NOTICE_ENABLED_ENV_VAR_NAME,
title: 'Send recording notice',
description:
'Post a notice in the meeting chat when the recorder joins. Zoom, Google Meet and Microsoft Teams only.',
Icon: IconMessage,
};
export const CALL_RECORDER_RECORDING_NOTICE_MESSAGE_FIELD = {
variableKey: CALL_RECORDER_RECORDING_NOTICE_MESSAGE_ENV_VAR_NAME,
label: 'Recording notice message',
hint: `Truncated to ${CALL_RECORDER_RECORDING_NOTICE_MAX_LENGTH} characters for Google Meet.`,
};
export const CALL_RECORDER_TIMING_ROWS: CallRecorderCounterRow[] = [
{
variableKey: CALL_RECORDER_JOIN_EARLY_MINUTES_ENV_VAR_NAME,
title: 'Join before start',
description:
'Minutes before the scheduled start time. Set 0 to join on time.',
Icon: IconClockPlay,
},
{
variableKey: CALL_RECORDER_WAITING_ROOM_TIMEOUT_SECONDS_ENV_VAR_NAME,
title: 'Leave the lobby after',
description: 'Seconds the recorder waits to be admitted before giving up.',
Icon: IconDoorEnter,
},
{
variableKey: CALL_RECORDER_NOONE_JOINED_TIMEOUT_SECONDS_ENV_VAR_NAME,
title: 'Leave an empty call after',
description: 'Seconds to wait when nobody else ever joins the meeting.',
Icon: IconHourglassHigh,
},
{
variableKey: CALL_RECORDER_EVERYONE_LEFT_TIMEOUT_SECONDS_ENV_VAR_NAME,
title: 'Keep recording after everyone leaves',
description: 'Seconds of grace before the recording is ended.',
Icon: IconPlayerStop,
},
];
export const CALL_RECORDER_TRANSCRIPT_PROVIDER_ROW = {
variableKey: CALL_RECORDER_TRANSCRIPT_PROVIDER_ENV_VAR_NAME,
title: 'Transcript provider',
description: 'Speech-to-text service used once the call ends.',
Icon: IconLanguage,
};
export const CALL_RECORDER_SUMMARY_ENABLED_ROW = {
variableKey: CALL_RECORDER_SUMMARY_ENABLED_ENV_VAR_NAME,
title: 'AI summaries',
description: 'Generate a summary for every recording. Uses AI credits.',
Icon: IconSparkles,
};
export const CALL_RECORDER_USE_WORKSPACE_LOGO_ROW = {
variableKey: CALL_RECORDER_USE_WORKSPACE_LOGO_ENV_VAR_NAME,
title: 'Show workspace logo',
description: 'Display your logo on the recorder camera tile.',
Icon: IconPhoto,
};
export const CALL_RECORDER_TILE_BACKGROUND_ROW = {
variableKey: CALL_RECORDER_BOT_IMAGE_BACKGROUND_ENV_VAR_NAME,
title: 'Tile background',
description: 'Colour drawn behind the logo on the camera tile.',
Icon: IconColorSwatch,
};
export const CALL_RECORDER_NAME_FIELD = {
variableKey: CALL_RECORDER_NAME_ENV_VAR_NAME,
label: 'Recorder name',
hint: 'Shown to everyone else in the meeting when the recorder joins.',
};
export const CALL_RECORDER_SUMMARY_PROMPT_FIELD = {
variableKey: CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT_ENV_VAR_NAME,
label: 'Extra summary instructions',
hint: 'Appended to the built-in prompt. Leave empty to use it alone.',
};
// Keep this list aligned with the app config so every application variable is
// intentionally placed in the synchronous settings UI.
export const CALL_RECORDER_MAPPED_VARIABLE_KEYS: string[] = [
CALL_RECORDER_CALENDAR_BOT_SCHEDULING_ROW.variableKey,
CALL_RECORDER_NAME_FIELD.variableKey,
CALL_RECORDER_RECORDING_NOTICE_ROW.variableKey,
CALL_RECORDER_RECORDING_NOTICE_MESSAGE_FIELD.variableKey,
...CALL_RECORDER_TIMING_ROWS.map((row) => row.variableKey),
CALL_RECORDER_TRANSCRIPT_PROVIDER_ROW.variableKey,
CALL_RECORDER_SUMMARY_ENABLED_ROW.variableKey,
CALL_RECORDER_SUMMARY_PROMPT_FIELD.variableKey,
CALL_RECORDER_USE_WORKSPACE_LOGO_ROW.variableKey,
CALL_RECORDER_TILE_BACKGROUND_ROW.variableKey,
];

View File

@@ -0,0 +1 @@
export const EMPTY_OPTION_LABEL = 'Select a value';

View File

@@ -0,0 +1,9 @@
export const SETTINGS_SELECT_MENU_GAP_PIXELS = 4;
export const SETTINGS_SELECT_MENU_MAX_HEIGHT_PIXELS = 320;
export const SETTINGS_SELECT_MENU_WIDTH_PIXELS = 200;
export const SETTINGS_SELECT_MENU_BACKDROP_Z_INDEX = 2000;
export const SETTINGS_SELECT_MENU_Z_INDEX = 2001;

View File

@@ -0,0 +1,33 @@
import { type ThemeColor } from 'twenty-ui/theme';
// twenty-ui's MAIN_COLORS_LIGHT holds display-p3 strings, which the hex
// validator in get-bot-image-background rejects — it would silently fall back
// to the default background. Named colours are stored as the sRGB equivalent
// of the same Radix step instead.
export const THEME_COLOR_HEX: Record<ThemeColor, string> = {
red: '#e5484d',
ruby: '#e54666',
crimson: '#e93d82',
tomato: '#e54d2e',
orange: '#f76b15',
amber: '#ffc53d',
yellow: '#ffe629',
lime: '#bdee63',
grass: '#46a758',
green: '#30a46c',
jade: '#29a383',
mint: '#86ead4',
turquoise: '#12a594',
cyan: '#00a2c7',
sky: '#7ce2fe',
blue: '#3e63dd',
iris: '#5b5bd6',
violet: '#6e56cf',
purple: '#8e4ec6',
plum: '#ab4aba',
pink: '#d6409f',
bronze: '#a18072',
gold: '#978365',
brown: '#ad7f58',
gray: '#999999',
};

View File

@@ -0,0 +1,27 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { Command, useSelectedRecordIds } from 'twenty-sdk/front-component';
import { GENERATE_CALL_RECORDING_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { requestCallRecordingSummaryGeneration } from 'src/front-components/utils/request-call-recording-summary-generation.util';
const GenerateCallRecordingSummary = () => {
const calendarEventIds = useSelectedRecordIds();
return (
<Command
execute={() =>
requestCallRecordingSummaryGeneration({ calendarEventIds })
}
/>
);
};
export default defineFrontComponent({
universalIdentifier:
GENERATE_CALL_RECORDING_SUMMARY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'generate-call-recording-summary-effect',
description:
'Requests AI summary generation for the call recordings of the selected calendar events.',
component: GenerateCallRecordingSummary,
isHeadless: true,
});

View File

@@ -0,0 +1,75 @@
import { isUndefined } from '@sniptt/guards';
import { useCallback, useRef } from 'react';
import { useDebouncedCallback } from 'use-debounce';
import { APPLICATION_VARIABLE_SAVE_DEBOUNCE_MILLISECONDS } from 'src/front-components/constants/application-variable-save-debounce.constant';
import { useSaveApplicationVariable } from 'src/front-components/hooks/use-save-application-variable';
import { createApplicationVariableSaveQueue } from 'src/front-components/utils/create-application-variable-save-queue.util';
type UseAutosaveApplicationVariableParams = {
frontComponentId: string;
variableKey: string;
onSaveSuccess?: (value: string) => void | Promise<void>;
onSaveError?: (value: string) => void;
};
export const useAutosaveApplicationVariable = ({
frontComponentId,
variableKey,
onSaveSuccess,
onSaveError,
}: UseAutosaveApplicationVariableParams) => {
const { saveApplicationVariable } =
useSaveApplicationVariable(frontComponentId);
const saveApplicationVariableRef = useRef(saveApplicationVariable);
const onSaveSuccessRef = useRef(onSaveSuccess);
const onSaveErrorRef = useRef(onSaveError);
const variableKeyRef = useRef(variableKey);
saveApplicationVariableRef.current = saveApplicationVariable;
onSaveSuccessRef.current = onSaveSuccess;
onSaveErrorRef.current = onSaveError;
variableKeyRef.current = variableKey;
const saveQueueRef = useRef<
ReturnType<typeof createApplicationVariableSaveQueue> | undefined
>(undefined);
if (isUndefined(saveQueueRef.current)) {
saveQueueRef.current = createApplicationVariableSaveQueue({
saveValue: async (value, isSupersededValue) => {
const isSaved = await saveApplicationVariableRef.current({
variableKey: variableKeyRef.current,
value,
});
if (isSaved) {
await onSaveSuccessRef.current?.(value);
return;
}
if (isSupersededValue()) {
return;
}
onSaveErrorRef.current?.(value);
},
});
}
const saveDebounced = useDebouncedCallback(
saveQueueRef.current.enqueueSave,
APPLICATION_VARIABLE_SAVE_DEBOUNCE_MILLISECONDS,
);
const saveImmediately = useCallback(
(value: string) => {
saveDebounced.cancel();
saveQueueRef.current?.enqueueSave(value);
},
[saveDebounced],
);
return { saveDebounced, saveImmediately };
};

View File

@@ -0,0 +1,31 @@
import { enqueueSnackbar } from 'twenty-sdk/front-component';
import { useUpdateApplicationVariable } from 'src/front-components/hooks/use-update-application-variable';
type SaveApplicationVariableParams = {
variableKey: string;
value: string;
};
export const useSaveApplicationVariable = (frontComponentId: string) => {
const { updateApplicationVariable } =
useUpdateApplicationVariable(frontComponentId);
const saveApplicationVariable = async ({
variableKey,
value,
}: SaveApplicationVariableParams) => {
const isUpdated = await updateApplicationVariable({ variableKey, value });
if (!isUpdated) {
enqueueSnackbar({
message: `Could not save ${variableKey}.`,
variant: 'error',
});
}
return isUpdated;
};
return { saveApplicationVariable };
};

View File

@@ -0,0 +1,41 @@
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { resolveCallRecorderApplicationId } from 'src/front-components/utils/resolve-call-recorder-application-id.util';
type UpdateApplicationVariableParams = {
variableKey: string;
value: string;
};
type UpdateApplicationVariableState = {
updateApplicationVariable: (
params: UpdateApplicationVariableParams,
) => Promise<boolean>;
};
export const useUpdateApplicationVariable = (
frontComponentId: string,
): UpdateApplicationVariableState => {
const updateApplicationVariable = async ({
variableKey,
value,
}: UpdateApplicationVariableParams): Promise<boolean> => {
try {
const applicationId =
await resolveCallRecorderApplicationId(frontComponentId);
const client = new MetadataApiClient();
await client.mutation({
updateOneApplicationVariable: {
__args: { key: variableKey, value, applicationId },
},
});
return true;
} catch {
return false;
}
};
return { updateApplicationVariable };
};

View File

@@ -0,0 +1,12 @@
import { defineSettingsFrontComponent } from 'twenty-sdk/define';
import { CALL_RECORDER_SETTINGS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recorder-settings-front-component-universal-identifier';
import { CallRecorderSettings } from 'src/front-components/components/CallRecorderSettings';
export default defineSettingsFrontComponent({
universalIdentifier:
CALL_RECORDER_SETTINGS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'call-recorder-settings',
description: 'Settings panel for the call recorder application variables.',
component: CallRecorderSettings,
});

View File

@@ -0,0 +1,70 @@
import { describe, expect, it, vi } from 'vitest';
import { createApplicationVariableSaveQueue } from 'src/front-components/utils/create-application-variable-save-queue.util';
const createDeferredPromise = () => {
let resolvePromise: () => void = () => {};
const promise = new Promise<void>((resolve) => {
resolvePromise = resolve;
});
return { promise, resolvePromise };
};
describe('createApplicationVariableSaveQueue', () => {
it('persists one value at a time and coalesces queued values to the latest', async () => {
const firstSave = createDeferredPromise();
const latestSave = createDeferredPromise();
const saveValue = vi
.fn<(value: string, isSupersededValue: () => boolean) => Promise<void>>()
.mockReturnValueOnce(firstSave.promise)
.mockReturnValueOnce(latestSave.promise);
const { enqueueSave } = createApplicationVariableSaveQueue({ saveValue });
enqueueSave('first');
enqueueSave('second');
enqueueSave('latest');
expect(saveValue).toHaveBeenCalledTimes(1);
expect(saveValue).toHaveBeenNthCalledWith(1, 'first', expect.any(Function));
firstSave.resolvePromise();
await firstSave.promise;
await Promise.resolve();
expect(saveValue).toHaveBeenCalledTimes(2);
expect(saveValue).toHaveBeenNthCalledWith(
2,
'latest',
expect.any(Function),
);
latestSave.resolvePromise();
await latestSave.promise;
});
it('reports a value as superseded only while a newer one is queued', async () => {
const firstSave = createDeferredPromise();
const supersededByValue: Record<string, boolean> = {};
const saveValue = vi
.fn<(value: string, isSupersededValue: () => boolean) => Promise<void>>()
.mockImplementation(async (value, isSupersededValue) => {
if (value === 'first') {
await firstSave.promise;
}
supersededByValue[value] = isSupersededValue();
});
const { enqueueSave } = createApplicationVariableSaveQueue({ saveValue });
enqueueSave('first');
enqueueSave('latest');
firstSave.resolvePromise();
await firstSave.promise;
await Promise.resolve();
await Promise.resolve();
expect(supersededByValue).toEqual({ first: true, latest: false });
});
});

View File

@@ -0,0 +1,26 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getApplicationVariableValue } from 'src/front-components/utils/get-application-variable-value.util';
describe('getApplicationVariableValue', () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it('reads the value injected into the front component synchronously', () => {
vi.stubEnv(
'applicationVariables',
JSON.stringify({ RECORDER_NAME: 'Meeting assistant' }),
);
expect(getApplicationVariableValue('RECORDER_NAME')).toBe(
'Meeting assistant',
);
});
it('returns an empty value when the variable is not configured', () => {
vi.stubEnv('applicationVariables', '{}');
expect(getApplicationVariableValue('RECORDER_NAME')).toBe('');
});
});

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { getNextActiveOptionIndex } from 'src/front-components/utils/get-next-active-option-index.util';
describe('getNextActiveOptionIndex', () => {
it.each([
{ key: 'ArrowDown' as const, currentIndex: 1, expectedIndex: 2 },
{ key: 'ArrowDown' as const, currentIndex: 3, expectedIndex: 0 },
{ key: 'ArrowUp' as const, currentIndex: 2, expectedIndex: 1 },
{ key: 'ArrowUp' as const, currentIndex: 0, expectedIndex: 3 },
{ key: 'Home' as const, currentIndex: 2, expectedIndex: 0 },
{ key: 'End' as const, currentIndex: 1, expectedIndex: 3 },
])(
'returns $expectedIndex for $key from $currentIndex',
({ key, currentIndex, expectedIndex }) => {
expect(
getNextActiveOptionIndex({
key,
currentIndex,
optionCount: 4,
}),
).toBe(expectedIndex);
},
);
it('returns zero when there are no options', () => {
expect(
getNextActiveOptionIndex({
key: 'ArrowDown',
currentIndex: 0,
optionCount: 0,
}),
).toBe(0);
});
});

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { getNormalizedHexValue } from 'src/front-components/utils/get-normalized-hex-value.util';
describe('getNormalizedHexValue', () => {
it('normalizes valid hex values', () => {
expect(getNormalizedHexValue('#1D1D1D')).toBe('#1d1d1d');
expect(getNormalizedHexValue(' #abc ')).toBe('#aabbcc');
});
it('keeps an empty value so the variable can be cleared', () => {
expect(getNormalizedHexValue('')).toBe('');
expect(getNormalizedHexValue(' ')).toBe('');
});
it('returns undefined for values that are not hex colours', () => {
expect(getNormalizedHexValue('#12')).toBeUndefined();
expect(getNormalizedHexValue('1d1d1d')).toBeUndefined();
expect(getNormalizedHexValue('red')).toBeUndefined();
});
});

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { getNormalizedNumberValue } from 'src/front-components/utils/get-normalized-number-value.util';
describe('getNormalizedNumberValue', () => {
it('normalizes numeric values', () => {
expect(getNormalizedNumberValue('0')).toBe('0');
expect(getNormalizedNumberValue(' 7 ')).toBe('7');
expect(getNormalizedNumberValue('-1.50')).toBe('-1.5');
});
it('keeps an empty value so a variable can be cleared', () => {
expect(getNormalizedNumberValue('')).toBe('');
expect(getNormalizedNumberValue(' ')).toBe('');
});
it('returns undefined for values that are not numbers', () => {
expect(getNormalizedNumberValue('abc')).toBeUndefined();
expect(getNormalizedNumberValue('12abc')).toBeUndefined();
expect(getNormalizedNumberValue('Infinity')).toBeUndefined();
});
});

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { THEME_COLOR_HEX } from 'src/front-components/constants/theme-color-hex.constant';
import { getThemeColorFromHex } from 'src/front-components/utils/get-theme-color-from-hex.util';
describe('getThemeColorFromHex', () => {
it('maps a stored hex back to its theme colour name', () => {
expect(getThemeColorFromHex('#3E63DD')).toBe('blue');
expect(getThemeColorFromHex('#e5484d')).toBe('red');
});
it('returns undefined for a colour outside the palette', () => {
expect(getThemeColorFromHex('#1d1d1d')).toBeUndefined();
});
it('stores every palette colour as a hex the bot image validator accepts', () => {
const hexPattern = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
for (const hex of Object.values(THEME_COLOR_HEX)) {
expect(hex).toMatch(hexPattern);
}
});
});

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { normalizeHexColor } from 'src/front-components/utils/normalize-hex-color.util';
describe('normalizeHexColor', () => {
it('expands short hex values', () => {
expect(normalizeHexColor('#ABC')).toBe('#aabbcc');
});
it('lowercases long hex values', () => {
expect(normalizeHexColor(' #1D1D1D ')).toBe('#1d1d1d');
});
it('rejects anything that is not a hex colour', () => {
expect(
normalizeHexColor('color(display-p3 0.83 0.329 0.324)'),
).toBeUndefined();
expect(normalizeHexColor('red')).toBeUndefined();
expect(normalizeHexColor('')).toBeUndefined();
});
});

View File

@@ -0,0 +1,129 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH } from 'src/constants/generate-call-recording-summaries-route-path';
import { requestCallRecordingSummaryGeneration } from 'src/front-components/utils/request-call-recording-summary-generation.util';
const enqueueSnackbarMock = vi.hoisted(() => vi.fn());
const postMock = vi.hoisted(() => vi.fn());
const restApiClientMock = vi.hoisted(() => vi.fn());
vi.mock('twenty-sdk/front-component', () => ({
enqueueSnackbar: enqueueSnackbarMock,
}));
vi.mock('twenty-client-sdk/rest', () => ({
RestApiClient: restApiClientMock,
}));
describe('requestCallRecordingSummaryGeneration', () => {
beforeEach(() => {
vi.clearAllMocks();
restApiClientMock.mockImplementation(function RestApiClient() {
return { post: postMock };
});
postMock.mockResolvedValue({
outcome: 'processed',
generatedCallRecordingIds: ['call-recording-1'],
failedCallRecordingIds: [],
erroredCallRecordingIds: [],
});
});
it('posts the /s-prefixed route path and lets the client resolve the url', async () => {
await requestCallRecordingSummaryGeneration({
calendarEventIds: ['calendar-event-1'],
});
expect(restApiClientMock).toHaveBeenCalledWith();
expect(postMock).toHaveBeenCalledWith(
`/s${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`,
{ calendarEventIds: ['calendar-event-1'] },
);
});
it('does nothing when no calendar events are selected', async () => {
await requestCallRecordingSummaryGeneration({ calendarEventIds: [] });
expect(postMock).not.toHaveBeenCalled();
expect(enqueueSnackbarMock).not.toHaveBeenCalled();
});
it('reports mixed generation results', async () => {
postMock.mockResolvedValue({
outcome: 'processed',
generatedCallRecordingIds: ['call-recording-1'],
failedCallRecordingIds: ['call-recording-2'],
erroredCallRecordingIds: [],
});
await requestCallRecordingSummaryGeneration({
calendarEventIds: ['calendar-event-1', 'calendar-event-2'],
});
expect(postMock).toHaveBeenCalledWith(
`/s${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`,
{ calendarEventIds: ['calendar-event-1', 'calendar-event-2'] },
);
expect(enqueueSnackbarMock).toHaveBeenCalledWith({
message: 'Some summaries generated, some failed.',
variant: 'error',
});
});
it('reports generation errors as failed summaries', async () => {
postMock.mockResolvedValue({
outcome: 'processed',
generatedCallRecordingIds: [],
failedCallRecordingIds: [],
erroredCallRecordingIds: ['call-recording-1'],
});
await requestCallRecordingSummaryGeneration({
calendarEventIds: ['calendar-event-1'],
});
expect(enqueueSnackbarMock).toHaveBeenCalledWith({
message: 'Summary generation failed.',
variant: 'error',
});
});
it('reports when the call recording has no usable transcript', async () => {
postMock.mockResolvedValue({
outcome: 'processed',
generatedCallRecordingIds: [],
failedCallRecordingIds: [],
erroredCallRecordingIds: [],
skippedCallRecordingIds: ['call-recording-1'],
});
await requestCallRecordingSummaryGeneration({
calendarEventIds: ['calendar-event-1'],
});
expect(enqueueSnackbarMock).toHaveBeenCalledWith({
message: 'No usable call recording transcript found for this event.',
variant: 'error',
});
});
it('reports when the transcript has nothing substantive to summarize', async () => {
postMock.mockResolvedValue({
outcome: 'processed',
generatedCallRecordingIds: [],
failedCallRecordingIds: [],
erroredCallRecordingIds: [],
skippedCallRecordingIds: [],
unavailableCallRecordingIds: ['call-recording-1'],
});
await requestCallRecordingSummaryGeneration({
calendarEventIds: ['calendar-event-1'],
});
expect(enqueueSnackbarMock).toHaveBeenCalledWith({
message: 'Summary unavailable for this event.',
variant: 'error',
});
});
});

View File

@@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { metadataQuery } = vi.hoisted(() => ({
metadataQuery: vi.fn(),
}));
vi.mock('twenty-client-sdk/metadata', () => ({
MetadataApiClient: class {
query = metadataQuery;
},
}));
import { resolveCallRecorderApplicationId } from 'src/front-components/utils/resolve-call-recorder-application-id.util';
describe('resolveCallRecorderApplicationId', () => {
beforeEach(() => {
metadataQuery.mockReset();
});
it('shares one application lookup across concurrent saves', async () => {
metadataQuery.mockResolvedValue({
frontComponent: { applicationId: 'application-id' },
});
const firstResolution =
resolveCallRecorderApplicationId('front-component-id');
const secondResolution =
resolveCallRecorderApplicationId('front-component-id');
await expect(firstResolution).resolves.toBe('application-id');
await expect(secondResolution).resolves.toBe('application-id');
expect(metadataQuery).toHaveBeenCalledOnce();
});
it('retries the lookup after a failed resolution', async () => {
metadataQuery
.mockResolvedValueOnce({ frontComponent: null })
.mockResolvedValueOnce({
frontComponent: { applicationId: 'application-id' },
});
await expect(
resolveCallRecorderApplicationId('retry-front-component-id'),
).rejects.toThrow('Could not resolve the call recorder application.');
await expect(
resolveCallRecorderApplicationId('retry-front-component-id'),
).resolves.toBe('application-id');
expect(metadataQuery).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { extractRichTextMarkdown } from 'src/logic-functions/utils/extract-rich-text-markdown.util';
import { serializeRichTextMarkdown } from 'src/front-components/utils/serialize-rich-text-markdown.util';
describe('serializeRichTextMarkdown', () => {
it('wraps markdown into the stored rich text shape', () => {
expect(serializeRichTextMarkdown('Write terse notes.')).toBe(
'{"blocknote":null,"markdown":"Write terse notes."}',
);
});
it('serializes an empty prompt using the stored rich text shape', () => {
expect(serializeRichTextMarkdown('')).toBe(
'{"blocknote":null,"markdown":null}',
);
});
it('round-trips through the reader used by the summary prompt', () => {
expect(
extractRichTextMarkdown(serializeRichTextMarkdown('Focus on decisions.')),
).toBe('Focus on decisions.');
});
});

View File

@@ -0,0 +1,47 @@
import { isUndefined } from '@sniptt/guards';
type CreateApplicationVariableSaveQueueParams = {
saveValue: (
value: string,
isSupersededValue: () => boolean,
) => Promise<void>;
};
export const createApplicationVariableSaveQueue = ({
saveValue,
}: CreateApplicationVariableSaveQueueParams) => {
let queuedValue: string | undefined;
let isSaving = false;
const persistQueuedValues = async () => {
if (isSaving) {
return;
}
isSaving = true;
try {
while (!isUndefined(queuedValue)) {
const valueToSave = queuedValue;
queuedValue = undefined;
await saveValue(valueToSave, () => !isUndefined(queuedValue));
}
} finally {
isSaving = false;
if (!isUndefined(queuedValue)) {
void persistQueuedValues();
}
}
};
const enqueueSave = (value: string) => {
queuedValue = value;
void persistQueuedValues();
};
return { enqueueSave };
};

View File

@@ -0,0 +1,4 @@
import { getApplicationVariable } from 'twenty-sdk/front-component';
export const getApplicationVariableValue = (variableKey: string): string =>
getApplicationVariable(variableKey) ?? '';

View File

@@ -0,0 +1,33 @@
export type SettingsSelectNavigationKey =
| 'ArrowDown'
| 'ArrowUp'
| 'Home'
| 'End';
export const getNextActiveOptionIndex = ({
key,
currentIndex,
optionCount,
}: {
key: SettingsSelectNavigationKey;
currentIndex: number;
optionCount: number;
}): number => {
if (optionCount <= 0) {
return 0;
}
if (key === 'ArrowDown') {
return (currentIndex + 1) % optionCount;
}
if (key === 'ArrowUp') {
return (currentIndex - 1 + optionCount) % optionCount;
}
if (key === 'Home') {
return 0;
}
return optionCount - 1;
};

View File

@@ -0,0 +1,9 @@
import { normalizeHexColor } from 'src/front-components/utils/normalize-hex-color.util';
export const getNormalizedHexValue = (value: string): string | undefined => {
if (value.trim() === '') {
return '';
}
return normalizeHexColor(value);
};

View File

@@ -0,0 +1,15 @@
// Returns undefined when the draft cannot be stored, so a half-typed number is
// kept on screen instead of being persisted as a broken value.
export const getNormalizedNumberValue = (
value: string,
): string | undefined => {
const trimmedValue = value.trim();
if (trimmedValue === '') {
return '';
}
const parsedValue = Number(trimmedValue);
return Number.isFinite(parsedValue) ? String(parsedValue) : undefined;
};

View File

@@ -0,0 +1,37 @@
import { themeCssVariables } from 'twenty-ui/theme-constants';
// Mirrors the settings inputs twenty-front renders for application variables,
// which cannot be imported here: twenty-ui ships no text, select or textarea
// input, and front components cannot reach into twenty-front internals.
export const getSettingsControlStyles = () => `
background-color: ${themeCssVariables.background.transparent.light};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.md};
box-sizing: border-box;
color: ${themeCssVariables.font.color.primary};
font-family: ${themeCssVariables.font.family};
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.regular};
outline: none;
padding: ${themeCssVariables.spacing[2]};
width: 100%;
&::placeholder,
&::-webkit-input-placeholder {
color: ${themeCssVariables.font.color.tertiary};
font-family: ${themeCssVariables.font.family};
font-weight: ${themeCssVariables.font.weight.medium};
}
&:disabled {
color: ${themeCssVariables.font.color.secondary};
}
&[readonly] {
pointer-events: none;
}
&:focus {
border-color: ${themeCssVariables.color.blue};
}
`;

View File

@@ -0,0 +1,17 @@
import { isUndefined } from '@sniptt/guards';
import { MAIN_COLOR_NAMES, type ThemeColor } from 'twenty-ui/theme';
import { THEME_COLOR_HEX } from 'src/front-components/constants/theme-color-hex.constant';
import { normalizeHexColor } from 'src/front-components/utils/normalize-hex-color.util';
export const getThemeColorFromHex = (value: string): ThemeColor | undefined => {
const normalizedValue = normalizeHexColor(value);
if (isUndefined(normalizedValue)) {
return undefined;
}
return MAIN_COLOR_NAMES.find(
(colorName) => THEME_COLOR_HEX[colorName] === normalizedValue,
);
};

View File

@@ -0,0 +1,16 @@
const SHORT_HEX_PATTERN = /^#[0-9a-f]{3}$/;
const LONG_HEX_PATTERN = /^#[0-9a-f]{6}$/;
export const normalizeHexColor = (value: string): string | undefined => {
const normalizedValue = value.trim().toLowerCase();
if (SHORT_HEX_PATTERN.test(normalizedValue)) {
return `#${normalizedValue
.slice(1)
.split('')
.map((character) => `${character}${character}`)
.join('')}`;
}
return LONG_HEX_PATTERN.test(normalizedValue) ? normalizedValue : undefined;
};

View File

@@ -0,0 +1,51 @@
import { isUndefined } from '@sniptt/guards';
import { RestApiClient } from 'twenty-client-sdk/rest';
import { enqueueSnackbar } from 'twenty-sdk/front-component';
import { SYNC_CALENDAR_BOT_SCHEDULING_ROUTE_PATH } from 'src/constants/sync-calendar-bot-scheduling-route-path';
type SyncCalendarBotSchedulingResponse = {
outcome?: string;
canceledCallRecordingCount?: number;
};
const buildSnackbarForResponse = (
response: SyncCalendarBotSchedulingResponse,
): { message: string; variant: 'success' | 'error' } | undefined => {
if (response.outcome === 'sweep-enqueued') {
return undefined;
}
const canceledCount = response.canceledCallRecordingCount ?? 0;
if (canceledCount === 0) {
return undefined;
}
return {
message: `Canceled ${canceledCount} scheduled recording${canceledCount === 1 ? '' : 's'}.`,
variant: 'success',
};
};
export const requestCalendarBotSchedulingSync = async (): Promise<void> => {
try {
const response =
await new RestApiClient().post<SyncCalendarBotSchedulingResponse>(
`/s${SYNC_CALENDAR_BOT_SCHEDULING_ROUTE_PATH}`,
{},
);
const snackbar = buildSnackbarForResponse(response ?? {});
if (!isUndefined(snackbar)) {
await enqueueSnackbar(snackbar);
}
} catch {
await enqueueSnackbar({
message:
'Setting saved, but scheduled recordings could not be updated yet.',
variant: 'error',
});
}
};

View File

@@ -0,0 +1,95 @@
import { RestApiClient } from 'twenty-client-sdk/rest';
import { enqueueSnackbar } from 'twenty-sdk/front-component';
import { GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH } from 'src/constants/generate-call-recording-summaries-route-path';
type GenerateSummariesResponse = {
outcome?: string;
generatedCallRecordingIds?: string[];
failedCallRecordingIds?: string[];
erroredCallRecordingIds?: string[];
skippedCallRecordingIds?: string[];
unavailableCallRecordingIds?: string[];
};
const buildSnackbarForResponse = (
response: GenerateSummariesResponse,
): { message: string; variant: 'success' | 'error' } => {
const generatedCallRecordingCount = (response.generatedCallRecordingIds ?? [])
.length;
const failedCallRecordingCount =
(response.failedCallRecordingIds ?? []).length +
(response.erroredCallRecordingIds ?? []).length;
if (response.outcome === 'disabled') {
return {
message: 'Call summaries are disabled for this workspace.',
variant: 'error',
};
}
if (response.outcome === 'no-call-recordings-for-calendar-events') {
return {
message: 'No call recording found for this event.',
variant: 'error',
};
}
if (generatedCallRecordingCount > 0 && failedCallRecordingCount > 0) {
return {
message: 'Some summaries generated, some failed.',
variant: 'error',
};
}
if (generatedCallRecordingCount > 0) {
return { message: 'Summary generated.', variant: 'success' };
}
if (failedCallRecordingCount > 0) {
return { message: 'Summary generation failed.', variant: 'error' };
}
if ((response.unavailableCallRecordingIds ?? []).length > 0) {
return {
message: 'Summary unavailable for this event.',
variant: 'error',
};
}
if ((response.skippedCallRecordingIds ?? []).length > 0) {
return {
message: 'No usable call recording transcript found for this event.',
variant: 'error',
};
}
return {
message: 'No summary was generated for this event.',
variant: 'error',
};
};
export const requestCallRecordingSummaryGeneration = async ({
calendarEventIds,
}: {
calendarEventIds: string[];
}): Promise<void> => {
if (calendarEventIds.length === 0) {
return;
}
try {
const response = await new RestApiClient().post<GenerateSummariesResponse>(
`/s${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`,
{ calendarEventIds },
);
await enqueueSnackbar(buildSnackbarForResponse(response ?? {}));
} catch {
await enqueueSnackbar({
message: 'Summary generation failed.',
variant: 'error',
});
}
};

View File

@@ -0,0 +1,54 @@
import { isNonEmptyString, isUndefined } from '@sniptt/guards';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
const applicationIdPromiseByFrontComponentId = new Map<
string,
Promise<string>
>();
const fetchCallRecorderApplicationId = async (frontComponentId: string) => {
const client = new MetadataApiClient();
const frontComponentResult = await client.query({
frontComponent: {
__args: { id: frontComponentId },
applicationId: true,
},
});
const applicationId = frontComponentResult.frontComponent?.applicationId;
if (!isNonEmptyString(applicationId)) {
throw new Error('Could not resolve the call recorder application.');
}
return applicationId;
};
export const resolveCallRecorderApplicationId = (
frontComponentId: string,
): Promise<string> => {
const existingApplicationIdPromise =
applicationIdPromiseByFrontComponentId.get(frontComponentId);
if (isUndefined(existingApplicationIdPromise)) {
const applicationIdPromise =
fetchCallRecorderApplicationId(frontComponentId);
applicationIdPromiseByFrontComponentId.set(
frontComponentId,
applicationIdPromise,
);
void applicationIdPromise.catch(() => {
if (
applicationIdPromiseByFrontComponentId.get(frontComponentId) ===
applicationIdPromise
) {
applicationIdPromiseByFrontComponentId.delete(frontComponentId);
}
});
return applicationIdPromise;
}
return existingApplicationIdPromise;
};

View File

@@ -0,0 +1,8 @@
// The settings tab stores RICH_TEXT variables as the blocknote/markdown pair
// produced by Twenty's rich text editor, so plain markdown has to be wrapped
// back into that shape to stay readable by both editors.
export const serializeRichTextMarkdown = (markdown: string): string =>
JSON.stringify({
blocknote: null,
markdown: markdown === '' ? null : markdown,
});