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 @@ + + + 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 @@ + + 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 { const I18nKey extends string = Id >( id_: Id | { id: Id }, - options?: FnOptions, + options?: FnOptions, errorDef?: Error ) => { const id = typeof id_ === "string" ? id_ : id_.id @@ -1418,21 +1425,40 @@ export class CommanderImpl { Effect.sync(() => makeContext_()) ) ) + const waitId = options?.waitKey ? options.waitKey(id) : undefined + const blockId = options?.blockKey ? options.blockKey(id) : undefined const [result, exec_] = asResult(theHandler) - // probably could be nice to use a namespaced, computable wait key instead not unlike query invalidation? - // ["Something.Update", { id }] for instance - const exec = options?.disableSharedWaiting - ? exec_ - : Effect - .fnUntraced(function*(...args: [any, any]) { - registerWait(id) + + const exec = Effect + .fnUntraced( + function*(...args: [any, any]) { + if (waitId !== undefined) registerWait(waitId) + if (blockId !== undefined && blockId !== waitId) { + registerWait(blockId) + } return yield* exec_(...args) - }, Effect.onExit(() => Effect.sync(() => unregisterWait(id)))) + }, + Effect.onExit(() => + Effect.sync(() => { + if (waitId !== undefined) unregisterWait(waitId) + if (blockId !== undefined && blockId !== waitId) { + unregisterWait(blockId) + } + }) + ) + ) + + const waiting = waitId !== undefined + ? computed(() => result.value.waiting || (waitState.value[waitId] ?? 0) > 0) + : computed(() => result.value.waiting) - const waiting = options?.disableSharedWaiting - ? computed(() => result.value.waiting) - : computed(() => result.value.waiting || (waitState.value[id] ?? 0) > 0) + const blocked = blockId !== undefined + ? computed(() => waiting.value || (waitState.value[blockId] ?? 0) > 0) + : computed(() => waiting.value) + + const computeAllowed = options?.allowed + const allowed = computeAllowed ? computed(() => computeAllowed(id, state)) : true const handle = Object.assign((arg: Arg) => { // we capture the call site stack here @@ -1503,6 +1529,10 @@ export class CommanderImpl { /** reactive */ waiting, /** reactive */ + blocked, + /** reactive */ + allowed, + /** reactive */ action, /** reactive */ label, @@ -1600,7 +1630,7 @@ export class CommanderImpl { const I18nKey extends string = Id >( id: Id | { id: Id }, - options?: FnOptions + options?: FnOptions ): Commander.Gen & Commander.NonGen & { state: Context.Tag<`Commander.Command.${Id}.state`, State> } => @@ -1647,7 +1677,7 @@ export class CommanderImpl { | Id | { id: Id; mutate: (arg: MutArg) => Effect.Effect } | ((arg: MutArg) => Effect.Effect) & { id: Id }, - options?: FnOptions + options?: FnOptions ) => & Commander.CommandContextLocal & (( @@ -1738,7 +1768,7 @@ export class CommanderImpl { mutation: | { mutate: (arg: Arg) => Effect.Effect; id: Id } | ((arg: Arg) => Effect.Effect) & { id: Id }, - options?: FnOptions + options?: FnOptions ): Commander.CommanderWrap => Object.assign( ( diff --git a/packages/vue/src/makeClient.ts b/packages/vue/src/makeClient.ts index ad7df1a148..38bfe8cafe 100644 --- a/packages/vue/src/makeClient.ts +++ b/packages/vue/src/makeClient.ts @@ -1481,6 +1481,8 @@ export type ToCamel = S extends string export interface CommandBase { handle: (input: I) => A waiting: boolean + blocked: boolean + allowed: boolean action: string label: string } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c82f17bf9e..059d75216a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -701,6 +701,9 @@ importers: vue-router: specifier: ^4.5.1 version: 4.5.1(vue@3.5.22(typescript@5.9.2(patch_hash=9f9678d78a879a2cbd842897c639ebfa754353e8098bac340a8ce599ae2161c0))) + vue-toastification: + specifier: ^2.0.0-rc.5 + version: 2.0.0-rc.5(vue@3.5.22(typescript@5.9.2(patch_hash=9f9678d78a879a2cbd842897c639ebfa754353e8098bac340a8ce599ae2161c0))) vue-tsc: specifier: ^3.1.0 version: 3.1.0(typescript@5.9.2(patch_hash=9f9678d78a879a2cbd842897c639ebfa754353e8098bac340a8ce599ae2161c0)) @@ -6533,6 +6536,11 @@ packages: peerDependencies: vue: ^3.5.22 + vue-toastification@2.0.0-rc.5: + resolution: {integrity: sha512-q73e5jy6gucEO/U+P48hqX+/qyXDozAGmaGgLFm5tXX4wJBcVsnGp4e/iJqlm9xzHETYOilUuwOUje2Qg1JdwA==} + peerDependencies: + vue: ^3.5.22 + vue-tsc@3.1.0: resolution: {integrity: sha512-fbMynMG7kXSnqZTRBSCh9ROYaVpXfCZbEO0gY3lqOjLbp361uuS88n6BDajiUriDIF+SGLWoinjvf6stS2J3Gg==} hasBin: true @@ -13286,6 +13294,10 @@ snapshots: '@vue/devtools-api': 6.6.4 vue: 3.5.22(typescript@5.9.2(patch_hash=9f9678d78a879a2cbd842897c639ebfa754353e8098bac340a8ce599ae2161c0)) + vue-toastification@2.0.0-rc.5(vue@3.5.22(typescript@5.9.2(patch_hash=9f9678d78a879a2cbd842897c639ebfa754353e8098bac340a8ce599ae2161c0))): + dependencies: + vue: 3.5.22(typescript@5.9.2(patch_hash=9f9678d78a879a2cbd842897c639ebfa754353e8098bac340a8ce599ae2161c0)) + vue-tsc@3.1.0(typescript@5.9.2(patch_hash=9f9678d78a879a2cbd842897c639ebfa754353e8098bac340a8ce599ae2161c0)): dependencies: '@volar/typescript': 2.4.23