Skip to content

Пример 201: Зависимый от нескольких синалов ресурс

Структура директорий выбрана условно для понимания идеи.

Сервис secondary - его внутренний ресурс подписан на выбор персоны из списка который пришел в ответе на запрос сервиса firstly (его внутренний ресурс триггерится собственным счетчиком, который меняет сам пользователь по кнопке Refresh).

tsx
import baseClasses from '~/ui.common.module.scss'
import btnClasses from '~/ui.button.module.scss'
import clsx from 'clsx'
import { useUserInfoService, useSecondaryService } from './store'
import { useReactiveValue as useR } from '@pravosleva/reactive-engine/react'

const BASE_API_URL = import.meta.env.VITE_BASE_API_URL

export const Example201 = () => {
  const _userInfo = useUserInfoService()
  const personList = useR(_userInfo.personList)
  const activePersonId = useR(_userInfo.activePersonId)
  const firstlyState = useR(_userInfo.apiState)

  const _secondaryService = useSecondaryService()
  const secondaryApiState = useR(_secondaryService.apiState)

  return (
    <div
      className={clsx(
        baseClasses.unit,
        baseClasses['unit--wide'],
        baseClasses.stack2)
      }
    >
      <div
        className={baseClasses.absoluteUnitLabel}
        title='Chain of Resources'
      >
        Chain of Resources
      </div>
      <code>{BASE_API_URL}</code>
      <div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
        <button onClick={_userInfo.inc} className={clsx(btnClasses.btn, btnClasses.neonBtn, btnClasses['neonBtn--primary'], btnClasses['neonBtn--outlined'])}>
          Refresh account data
        </button>
        {
          personList.map((p) => (
            <button
              key={p.id}
              onClick={() => _userInfo.setActivePersonId(p.id)}
              className={
                clsx(
                  btnClasses.btn,
                  btnClasses.neonBtn,
                  btnClasses['neonBtn--secondary'],
                  {
                    [btnClasses['neonBtn--contained']]: !!activePersonId && p.id === activePersonId,
                    [btnClasses['neonBtn--outlined']]: p.id !== activePersonId,
                  },
                )
              }
            >{p.name}</button>
          ))
        }
      </div>
      <div className={baseClasses.unitInternalWrapper}>
        <div className={baseClasses.stack2}>
          <div>{firstlyState.loading ? '🟡 loading...' : !!firstlyState.data ? '🟢 ok' : !!firstlyState.error ? `🔴 err | ${firstlyState.error?.message || 'No error msg'}` : '⚪'}</div>
          <pre className={baseClasses.preNormalizedMin}>{JSON.stringify({ data: firstlyState.data }, null, 2)}</pre>
        </div>
        <div className={baseClasses.stack2}>
          <div>{secondaryApiState.loading ? '🟡 loading...' : !!secondaryApiState.data ? '🟢 ok' : !!secondaryApiState.error ? `🔴 err | ${secondaryApiState.error?.message || 'No error msg'}` : '⚪'}</div>
          <pre className={baseClasses.preNormalizedMin}>{JSON.stringify({ data: secondaryApiState.data }, null, 2)}</pre>
        </div>
      </div>
    </div>
  )
}

service.firstly.ts - подписан на счетчик.

ts
import { AbstractService } from '@pravosleva/reactive-engine'

const BASE_API_URL = import.meta.env.VITE_BASE_API_URL

type TPerson = {
  id: string;
  name: string;
}

export class UserInfoService extends AbstractService {
  // -- NOTE: DERTY_MISTAKE 2/3 ✅ Correct way (variant 2)
  // constructor(engine: ReactiveEngine) {
  //   super(engine);
  //   this.engine.effect(() => {
  //     // Подписываемся на изменение counter
  //     const _ = this.counter.value;
  //     // Синхронно сбрасываем выбранного пользователя при каждом изменении счетчика
  //     this.engine.untrack(() => this.resetActivePersonId());
  //   });
  // }
  // --

