Files
calendar/packages/features/ee/integration-attribute-sync/components/FieldMappingBuilder.tsx
T
Joe Au-YeungGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Eunjae Lee
8dbe3848e7 feat: Create Integration Attribute Sync records (#26007)
* Add db schema

* Add `CredentialRepository.findByTeamIdAndSlugs`

* Add enabled app slugs for attribute syncing

* Create repository for `IntegrationAttributeSync`

* Create zod schemas

* Create `AttributeSyncUserRuleOutputMapper`

* Create `IntegrationAttributeSyncService`

* Create DI contianer

* Create trpc endpoints

* Create page

* Include team name in `CredentialRepository.findByTeamIdAndSlugs`

* Update schema and relations

* Update types and schemas

* Add more methods to IntegrationAttributeSyncRepository

* Add more services to `IntegrationAttributeSyncService`
- getById
- Init updateIncludeRulesAndMappings

* Refactor `getTeams.handler` to use repository

* Create `createAttributeSync` trpc endpoint

* Create `updateAttributeSync` trpc endpoint

* Add router to trpc

* Create attribute sync child components

* Pass custom actions to `FormCard`

* Create `IntegrationAttributeSyncCard`

* Pass inital props via server side

* Fix prop

* Only refetch on mutation

* Fixes

* Add form error when duplicate field and attribute combo

* Add `updateTransactionWithRUleAndMappings` logic

* Adjust zod schemas

* Service add `updateIncludeRulesAndMappings`

* Pass orgId from server to component

* Rename types

* Add deleteById method to repository

* Add name to integrationAttributeSync

* Add deleteById method to service

* Rename method

* Add deleteAttributeSync trpc endpoint

* Make the IntegrationAttributeSyncCard a dummy component

* test: add tests for IntegrationAttributeSync feature

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Move creating a attribute sync record to the service

* Add i18n strings

* Safe select credential in find by id and team

* Fix default credentialId value in form

* Update repository return types

* Add i18n string

* Make credentialId optional for form schema

* Fix label

* Add cascade deletes

* Add verification that syncs belong to org

* Create mapper for repository output

* Type fixes

* Remove old test file

* Pass `attributeOptions` from parent to children

* Infer types from zod schema

* Type fixes

* Type fix

* Clean up

* Add i18n strings

* Remove unused file

* Address feedback

* Add migration file

* Address feedback

* Add validation for new integration values

* Remove unused router

* Move away from z.infer to z.ZodType

* Clean up comments

* Type fix

* Type fixes

* Type fix

* fix: add passthrough to syncFormDataSchema to preserve extra fields

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: remove incorrect test that expected extra fields to pass through syncFormDataSchema

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Add indexes

* Add aria label

* Address feedback - consistent validation

* Fix import paths for attribute types

* refactor: change attributeSyncRules array to singular attributeSyncRule

The database schema enforces a one-to-one relationship between
IntegrationAttributeSync and AttributeSyncRule (via @unique constraint),
and the UI only supports a single rule. This change makes the TypeScript
type match the database schema and UI behavior.

Changes:
- Update IntegrationAttributeSync interface to use attributeSyncRule: AttributeSyncRule | null
- Update mapper to return singular rule instead of wrapping in array
- Update UI component to access sync.attributeSyncRule directly
- Update IIntegrationAttributeSyncUpdateParams Omit type
- Update tests to use attributeSyncRule: null instead of attributeSyncRules: []

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* feat: implement FeatureOptInService (#25805)

* feat: implement FeatureOptInService WIP

* clean up

* feat: consolidate feature repositories and add updateFeatureForUser

- Implement updateFeatureForUser in FeaturesRepository (similar to updateFeatureForTeam)
- Move getUserFeatureState and getTeamFeatureState from PrismaFeatureOptInRepository to FeaturesRepository
- Update FeatureOptInService to use only FeaturesRepository
- Add setUserFeatureState and setTeamFeatureState methods to FeatureOptInService
- Update _router.ts to remove PrismaFeatureOptInRepository usage
- Remove PrismaFeatureOptInRepository.ts and FeatureOptInRepositoryInterface.ts
- Update features.repository.interface.ts and features.repository.mock.ts
- Add integration tests for updateFeatureForUser, getUserFeatureState, getTeamFeatureState
- Update service.integration-test.ts to use FeaturesRepository

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: rename updateFeatureForUser to setUserFeatureState

Rename to match the convention used for setTeamFeatureState

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: return FeatureState type from getUserFeatureState and getTeamFeatureState

* fix integration tests

* clean up logics

* update services and router

* refactor: change getUserFeatureState and getTeamFeatureState to accept featureIds array

- Renamed getUserFeatureState to getUserFeatureStates
- Renamed getTeamFeatureState to getTeamFeatureStates
- Changed parameter from featureId: string to featureIds: string[]
- Changed return type from FeatureState to Record<string, FeatureState>
- Updated FeatureOptInService to use the new batch methods
- Added tests for querying multiple features in a single call
- Optimized listFeaturesForTeam to fetch all feature states in one query

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* feat: add getFeatureStateForTeams for batch querying multiple teams

- Added getFeatureStateForTeams method to query a single feature across multiple teams in one call
- Updated FeatureOptInService.resolveFeatureStateAcrossTeams to use the new batch method
- Replaces N+1 queries with a single database query for team states
- Added comprehensive integration tests for the new method

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: combine org and team state queries into single call

- Include orgId in the teamIds array passed to getFeatureStateForTeams
- Extract org state and team states from the combined result
- Reduces database queries from 3 to 2 in resolveFeatureStateAcrossTeams

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: use team.isOrganization and clarify computeEffectiveState comment

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: use MembershipRepository.findAllByUserId with isOrganization

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* feat: add featureId validation using isOptInFeature type guard

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* less queries

* add fallback value

* fix type error

* move files

* add autoOptInFeatures column

* use autoOptInFeatures flag within FeatureOptInService

* add setUserAutoOptIn and setTeamAutoOptIn

* fix computeEffectiveState logic

* rewrite computeEffectiveState

* clean up integration tests

* clean up in afterEach

* fix type error

* refactor: use FeaturesRepository methods instead of direct Prisma calls

Replace all manual userFeatures and teamFeatures Prisma operations with
the new setUserFeatureState and setTeamFeatureState repository methods.

Changes include:
- Admin handlers (assignFeatureToTeam, unassignFeatureFromTeam)
- Test fixtures and integration tests
- Playwright fixtures
- Development scripts

This ensures consistent feature flag management through the repository
pattern and supports the new tri-state semantics (enabled/disabled/inherit).

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* clean up

* fix the logic

* extract some logic into applyAutoOptIn()

* remove wrong code

* refactor: convert setUserFeatureState and setTeamFeatureState to object params with discriminated union

- Convert multiple positional parameters to single object parameter
- Use discriminated union types: assignedBy required for enabled/disabled, omitted for inherit
- Update all callers across repository, service, handlers, fixtures, and tests

* fix type error

* use Promise.all

* fix

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* Prevent duplicate field and attribute mappings

* Add validation that attribute belongs to the org

* fix: address Cubic AI review feedback

- Add @@index([credentialId]) to IntegrationAttributeSync model for efficient cascade deletes and credential-based queries (confidence: 9/10)
- Fix translation key from 'credential_required' to 'attribute_sync_credential_required' to match existing locale definition (confidence: 10/10)

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: address remaining cubic review feedback

- RuleBuilder.tsx: Use unique IDs for React keys instead of array index to prevent reconciliation bugs when removing conditions
- updateAttributeSync.handler.ts: Add early organization check before service calls for consistency
- createAttributeSync.handler.ts: Add CredentialNotFoundError class for type-safe error handling instead of string matching
- IntegrationAttributeSyncService.test.ts: Replace expect.fail() with proper Vitest rejects.toSatisfy() pattern

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Pull test file from main

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2026-01-12 22:57:28 +00:00

197 lines
5.8 KiB
TypeScript

import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { Input, Select, Switch } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { getDefaultFieldMapping } from "../lib/fieldMappingHelpers";
import type {
IFieldMappingFormState,
IFieldMappingWithOptionalId,
} from "../repositories/IIntegrationAttributeSyncRepository";
type AttributeOptions = {
label: string;
value: string;
}[];
interface FieldMappingBuilderProps {
value: IFieldMappingFormState;
onChange: (value: IFieldMappingFormState) => void;
attributeOptions: AttributeOptions;
isLoadingAttributes?: boolean;
}
interface MappingRowProps {
mapping: IFieldMappingWithOptionalId;
onChange: (mapping: IFieldMappingWithOptionalId) => void;
onRemove: () => void;
attributeOptions: AttributeOptions;
isLoading?: boolean;
error?: string;
}
const MappingRow = ({ mapping, onChange, onRemove, attributeOptions, isLoading, error }: MappingRowProps) => {
const { t } = useLocale();
const selectedAttribute = attributeOptions.find((opt) => opt.value === mapping.attributeId);
const handleFieldNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange({
...mapping,
integrationFieldName: e.target.value,
});
};
const handleAttributeChange = (selected: { value: string; label: string } | null) => {
if (!selected) return;
onChange({
...mapping,
attributeId: selected.value,
});
};
const handleEnabledChange = (checked: boolean) => {
onChange({
...mapping,
enabled: checked,
});
};
return (
<div>
<div
className={`bg-default border-subtle flex flex-wrap items-center gap-3 rounded-lg border p-3 ${error ? "border-red-500" : ""}`}>
<div className="flex-1" style={{ minWidth: "200px" }}>
<Input
type="text"
value={mapping.integrationFieldName}
onChange={handleFieldNameChange}
disabled={isLoading}
placeholder={t("attribute_sync_field_placeholder")}
className="h-7 text-sm"
/>
</div>
<Icon name="arrow-right" className="text-subtle h-4 w-4" />
<div className="flex-1" style={{ minWidth: "200px" }}>
<Select
size="sm"
placeholder={t("attribute_sync_select_attribute")}
options={attributeOptions}
value={selectedAttribute}
onChange={handleAttributeChange}
isLoading={isLoading}
isDisabled={isLoading}
className="w-full"
/>
</div>
<Switch checked={mapping.enabled} onCheckedChange={handleEnabledChange} disabled={isLoading} />
<Button
color="minimal"
size="sm"
StartIcon="trash-2"
onClick={onRemove}
disabled={isLoading}
className="text-subtle hover:text-default"
aria-label={t("remove")}
/>
</div>
{error && <p className="mt-1 text-xs text-red-600">{error}</p>}
</div>
);
};
export const FieldMappingBuilder = ({
value,
onChange,
attributeOptions,
isLoadingAttributes,
}: FieldMappingBuilderProps) => {
const { t } = useLocale();
// Find indices of rows with duplicate attribute mappings
const duplicateIndices = new Set<number>();
const seenAttributes = new Map<string, number>(); // attributeId -> first index
value.mappings.forEach((mapping, index) => {
if (mapping.attributeId) {
const firstIndex = seenAttributes.get(mapping.attributeId);
if (firstIndex !== undefined) {
// Mark both the first occurrence and current as duplicates
duplicateIndices.add(firstIndex);
duplicateIndices.add(index);
} else {
seenAttributes.set(mapping.attributeId, index);
}
}
});
const handleAddMapping = () => {
onChange({
...value,
mappings: [...value.mappings, getDefaultFieldMapping()],
});
};
const handleMappingChange = (index: number, newMapping: IFieldMappingWithOptionalId) => {
const newMappings = [...value.mappings];
newMappings[index] = newMapping;
onChange({
...value,
mappings: newMappings,
});
};
const handleRemoveMapping = (index: number) => {
onChange({
...value,
mappings: value.mappings.filter((_, i) => i !== index),
});
};
return (
<div className="bg-default border-subtle rounded-2xl border p-2">
<div className="ml-2 flex items-center gap-0.5">
<div className="border-subtle rounded-lg border p-1">
<Icon name="link" className="text-subtle h-4 w-4" />
</div>
<span className="text-emphasis ml-2 text-sm font-medium">{t("attribute_sync_field_mappings")}</span>
</div>
<div className="bg-muted mt-2 space-y-2 rounded-xl p-2">
{value.mappings.length === 0 ? (
<div className="text-subtle py-6 text-center text-sm">{t("attribute_sync_no_mappings")}</div>
) : (
value.mappings.map((mapping, index) => (
<MappingRow
key={mapping.id || `new-${index}`}
mapping={mapping}
onChange={(newMapping) => handleMappingChange(index, newMapping)}
onRemove={() => handleRemoveMapping(index)}
attributeOptions={attributeOptions}
isLoading={isLoadingAttributes}
error={
duplicateIndices.has(index)
? t("attribute_sync_duplicate_attribute_mapping")
: undefined
}
/>
))
)}
</div>
<Button
color="minimal"
size="sm"
StartIcon="plus"
className="mt-2"
onClick={handleAddMapping}
disabled={isLoadingAttributes}>
{t("attribute_sync_add_mapping")}
</Button>
</div>
);
};