Skip to main content
All Examples

Interrupting Commands

Simulated file uploads driven by interruptible Commands. Cancel a single upload, cancel every upload in flight, or restart a cancelled one; each cancellation resolves through a keyed interrupt registry and an outcome-carrying result Message.

Commands
Concurrency
/
import clsx from 'clsx'
import {
  Array,
  Duration,
  Effect,
  Match as M,
  Number,
  Option,
  Schema as S,
  pipe,
} from 'effect'
import { Command, Runtime } from 'foldkit'
import { Document, Html, html } from 'foldkit/html'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'

// MODEL

export const UploadStatus = S.Literals(['Uploading', 'Done', 'Cancelled'])
export type UploadStatus = typeof UploadStatus.Type

export const Upload = S.Struct({
  id: S.Number,
  fileName: S.String,
  sizeMegabytes: S.Number,
  status: UploadStatus,
})
export type Upload = typeof Upload.Type

export const Model = S.Struct({
  uploadId: S.Number,
  uploads: S.Array(Upload),
})
export type Model = typeof Model.Type

// MESSAGE

export const ClickedStartUpload = m('ClickedStartUpload')
export const ClickedCancelUpload = m('ClickedCancelUpload', {
  uploadId: S.Number,
})
export const ClickedCancelAllUploads = m('ClickedCancelAllUploads')
export const ClickedRestartUpload = m('ClickedRestartUpload', {
  uploadId: S.Number,
})
export const SucceededUploadFile = m('SucceededUploadFile', {
  uploadId: S.Number,
})
export const CompletedCancelUploadFile = m('CompletedCancelUploadFile', {
  uploadId: S.Number,
  outcome: Command.Interruptible.Outcome,
})

export const Message = S.Union([
  ClickedStartUpload,
  ClickedCancelUpload,
  ClickedCancelAllUploads,
  ClickedRestartUpload,
  SucceededUploadFile,
  CompletedCancelUploadFile,
])
export type Message = typeof Message.Type

// INIT

export const initialModel: Model = {
  uploadId: 0,
  uploads: [],
}

export const init: Runtime.ApplicationInit<Model, Message> = () => [
  initialModel,
  [],
]

// FAKE FILES

export const FakeFile = S.Struct({ name: S.String, sizeMegabytes: S.Number })
export type FakeFile = typeof FakeFile.Type

export const FAKE_FILES: Array.NonEmptyReadonlyArray<FakeFile> = [
  { name: 'vacation-photos.zip', sizeMegabytes: 48 },
  { name: 'demo-recording.mp4', sizeMegabytes: 87 },
  { name: 'quarterly-report.pdf', sizeMegabytes: 12 },
  { name: 'design-assets.sketch', sizeMegabytes: 34 },
  { name: 'database-backup.sql', sizeMegabytes: 61 },
]

const fakeFileForUpload = (uploadId: number): FakeFile =>
  pipe(
    FAKE_FILES,
    Array.get(uploadId % Array.length(FAKE_FILES)),
    Option.getOrElse(() => Array.headNonEmpty(FAKE_FILES)),
  )

// COMMAND

const MILLISECONDS_PER_MEGABYTE = 100

export const UploadKey = S.Struct({ uploadId: S.Number })
export type UploadKey = typeof UploadKey.Type

export const UploadFile = Command.Interruptible.define(
  'UploadFile',
  { ...UploadKey.fields, sizeMegabytes: S.Number },
  ({ uploadId }: UploadKey) => String(uploadId),
  SucceededUploadFile,
)(({ uploadId, sizeMegabytes }) =>
  Effect.gen(function* () {
    yield* Effect.sleep(
      Duration.millis(sizeMegabytes * MILLISECONDS_PER_MEGABYTE),
    )
    return SucceededUploadFile({ uploadId })
  }),
)

export const CancelUploadFile = ({ uploadId }: UploadKey) =>
  UploadFile.Interrupt({ uploadId }, outcome =>
    CompletedCancelUploadFile({ uploadId, outcome }),
  )

// UPDATE

const setStatusForId = (uploadId: number, status: UploadStatus) =>
  Array.map((upload: Upload) =>
    upload.id === uploadId ? evo(upload, { status: () => status }) : upload,
  )

export const update = (
  model: Model,
  message: Message,
): readonly [Model, ReadonlyArray<Command.Command<Message>>] =>
  M.value(message).pipe(
    M.withReturnType<
      readonly [Model, ReadonlyArray<Command.Command<Message>>]
    >(),
    M.tagsExhaustive({
      ClickedStartUpload: () => {
        const fakeFile = fakeFileForUpload(model.uploadId)
        const startedUpload = Upload.make({
          id: model.uploadId,
          fileName: fakeFile.name,
          sizeMegabytes: fakeFile.sizeMegabytes,
          status: 'Uploading',
        })
        return [
          evo(model, {
            uploadId: Number.increment,
            uploads: Array.append(startedUpload),
          }),
          [
            UploadFile({
              uploadId: startedUpload.id,
              sizeMegabytes: startedUpload.sizeMegabytes,
            }),
          ],
        ]
      },

      ClickedCancelUpload: ({ uploadId }) => [
        model,
        [CancelUploadFile({ uploadId })],
      ],

      ClickedCancelAllUploads: () => [
        model,
        pipe(
          model.uploads,
          Array.filter(upload => upload.status === 'Uploading'),
          Array.map(upload => CancelUploadFile({ uploadId: upload.id })),
        ),
      ],

      ClickedRestartUpload: ({ uploadId }) =>
        pipe(
          model.uploads,
          Array.findFirst(
            upload => upload.id === uploadId && upload.status === 'Cancelled',
          ),
          Option.match({
            onNone: () => [model, []],
            onSome: upload => [
              evo(model, { uploads: setStatusForId(uploadId, 'Uploading') }),
              [UploadFile({ uploadId, sizeMegabytes: upload.sizeMegabytes })],
            ],
          }),
        ),

      SucceededUploadFile: ({ uploadId }) => [
        evo(model, { uploads: setStatusForId(uploadId, 'Done') }),
        [],
      ],

      CompletedCancelUploadFile: ({ uploadId, outcome }) =>
        M.value(outcome).pipe(
          M.withReturnType<
            readonly [Model, ReadonlyArray<Command.Command<Message>>]
          >(),
          M.tagsExhaustive({
            Interrupted: () => [
              evo(model, { uploads: setStatusForId(uploadId, 'Cancelled') }),
              [],
            ],
            NotFound: () => [model, []],
          }),
        ),
    }),
  )

