Files
calendar/packages/ui/components/card/FormCard.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

222 lines
6.6 KiB
TypeScript

"use client";
import Link from "next/link";
import { useState } from "react";
import classNames from "@calcom/ui/classNames";
import type { BadgeProps } from "../badge";
import { Badge } from "../badge";
import { Button } from "../button";
import { Dropdown, DropdownMenuTrigger, DropdownMenuContent, DropdownItem } from "../dropdown";
import { Input } from "../form/inputs/TextField";
import { Icon } from "../icon";
import type { IconName } from "../icon";
type Action = { check: () => boolean; fn: () => void; color?: "destructive" | "minimal"; disabled?: boolean };
type FormCardActionsProps = {
deleteField?: Action | null;
duplicateField?: Action | null;
};
const FormCardActions = ({ deleteField, duplicateField }: FormCardActionsProps) => {
type ActionItem = {
label: string;
icon: IconName;
onClick: () => void;
color: "destructive" | "minimal";
disabled?: boolean;
};
const actions: ActionItem[] = [
duplicateField?.fn && {
label: "Duplicate",
icon: "copy",
onClick: () => duplicateField.fn(),
},
deleteField?.fn && {
label: "Delete",
icon: "trash",
onClick: () => deleteField.fn(),
color: deleteField.color ?? "minimal",
disabled: deleteField.disabled,
},
].filter((action): action is ActionItem => !!action);
if (actions.length === 0) return null;
// If only one action, show a single icon button
if (actions.length === 1) {
const action = actions[0];
return (
<Button
type="button"
variant="icon"
color={action.color || "minimal"}
className="ml-2"
onClick={action.onClick}
StartIcon={action.icon}
title={action.label}
disabled={action.disabled}
/>
);
}
// If multiple actions, show dropdown
return (
<Dropdown>
<DropdownMenuTrigger asChild>
<Button type="button" variant="icon" color="minimal" className="ml-2">
<Icon name="ellipsis" className="text-default h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{actions.map((action) => (
<DropdownItem
key={action.label}
StartIcon={action.icon}
onClick={(e) => {
e.preventDefault();
action.onClick();
}}
disabled={action.disabled}
color={action.color}>
{action.label}
</DropdownItem>
))}
</DropdownMenuContent>
</Dropdown>
);
};
export default function FormCard({
children,
label,
isLabelEditable,
onLabelChange,
deleteField,
duplicateField,
moveUp,
moveDown,
className,
badge,
collapsible = true,
leftIcon,
customActions,
...restProps
}: {
children: React.ReactNode;
label: React.ReactNode;
isLabelEditable?: boolean;
onLabelChange?: (label: string) => void;
deleteField?: Action | null;
duplicateField?: Action | null;
moveUp?: Action | null;
moveDown?: Action | null;
className?: string;
badge?: { text: string; href?: string; variant: BadgeProps["variant"] } | null;
leftIcon?: IconName;
collapsible?: boolean;
customActions?: React.ReactNode;
} & JSX.IntrinsicElements["div"]) {
className = classNames(
"flex items-center group relative w-full rounded-2xl p-1 border border-subtle bg-cal-muted mb-2",
className
);
const [isCollapsed, setIsCollapsed] = useState(false);
const toggleFormCard = () => {
setIsCollapsed((prev) => !prev);
};
return (
<div className={className} {...restProps}>
<div className="absolute left-0 top-1/2 -translate-y-1/2">
{moveUp?.check() ? (
<button
type="button"
className="bg-default text-muted hover:text-emphasis invisible -ml-2 mb-1 flex h-6 w-6 scale-0 items-center justify-center rounded-md border p-1 transition-all hover:border-transparent hover:shadow group-hover:visible group-hover:scale-100"
onClick={() => moveUp?.fn()}>
<Icon name="arrow-up" />
</button>
) : null}
{moveDown?.check() ? (
<button
type="button"
className="bg-default text-muted hover:text-emphasis invisible -ml-2 flex h-6 w-6 scale-0 items-center justify-center rounded-md border p-1 transition-all hover:border-transparent hover:shadow group-hover:visible group-hover:scale-100"
onClick={() => moveDown?.fn()}>
<Icon name="arrow-down" />
</button>
) : null}
</div>
<div className="w-full">
<div className="flex items-center justify-between p-2">
<div className="flex items-center gap-2">
{leftIcon && (
<div className="text-subtle border-subtle rounded-lg border p-1.5">
<Icon name={leftIcon} className="text-default h-4 w-4" />
</div>
)}
{collapsible && (
<Button
size="sm"
variant="icon"
color="minimal"
CustomStartIcon={
<Icon
name="chevron-up"
className={classNames(
"text-default h-4 w-4 transition-transform",
isCollapsed && "rotate-180"
)}
/>
}
onClick={() => {
toggleFormCard();
}}
className="text-muted"
/>
)}
{typeof label === "string" ? (
isLabelEditable ? (
<Input type="text" value={label} onChange={(e) => onLabelChange?.(e.target.value)} />
) : (
<span className="text-emphasis text-sm font-semibold">{label}</span>
)
) : (
label
)}
{badge && (
<Badge className="ml-2" variant={badge.variant}>
{badge.href ? <Link href={badge.href}>{badge.text}</Link> : badge.text}
</Badge>
)}
</div>
<div className="flex items-center gap-2">
{customActions}
<FormCardActions deleteField={deleteField} duplicateField={duplicateField} />
</div>
</div>
<div className={isCollapsed ? "hidden" : ""}>{children}</div>
</div>
</div>
);
}
export function FormCardBody({
children,
className = "",
...props
}: {
children: React.ReactNode;
className?: string;
} & React.HTMLAttributes<HTMLDivElement>) {
return (
<div className={`bg-default border-default w-full gap-3 rounded-2xl border p-3 ${className}`} {...props}>
{children}
</div>
);
}