@m5kdev/web-ui 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ import { type ReactElement } from "react";
2
+ import type { z } from "zod";
3
+ import type { UseBackendTRPC } from "../../../types";
4
+ import { type ControlsFor, type PreferenceEditorLabels } from "./PreferencesEditor";
5
+ type OrganizationPreferenceLabels = PreferenceEditorLabels & {
6
+ noActiveOrganization: string;
7
+ loadError: string;
8
+ };
9
+ export type OrganizationPreferencesTarget = "preferences" | "flags";
10
+ export type OrganizationPreferencesProps<S extends z.ZodObject<z.ZodRawShape>> = {
11
+ useTRPC: UseBackendTRPC;
12
+ schema: S;
13
+ controls: ControlsFor<z.infer<S>>;
14
+ target?: OrganizationPreferencesTarget;
15
+ labels?: Partial<OrganizationPreferenceLabels>;
16
+ onInvalidateScopedQueries?: () => void | Promise<void>;
17
+ };
18
+ export declare function OrganizationPreferences<S extends z.ZodObject<z.ZodRawShape>>({ useTRPC, schema, controls, target, labels, onInvalidateScopedQueries, }: OrganizationPreferencesProps<S>): ReactElement;
19
+ export {};
@@ -0,0 +1,133 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Card, CardBody, CardHeader } from "@heroui/react";
3
+ import { useSession } from "@m5kdev/frontend/modules/auth/hooks/useSession";
4
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
5
+ import { useCallback, useMemo } from "react";
6
+ import { useTranslation } from "react-i18next";
7
+ import { PreferencesEditor, } from "./PreferencesEditor";
8
+ function OrganizationStateCard({ title, message }) {
9
+ return (_jsx("div", { className: "p-6", children: _jsxs(Card, { children: [_jsx(CardHeader, { className: "text-lg font-semibold", children: title }), _jsx(CardBody, { children: message })] }) }));
10
+ }
11
+ function getFlagValues(controls, flags) {
12
+ const activeFlags = new Set(flags);
13
+ return Object.fromEntries(Object.keys(controls).map((key) => [key, activeFlags.has(key)]));
14
+ }
15
+ function getFlagsFromValues(values) {
16
+ return Object.entries(values)
17
+ .filter(([, value]) => value === true)
18
+ .map(([key]) => key);
19
+ }
20
+ export function OrganizationPreferences({ useTRPC, schema, controls, target = "preferences", labels, onInvalidateScopedQueries, }) {
21
+ const { data: session, isLoading: isSessionLoading } = useSession();
22
+ const { t } = useTranslation("web-ui");
23
+ const trpc = useTRPC();
24
+ const queryClient = useQueryClient();
25
+ const activeOrganizationId = session?.session.activeOrganizationId ?? "";
26
+ const labelKeyPrefix = target === "flags" ? "web-ui:organization.flags" : "web-ui:organization.preferences";
27
+ const resolvedLabels = useMemo(() => ({
28
+ title: labels?.title ?? t(`${labelKeyPrefix}.title`),
29
+ submit: labels?.submit ?? t(`${labelKeyPrefix}.submit`),
30
+ updated: labels?.updated ?? t(`${labelKeyPrefix}.updated`),
31
+ updateError: labels?.updateError ?? t(`${labelKeyPrefix}.updateError`),
32
+ loading: labels?.loading ?? t(`${labelKeyPrefix}.loading`),
33
+ noActiveOrganization: labels?.noActiveOrganization ?? t(`${labelKeyPrefix}.noActive`),
34
+ loadError: labels?.loadError ?? t(`${labelKeyPrefix}.loadError`),
35
+ }), [labelKeyPrefix, labels, t]);
36
+ const preferencesQuery = useQuery({
37
+ ...trpc.auth.getOrganizationPreferences.queryOptions(),
38
+ enabled: target === "preferences" && Boolean(activeOrganizationId),
39
+ });
40
+ const flagsQuery = useQuery({
41
+ ...trpc.auth.getOrganizationFlags.queryOptions(),
42
+ enabled: target === "flags" && Boolean(activeOrganizationId),
43
+ });
44
+ const setPreferencesMutation = useMutation(trpc.auth.setOrganizationPreferences.mutationOptions());
45
+ const setFlagsMutation = useMutation(trpc.auth.setOrganizationFlags.mutationOptions());
46
+ const currentValues = useMemo(() => {
47
+ if (target === "flags") {
48
+ return getFlagValues(controls, flagsQuery.data ?? []);
49
+ }
50
+ return (preferencesQuery.data ?? {});
51
+ }, [controls, flagsQuery.data, preferencesQuery.data, target]);
52
+ const updateValues = useCallback((partialValues, options) => {
53
+ if (target === "flags") {
54
+ const nextValues = {
55
+ ...currentValues,
56
+ ...partialValues,
57
+ };
58
+ const nextFlags = getFlagsFromValues(nextValues);
59
+ if (!options.noOptimisticUpdate) {
60
+ queryClient.setQueryData(trpc.auth.getOrganizationFlags.queryKey(), nextFlags);
61
+ }
62
+ setFlagsMutation.mutate(nextFlags, {
63
+ onSuccess: async (result) => {
64
+ queryClient.setQueryData(trpc.auth.getOrganizationFlags.queryKey(), result);
65
+ if (activeOrganizationId) {
66
+ await queryClient.invalidateQueries({
67
+ queryKey: ["auth-organization-details", activeOrganizationId],
68
+ });
69
+ }
70
+ await onInvalidateScopedQueries?.();
71
+ options.onSuccess?.();
72
+ },
73
+ onError: async (error) => {
74
+ if (!options.noOptimisticUpdate) {
75
+ await queryClient.invalidateQueries({
76
+ queryKey: trpc.auth.getOrganizationFlags.queryKey(),
77
+ });
78
+ }
79
+ options.onError?.(error);
80
+ },
81
+ });
82
+ return;
83
+ }
84
+ const nextPreferences = {
85
+ ...(preferencesQuery.data ?? {}),
86
+ ...partialValues,
87
+ };
88
+ if (!options.noOptimisticUpdate) {
89
+ queryClient.setQueryData(trpc.auth.getOrganizationPreferences.queryKey(), nextPreferences);
90
+ }
91
+ setPreferencesMutation.mutate(nextPreferences, {
92
+ onSuccess: async (result) => {
93
+ queryClient.setQueryData(trpc.auth.getOrganizationPreferences.queryKey(), result);
94
+ if (activeOrganizationId) {
95
+ await queryClient.invalidateQueries({
96
+ queryKey: ["auth-organization-details", activeOrganizationId],
97
+ });
98
+ }
99
+ await onInvalidateScopedQueries?.();
100
+ options.onSuccess?.();
101
+ },
102
+ onError: async (error) => {
103
+ if (!options.noOptimisticUpdate) {
104
+ await queryClient.invalidateQueries({
105
+ queryKey: trpc.auth.getOrganizationPreferences.queryKey(),
106
+ });
107
+ }
108
+ options.onError?.(error);
109
+ },
110
+ });
111
+ }, [
112
+ activeOrganizationId,
113
+ currentValues,
114
+ onInvalidateScopedQueries,
115
+ preferencesQuery.data,
116
+ queryClient,
117
+ setFlagsMutation,
118
+ setPreferencesMutation,
119
+ target,
120
+ trpc.auth.getOrganizationFlags,
121
+ trpc.auth.getOrganizationPreferences,
122
+ ]);
123
+ const isLoading = isSessionLoading || (target === "flags" ? flagsQuery.isLoading : preferencesQuery.isLoading);
124
+ const isPending = target === "flags" ? setFlagsMutation.isPending : setPreferencesMutation.isPending;
125
+ const queryError = target === "flags" ? flagsQuery.error : preferencesQuery.error;
126
+ if (!isSessionLoading && !activeOrganizationId) {
127
+ return (_jsx(OrganizationStateCard, { title: resolvedLabels.title, message: resolvedLabels.noActiveOrganization }));
128
+ }
129
+ if (queryError) {
130
+ return (_jsx(OrganizationStateCard, { title: resolvedLabels.title, message: queryError instanceof Error ? queryError.message : resolvedLabels.loadError }));
131
+ }
132
+ return (_jsx(PreferencesEditor, { schema: schema, controls: controls, values: currentValues, isLoading: isLoading, isPending: isPending, labels: resolvedLabels, updateValues: updateValues }));
133
+ }
@@ -0,0 +1,38 @@
1
+ import type { ReactElement } from "react";
2
+ import type { z } from "zod";
3
+ export type UpdatePreferencesOptions = {
4
+ noOptimisticUpdate?: boolean;
5
+ onSuccess?: () => void;
6
+ onError?: (error: unknown) => void;
7
+ };
8
+ export interface ControlDefinition {
9
+ label: string;
10
+ element: "switch" | "select" | "number";
11
+ options?: {
12
+ label: string;
13
+ value: string;
14
+ }[];
15
+ min?: number;
16
+ max?: number;
17
+ step?: number;
18
+ }
19
+ export type ControlsFor<Preferences> = {
20
+ [K in keyof Preferences]: ControlDefinition;
21
+ };
22
+ export type PreferenceEditorLabels = {
23
+ title: string;
24
+ submit: string;
25
+ updated: string;
26
+ updateError: string;
27
+ loading?: string;
28
+ };
29
+ export type PreferenceEditorProps<S extends z.ZodObject<z.ZodRawShape>> = {
30
+ schema: S;
31
+ controls: ControlsFor<z.infer<S>>;
32
+ values: Partial<z.infer<S>>;
33
+ isLoading: boolean;
34
+ isPending: boolean;
35
+ labels: PreferenceEditorLabels;
36
+ updateValues: (partialValues: Partial<z.infer<S>>, options: UpdatePreferencesOptions) => void;
37
+ };
38
+ export declare function PreferencesEditor<S extends z.ZodObject<z.ZodRawShape>>({ schema, controls, values, isLoading, isPending, labels, updateValues, }: PreferenceEditorProps<S>): ReactElement;
@@ -0,0 +1,61 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Button, Form, Input, Select, SelectItem, Switch } from "@heroui/react";
3
+ import { toast } from "sonner";
4
+ export function PreferencesEditor({ schema, controls, values, isLoading, isPending, labels, updateValues, }) {
5
+ function handleSubmit(event) {
6
+ event.preventDefault();
7
+ const formData = new FormData(event.currentTarget);
8
+ const keys = Object.keys(controls);
9
+ const raw = {};
10
+ for (const key of keys) {
11
+ const control = controls[key];
12
+ if (control.element === "switch") {
13
+ raw[String(key)] = formData.get(String(key)) != null;
14
+ }
15
+ if (control.element === "select") {
16
+ raw[String(key)] = formData.get(String(key));
17
+ }
18
+ if (control.element === "number") {
19
+ const value = formData.get(String(key));
20
+ raw[String(key)] = value == null || value === "" ? undefined : Number(value);
21
+ }
22
+ }
23
+ const result = schema.safeParse(raw);
24
+ if (result.success) {
25
+ updateValues(result.data, {
26
+ noOptimisticUpdate: true,
27
+ onSuccess: () => {
28
+ toast.success(labels.updated);
29
+ },
30
+ onError: () => {
31
+ toast.error(labels.updateError);
32
+ },
33
+ });
34
+ }
35
+ else {
36
+ // eslint-disable-next-line no-console
37
+ console.error(result.error);
38
+ }
39
+ }
40
+ const keys = Object.keys(controls);
41
+ const formKey = keys
42
+ .map((key) => `${String(key)}:${String(values[key])}`)
43
+ .join("|");
44
+ if (isLoading) {
45
+ return _jsx("div", { children: labels.loading ?? "Loading..." });
46
+ }
47
+ return (_jsxs(Form, { onSubmit: handleSubmit, className: "flex flex-col gap-4 p-6", children: [_jsx("h1", { className: "text-2xl font-bold", children: labels.title }), keys.map((key) => {
48
+ const control = controls[key];
49
+ const value = values[key];
50
+ switch (control.element) {
51
+ case "switch":
52
+ return (_jsx(Switch, { name: String(key), value: "on", defaultSelected: Boolean(value), children: control.label }, String(key)));
53
+ case "select":
54
+ return (_jsx(Select, { name: String(key), label: control.label, labelPlacement: "outside-top", defaultSelectedKeys: value == null || value === "" ? [] : [String(value)], children: (control.options ?? []).map((option) => (_jsx(SelectItem, { children: option.label }, option.value))) }, String(key)));
55
+ case "number":
56
+ return (_jsx(Input, { name: String(key), type: "number", label: control.label, labelPlacement: "outside-top", defaultValue: value == null ? "" : String(value), min: control.min, max: control.max, step: control.step }, String(key)));
57
+ default:
58
+ return _jsx("div", { children: "Invalid control" }, String(key));
59
+ }
60
+ }), _jsx(Button, { type: "submit", color: "success", isLoading: isPending, children: labels.submit })] }, formKey));
61
+ }
@@ -1,24 +1,7 @@
1
1
  import type { ReactElement } from "react";