// VIEW

const badgeClass = (status: UploadStatus): string =>
  M.value(status).pipe(
    M.when('Uploading', () => 'bg-blue-100 text-blue-700'),
    M.when('Done', () => 'bg-green-100 text-green-700'),
    M.when('Cancelled', () => 'bg-gray-200 text-gray-600'),
    M.exhaustive,
  )

const ACTION_BUTTON_CLASS =
  'px-3 py-1 text-sm font-medium rounded-md border transition'

const uploadActionView = (upload: Upload): Html => {
  const h = html<Message>()

  return M.value(upload.status).pipe(
    M.when('Uploading', () =>
      h.keyed('button')(
        'Uploading',
        [
          h.OnClick(ClickedCancelUpload({ uploadId: upload.id })),
          h.AriaLabel(`Cancel upload ${upload.id}`),
          h.Class(
            clsx(
              ACTION_BUTTON_CLASS,
              'border-red-300 text-red-600 hover:bg-red-50',
            ),
          ),
        ],
        ['Cancel'],
      ),
    ),
    M.when('Cancelled', () =>
      h.keyed('button')(
        'Cancelled',
        [
          h.OnClick(ClickedRestartUpload({ uploadId: upload.id })),
          h.AriaLabel(`Restart upload ${upload.id}`),
          h.Class(
            clsx(
              ACTION_BUTTON_CLASS,
              'border-blue-300 text-blue-600 hover:bg-blue-50',
            ),
          ),
        ],
        ['Restart'],
      ),
    ),
    M.when('Done', () => h.empty),
    M.exhaustive,
  )
}

const uploadView = (upload: Upload): Html => {
  const h = html<Message>()

  return h.keyed('li')(
    String(upload.id),
    [h.Class('p-4 bg-white rounded-lg shadow flex flex-col gap-2')],
    [
      h.div(
        [h.Class('flex items-center justify-between gap-3')],
        [
          h.div(
            [h.Class('flex items-baseline gap-2 min-w-0')],
            [
              h.span(
                [h.Class('font-medium text-gray-800 truncate')],
                [upload.fileName],
              ),
              h.span(
                [h.Class('text-sm text-gray-500 shrink-0')],
                [`${upload.sizeMegabytes} MB`],
              ),
            ],
          ),
          h.div(
            [h.Class('flex items-center gap-3 shrink-0')],
            [
              h.span(
                [
                  h.Class(
                    clsx(
                      'px-2 py-0.5 text-xs font-semibold rounded-full',
                      badgeClass(upload.status),
                    ),
                  ),
                ],
                [upload.status],
              ),
              uploadActionView(upload),
            ],
          ),
        ],
      ),
      upload.status === 'Uploading'
        ? h.div([h.Class('h-1.5 rounded-full bg-blue-400 animate-pulse')], [])
        : h.empty,
    ],
  )
}

export const view = (model: Model): Document => {
  const h = html<Message>()

  const isAnyUploadRunning = Array.some(
    model.uploads,
    upload => upload.status === 'Uploading',
  )

  const body = h.div(
    [h.Class('min-h-screen bg-gray-100 py-8')],
    [
      h.div(
        [h.Class('max-w-lg mx-auto flex flex-col gap-6 px-4')],
        [
          h.h1(
            [h.Class('text-3xl font-bold text-gray-800 text-center')],
            ['File Uploads'],
          ),
          h.div(
            [h.Class('flex justify-center gap-3')],
            [
              h.button(
                [
                  h.OnClick(ClickedStartUpload()),
                  h.Class(
                    'px-4 py-2 rounded-md bg-blue-500 text-white font-medium hover:bg-blue-600 transition',
                  ),
                ],
                ['Upload a file'],
              ),
              isAnyUploadRunning
                ? h.keyed('button')(
                    'CancelAll',
                    [
                      h.OnClick(ClickedCancelAllUploads()),
                      h.Class(
                        'px-4 py-2 rounded-md border border-red-300 text-red-600 font-medium hover:bg-red-50 transition',
                      ),
                    ],
                    ['Cancel all'],
                  )
                : h.empty,
            ],
          ),
          Array.match(model.uploads, {
            onEmpty: () =>
              h.keyed('p')(
                'NoUploads',
                [h.Class('text-center text-gray-500')],
                ['Nothing here yet. Start an upload.'],
              ),
            onNonEmpty: uploads =>
              h.keyed('ul')(
                'UploadList',
                [h.Class('flex flex-col gap-3')],
                Array.map(uploads, uploadView),
              ),
          }),
        ],
      ),
    ],
  )

  return { title: 'Foldkit Interrupting Commands Example', body }
}