Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/shaggy-geese-attend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@effect-app/vue-components": minor
"@effect-app/vue": minor
---

improve: split wait vs blocked state and add allowed state

# Allowed states

Adds support to model allowed states, e.g based on roles, so that you can conditionally render buttons based on role memberships or other states.
Could work together with role assignments configured on API Mutations, combined with the user's role memberships hook.

# Blocked state

When an entity mutation is in progress, you may want to block overlapping actions, not just the clicked button.
While `waiting` state is managing both the disabled and loading state of a button, `blocked` only affects the disabled state.
This way you can separate which buttons show loading state and which are only blocked.
Controlled via `blockKey` and `waitKey` options.
6 changes: 6 additions & 0 deletions packages/vue-components/.storybook/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ import { aliases, mdi } from "vuetify/iconsets/mdi-svg"
import VueHighlightJS from "vue3-highlightjs"
import "highlight.js/styles/default.css" // Or your preferred theme

import Toast from "vue-toastification"

// Import the CSS or use your own!
import "vue-toastification/dist/index.css"

const vuetify = createVuetify({
components,
directives,
Expand All @@ -27,6 +32,7 @@ setup((app) => {
app.use(vuetify)
// Register highlight.js
app.use(VueHighlightJS)
app.use("default" in Toast ? (Toast as any).default : Toast, {})
})

const preview: Preview = {
Expand Down
1 change: 1 addition & 0 deletions packages/vue-components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"vitepress": "^1.6.4",
"vitest": "^3.2.4",
"vue-router": "^4.5.1",
"vue-toastification": "^2.0.0-rc.5",
"vue-tsc": "^3.1.0"
},
"files": [
Expand Down
74 changes: 74 additions & 0 deletions packages/vue-components/src/components/CommandButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<script
setup
lang="ts"
generic="I = never"
>
import type { CommandBase } from "@effect-app/vue"
import type { VBtn } from "vuetify/components"

type VBtnProps = VBtn["$props"]

/* @vue-ignore */
interface ButtonProps extends VBtnProps {}

const props = defineProps<
& (
| {
input: NoInfer<I>
command: CommandBase<I>
empty?: boolean
}
| {
command: CommandBase
input?: undefined
empty?: boolean
}
)
& {
disabled?: ButtonProps["disabled"]
title?: string // why isn't it part of VBtnProps??
}
& ButtonProps
>()
</script>
<script lang="ts">
/** Command Button is an easy way to connect commands and have it execute on click, while keeping track of disabled/loading states automatically */
export default {
name: "CommandButton"
}
</script>
<template>
<v-btn
v-if="command.allowed && !empty"
v-bind="$attrs"
:loading="command.waiting"
:disabled="command.blocked || disabled"
:title="title ?? command.action"
@click="(command.handle as any)(
(`input` in props && props.input
? props.input
: undefined) as unknown as I
)"
>
<slot
:loading="command.waiting"
:disabled="command.blocked || disabled"
:label="command.label"
:title="title ?? command.action"
>
<span>{{ command.label }}</span>
</slot>
</v-btn>
<v-btn
v-else-if="command.allowed"
v-bind="$attrs"
:loading="command.waiting"
:disabled="command.blocked || disabled"
:title="title ?? command.action"
@click="(command.handle as any)(
(`input` in props && props.input
? props.input
: undefined) as unknown as I
)"
/>
</template>
1 change: 1 addition & 0 deletions packages/vue-components/src/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { default as CommandButton } from "./CommandButton.vue"
export { default as Dialog } from "./Dialog.vue"
export * from "./OmegaForm"
41 changes: 41 additions & 0 deletions packages/vue-components/stories/Commands.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { type makeIntl } from "@effect-app/vue"
import { type Meta, type StoryObj } from "@storybook/vue3"
import { ref } from "vue"
import { provideIntl } from "../src"
import One from "./Commands/One.vue"

const mockIntl = {
locale: ref("en"),
trans: (id: string) => id,
intl: ref({ formatMessage: (msg: { id: string }) => msg.id })
} as unknown as ReturnType<ReturnType<typeof makeIntl<string>>["useIntl"]>

const meta: Meta = {
title: "Components/Commands",
// component: Commands,
// argTypes: {
// schema: { control: "object" },
// onSubmit: { action: "submitted" },
// defaultValues: { control: "object" }
// },
decorators: [
(story) => ({
components: { story },
setup() {
provideIntl(() => mockIntl)
return {}
},
template: "<story />"
})
]
}

export default meta
type Story = StoryObj<typeof meta>

export const OneStory: Story = {
render: () => ({
components: { One },
template: "<One />"
})
}
114 changes: 114 additions & 0 deletions packages/vue-components/stories/Commands/One.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<template>
<v-btn @click="role = role === 'user' ? 'admin' : 'user'">
Switch to {{ role === "user" ? "admin" : "user" }}
</v-btn>
<v-table>
<tr
v-for="item of items"
:key="item"
>
<td>{{ item }}</td>
<td>
<CommandButton :command="updateName(item)" />
<CommandButton :command="updateState(item)" />
<CommandButton :command="remove(item)" />
</td>
</tr>
</v-table>

<v-table>
<tr
v-for="item of items"
:key="item"
>
<td>{{ item }}</td>
<td>
<CommandButton :command="updateName2(item)" />
</td>
</tr>
</v-table>
</template>
<script setup lang="ts">
import { Effect } from "effect"
import { ref } from "vue"
import { CommandButton } from "./components"
import { makeFamily, useCommand } from "./helpers"

const Command = useCommand({
"action.update_thing": "Update {field}{_isLabel, select, true {} other { {item}}}",
"action.remove_thing": "Remove {_isLabel, select, true {} other { {item}}}"
})

const items = [
"one",
"two"
]

const role = ref<"user" | "admin">("user")

const updateMutation = Object.assign(
Effect.fn(function*(item: string, props: { name?: string; state?: number }) {
yield* Effect.sleep(1000)
}),
{ id: "update_thing" }
)
const removeMutation = Object.assign(
Effect.fn(function*(item: string) {
yield* Effect.sleep(1000)
}),
{ id: "remove_thing" }
)

const updateName = makeFamily((item: string) =>
Command.fn(updateMutation, {
state: () => ({ item, field: "name" }),
waitKey: (id) => `${id}.${item}.name`,
blockKey: () => `modify_thing.${item}`
})(
function*() {
yield* updateMutation(item, { name: `New name for ${item}` })
},
Command.withDefaultToast({ stableToastId: (id) => `${id}.${item}.name` })
)
)

const updateName2 = makeFamily((item: string) =>
Command.fn(updateMutation, {
state: () => ({ item, field: "name" }),
waitKey: (id) => `${id}.${item}.name`,
blockKey: () => `modify_thing.${item}`
})(
function*() {
yield* updateMutation(item, { name: `New name for ${item}` })
},
Command.withDefaultToast({ stableToastId: (id) => `${id}.${item}.name` })
)
)

const updateState = makeFamily((item: string) =>
Command.fn(updateMutation, {
state: () => ({ item, field: "state" }),
waitKey: (id) => `${id}.${item}.state`,
blockKey: () => `modify_thing.${item}`
})(
function*() {
yield* updateMutation(item, { state: Math.floor(Math.random() * 100) })
},
Command.withDefaultToast({ stableToastId: (id) => `${id}.${item}.state` })
)
)

const remove = makeFamily((item: string) =>
Command.fn(removeMutation, {
state: () => ({ item }),
waitKey: (id) => `${id}.${item}`,
blockKey: () => `modify_thing.${item}`,
allowed: () => role.value === "admin"
})(
function*() {
yield* removeMutation(item)
},
Command.withDefaultToast({ stableToastId: (id) => `${id}.${item}` })
)
)
</script>
1 change: 1 addition & 0 deletions packages/vue-components/stories/Commands/components.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as CommandButton } from "../../src/components/CommandButton.vue"
73 changes: 73 additions & 0 deletions packages/vue-components/stories/Commands/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { LegacyMutation, makeClient, makeIntl } from "@effect-app/vue"
import { Commander, DefaultIntl } from "@effect-app/vue/experimental/commander"
import { Confirm } from "@effect-app/vue/experimental/confirm"
import { I18n } from "@effect-app/vue/experimental/intl"
import * as Toast_ from "@effect-app/vue/experimental/toast"
import { WithToast } from "@effect-app/vue/experimental/withToast"
import { FetchHttpClient } from "@effect/platform"
import { Effect, Layer, ManagedRuntime, Option } from "effect"
import { ApiClientFactory } from "effect-app/client"
import { onUnmounted, ref } from "vue"
import { useToast } from "vue-toastification"
import { Router } from "./useEffectRouter"

