diff --git a/.changeset/shaggy-geese-attend.md b/.changeset/shaggy-geese-attend.md
new file mode 100644
index 0000000000..a5dda19bda
--- /dev/null
+++ b/.changeset/shaggy-geese-attend.md
@@ -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.
diff --git a/packages/vue-components/.storybook/preview.ts b/packages/vue-components/.storybook/preview.ts
index 3339291d50..c6e8135505 100644
--- a/packages/vue-components/.storybook/preview.ts
+++ b/packages/vue-components/.storybook/preview.ts
@@ -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,
@@ -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 = {
diff --git a/packages/vue-components/package.json b/packages/vue-components/package.json
index 115c3aa56a..d1e8bf14d5 100644
--- a/packages/vue-components/package.json
+++ b/packages/vue-components/package.json
@@ -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": [
diff --git a/packages/vue-components/src/components/CommandButton.vue b/packages/vue-components/src/components/CommandButton.vue
new file mode 100644
index 0000000000..1b4c2aa635
--- /dev/null
+++ b/packages/vue-components/src/components/CommandButton.vue
@@ -0,0 +1,74 @@
+
+
+
+
+
+ {{ command.label }}
+
+
+
+
diff --git a/packages/vue-components/src/components/index.ts b/packages/vue-components/src/components/index.ts
index 478b846263..d773b893e6 100644
--- a/packages/vue-components/src/components/index.ts
+++ b/packages/vue-components/src/components/index.ts
@@ -1,2 +1,3 @@
+export { default as CommandButton } from "./CommandButton.vue"
export { default as Dialog } from "./Dialog.vue"
export * from "./OmegaForm"
diff --git a/packages/vue-components/stories/Commands.stories.ts b/packages/vue-components/stories/Commands.stories.ts
new file mode 100644
index 0000000000..cbfa81f2ea
--- /dev/null
+++ b/packages/vue-components/stories/Commands.stories.ts
@@ -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>["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: ""
+ })
+ ]
+}
+
+export default meta
+type Story = StoryObj
+
+export const OneStory: Story = {
+ render: () => ({
+ components: { One },
+ template: ""
+ })
+}
diff --git a/packages/vue-components/stories/Commands/One.vue b/packages/vue-components/stories/Commands/One.vue
new file mode 100644
index 0000000000..941501d14c
--- /dev/null
+++ b/packages/vue-components/stories/Commands/One.vue
@@ -0,0 +1,114 @@
+
+
+ Switch to {{ role === "user" ? "admin" : "user" }}
+
+
+
+ | {{ item }} |
+
+
+
+
+ |
+
+
+
+
+
+ | {{ item }} |
+
+
+ |
+
+
+
+
diff --git a/packages/vue-components/stories/Commands/components.ts b/packages/vue-components/stories/Commands/components.ts
new file mode 100644
index 0000000000..13ea07886d
--- /dev/null
+++ b/packages/vue-components/stories/Commands/components.ts
@@ -0,0 +1 @@
+export { default as CommandButton } from "../../src/components/CommandButton.vue"
diff --git a/packages/vue-components/stories/Commands/helpers.ts b/packages/vue-components/stories/Commands/helpers.ts
new file mode 100644
index 0000000000..3849561422
--- /dev/null
+++ b/packages/vue-components/stories/Commands/helpers.ts
@@ -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 = any>(maker: Maker) => {
+ type K = Parameters[0]
+ const map = new Map>()
+ onUnmounted(() => map.clear())
+ return (k: K) => {
+ if (!map.has(k)) {
+ map.set(k, maker(k))
+ }
+ return map.get(k)!
+ }
+}
diff --git a/packages/vue-components/stories/Commands/useEffectRouter.ts b/packages/vue-components/stories/Commands/useEffectRouter.ts
new file mode 100644
index 0000000000..bc6ea358f8
--- /dev/null
+++ b/packages/vue-components/stories/Commands/useEffectRouter.ts
@@ -0,0 +1,63 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { Effect } from "effect-app"
+import { type RouteLocationAsPath, type RouteLocationAsRelative, type RouteLocationAsRelativeTyped, type RouteLocationAsString, type RouteLocationNormalizedLoaded, type RouteLocationRaw, type RouteLocationResolved, type RouteMap, type RouteRecordNameGeneric, type RouteRecordRaw, useRoute, useRouter } from "vue-router"
+
+/**
+ * Effectified version of `useRouter`
+ */
+export const useEffectRouter = () => {
+ const r = useRouter()
+ const current = useRoute()
+ const effectified = {
+ ...r,
+ current,
+ replace: (to: RouteLocationRaw) => Effect.promise(() => r.replace(to)),
+ push: (to: RouteLocationRaw) => Effect.promise(() => r.push(to)),
+ isReady: Effect.promise(() => r.isReady())
+ }
+ return effectified
+}
+
+export class Router extends Effect.Service()("Router", {
+ sync: useEffectRouter,
+ accessors: true
+}) {
+ static readonly addRoute: {
+ /**
+ * Add a new {@link RouteRecordRaw | route record} as the child of an existing route.
+ *
+ * @param parentName - Parent Route Record where `route` should be appended at
+ * @param route - Route Record to add
+ */
+ (
+ parentName: NonNullable,
+ route: RouteRecordRaw
+ ): Effect.Effect
+ /**
+ * Add a new {@link RouteRecordRaw | route record} to the router.
+ *
+ * @param route - Route Record to add
+ */
+ (route: RouteRecordRaw): Effect.Effect
+ } = ((...args: any[]) => Router.use((_) => _.addRoute(...(args as [any, any])))) as any
+
+ static override readonly resolve: {
+ /**
+ * Returns the {@link RouteLocation | normalized version} of a
+ * {@link RouteLocationRaw | route location}. Also includes an `href` property
+ * that includes any existing `base`. By default, the `currentLocation` used is
+ * `router.currentRoute` and should only be overridden in advanced use cases.
+ *
+ * @param to - Raw route location to resolve
+ * @param currentLocation - Optional current location to resolve against
+ */
+ (
+ to: RouteLocationAsRelativeTyped,
+ currentLocation?: RouteLocationNormalizedLoaded
+ ): Effect.Effect, never, Router>
+ (
+ to: RouteLocationAsString | RouteLocationAsRelative | RouteLocationAsPath,
+ currentLocation?: RouteLocationNormalizedLoaded
+ ): Effect.Effect
+ } = (...args: any[]) => Router.use((_) => _.resolve(...(args as [any, any])))
+}
diff --git a/packages/vue/src/experimental/commander.ts b/packages/vue/src/experimental/commander.ts
index ed020c3de9..ca6a55b43c 100644
--- a/packages/vue/src/experimental/commander.ts
+++ b/packages/vue/src/experimental/commander.ts
@@ -16,7 +16,7 @@ import { I18n } from "./intl.js"
import { WithToast } from "./withToast.js"
type IntlRecord = Record>
-type FnOptions = {
+type FnOptions = {
i18nCustomKey?: I18nCustomKey
/**
* passed to the i18n formatMessage calls so you can use it in translation messagee
@@ -25,7 +25,10 @@ type FnOptions | (() => State)
- disableSharedWaiting?: boolean
+ // TODO: namespaced keys like reactivity keys: ["modify_thing", item], so that one can block also on "modify_thing" *
+ blockKey?: (id: Id) => string | undefined
+ waitKey?: (id: Id) => string | undefined
+ allowed?: (id: Id, state: ComputedRef) => boolean
}
type FnOptionsInternal = {
@@ -143,7 +146,11 @@ export declare namespace Commander {
/** reactive */
waiting: boolean
/** reactive */
- state: ComputedRef
+ blocked: boolean
+ /** reactive */
+ allowed: boolean
+ /** reactive */
+ state: State
}
export interface CommandOut<
@@ -1282,8 +1289,8 @@ const unregisterWait = (id: string) => {
}
}
-const getStateValues = (
- options?: FnOptions
+const getStateValues = (
+ options?: FnOptions
): ComputedRef => {
const state_ = options?.state
const state = !state_ ? computed(() => undefined as State) : typeof state_ === "function"
@@ -1339,7 +1346,7 @@ export class CommanderImpl