Browse Source

feat: open source gsApp (revised) (#85592)

This open sources gsApp. Getsentry will remain unaffected after this is
merged, then https://github.com/getsentry/getsentry/pull/16675 will
remove gsApp in getsentry.

Differences:
- gsApp's entrypoint index.tsx is inlined into the main sentry app, and
makes use of sentry mode
(https://github.com/getsentry/sentry/pull/80342) so that open source
sentry will not initialize gsApp.
- getsentry devserver will set sentryMode appropriately so that gsApp is
initialized

getsentry devserver is still needed to work on gsApp, even though it
lives in sentry. Eventually it'll all (gsAdmin, jest/webpack/eslint
configs) be moved/merged over.
joshuarli 1 week ago
parent
commit
af7532a51f

+ 4 - 1
config/tsconfig.base.json

@@ -100,10 +100,13 @@
       "sentry/*": ["static/app/*"],
       "sentry/*": ["static/app/*"],
       "sentry-fixture/*": ["tests/js/fixtures/*"],
       "sentry-fixture/*": ["tests/js/fixtures/*"],
       "sentry-test/*": ["tests/js/sentry-test/*"],
       "sentry-test/*": ["tests/js/sentry-test/*"],
+      "getsentry-test/*": ["tests/js/getsentry-test/*"],
       "sentry-images/*": ["static/images/*"],
       "sentry-images/*": ["static/images/*"],
       "sentry-locale/*": ["src/sentry/locale/*"],
       "sentry-locale/*": ["src/sentry/locale/*"],
       "sentry-logos/*": ["src/sentry/static/sentry/images/logos/*"],
       "sentry-logos/*": ["src/sentry/static/sentry/images/logos/*"],
-      "sentry-fonts/*": ["static/fonts/*"]
+      "sentry-fonts/*": ["static/fonts/*"],
+      "getsentry/*": ["static/gsApp/*"],
+      "getsentry-images/*": ["static/images/*"]
     },
     },
 
 
     "plugins": [
     "plugins": [

+ 3 - 3
eslint.config.mjs

@@ -412,8 +412,10 @@ export default typescript.config([
       'prefer-spread': 'off',
       'prefer-spread': 'off',
       '@typescript-eslint/prefer-enum-initializers': 'error',
       '@typescript-eslint/prefer-enum-initializers': 'error',
 
 
+      '@typescript-eslint/no-unused-expressions': 'off', // TODO(ryan953): Fix violations and delete this line
+
       // Recommended overrides
       // Recommended overrides
-      '@typescript-eslint/no-empty-object-type': ['error', {allowInterfaces: 'always'}],
+      '@typescript-eslint/no-empty-object-type': 'off', // TODO: fix and restore ['error', {allowInterfaces: 'always'}],
       '@typescript-eslint/no-explicit-any': 'off',
       '@typescript-eslint/no-explicit-any': 'off',
       '@typescript-eslint/no-namespace': 'off',
       '@typescript-eslint/no-namespace': 'off',
       '@typescript-eslint/no-non-null-asserted-optional-chain': 'off', // TODO(ryan953): Fix violations and delete this line
       '@typescript-eslint/no-non-null-asserted-optional-chain': 'off', // TODO(ryan953): Fix violations and delete this line
@@ -502,8 +504,6 @@ export default typescript.config([
             // Internal packages.
             // Internal packages.
             ['^(sentry-locale|sentry-images)(/.*|$)'],
             ['^(sentry-locale|sentry-images)(/.*|$)'],
 
 
-            ['^(getsentry-images)(/.*|$)'],
-
             ['^(app|sentry)(/.*|$)'],
             ['^(app|sentry)(/.*|$)'],
 
 
             // Getsentry packages.
             // Getsentry packages.

+ 4 - 1
jest.config.ts

@@ -231,11 +231,14 @@ const config: Config.InitialOptions = {
   coverageReporters: ['html', 'cobertura'],
   coverageReporters: ['html', 'cobertura'],
   coverageDirectory: '.artifacts/coverage',
   coverageDirectory: '.artifacts/coverage',
   moduleNameMapper: {
   moduleNameMapper: {
+    '\\.(css|less|png|jpg|woff|mp4)$':
+      '<rootDir>/tests/js/sentry-test/importStyleMock.js',
     '^sentry/(.*)': '<rootDir>/static/app/$1',
     '^sentry/(.*)': '<rootDir>/static/app/$1',
+    '^getsentry/(.*)': '<rootDir>/static/gsApp/$1',
     '^sentry-fixture/(.*)': '<rootDir>/tests/js/fixtures/$1',
     '^sentry-fixture/(.*)': '<rootDir>/tests/js/fixtures/$1',
     '^sentry-test/(.*)': '<rootDir>/tests/js/sentry-test/$1',
     '^sentry-test/(.*)': '<rootDir>/tests/js/sentry-test/$1',
+    '^getsentry-test/(.*)': '<rootDir>/tests/js/getsentry-test/$1',
     '^sentry-locale/(.*)': '<rootDir>/src/sentry/locale/$1',
     '^sentry-locale/(.*)': '<rootDir>/src/sentry/locale/$1',
-    '\\.(css|less|png|jpg|mp4)$': '<rootDir>/tests/js/sentry-test/importStyleMock.js',
     '\\.(svg)$': '<rootDir>/tests/js/sentry-test/svgMock.js',
     '\\.(svg)$': '<rootDir>/tests/js/sentry-test/svgMock.js',
 
 
     // Disable echarts in test, since they're very slow and take time to
     // Disable echarts in test, since they're very slow and take time to

+ 21 - 0
static/app/index.tsx

@@ -76,8 +76,29 @@ async function app() {
   const {bootstrap} = await bootstrapImport;
   const {bootstrap} = await bootstrapImport;
   const config = await bootstrap();
   const config = await bootstrap();
 
 
+  if (config.sentryMode !== 'SAAS') {
+    const {initializeMain} = await initalizeMainImport;
+    initializeMain(config);
+    return;
+  }
+
+  // We have split up the imports this way so that locale is initialized as
+  // early as possible, (e.g. before `registerHooks` is imported otherwise the
+  // imports in `registerHooks` will not be in the correct locale.
+  const [{default: registerHooks}, {initializeBundleMetrics}] = await Promise.all([
+    import('getsentry/registerHooks'),
+    import('getsentry/initializeBundleMetrics'),
+  ]);
+
+  // getsentry augments Sentry's application through a 'hook' mechanism. Sentry
+  // provides various hooks into parts of its application. Thus all getsentry
+  // functionality is initialized by registering its hook functions.
+  registerHooks();
+
   const {initializeMain} = await initalizeMainImport;
   const {initializeMain} = await initalizeMainImport;
   initializeMain(config);
   initializeMain(config);
+
+  initializeBundleMetrics();
 }
 }
 
 
 app();
 app();

+ 1 - 0
static/app/types/fileLoader.d.ts

@@ -4,6 +4,7 @@
 declare module '*.png';
 declare module '*.png';
 declare module '*.jpg';
 declare module '*.jpg';
 declare module '*.mp4';
 declare module '*.mp4';
+declare module '*.woff';
 declare module '*.svg' {
 declare module '*.svg' {
   const content: any;
   const content: any;
   export default content;
   export default content;

+ 36 - 0
static/gsApp/__fixtures__/pendingChanges.tsx

@@ -0,0 +1,36 @@
+import {PlanFixture} from 'getsentry/__fixtures__/plan';
+import {MONTHLY} from 'getsentry/constants';
+import type {Subscription} from 'getsentry/types';
+
+export function PendingChangesFixture(
+  fields: Partial<Subscription['pendingChanges']>
+): Subscription['pendingChanges'] {
+  return {
+    customPrice: null,
+    customPriceAttachments: null,
+    customPriceErrors: null,
+    customPricePcss: null,
+    customPriceTransactions: null,
+    // TODO:categories remove customPrice{Categories}
+    customPrices: {},
+    effectiveDate: '2021-02-01',
+    onDemandBudgets: null,
+    onDemandEffectiveDate: '2021-02-01',
+    onDemandMaxSpend: 0,
+    plan: 'am1_team',
+    planDetails: PlanFixture({
+      name: 'Team',
+      contractInterval: MONTHLY,
+    }),
+    planName: 'Team',
+    // TODO:categories remove reserved{Categories}
+    reserved: {},
+    reservedAttachments: null,
+    reservedErrors: null,
+    reservedEvents: 0,
+    reservedTransactions: null,
+    reservedBudgets: [],
+    reservedCpe: {},
+    ...fields,
+  };
+}

+ 49 - 0
static/gsApp/__fixtures__/plan.tsx

@@ -0,0 +1,49 @@
+import type {Plan} from 'getsentry/types';
+
+export function PlanFixture(fields: Partial<Plan>): Plan {
+  return {
+    allowAdditionalReservedEvents: false,
+    allowOnDemand: false,
+    availableCategories: [],
+    basePrice: 0,
+    billingInterval: 'monthly',
+    categories: [],
+    checkoutCategories: [],
+    contractInterval: 'monthly',
+    description: '',
+    features: [],
+    hasOnDemandModes: false,
+    id: 'am2_f',
+    maxMembers: 1,
+    name: 'Developer',
+    onDemandCategories: [],
+    onDemandEventPrice: 0,
+    planCategories: {
+      errors: [
+        {events: 50000, unitPrice: 0.089, price: 0},
+        {events: 100000, unitPrice: 0.05, price: 4500},
+      ],
+      transactions: [
+        {events: 100000, unitPrice: 0.0445, price: 0},
+        {events: 250000, unitPrice: 0.0358, price: 4500},
+      ],
+      replays: [
+        {events: 500, unitPrice: 0.2925, price: 0},
+        {events: 10000, unitPrice: 0.288, price: 2900},
+      ],
+      attachments: [
+        {events: 1, unitPrice: 25, price: 0},
+        {events: 25, unitPrice: 25, price: 600},
+      ],
+      monitorSeats: [{events: 1, unitPrice: 60, price: 0, onDemandPrice: 78}],
+    },
+    price: 0,
+    reservedMinimum: 0,
+    retentionDays: 0,
+    totalPrice: 0,
+    trialPlan: null,
+    userSelectable: true,
+    categoryDisplayNames: {},
+    ...fields,
+  };
+}

+ 26 - 0
static/gsApp/__fixtures__/previewData.tsx

@@ -0,0 +1,26 @@
+import type {PreviewData} from 'getsentry/types';
+import {InvoiceItemType} from 'getsentry/types';
+
+export function PreviewDataFixture(fields: Partial<PreviewData>): PreviewData {
+  return {
+    atPeriodEnd: false,
+    balanceChange: 0,
+    billedAmount: 0,
+    creditApplied: 0,
+    effectiveAt: '2023-01-01T00:00:00Z',
+    invoiceItems: [
+      {
+        amount: 8900,
+        type: InvoiceItemType.SUBSCRIPTION,
+        description: 'Subscription to Business',
+        data: {},
+        period_end: '',
+        period_start: '',
+      },
+    ],
+    newBalance: -10000,
+    previewToken: '1:2023-01-01T00:00:00',
+    proratedAmount: 0,
+    ...fields,
+  };
+}

+ 12 - 0
static/gsApp/__mocks__/@amplitude/analytics-browser.tsx

@@ -0,0 +1,12 @@
+const identifyInstance: any = {
+  set: jest.fn(() => identifyInstance),
+};
+
+export const Identify = jest.fn(() => identifyInstance);
+export const setUserId = jest.fn();
+export const identify = jest.fn();
+export const init = jest.fn();
+export const track = jest.fn();
+export const setGroup = jest.fn();
+
+export const _identifyInstance = identifyInstance;

+ 274 - 0
static/gsApp/actionCreators/modal.tsx

@@ -0,0 +1,274 @@
+import type {ComponentType} from 'react';
+import styled from '@emotion/styled';
+import type {Location} from 'history';
+
+import {openModal} from 'sentry/actionCreators/modal';
+import {promptsUpdate} from 'sentry/actionCreators/prompts';
+import {Client} from 'sentry/api';
+import {openConfirmModal} from 'sentry/components/confirm';
+import {t} from 'sentry/locale';
+import type {Organization} from 'sentry/types/organization';
+
+import type {PromotionModalBodyProps} from 'getsentry/components/promotionModal';
+import type {Reservations} from 'getsentry/components/upgradeNowModal/types';
+import SubscriptionStore from 'getsentry/stores/subscriptionStore';
+import type {
+  Invoice,
+  Plan,
+  PreviewData,
+  Promotion,
+  PromotionClaimed,
+  Subscription,
+} from 'getsentry/types';
+import type {AM2UpdateSurfaces} from 'getsentry/utils/trackGetsentryAnalytics';
+
+type UpsellModalOptions = {
+  organization: Organization;
+  source: string;
+  defaultSelection?: string;
+};
+
+export async function openUpsellModal(options: UpsellModalOptions) {
+  const {default: Modal, modalCss} = await import('getsentry/components/upsellModal');
+  openModal(deps => <Modal {...deps} {...options} />, {modalCss});
+}
+
+type TrialModalProps = {
+  organization: Organization;
+};
+
+type PartnerPlanModalProps = {
+  organization: Organization;
+  subscription: Subscription;
+};
+
+function genTrialModalOnClose(
+  options: TrialModalProps,
+  type: 'trialEnd' | 'forcedTrial'
+) {
+  let feature: string, subField: string;
+  switch (type) {
+    case 'trialEnd':
+      feature = 'trial_ended_notice';
+      subField = 'hasDismissedTrialEndingNotice';
+      break;
+    case 'forcedTrial':
+      feature = 'forced_trial_notice';
+      subField = 'hasDismissedForcedTrialNotice';
+      break;
+    default:
+      throw new Error('Unexpected type');
+  }
+  const api = new Client();
+  const promptParams = {
+    organization: options.organization,
+    feature,
+    status: 'dismissed',
+  } as const;
+  const subUpdate = {
+    [subField]: true,
+  } as const;
+
+  // Handle marking the feature prompt as seen when the modal is
+  // closed
+  return () => {
+    promptsUpdate(api, promptParams);
+    SubscriptionStore.set(options.organization.slug, subUpdate);
+  };
+}
+
+export async function openTrialEndingModal(options: TrialModalProps) {
+  const {default: Modal, modalCss} = await import(
+    'getsentry/components/trialEndingModal'
+  );
+
+  const onClose = genTrialModalOnClose(options, 'trialEnd');
+
+  openModal(deps => <Modal {...deps} {...options} />, {modalCss, onClose});
+}
+
+export async function openForcedTrialModal(options: TrialModalProps) {
+  const {default: Modal, modalCss} = await import(
+    'getsentry/components/forcedTrialModal'
+  );
+
+  const onClose = genTrialModalOnClose(options, 'forcedTrial');
+
+  openModal(deps => <Modal {...deps} {...options} />, {
+    modalCss,
+    onClose,
+  });
+}
+
+export async function openPartnerPlanEndingModal(options: PartnerPlanModalProps) {
+  const {default: Modal, modalCss} = await import(
+    'getsentry/components/partnerPlanEndingModal'
+  );
+  const api = new Client();
+  const promptParams = {
+    organization: options.organization,
+    feature: 'partner_plan_ending_modal',
+    status: 'dismissed',
+  } as const;
+
+  const onClose = () => {
+    promptsUpdate(api, promptParams);
+  };
+
+  openModal(deps => <Modal {...deps} {...options} />, {modalCss, onClose});
+}
+
+type EditCreditCardOptions = {
+  onSuccess: (data: Subscription) => void;
+  organization: Organization;
+  location?: Location;
+};
+
+export async function openEditCreditCard(options: EditCreditCardOptions) {
+  const {default: Modal} = await import('getsentry/components/creditCardEditModal');
+
+  openModal(deps => <Modal {...deps} {...options} />);
+}
+
+type OpenInvoicePaymentOptions = {
+  invoice: Invoice;
+  organization: Organization;
+  reloadInvoice: () => void;
+};
+
+export async function openInvoicePaymentModal(options: OpenInvoicePaymentOptions) {
+  const {default: Modal} = await import('getsentry/views/invoiceDetails/paymentForm');
+
+  openModal(deps => <Modal {...deps} {...options} />);
+}
+
+type UpsellModalProps = {
+  organization: Organization;
+  plan: Plan;
+  previewData: PreviewData;
+  reservations: Reservations;
+  subscription: Subscription;
+  surface: AM2UpdateSurfaces;
+  isActionDisabled?: boolean;
+  onComplete?: () => void;
+};
+
+export async function openAM2UpsellModal(options: UpsellModalProps) {
+  const {default: Modal, modalCss} = await import(
+    'getsentry/components/upgradeNowModal/index'
+  );
+
+  openModal(deps => <Modal {...deps} {...options} />, {modalCss});
+}
+
+export type UpsellModalSamePriceProps = {
+  organization: Organization;
+  plan: Plan;
+  previewData: PreviewData;
+  reservations: Reservations;
+  subscription: Subscription;
+  surface: AM2UpdateSurfaces;
+  onComplete?: () => void;
+};
+
+export async function openAM2UpsellModalSamePrice(options: UpsellModalSamePriceProps) {
+  const {default: Modal, modalCss} = await import(
+    'getsentry/components/upgradeNowModal/modalSamePrice'
+  );
+
+  openModal(deps => <Modal {...deps} {...options} />, {modalCss});
+}
+
+type ProfilingUpsellModalProps = {
+  organization: Organization;
+  subscription: Subscription;
+  isActionDisabled?: boolean;
+  onComplete?: () => void;
+};
+
+export async function openAM2ProfilingUpsellModal(options: ProfilingUpsellModalProps) {
+  const {default: Modal, modalCss} = await import(
+    'getsentry/components/profiling/profilingUpgradeModal'
+  );
+
+  openModal(deps => <Modal {...deps} {...options} />, {modalCss});
+}
+
+type PromotionModalOptions = {
+  organization: Organization;
+  price: number;
+  promotion: Promotion;
+  promptFeature: string;
+  PromotionModalBody?: ComponentType<PromotionModalBodyProps>;
+  acceptButtonText?: string;
+  api?: Client;
+  declineButtonText?: string;
+  onAccept?: () => void;
+};
+
+export async function openPromotionModal(options: PromotionModalOptions) {
+  const {default: Modal, modalCss} = await import('getsentry/components/promotionModal');
+  openModal(deps => <Modal {...deps} {...options} />, {closeEvents: 'none', modalCss});
+}
+
+export function openPromotionReminderModal(
+  promotionClaimed: PromotionClaimed,
+  onCancel?: () => void,
+  onConfirm?: () => void
+) {
+  const {dateCompleted} = promotionClaimed;
+  const promo = promotionClaimed.promotion;
+  const {amount, billingInterval, billingPeriods, maxCentsPerPeriod, reminderText} =
+    promo.discountInfo;
+  const date = new Date(dateCompleted);
+  const percentOff = amount / 100;
+
+  const interval = billingInterval === 'monthly' ? t('months') : t('years');
+  const intervalSingular = interval.slice(0, -1);
+
+  /**
+   * Removed translation because of complicated pluralization and lots of changing
+   * parameters from the different promotions we can use this for
+   */
+
+  openConfirmModal({
+    message: (
+      <div>
+        <p>{reminderText}</p>
+        <Subheader>{t('Current Promotion:')} </Subheader>
+        <p>
+          {percentOff}% off (up to ${maxCentsPerPeriod / 100} per {intervalSingular}) for{' '}
+          {billingPeriods} {interval} starting on {date.toLocaleDateString('en-US')}
+        </p>
+      </div>
+    ),
+    header: <HeaderText>Promotion Conflict</HeaderText>,
+    priority: 'danger',
+    confirmText: 'Downgrade Anyway',
+    onCancel: () => onCancel?.(),
+    onConfirm: () => onConfirm?.(),
+  });
+}
+
+export async function openCodecovModal(options: {organization: Organization}) {
+  const {default: Modal, modalCss} = await import(
+    'getsentry/components/codecovPromotionModal'
+  );
+  openModal(deps => <Modal {...deps} {...options} />, {modalCss, closeEvents: 'none'});
+}
+
+const HeaderText = styled('div')`
+  font-size: ${p => p.theme.fontSizeExtraLarge};
+  font-weight: bold;
+`;
+
+const Subheader = styled('div')`
+  font-weight: bold;
+  font-size: ${p => p.theme.fontSizeMedium};
+`;
+
+export async function openDataConsentModal() {
+  const {default: Modal} = await import('getsentry/components/dataConsentModal');
+
+  openModal(deps => <Modal {...deps} />);
+}

Some files were not shown because too many files changed in this diff