diff --git a/.github/workflows/codex-review.yml b/.github/workflows/codex-review.yml new file mode 100644 index 000000000..55e5582ba --- /dev/null +++ b/.github/workflows/codex-review.yml @@ -0,0 +1,18 @@ +name: Codex Code Review + +on: + pull_request: + types: [opened, synchronize, reopened] + +concurrency: + group: codex-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + codex-review: + uses: Dashverse/infra/.github/workflows/codex-review.yml@main + secrets: + openai_api_key: ${{ secrets.OPENAI_API_KEY }} + app_id: ${{ secrets.APP_ID }} + app_private_key: ${{ secrets.APP_PRIVATE_KEY }} + diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 9717b531d..9f2d2407a 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -3,19 +3,16 @@ name: Docker Build and Push on: workflow_dispatch: push: - # branches: [ "main" ] + branches: [ "main" ] paths: - "apps/api/**" - "apps/worker/**" - - "apps/public/**" + - "apps/start/**" - "packages/**" - "!packages/sdks/**" - "**Dockerfile" - ".github/workflows/**" -env: - repo_owner: "openpanel-dev" - jobs: changes: runs-on: ubuntu-latest @@ -48,89 +45,28 @@ jobs: - 'packages/**' - '.github/workflows/**' - lint-and-test: - needs: changes - if: ${{ needs.changes.outputs.api == 'true' || needs.changes.outputs.worker == 'true' || needs.changes.outputs.public == 'true' || needs.changes.outputs.dashboard == 'true' }} - runs-on: ubuntu-latest - services: - redis: - image: redis:7-alpine - ports: - - 6379:6379 - options: >- - --health-cmd "redis-cli ping || exit 1" - --health-interval 5s - --health-timeout 3s - --health-retries 20 - steps: - - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - - - name: Install pnpm - uses: pnpm/action-setup@v4 - - - name: Get pnpm store directory - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - - name: Setup pnpm cache - uses: actions/cache@v3 - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - - name: Install dependencies - run: pnpm install - - - name: Codegen - run: pnpm codegen - - # - name: Run Biome - # run: pnpm lint - - # - name: Run TypeScript checks - # run: pnpm typecheck - - # - name: Run tests - # run: pnpm test - build-and-push-api: - permissions: - packages: write - contents: write - needs: [changes, lint-and-test] + needs: changes if: ${{ needs.changes.outputs.api == 'true' }} runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Generate tags - id: tags + - name: Generate short SHA + id: short-sha run: | - # Sanitize branch name by replacing / with - - BRANCH_NAME=$(echo "${{ github.ref_name }}" | sed 's/\//-/g') - # Get first 4 characters of commit SHA - SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-4) - echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT - echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT + echo "sha=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Log in to GitHub Container Registry + - name: Login to Azure Container Registry uses: docker/login-action@v3 with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + registry: ${{ secrets.AZURE_DASH_REGISTRY_URL }} + username: ${{ secrets.AZURE_DASH_REGISTRY_CLIENT_ID }} + password: ${{ secrets.AZURE_DASH_REGISTRY_CLIENT_SECRET }} - name: Build and push Docker image uses: docker/build-push-action@v6 @@ -141,57 +77,33 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max tags: | - ghcr.io/${{ env.repo_owner }}/api:${{ steps.tags.outputs.branch_name }}-${{ steps.tags.outputs.short_sha }} + ${{ secrets.AZURE_DASH_REGISTRY_URL }}/openpanel-api:${{ steps.short-sha.outputs.sha }} + ${{ secrets.AZURE_DASH_REGISTRY_URL }}/openpanel-api:latest build-args: | DATABASE_URL=postgresql://dummy:dummy@localhost:5432/dummy - - name: Create/Update API tag - if: github.ref == 'refs/heads/main' - run: | - # Delete existing tag if it exists - if git tag -l "api" | grep -q "api"; then - git tag -d "api" - echo "Deleted local tag: api" - fi - - # Create new tag - git tag "api" "${{ github.sha }}" - echo "Created tag: api" - - # Push tag to remote - git push origin "api" --force - echo "Pushed tag: api" - build-and-push-worker: - permissions: - packages: write - contents: write - needs: [changes, lint-and-test] + needs: changes if: ${{ needs.changes.outputs.worker == 'true' }} runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Generate tags - id: tags + - name: Generate short SHA + id: short-sha run: | - # Sanitize branch name by replacing / with - - BRANCH_NAME=$(echo "${{ github.ref_name }}" | sed 's/\//-/g') - # Get first 4 characters of commit SHA - SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-4) - echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT - echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT + echo "sha=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Log in to GitHub Container Registry + - name: Login to Azure Container Registry uses: docker/login-action@v3 with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + registry: ${{ secrets.AZURE_DASH_REGISTRY_URL }} + username: ${{ secrets.AZURE_DASH_REGISTRY_CLIENT_ID }} + password: ${{ secrets.AZURE_DASH_REGISTRY_CLIENT_SECRET }} - name: Build and push Docker image uses: docker/build-push-action@v6 @@ -202,57 +114,33 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max tags: | - ghcr.io/${{ env.repo_owner }}/worker:${{ steps.tags.outputs.branch_name }}-${{ steps.tags.outputs.short_sha }} + ${{ secrets.AZURE_DASH_REGISTRY_URL }}/openpanel-worker:${{ steps.short-sha.outputs.sha }} + ${{ secrets.AZURE_DASH_REGISTRY_URL }}/openpanel-worker:latest build-args: | DATABASE_URL=postgresql://dummy:dummy@localhost:5432/dummy - - name: Create/Update Worker tag - if: github.ref == 'refs/heads/main' - run: | - # Delete existing tag if it exists - if git tag -l "worker" | grep -q "worker"; then - git tag -d "worker" - echo "Deleted local tag: worker" - fi - - # Create new tag - git tag "worker" "${{ github.sha }}" - echo "Created tag: worker" - - # Push tag to remote - git push origin "worker" --force - echo "Pushed tag: worker" - build-and-push-dashboard: - permissions: - packages: write - contents: write - needs: [changes, lint-and-test] + needs: changes if: ${{ needs.changes.outputs.dashboard == 'true' }} runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Generate tags - id: tags + - name: Generate short SHA + id: short-sha run: | - # Sanitize branch name by replacing / with - - BRANCH_NAME=$(echo "${{ github.ref_name }}" | sed 's/\//-/g') - # Get first 4 characters of commit SHA - SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-4) - echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT - echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT + echo "sha=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Log in to GitHub Container Registry + - name: Login to Azure Container Registry uses: docker/login-action@v3 with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + registry: ${{ secrets.AZURE_DASH_REGISTRY_URL }} + username: ${{ secrets.AZURE_DASH_REGISTRY_CLIENT_ID }} + password: ${{ secrets.AZURE_DASH_REGISTRY_CLIENT_SECRET }} - name: Build and push Docker image uses: docker/build-push-action@v6 @@ -263,23 +151,7 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max tags: | - ghcr.io/${{ env.repo_owner }}/dashboard:${{ steps.tags.outputs.branch_name }}-${{ steps.tags.outputs.short_sha }} + ${{ secrets.AZURE_DASH_REGISTRY_URL }}/openpanel-dashboard:${{ steps.short-sha.outputs.sha }} + ${{ secrets.AZURE_DASH_REGISTRY_URL }}/openpanel-dashboard:latest build-args: | NO_CLOUDFLARE=1 - - - name: Create/Update Dashboard tag - if: github.ref == 'refs/heads/main' - run: | - # Delete existing tag if it exists - if git tag -l "dashboard" | grep -q "dashboard"; then - git tag -d "dashboard" - echo "Deleted local tag: dashboard" - fi - - # Create new tag - git tag "dashboard" "${{ github.sha }}" - echo "Created tag: dashboard" - - # Push tag to remote - git push origin "dashboard" --force - echo "Pushed tag: dashboard" diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml new file mode 100644 index 000000000..3c8a6250e --- /dev/null +++ b/.github/workflows/test-build.yml @@ -0,0 +1,89 @@ +name: Test Build + +on: + push: + branches: + - '**' + - '!main' + paths: + - "apps/api/**" + - "apps/worker/**" + - "apps/start/**" + - "packages/**" + - "!packages/sdks/**" + - "**Dockerfile" + - ".github/workflows/**" + +jobs: + changes: + runs-on: ubuntu-latest + outputs: + api: ${{ steps.filter.outputs.api }} + worker: ${{ steps.filter.outputs.worker }} + public: ${{ steps.filter.outputs.public }} + dashboard: ${{ steps.filter.outputs.dashboard }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v2 + id: filter + with: + base: ${{ github.ref }} + filters: | + api: + - 'apps/api/**' + - 'packages/**' + - '.github/workflows/**' + worker: + - 'apps/worker/**' + - 'packages/**' + - '.github/workflows/**' + public: + - 'apps/public/**' + - 'packages/**' + - '.github/workflows/**' + dashboard: + - 'apps/start/**' + - 'packages/**' + - '.github/workflows/**' + + test-build-api: + needs: changes + if: ${{ needs.changes.outputs.api == 'true' }} + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Test build + run: docker buildx build --file apps/api/Dockerfile --build-arg DATABASE_URL=postgresql://dummy:dummy@localhost:5432/dummy . + + test-build-worker: + needs: changes + if: ${{ needs.changes.outputs.worker == 'true' }} + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Test build + run: docker buildx build --file apps/worker/Dockerfile --build-arg DATABASE_URL=postgresql://dummy:dummy@localhost:5432/dummy . + + test-build-dashboard: + needs: changes + if: ${{ needs.changes.outputs.dashboard == 'true' }} + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Test build + run: docker buildx build --file apps/start/Dockerfile --build-arg NO_CLOUDFLARE=1 . diff --git a/apps/api/package.json b/apps/api/package.json index 869c81a5a..f15240e51 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -65,4 +65,4 @@ "tsdown": "0.14.2", "typescript": "catalog:" } -} \ No newline at end of file +} diff --git a/apps/api/src/controllers/live.controller.ts b/apps/api/src/controllers/live.controller.ts index 29931aa84..cd7afe914 100644 --- a/apps/api/src/controllers/live.controller.ts +++ b/apps/api/src/controllers/live.controller.ts @@ -8,17 +8,10 @@ import { transformMinimalEvent, } from '@openpanel/db'; import { setSuperJson } from '@openpanel/json'; -import { - psubscribeToPublishedEvent, - subscribeToPublishedEvent, -} from '@openpanel/redis'; +import { subscribeToPublishedEvent } from '@openpanel/redis'; import { getProjectAccess } from '@openpanel/trpc'; import { getOrganizationAccess } from '@openpanel/trpc/src/access'; -export function getLiveEventInfo(key: string) { - return key.split(':').slice(2) as [string, string]; -} - export function wsVisitors( socket: WebSocket, req: FastifyRequest<{ @@ -36,21 +29,8 @@ export function wsVisitors( } }); - const punsubscribe = psubscribeToPublishedEvent( - '__keyevent@0__:expired', - (key) => { - const [projectId] = getLiveEventInfo(key); - if (projectId && projectId === params.projectId) { - eventBuffer.getActiveVisitorCount(params.projectId).then((count) => { - socket.send(String(count)); - }); - } - }, - ); - socket.on('close', () => { unsubscribe(); - punsubscribe(); }); } diff --git a/apps/api/src/controllers/track.controller.ts b/apps/api/src/controllers/track.controller.ts index 2ee11fc98..87e881bf9 100644 --- a/apps/api/src/controllers/track.controller.ts +++ b/apps/api/src/controllers/track.controller.ts @@ -334,7 +334,14 @@ async function increment({ projectId: string; }) { const { profileId, property, value } = payload; - const profile = await getProfileById(profileId, projectId); + let profile; + try { + profile = await getProfileById(profileId, projectId); + } catch (error) { + throw new HttpError('Failed to fetch profile for increment', { + status: 500, + }); + } if (!profile) { throw new Error('Not found'); } @@ -370,7 +377,14 @@ async function decrement({ projectId: string; }) { const { profileId, property, value } = payload; - const profile = await getProfileById(profileId, projectId); + let profile; + try { + profile = await getProfileById(profileId, projectId); + } catch (error) { + throw new HttpError('Failed to fetch profile for decrement', { + status: 500, + }); + } if (!profile) { throw new Error('Not found'); } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 7d3632ca2..dd93d0f73 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -248,7 +248,6 @@ const startServer = async () => { }); process.on('unhandledRejection', async (reason, promise) => { logger.error('Unhandled rejection', { reason, promise }); - await shutdown(fastify, 'unhandledRejection', 1); }); } diff --git a/apps/api/src/routes/event.router.ts b/apps/api/src/routes/event.router.ts index 5efa52add..4dd1eeef0 100644 --- a/apps/api/src/routes/event.router.ts +++ b/apps/api/src/routes/event.router.ts @@ -6,7 +6,7 @@ import { duplicateHook } from '@/hooks/duplicate.hook'; import { isBotHook } from '@/hooks/is-bot.hook'; const eventRouter: FastifyPluginCallback = async (fastify) => { - fastify.addHook('preValidation', duplicateHook); + // fastify.addHook('preValidation', duplicateHook); fastify.addHook('preHandler', clientHook); fastify.addHook('preHandler', isBotHook); diff --git a/apps/api/src/routes/track.router.ts b/apps/api/src/routes/track.router.ts index ec777a3ab..5c83eabbe 100644 --- a/apps/api/src/routes/track.router.ts +++ b/apps/api/src/routes/track.router.ts @@ -6,7 +6,7 @@ import { duplicateHook } from '@/hooks/duplicate.hook'; import { isBotHook } from '@/hooks/is-bot.hook'; const trackRouter: FastifyPluginCallback = async (fastify) => { - fastify.addHook('preValidation', duplicateHook); + // fastify.addHook('preValidation', duplicateHook); fastify.addHook('preHandler', clientHook); fastify.addHook('preHandler', isBotHook); diff --git a/apps/api/src/utils/graceful-shutdown.ts b/apps/api/src/utils/graceful-shutdown.ts index 276762ae6..c52068aa9 100644 --- a/apps/api/src/utils/graceful-shutdown.ts +++ b/apps/api/src/utils/graceful-shutdown.ts @@ -8,9 +8,11 @@ import { } from '@openpanel/queue'; import { getRedisCache, + getRedisEvent, getRedisPub, getRedisQueue, getRedisSub, + getRedisSession, } from '@openpanel/redis'; import type { FastifyInstance } from 'fastify'; import { logger } from './logger'; @@ -89,6 +91,8 @@ export async function shutdown( getRedisPub(), getRedisSub(), getRedisQueue(), + getRedisSession(), + getRedisEvent(), ]; await Promise.all( diff --git a/apps/start/src/components/cohort/cohort-criteria-builder.tsx b/apps/start/src/components/cohort/cohort-criteria-builder.tsx new file mode 100644 index 000000000..7788eabc5 --- /dev/null +++ b/apps/start/src/components/cohort/cohort-criteria-builder.tsx @@ -0,0 +1,638 @@ +import { Button } from '@/components/ui/button'; +import { ComboboxAdvanced } from '@/components/ui/combobox-advanced'; +import { DropdownMenuComposed } from '@/components/ui/dropdown-menu'; +import { InputWithLabel } from '@/components/forms/input-with-label'; +import { useAppParams } from '@/hooks/use-app-params'; +import { useEventNames } from '@/hooks/use-event-names'; +import { operators } from '@openpanel/constants'; +import type { + CohortDefinition, + EventBasedCohortDefinition, + PropertyBasedCohortDefinition, + EventCriteria, + IChartEventFilter, +} from '@openpanel/validation'; +import { mapKeys } from '@openpanel/validation'; +import { PlusIcon, TrashIcon, FilterIcon } from 'lucide-react'; +import { useState } from 'react'; +import { ColorSquare } from '@/components/color-square'; +import { SlidersHorizontal } from 'lucide-react'; +import { PureFilterItem } from '@/components/report/sidebar/filters/FilterItem'; +import { PropertiesCombobox } from '@/components/report/sidebar/PropertiesCombobox'; + +interface CohortCriteriaBuilderProps { + definition: CohortDefinition; + onChange: (definition: CohortDefinition) => void; +} + +export function CohortCriteriaBuilder({ + definition, + onChange, +}: CohortCriteriaBuilderProps) { + const { projectId } = useAppParams(); + const eventNames = useEventNames({ projectId }); + + const handleTypeChange = (type: 'event' | 'property') => { + if (type === 'event') { + onChange({ + type: 'event', + criteria: { + events: [], + operator: 'or', + }, + }); + } else { + onChange({ + type: 'property', + criteria: { + properties: [], + operator: 'or', + }, + }); + } + }; + + return ( +
+
+ + +
+ + {definition.type === 'event' && ( + + )} + + {definition.type === 'property' && ( + + )} +
+ ); +} + +interface EventBasedBuilderProps { + definition: EventBasedCohortDefinition; + onChange: (definition: EventBasedCohortDefinition) => void; + eventNames: Array<{ name: string; count: number; meta: any }>; +} + +function EventBasedBuilder({ + definition, + onChange, + eventNames: eventNamesArray, +}: EventBasedBuilderProps) { + // Transform array of event objects to format expected by ComboboxAdvanced + const eventNames = eventNamesArray.map((event) => ({ + value: event.name, + label: event.name, + count: event.count, + })); + + const addEventCriteria = () => { + onChange({ + ...definition, + criteria: { + ...definition.criteria, + events: [ + ...definition.criteria.events, + { + name: '', + filters: [], + timeframe: { type: 'relative', value: '30d' }, + frequency: { operator: 'at_least', count: 1 }, + }, + ], + }, + }); + }; + + const removeEventCriteria = (index: number) => { + onChange({ + ...definition, + criteria: { + ...definition.criteria, + events: definition.criteria.events.filter((_, i) => i !== index), + }, + }); + }; + + const updateEventCriteria = (index: number, criteria: EventCriteria) => { + onChange({ + ...definition, + criteria: { + ...definition.criteria, + events: definition.criteria.events.map((e, i) => + i === index ? criteria : e, + ), + }, + }); + }; + + const updateOperator = (operator: 'or' | 'and') => { + onChange({ + ...definition, + criteria: { + ...definition.criteria, + operator, + }, + }); + }; + + return ( +
+
+ Match + + + +
+ + {definition.criteria.events.map((eventCriteria, index) => ( + updateEventCriteria(index, criteria)} + onRemove={() => removeEventCriteria(index)} + eventNames={eventNames} + /> + ))} + + +
+ ); +} + +interface EventCriteriaItemProps { + criteria: EventCriteria; + onChange: (criteria: EventCriteria) => void; + onRemove: () => void; + eventNames: Array<{ value: string; label: string; count: number }>; +} + +function EventCriteriaItem({ + criteria, + onChange, + onRemove, + eventNames, +}: EventCriteriaItemProps) { + const addFilter = (propertyName: string) => { + onChange({ + ...criteria, + filters: [ + ...criteria.filters, + { + id: Math.random().toString(36).substring(7), + name: propertyName, + operator: 'is', + value: [], + }, + ], + }); + }; + + const removeFilter = (filter: IChartEventFilter) => { + onChange({ + ...criteria, + filters: criteria.filters.filter((f) => f.id !== filter.id), + }); + }; + + const updateFilterValue = ( + value: Array, + filter: IChartEventFilter, + ) => { + onChange({ + ...criteria, + filters: criteria.filters.map((f) => + f.id === filter.id ? { ...f, value } : f, + ), + }); + }; + + const updateFilterOperator = ( + operator: IChartEventFilter['operator'], + filter: IChartEventFilter, + ) => { + onChange({ + ...criteria, + filters: criteria.filters.map((f) => + f.id === filter.id ? { ...f, operator, value: f.value.slice(0, 1) } : f, + ), + }); + }; + + return ( +
+
+
+ + + onChange({ ...criteria, name: values[0] || '' }) + } + placeholder="Select event..." + className="w-full" + /> +
+ +
+ + {/* Frequency */} +
+ +
+ + onChange({ + ...criteria, + frequency: { ...criteria.frequency!, operator }, + }) + } + items={[ + { value: 'at_least', label: 'At least' }, + { value: 'exactly', label: 'Exactly' }, + { value: 'at_most', label: 'At most' }, + ]} + label="Operator" + > + + + + onChange({ + ...criteria, + frequency: { + ...criteria.frequency!, + count: parseInt(e.target.value) || 1, + }, + }) + } + className="w-20 rounded border px-2 py-1 text-sm" + /> + + times + +
+
+ + {/* Timeframe */} +
+ +
+
+ { + if (type === 'relative') { + onChange({ + ...criteria, + timeframe: { type: 'relative', value: '30d' }, + }); + } else if (type === 'since') { + onChange({ + ...criteria, + timeframe: { + type: 'absolute', + start: new Date().toISOString().split('T')[0], + }, + }); + } else { + // between + const today = new Date().toISOString().split('T')[0]; + const thirtyDaysAgo = new Date( + Date.now() - 30 * 24 * 60 * 60 * 1000, + ) + .toISOString() + .split('T')[0]; + onChange({ + ...criteria, + timeframe: { + type: 'absolute', + start: thirtyDaysAgo, + end: today, + }, + }); + } + }} + items={[ + { value: 'relative', label: 'Last' }, + { value: 'since', label: 'Since' }, + { value: 'between', label: 'Between' }, + ]} + label="Type" + > + + + {criteria.timeframe.type === 'relative' ? ( + + onChange({ + ...criteria, + timeframe: { type: 'relative', value }, + }) + } + items={[ + { value: '7d', label: '7 days' }, + { value: '30d', label: '30 days' }, + { value: '90d', label: '90 days' }, + { value: '180d', label: '180 days' }, + { value: '365d', label: '365 days' }, + ]} + label="Period" + > + + + ) : !criteria.timeframe.end ? ( + + onChange({ + ...criteria, + timeframe: { type: 'absolute', start: e.target.value }, + }) + } + className="rounded border px-2 py-1 text-sm" + /> + ) : null} +
+ {criteria.timeframe.type === 'absolute' && criteria.timeframe.end && ( +
+ + onChange({ + ...criteria, + timeframe: { + type: 'absolute', + start: e.target.value, + end: criteria.timeframe.end, + }, + }) + } + className="flex-1 rounded border px-2 py-1 text-sm" + /> + to + + onChange({ + ...criteria, + timeframe: { + type: 'absolute', + start: criteria.timeframe.start, + end: e.target.value, + }, + }) + } + className="flex-1 rounded border px-2 py-1 text-sm" + /> +
+ )} +
+
+ + {/* Filters */} + {criteria.filters.length > 0 && ( +
+ +
+ {criteria.filters.map((filter) => ( + + ))} +
+
+ )} + + { + addFilter(action.value); + }} + mode="events" + > + {(setOpen) => ( + + )} + +
+ ); +} + +interface PropertyBasedBuilderProps { + definition: PropertyBasedCohortDefinition; + onChange: (definition: PropertyBasedCohortDefinition) => void; +} + +function PropertyBasedBuilder({ + definition, + onChange, +}: PropertyBasedBuilderProps) { + const addPropertyFilter = (propertyName: string) => { + onChange({ + ...definition, + criteria: { + ...definition.criteria, + properties: [ + ...definition.criteria.properties, + { + id: Math.random().toString(36).substring(7), + name: propertyName, + operator: 'is', + value: [], + }, + ], + }, + }); + }; + + const removePropertyFilter = (filter: IChartEventFilter) => { + onChange({ + ...definition, + criteria: { + ...definition.criteria, + properties: definition.criteria.properties.filter( + (f) => f.id !== filter.id, + ), + }, + }); + }; + + const updatePropertyFilterValue = ( + value: Array, + filter: IChartEventFilter, + ) => { + onChange({ + ...definition, + criteria: { + ...definition.criteria, + properties: definition.criteria.properties.map((f) => + f.id === filter.id ? { ...f, value } : f, + ), + }, + }); + }; + + const updatePropertyFilterOperator = ( + operator: IChartEventFilter['operator'], + filter: IChartEventFilter, + ) => { + onChange({ + ...definition, + criteria: { + ...definition.criteria, + properties: definition.criteria.properties.map((f) => + f.id === filter.id + ? { ...f, operator, value: f.value.slice(0, 1) } + : f, + ), + }, + }); + }; + + const updateOperator = (operator: 'or' | 'and') => { + onChange({ + ...definition, + criteria: { + ...definition.criteria, + operator, + }, + }); + }; + + return ( +
+
+ Match + + + +
+ + {definition.criteria.properties.length > 0 && ( +
+ {definition.criteria.properties.map((filter) => ( + + ))} +
+ )} + + { + addPropertyFilter(action.value); + }} + mode="profile" + > + {(setOpen) => ( + + )} + +
+ ); +} diff --git a/apps/start/src/components/custom-event/custom-event-builder.tsx b/apps/start/src/components/custom-event/custom-event-builder.tsx new file mode 100644 index 000000000..0503fe750 --- /dev/null +++ b/apps/start/src/components/custom-event/custom-event-builder.tsx @@ -0,0 +1,268 @@ +import { Button } from '@/components/ui/button'; +import { ComboboxAdvanced } from '@/components/ui/combobox-advanced'; +import { useAppParams } from '@/hooks/use-app-params'; +import { useEventNames } from '@/hooks/use-event-names'; +import type { + ICustomEventDefinition, + ICustomEventCriteria, + IChartEventFilter, +} from '@openpanel/validation'; +import { PlusIcon, TrashIcon, FilterIcon } from 'lucide-react'; +import { useState } from 'react'; +import { PureFilterItem } from '@/components/report/sidebar/filters/FilterItem'; +import { PropertiesCombobox } from '@/components/report/sidebar/PropertiesCombobox'; +import { ColorSquare } from '@/components/color-square'; + +interface CustomEventBuilderProps { + value: ICustomEventDefinition; + onChange: (definition: ICustomEventDefinition) => void; + projectId: string; +} + +export function CustomEventBuilder({ + value, + onChange, + projectId, +}: CustomEventBuilderProps) { + const eventNamesQuery = useEventNames({ projectId }); + + // Transform array of event objects to format expected by ComboboxAdvanced + // Filter out custom events (can't create custom event from custom events) + const eventNames = (eventNamesQuery || []) + .filter((event) => !event.isCustom && event.name !== '*') + .map((event) => ({ + value: event.name, + label: event.name, + count: event.count, + meta: event.meta, + })); + + const addEvent = () => { + onChange({ + ...value, + events: [ + ...value.events, + { + name: '', + filters: [], + }, + ], + }); + }; + + const removeEvent = (index: number) => { + onChange({ + ...value, + events: value.events.filter((_, i) => i !== index), + }); + }; + + const updateEvent = (index: number, criteria: ICustomEventCriteria) => { + onChange({ + ...value, + events: value.events.map((e, i) => (i === index ? criteria : e)), + }); + }; + + return ( +
+
+ Match when ANY of the following + events happen: +
+ + {value.events.length === 0 && ( +
+ No source events added yet. Click "Add Event" to get started. +
+ )} + + {value.events.map((eventCriteria, index) => ( + updateEvent(index, criteria)} + onRemove={() => removeEvent(index)} + eventNames={eventNames} + projectId={projectId} + /> + ))} + + + + {value.events.length > 20 && ( +
+ Maximum 20 events allowed (Mixpanel limit) +
+ )} +
+ ); +} + +interface EventCriteriaItemProps { + criteria: ICustomEventCriteria; + onChange: (criteria: ICustomEventCriteria) => void; + onRemove: () => void; + eventNames: Array<{ + value: string; + label: string; + count: number; + meta?: any; + }>; + projectId: string; +} + +function EventCriteriaItem({ + criteria, + onChange, + onRemove, + eventNames, + projectId, +}: EventCriteriaItemProps) { + const [showFilters, setShowFilters] = useState( + criteria.filters && criteria.filters.length > 0, + ); + + const addFilter = (propertyName: string) => { + onChange({ + ...criteria, + filters: [ + ...criteria.filters, + { + id: Math.random().toString(36).substring(7), + name: propertyName, + operator: 'is', + value: [], + }, + ], + }); + }; + + const removeFilter = (filter: IChartEventFilter) => { + onChange({ + ...criteria, + filters: criteria.filters.filter((f) => f.id !== filter.id), + }); + }; + + const updateFilterValue = ( + value: Array, + filter: IChartEventFilter, + ) => { + onChange({ + ...criteria, + filters: criteria.filters.map((f) => + f.id === filter.id ? { ...f, value } : f, + ), + }); + }; + + const updateFilterOperator = ( + operator: IChartEventFilter['operator'], + filter: IChartEventFilter, + ) => { + onChange({ + ...criteria, + filters: criteria.filters.map((f) => + f.id === filter.id ? { ...f, operator, value: f.value.slice(0, 1) } : f, + ), + }); + }; + + return ( +
+
+
+ onChange({ ...criteria, name: values[0] || '' })} + placeholder="Select event..." + items={eventNames} + renderLabel={(item) => ( +
+ {item.meta?.icon && {item.meta.icon}} + {item.meta?.color && } + {item.label} + {item.count > 0 && ( + + ({item.count.toLocaleString()}) + + )} +
+ )} + /> +
+ + + + +
+ + {showFilters && ( +
+ {criteria.filters && criteria.filters.length > 0 && ( +
+ + {criteria.filters.map((filter) => ( + + ))} +
+ )} + + { + addFilter(action.value); + }} + mode="events" + > + {(setOpen) => ( + + )} + +
+ )} +
+ ); +} diff --git a/apps/start/src/components/forms/textarea-with-label.tsx b/apps/start/src/components/forms/textarea-with-label.tsx new file mode 100644 index 000000000..666e41062 --- /dev/null +++ b/apps/start/src/components/forms/textarea-with-label.tsx @@ -0,0 +1,24 @@ +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { forwardRef } from 'react'; + +interface TextareaWithLabelProps + extends React.TextareaHTMLAttributes { + label: string; +} + +export const TextareaWithLabel = forwardRef< + HTMLTextAreaElement, + TextareaWithLabelProps +>(({ label, id, ...props }, ref) => { + const inputId = id || label.toLowerCase().replace(/\s+/g, '-'); + + return ( +
+ +