feat: add board access settings (#249)
* wip: add board access settings * wip: add user access control * wip: add user access control * feat: add user access control * refactor: move away from mantine-modal-manager * fix: ci issues and failing tests * fix: lint issue * fix: format issue * fix: deepsource issues * chore: address pull request feedback
This commit is contained in:
92
packages/modals/src/confirm-modal.tsx
Normal file
92
packages/modals/src/confirm-modal.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useCallback } from "react";
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
|
||||
import type {
|
||||
stringOrTranslation,
|
||||
TranslationFunction,
|
||||
} from "@homarr/translation";
|
||||
import { translateIfNecessary } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { ButtonProps, GroupProps } from "@homarr/ui";
|
||||
import { Box, Button, Group } from "@homarr/ui";
|
||||
|
||||
import { createModal } from "./creator";
|
||||
|
||||
type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
export interface ConfirmModalProps {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
onConfirm?: () => MaybePromise<void>;
|
||||
onCancel?: () => MaybePromise<void>;
|
||||
closeOnConfirm?: boolean;
|
||||
closeOnCancel?: boolean;
|
||||
cancelProps?: ButtonProps & ComponentPropsWithoutRef<"button">;
|
||||
confirmProps?: ButtonProps & ComponentPropsWithoutRef<"button">;
|
||||
groupProps?: GroupProps;
|
||||
|
||||
labels?: {
|
||||
confirm?: stringOrTranslation;
|
||||
cancel?: stringOrTranslation;
|
||||
};
|
||||
}
|
||||
|
||||
export const ConfirmModal = createModal<Omit<ConfirmModalProps, "title">>(
|
||||
({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const {
|
||||
children,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
cancelProps,
|
||||
confirmProps,
|
||||
groupProps,
|
||||
labels,
|
||||
} = innerProps;
|
||||
|
||||
const closeOnConfirm = innerProps.closeOnConfirm ?? true;
|
||||
const closeOnCancel = innerProps.closeOnCancel ?? true;
|
||||
|
||||
const cancelLabel =
|
||||
labels?.cancel ?? ((t: TranslationFunction) => t("common.action.cancel"));
|
||||
const confirmLabel =
|
||||
labels?.confirm ??
|
||||
((t: TranslationFunction) => t("common.action.confirm"));
|
||||
|
||||
const handleCancel = useCallback(
|
||||
async (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
typeof cancelProps?.onClick === "function" &&
|
||||
cancelProps?.onClick(event);
|
||||
typeof onCancel === "function" && (await onCancel());
|
||||
closeOnCancel && actions.closeModal();
|
||||
},
|
||||
[cancelProps?.onClick, onCancel, actions.closeModal],
|
||||
);
|
||||
|
||||
const handleConfirm = useCallback(
|
||||
async (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
typeof confirmProps?.onClick === "function" &&
|
||||
confirmProps?.onClick(event);
|
||||
typeof onConfirm === "function" && (await onConfirm());
|
||||
closeOnConfirm && actions.closeModal();
|
||||
},
|
||||
[confirmProps?.onClick, onConfirm, actions.closeModal],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{children && <Box mb="md">{children}</Box>}
|
||||
|
||||
<Group justify="flex-end" {...groupProps}>
|
||||
<Button variant="default" {...cancelProps} onClick={handleCancel}>
|
||||
{cancelProps?.children || translateIfNecessary(t, cancelLabel)}
|
||||
</Button>
|
||||
|
||||
<Button {...confirmProps} onClick={handleConfirm} color="red.9">
|
||||
{confirmProps?.children || translateIfNecessary(t, confirmLabel)}
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
},
|
||||
).withOptions({});
|
||||
14
packages/modals/src/creator.ts
Normal file
14
packages/modals/src/creator.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { CreateModalOptions, ModalComponent } from "./type";
|
||||
|
||||
export const createModal = <TInnerProps>(
|
||||
component: ModalComponent<TInnerProps>,
|
||||
) => {
|
||||
return {
|
||||
withOptions: (options: Partial<CreateModalOptions>) => {
|
||||
return {
|
||||
component,
|
||||
options,
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
141
packages/modals/src/index.tsx
Normal file
141
packages/modals/src/index.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useReducer,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { randomId } from "@mantine/hooks";
|
||||
|
||||
import type { stringOrTranslation } from "@homarr/translation";
|
||||
import { translateIfNecessary } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { getDefaultZIndex, Modal } from "@homarr/ui";
|
||||
|
||||
import type { ConfirmModalProps } from "./confirm-modal";
|
||||
import { ConfirmModal } from "./confirm-modal";
|
||||
import { modalReducer } from "./reducer";
|
||||
import type { inferInnerProps, ModalDefinition } from "./type";
|
||||
|
||||
interface ModalContextProps {
|
||||
openModalInner: <TModal extends ModalDefinition>(props: {
|
||||
modal: TModal;
|
||||
innerProps: inferInnerProps<TModal>;
|
||||
options: OpenModalOptions;
|
||||
}) => void;
|
||||
closeModal: (id: string) => void;
|
||||
}
|
||||
|
||||
export const ModalContext = createContext<ModalContextProps | null>(null);
|
||||
|
||||
export const ModalProvider = ({ children }: PropsWithChildren) => {
|
||||
const t = useI18n();
|
||||
const [state, dispatch] = useReducer(modalReducer, {
|
||||
modals: [],
|
||||
current: null,
|
||||
});
|
||||
const stateRef = useRef(state);
|
||||
stateRef.current = state;
|
||||
|
||||
const closeModal = useCallback(
|
||||
(id: string, canceled?: boolean) => {
|
||||
dispatch({ type: "CLOSE", modalId: id, canceled });
|
||||
},
|
||||
[stateRef, dispatch],
|
||||
);
|
||||
|
||||
const openModalInner: ModalContextProps["openModalInner"] = useCallback(
|
||||
({ modal, innerProps, options }) => {
|
||||
const id = randomId();
|
||||
const { title, ...rest } = options;
|
||||
dispatch({
|
||||
type: "OPEN",
|
||||
modal: {
|
||||
id,
|
||||
modal,
|
||||
props: {
|
||||
...modal.options,
|
||||
...rest,
|
||||
defaultTitle: title ?? modal.options.defaultTitle,
|
||||
innerProps,
|
||||
},
|
||||
},
|
||||
});
|
||||
return id;
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleCloseModal = useCallback(
|
||||
() => state.current && closeModal(state.current.id),
|
||||
[closeModal, state.current?.id],
|
||||
);
|
||||
|
||||
const activeModals = state.modals.filter(
|
||||
(modal) => modal.id === state.current?.id || modal.props.keepMounted,
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalContext.Provider value={{ openModalInner, closeModal }}>
|
||||
{activeModals.map((modal) => (
|
||||
<Modal
|
||||
key={modal.id}
|
||||
zIndex={getDefaultZIndex("modal") + 1}
|
||||
display={modal.id === state.current?.id ? undefined : "none"}
|
||||
style={{
|
||||
userSelect: modal.id === state.current?.id ? undefined : "none",
|
||||
}}
|
||||
styles={{
|
||||
title: {
|
||||
fontSize: "1.25rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
}}
|
||||
trapFocus={modal.id === state.current?.id}
|
||||
{...modal.reference.modalProps}
|
||||
title={translateIfNecessary(t, modal.props.defaultTitle)}
|
||||
opened={state.modals.length > 0}
|
||||
onClose={handleCloseModal}
|
||||
>
|
||||
{modal.reference.content}
|
||||
</Modal>
|
||||
))}
|
||||
|
||||
{children}
|
||||
</ModalContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
interface OpenModalOptions {
|
||||
keepMounted?: boolean;
|
||||
title?: stringOrTranslation;
|
||||
}
|
||||
|
||||
export const useModalAction = <TModal extends ModalDefinition>(
|
||||
modal: TModal,
|
||||
) => {
|
||||
const context = useContext(ModalContext);
|
||||
|
||||
if (!context) throw new Error("ModalContext is not provided");
|
||||
|
||||
return {
|
||||
openModal: (
|
||||
innerProps: inferInnerProps<TModal>,
|
||||
options: OpenModalOptions | void,
|
||||
) => {
|
||||
context.openModalInner({ modal, innerProps, options: options ?? {} });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const useConfirmModal = () => {
|
||||
const { openModal } = useModalAction(ConfirmModal);
|
||||
|
||||
return {
|
||||
openConfirmModal: (props: ConfirmModalProps) =>
|
||||
openModal(props, { title: props.title }),
|
||||
};
|
||||
};
|
||||
125
packages/modals/src/reducer.tsx
Normal file
125
packages/modals/src/reducer.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useContext } from "react";
|
||||
|
||||
import { ModalContext } from ".";
|
||||
import type { ModalDefinition, ModalState } from "./type";
|
||||
|
||||
type ModalStateWithReference = ModalState & {
|
||||
/**
|
||||
* Reference to modal component instance
|
||||
* Used so the modal can be persisted between navigating in newer modals
|
||||
*/
|
||||
reference: ReturnType<typeof getModal>;
|
||||
};
|
||||
|
||||
interface ModalsState {
|
||||
modals: ModalStateWithReference[];
|
||||
|
||||
/**
|
||||
* Modal that is currently open or was the last open one.
|
||||
* Keeping the last one is necessary for providing a clean exit transition.
|
||||
*/
|
||||
current: ModalStateWithReference | null;
|
||||
}
|
||||
|
||||
interface OpenAction {
|
||||
type: "OPEN";
|
||||
modal: ModalState;
|
||||
}
|
||||
|
||||
interface CloseAction {
|
||||
type: "CLOSE";
|
||||
modalId: string;
|
||||
canceled?: boolean;
|
||||
}
|
||||
|
||||
interface CloseAllAction {
|
||||
type: "CLOSE_ALL";
|
||||
canceled?: boolean;
|
||||
}
|
||||
|
||||
export const modalReducer = (
|
||||
state: ModalsState,
|
||||
action: OpenAction | CloseAction | CloseAllAction,
|
||||
): ModalsState => {
|
||||
switch (action.type) {
|
||||
case "OPEN": {
|
||||
const newModal = {
|
||||
...action.modal,
|
||||
reference: getModal(action.modal),
|
||||
};
|
||||
return {
|
||||
current: newModal,
|
||||
modals: [...state.modals, newModal],
|
||||
};
|
||||
}
|
||||
case "CLOSE": {
|
||||
const modal = state.modals.find((modal) => modal.id === action.modalId);
|
||||
if (!modal) {
|
||||
return state;
|
||||
}
|
||||
|
||||
modal.props.onClose?.();
|
||||
|
||||
const remainingModals = state.modals.filter(
|
||||
(modal) => modal.id !== action.modalId,
|
||||
);
|
||||
|
||||
return {
|
||||
current: remainingModals[remainingModals.length - 1] || state.current,
|
||||
modals: remainingModals,
|
||||
};
|
||||
}
|
||||
case "CLOSE_ALL": {
|
||||
if (!state.modals.length) {
|
||||
return state;
|
||||
}
|
||||
|
||||
// Resolve modal stack from top to bottom
|
||||
state.modals
|
||||
.concat()
|
||||
.reverse()
|
||||
.forEach((modal) => {
|
||||
modal.props.onClose?.();
|
||||
});
|
||||
|
||||
return {
|
||||
current: state.current,
|
||||
modals: [],
|
||||
};
|
||||
}
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getModal = <TModal extends ModalDefinition>(
|
||||
modal: ModalState<TModal>,
|
||||
) => {
|
||||
const ModalContent = modal.modal.component;
|
||||
|
||||
const { innerProps, ...rest } = modal.props;
|
||||
const FullModal = () => {
|
||||
const context = useContext(ModalContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("Modal component used outside of modal context");
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalContent
|
||||
innerProps={innerProps}
|
||||
actions={{
|
||||
closeModal: () => context.closeModal(modal.id),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
modalProps: rest,
|
||||
content: <FullModal />,
|
||||
};
|
||||
};
|
||||
43
packages/modals/src/type.ts
Normal file
43
packages/modals/src/type.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { stringOrTranslation } from "@homarr/translation";
|
||||
import type { ModalProps } from "@homarr/ui";
|
||||
|
||||
export type ModalComponent<TInnerProps> = (props: {
|
||||
actions: { closeModal: () => void };
|
||||
innerProps: TInnerProps;
|
||||
}) => ReactNode;
|
||||
|
||||
export type CreateModalOptions = Pick<
|
||||
ModalOptions<unknown>,
|
||||
| "size"
|
||||
| "fullScreen"
|
||||
| "centered"
|
||||
| "keepMounted"
|
||||
| "withCloseButton"
|
||||
| "zIndex"
|
||||
| "scrollAreaComponent"
|
||||
| "yOffset"
|
||||
> & {
|
||||
defaultTitle: stringOrTranslation;
|
||||
};
|
||||
|
||||
export interface ModalDefinition {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
component: ModalComponent<any>;
|
||||
options: Partial<CreateModalOptions>;
|
||||
}
|
||||
|
||||
type ModalOptions<TInnerProps> = Partial<Omit<ModalProps, "opened">> & {
|
||||
innerProps: TInnerProps;
|
||||
defaultTitle?: stringOrTranslation;
|
||||
};
|
||||
|
||||
export interface ModalState<TModal extends ModalDefinition = ModalDefinition> {
|
||||
id: string;
|
||||
modal: TModal;
|
||||
props: ModalOptions<inferInnerProps<TModal>>;
|
||||
}
|
||||
|
||||
export type inferInnerProps<TModal extends ModalDefinition> =
|
||||
TModal["component"] extends ModalComponent<infer P> ? P : never;
|
||||
Reference in New Issue
Block a user