2
2
  import type { z } from "zod";
3
- type UpdatePreferencesOptions = {
4
- noOptimisticUpdate?: boolean;
5
- onSuccess?: () => void;
6
- onError?: (error: unknown) => void;
7
- };
8
- interface ControlDefinition {
9
- label: string;
10
- element: "switch" | "select" | "number";
11
- options?: {
12
- label: string;
13
- value: string;
14
- }[];
15
- min?: number;
16
- max?: number;
17
- step?: number;
18
- }
19
- type ControlsFor<Preferences> = {
20
- [K in keyof Preferences]: ControlDefinition;
21
- };
3
+ import { type ControlsFor, type UpdatePreferencesOptions } from "./PreferencesEditor";
4
+ export type { ControlDefinition, ControlsFor, PreferenceEditorLabels, UpdatePreferencesOptions, } from "./PreferencesEditor";
22
5
  export declare function UserPreferences<S extends z.ZodObject<z.ZodRawShape>>({ schema, controls, preferences, isLoading, isPending, updatePreferences, }: {
23
6
  schema: S;
24
7
  controls: ControlsFor<z.infer<S>>;
@@ -27,4 +10,3 @@ export declare function UserPreferences<S extends z.ZodObject<z.ZodRawShape>>({
27
10
  isPending: boolean;
28
11
  updatePreferences: (partialPreferences: Partial<z.infer<S>>, options: UpdatePreferencesOptions) => void;
29
12
  }): ReactElement;
30
- export {};
@@ -1,60 +1,14 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Button, Form, Input, Select, SelectItem, Switch } from "@heroui/react";
1
+ import { jsx as _jsx } from "react/jsx-runtime";
3
2
  import { useTranslation } from "react-i18next";
4
- import { toast } from "sonner";
3
+ import { PreferencesEditor, } from "./PreferencesEditor";
5
4
  export function UserPreferences({ schema, controls, preferences, isLoading, isPending, updatePreferences, }) {
6
5
  const { t } = useTranslation("web-ui");
7
- function handleSubmit(event) {
8
- event.preventDefault();
9
- const formData = new FormData(event.currentTarget);
10
- const keys = Object.keys(controls);
11
- const raw = {};
12
- for (const key of keys) {
13
- const control = controls[key];
14
- if (control.element === "switch") {
15
- raw[String(key)] = formData.get(String(key)) != null;
16
- }
17
- if (control.element === "select") {
18
- raw[String(key)] = formData.get(String(key));
19
- }
20
- if (control.element === "number") {
21
- const value = formData.get(String(key));
22
- raw[String(key)] = value == null || value === "" ? undefined : Number(value);
23
- }
24
- }
25
- const result = schema.safeParse(raw);
26
- if (result.success) {
27
- updatePreferences(result.data, {
28
- noOptimisticUpdate: true,
29
- onSuccess: () => {
30
- toast.success("Preferences updated");
31
- },
32
- onError: () => {
33
- toast.error("Failed to update preferences");
34
- },
35
- });
36
- }
37
- else {
38
- // eslint-disable-next-line no-console
39
- console.error(result.error);
40
- }
41
- }
42
- const keys = Object.keys(controls);
43
- if (isLoading) {
44
- // FIXME: Add a loading state
45
- return _jsx("div", { children: "Loading..." });
46
- }
47
- return (_jsxs(Form, { onSubmit: handleSubmit, className: "p-6 flex flex-col gap-4", children: [_jsx("h1", { className: "text-2xl font-bold", children: t("web-ui:preferences.title") }), keys.map((key) => {
48
- const control = controls[key];
49
- switch (control.element) {
50
- case "switch":
51
- return (_jsx(Switch, { name: String(key), value: "on", defaultSelected: Boolean(preferences[key]), children: control.label }, String(key)));
52
- case "select":
53
- return (_jsx(Select, { name: String(key), label: control.label, labelPlacement: "outside-top", defaultSelectedKeys: [String(preferences[key])], children: (control.options ?? []).map((option) => (_jsx(SelectItem, { children: option.label }, option.value))) }, String(key)));
54
- case "number":
55
- return (_jsx(Input, { name: String(key), type: "number", label: control.label, labelPlacement: "outside-top", defaultValue: String(preferences[key] ?? ""), min: control.min, max: control.max, step: control.step }, String(key)));
56
- default:
57
- return _jsx("div", { children: "Invalid control" }, String(key));
58
- }
59
- }), _jsx(Button, { type: "submit", color: "success", isLoading: isPending, children: t("web-ui:preferences.submit") })] }));
6
+ const labels = {
7
+ title: t("web-ui:preferences.title"),
8
+ submit: t("web-ui:preferences.submit"),
9
+ updated: t("web-ui:preferences.updated"),
10
+ updateError: t("web-ui:preferences.updateError"),
11
+ loading: t("web-ui:preferences.loading"),
12
+ };
13
+ return (_jsx(PreferencesEditor, { schema: schema, controls: controls, values: preferences, isLoading: isLoading, isPending: isPending, labels: labels, updateValues: updatePreferences }));
60
14
  }
@@ -74,6 +74,9 @@
74
74
  "auth.resetPassword.error": "Failed to reset password",
75
75
  "preferences.title": "Preferences",
76
76
  "preferences.submit": "Save",
77
+ "preferences.updated": "Preferences updated",
78
+ "preferences.updateError": "Failed to update preferences",
79
+ "preferences.loading": "Loading preferences...",
77
80
  "billing.beta.badge": "Beta",
78
81
  "billing.title": "Billing & Pricing",
79
82
  "billing.subtitle": "{{appName}} is currently free to use during the beta. We’re finalizing pricing for the production launch.",
@@ -136,6 +139,20 @@
136
139
  "organization.settings.updateSuccess": "Organization updated",
137
140
  "organization.settings.updateError": "Failed to update organization",
138
141
  "organization.settings.loadError": "Failed to load organization",
142
+ "organization.preferences.title": "Organization preferences",
143
+ "organization.preferences.submit": "Save preferences",
144
+ "organization.preferences.updated": "Organization preferences updated",
145
+ "organization.preferences.updateError": "Failed to update organization preferences",
146
+ "organization.preferences.noActive": "No active organization selected.",
147
+ "organization.preferences.loadError": "Failed to load organization preferences",
148
+ "organization.preferences.loading": "Loading organization preferences...",
149
+ "organization.flags.title": "Organization flags",
150
+ "organization.flags.submit": "Save flags",
151
+ "organization.flags.updated": "Organization flags updated",
152
+ "organization.flags.updateError": "Failed to update organization flags",
153
+ "organization.flags.noActive": "No active organization selected.",
154
+ "organization.flags.loadError": "Failed to load organization flags",
155
+ "organization.flags.loading": "Loading organization flags...",
139
156
  "organization.members.loadError": "Failed to load organization",
140
157
  "organization.members.title": "Organization members",
141
158
  "organization.members.noActive": "No active organization selected.",