export const useCommand = (messages: {}) => {
const locale = ref("en" as const)
const { useIntl } = makeIntl({
en: {
...DefaultIntl.en,
...messages
}
}, locale)

const intlLayer = I18n.toLayer(Effect.sync(useIntl))
// TODO: use optional CurrentToastId to auto assign toastId when not null?
const toastLayer = Toast_.Toast.toLayer(
Effect.sync(() => {
const t = useToast()
const toast = {
error: t.error.bind(t),
info: t.info.bind(t),
success: t.success.bind(t),
warning: t.warning.bind(t),
dismiss: t.dismiss.bind(t)
}
return Toast_.wrap(toast)
})
)
const commanderLayer = Commander.Default.pipe(
Layer.provide([intlLayer, toastLayer])
)

const api = ApiClientFactory.layer({ url: "bogus", headers: Option.none() }).pipe(
Layer.provide(FetchHttpClient.layer)
)
const viewLayers = Layer.mergeAll(Router.Default, intlLayer, toastLayer)
const provideLayers = Layer
.mergeAll(
LegacyMutation.Default.pipe(Layer.provide([toastLayer, intlLayer])),
commanderLayer,
viewLayers,
WithToast.Default.pipe(Layer.provide(toastLayer)),
Confirm.Default.pipe(Layer.provide(intlLayer))
)
.pipe(Layer.provideMerge(api))

const mrt = ManagedRuntime.make(provideLayers)
const clientFor_ = ApiClientFactory.makeFor(Layer.empty)
const { Command } = makeClient(() => mrt, clientFor_)
return Command
}

/** borrowing the idea from Families in Effect Atom */
export const makeFamily = <Maker extends (input: any) => any>(maker: Maker) => {
type K = Parameters<Maker>[0]
const map = new Map<K, ReturnType<typeof maker>>()
onUnmounted(() => map.clear())
return (k: K) => {
if (!map.has(k)) {
map.set(k, maker(k))
}
return map.get(k)!
}
}
Loading
Loading