diff --git a/frontend/app/api_client.ts b/frontend/app/api_client.ts index 2a9394687..c967e9c0f 100644 --- a/frontend/app/api_client.ts +++ b/frontend/app/api_client.ts @@ -1,6 +1,7 @@ import store from 'App/store'; import { queried } from './routes'; import { setJwt } from 'Duck/user'; +import { projectStore } from 'App/mstore'; const siteIdRequiredPaths: string[] = [ '/dashboard', @@ -59,7 +60,7 @@ export default class APIClient { constructor() { const jwt = store.getState().getIn(['user', 'jwt']); - const siteId = store.getState().getIn(['site', 'siteId']); + const { siteId } = projectStore.getSiteId(); this.init = { headers: new Headers({ Accept: 'application/json', @@ -69,7 +70,7 @@ export default class APIClient { if (jwt !== null) { (this.init.headers as Headers).set('Authorization', `Bearer ${jwt}`); } - this.siteId = siteId; + this.siteId = siteId || undefined; } private getInit(method: string = 'GET', params?: any, reqHeaders?: Record): RequestInit { diff --git a/frontend/app/mstore/index.tsx b/frontend/app/mstore/index.tsx index b127fa585..3101f8a36 100644 --- a/frontend/app/mstore/index.tsx +++ b/frontend/app/mstore/index.tsx @@ -28,6 +28,9 @@ import UiPlayerStore from './uiPlayerStore'; import IssueReportingStore from './issueReportingStore'; import CustomFieldStore from './customFieldStore'; import { IntegrationsStore } from "./integrationsStore"; +import ProjectsStore from './projectsStore'; + +export const projectStore = new ProjectsStore(); export class RootStore { dashboardStore: DashboardStore; @@ -57,6 +60,7 @@ export class RootStore { issueReportingStore: IssueReportingStore; customFieldStore: CustomFieldStore; integrationsStore: IntegrationsStore + projectsStore: ProjectsStore; constructor() { this.dashboardStore = new DashboardStore(); @@ -86,6 +90,7 @@ export class RootStore { this.issueReportingStore = new IssueReportingStore(); this.customFieldStore = new CustomFieldStore(); this.integrationsStore = new IntegrationsStore(); + this.projectsStore = projectStore; } initClient() { diff --git a/frontend/app/mstore/projectsStore.ts b/frontend/app/mstore/projectsStore.ts new file mode 100644 index 000000000..cdd87fd61 --- /dev/null +++ b/frontend/app/mstore/projectsStore.ts @@ -0,0 +1,143 @@ +import { makeAutoObservable, runInAction } from 'mobx'; +import Project from './types/project'; +import GDPR from './types/gdpr'; +import { GLOBAL_HAS_NO_RECORDINGS, SITE_ID_STORAGE_KEY } from 'App/constants/storageKeys'; +import { projectsService } from "App/services"; + +export default class ProjectsStore { + list: Project[] = []; + instance: Project | null = null; + siteId: string | null = null; + active: Project | null = null; + sitesLoading = false; + + constructor() { + const storedSiteId = localStorage.getItem(SITE_ID_STORAGE_KEY); + this.siteId = storedSiteId ?? null; + makeAutoObservable(this); + } + + getSiteId = () => { + return { + siteId: this.siteId, + active: this.active, + }; + } + + initProject = (project: Partial) => { + this.instance = new Project(project); + } + + setSitesLoading = (loading: boolean) => { + this.sitesLoading = loading; + } + + setSiteId(siteId: string) { + this.siteId = siteId; + localStorage.setItem(SITE_ID_STORAGE_KEY, siteId.toString()); + this.active = this.list.find((site) => site.id! === siteId) ?? null; + } + + editGDPR(gdprData: Partial) { + if (this.instance) { + this.instance.gdpr.edit(gdprData); + } + } + + editInstance = (instance: Partial) => { + if (!this.instance) return; + this.instance.edit(instance); + } + + async fetchGDPR(siteId: string) { + try { + const response = await projectsService.fetchGDPR(siteId) + runInAction(() => { + if (this.instance) { + Object.assign(this.instance.gdpr, response.data); + } + }); + } catch (error) { + console.error('Failed to fetch GDPR:', error); + } + } + + saveGDPR = async (siteId: string) => { + if (!this.instance) return; + try { + const gdprData = this.instance.gdpr.toData(); + const response = await projectsService.saveGDPR(siteId, gdprData); + this.editGDPR(response.data); + } catch (error) { + console.error('Failed to save GDPR:', error); + } + } + + fetchList = async (siteIdFromPath: string) =>{ + this.setSitesLoading(true); + try { + const response = await projectsService.fetchList(); + runInAction(() => { + this.list = response.data.map((data) => new Project(data)); + const siteIds = this.list.map(site => site.id); + let siteId = this.siteId; + const siteExists = siteId ? siteIds.includes(siteId) : false; + + if (siteIdFromPath && siteIds.includes(siteIdFromPath)) { + siteId = siteIdFromPath; + } else if (!siteId || !siteExists) { + siteId = siteIds.includes(this.siteId) + ? this.siteId + : response.data[0].projectId; + } + + const hasRecordings = this.list.some(site => site.recorded); + if (!hasRecordings) { + localStorage.setItem(GLOBAL_HAS_NO_RECORDINGS, 'true'); + } else { + localStorage.removeItem(GLOBAL_HAS_NO_RECORDINGS); + } + if (siteId) { + this.setSiteId(siteId); + } + }); + } catch (error) { + console.error('Failed to fetch site list:', error); + } finally { + this.setSitesLoading(false); + } + } + + save = async (projectData: Partial) => { + try { + const response = await projectsService.saveProject(projectData); + runInAction(() => { + const newSite = new Project(response.data); + const index = this.list.findIndex(site => site.id === newSite.id); + if (index !== -1) { + this.list[index] = newSite; + } else { + this.list.push(newSite); + } + this.setSiteId(newSite.id); + this.active = newSite; + }); + } catch (error) { + console.error('Failed to save site:', error); + } + } + + updateProjectRecordingStatus = (siteId: string, status: boolean) => { + const site = this.list.find(site => site.id === siteId); + if (site) { + site.recorded = status; + const hasRecordings = this.list.some(site => site.recorded); + if (!hasRecordings) { + localStorage.setItem(GLOBAL_HAS_NO_RECORDINGS, 'true'); + } else { + localStorage.removeItem(GLOBAL_HAS_NO_RECORDINGS); + } + } + } +} + diff --git a/frontend/app/mstore/types/gdpr.ts b/frontend/app/mstore/types/gdpr.ts new file mode 100644 index 000000000..2871d763c --- /dev/null +++ b/frontend/app/mstore/types/gdpr.ts @@ -0,0 +1,30 @@ +import { makeAutoObservable } from 'mobx'; + +export default class GDPR { + id = undefined; + maskEmails = false; + maskNumbers = false; + defaultInputMode = 'plain'; + sampleRate = 0; + + constructor(data = {}) { + Object.assign(this, data); + makeAutoObservable(this); + } + + edit = (data: Partial) => { + Object.keys(data).forEach((key) => { + this[key] = data[key]; + }) + } + + toData = () => { + return { + id: this.id, + maskEmails: this.maskEmails, + maskNumbers: this.maskNumbers, + defaultInputMode: this.defaultInputMode, + sampleRate: this.sampleRate, + }; + } +} diff --git a/frontend/app/mstore/types/project.ts b/frontend/app/mstore/types/project.ts new file mode 100644 index 000000000..35f0da5a8 --- /dev/null +++ b/frontend/app/mstore/types/project.ts @@ -0,0 +1,54 @@ +import { makeAutoObservable } from 'mobx'; +import GDPR from './gdpr'; + +export default class Project { + id?: string; + name: string = ''; + host: string = ''; + platform: string = 'web'; + lastRecordedSessionAt?: any; + gdpr: GDPR; + recorded: boolean = false; + stackIntegrations: boolean = false; + projectKey?: string; + projectId?: number; + trackerVersion?: string; + saveRequestPayloads: boolean = false; + sampleRate: number = 0; + conditionsCount: number = 0; + + constructor(data: Partial = {}) { + Object.assign(this, data); + this.gdpr = data.gdpr ? new GDPR(data.gdpr) : new GDPR(); + this.id = data.projectId?.toString(); + this.host = data.name || ''; + makeAutoObservable(this); + } + + edit = (data: Partial) => { + Object.keys(data).forEach((key) => { + if (key in this) { + this[key] = data[key]; + } + }) + } + + toData = () => { + return { + id: this.id, + name: this.name, + host: this.host, + platform: this.platform, + lastRecordedSessionAt: this.lastRecordedSessionAt, + gdpr: this.gdpr.toData(), + recorded: this.recorded, + stackIntegrations: this.stackIntegrations, + projectKey: this.projectKey, + projectId: this.projectId, + trackerVersion: this.trackerVersion, + saveRequestPayloads: this.saveRequestPayloads, + sampleRate: this.sampleRate, + conditionsCount: this.conditionsCount, + }; + } +} diff --git a/frontend/app/services/ProjectsService.ts b/frontend/app/services/ProjectsService.ts new file mode 100644 index 000000000..848457096 --- /dev/null +++ b/frontend/app/services/ProjectsService.ts @@ -0,0 +1,27 @@ +import BaseService from "./BaseService"; + +export default class ProjectsService extends BaseService { + fetchGDPR = async (siteId: string) => { + const r = await this.client.get(`/${siteId}/gdpr`); + + return await r.json(); + } + + saveGDPR = async (siteId: string, gdprData: any) => { + const r = await this.client.post(`/${siteId}/gdpr`, gdprData); + + return await r.json(); + } + + fetchList = async () => { + const r = await this.client.get('/projects'); + + return await r.json(); + } + + saveProject = async (projectData: any) => { + const r = await this.client.post('/projects', projectData); + + return await r.json(); + } +} \ No newline at end of file diff --git a/frontend/app/services/index.ts b/frontend/app/services/index.ts index 6afacb791..93a2aee0a 100644 --- a/frontend/app/services/index.ts +++ b/frontend/app/services/index.ts @@ -23,6 +23,7 @@ import FilterService from "./FilterService"; import IssueReportsService from "./IssueReportsService"; import CustomFieldService from './CustomFieldService'; import IntegrationsService from './IntegrationsService'; +import ProjectsService from './ProjectsService'; export const dashboardService = new DashboardService(); export const metricService = new MetricService(); @@ -48,6 +49,7 @@ export const filterService = new FilterService(); export const issueReportsService = new IssueReportsService(); export const customFieldService = new CustomFieldService(); export const integrationsService = new IntegrationsService(); +export const projectsService = new ProjectsService(); export const services = [ dashboardService, @@ -74,4 +76,5 @@ export const services = [ issueReportsService, customFieldService, integrationsService, + projectsService, ];