  public counter = this.engine.signal<number>(0, 'example-201:UserInfoService:signal:counter')
  public activePersonId = this.engine.signal<string | null>(null)
  public setActivePersonId = (val: string) => this.activePersonId.value = val
  private resetActivePersonId = () => this.activePersonId.value = null

  public apiState = this.engine.resource<{ items: TPerson[] }, number>(
    async (source, abortSignal) => {
      // -- NOTE: DERTY_MISTAKE 1/3 ⛔ Dont do that here!
      // this.resetActivePersonId()
      // --

      const res = await fetch(
        [
          `${BASE_API_URL}/profile/search`,
          '?',
          [
            `counter=${source}`,
            '_responseDelay=3000',
            `_addData=${encodeURIComponent(JSON.stringify({
              items: [
                {
                  id: 'person-id-1',
                  name: 'John Doe'
                },
                {
                  id: 'person-id-2',
                  name: 'Jane Doe'
                }
              ]
            } as { items: TPerson[] }))}`
          ].join('&')
        ].join(''),
        { signal: abortSignal }
      )
      return res.json()
    },
    this.counter,
    'example-201:resource:profileSearch'
  )

  public inc = () => {
    this.resetActivePersonId() // NOTE: DERTY_MISTAKE 2/3 ✅ Correct way (variant 1)
    this.counter.value += 1
  }

  public personList = this.engine.computed(
    () => !this.apiState.loading && Array.isArray(this.apiState.data?.items) && this.apiState.data?.items?.length > 0
      ? this.apiState.data.items
      : []
  )
}

service.secondary.ts - подписан на успешный результат первого сервиса и выбор пользователя из списка в ответе от первого сервиса.

ts
import { AbstractService } from '@pravosleva/reactive-engine'
import { UserInfoService } from './service.firstly'

const BASE_API_URL = import.meta.env.VITE_BASE_API_URL

type TResData = {
  for_person_id: string;
}

export class SecondaryService extends AbstractService {
  private userInfoService = this.engine.inject(UserInfoService)

  private isUserDataReceived = this.engine.computed<boolean>(
    () => !this.userInfoService.apiState.loading && !!this.userInfoService.apiState.data,
    'example-201:computed:isUserDataReceived'
  )

  private apiDeps = this.engine.computed<[boolean, string | null]>(() => [
    this.isUserDataReceived.value,
    this.userInfoService.activePersonId.value,
  ])

  public apiState = this.engine.resource<TResData, [boolean, string | null]>(
    async (deps, abortSignal) => {
      const [isDataReceived, personId] = deps

      if (!isDataReceived) throw new Error('Waiting for user data response...')
      if (!personId) throw new Error('Person id wasnt selected...')

      const queryParams = new URLSearchParams({
        personId: String(personId),
        _responseDelay: '2000',
        _addData: JSON.stringify({ for_person_id: personId } as TResData)
      })

      const res = await fetch(
        `${BASE_API_URL}/profile/accessPolicies?${queryParams}`,
        { signal: abortSignal }
      )
      if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`)
      return res.json()
    },
    this.apiDeps,
    {
      name: 'example-201:resource:accessPolicies',
      resetDataOnSourceChange: true,
      responseValidate: (res) => {
        // Пример: Бэкенд ответил 200, но ожидаемое поле отсутствует, что для нас критично
        if (!res || typeof res.for_person_id !== 'string') {
          return 'No access policies found for this user' // Запишется в state.error
        }
        return true // Всё отлично, запишется в state.data
      }
    }
  )
}

store.ts

ts
import { UserInfoService } from './service.firstly'
import { SecondaryService } from './service.secondary'
import { ReactiveEngine } from '@pravosleva/reactive-engine/react'

const engine = new ReactiveEngine({
  logger: {
    isEnabled: true, // Включаем логгер
    traceTime: true, // Добавляем вывод таймингов по желанию
    filter: /^example-201:*/ // Можно фильтровать только нужные логи
  }
})

export const useUserInfoService = () => engine.inject(UserInfoService)
export const useSecondaryService = () => engine.inject(SecondaryService)

export { engine }