Feat/company card fields (#686)

* wip

* Ok

* asd

* Fixed cancel submit

* Renamed

* Fixed
This commit is contained in:
Lucas Bordeau
2023-07-16 04:17:31 +02:00
committed by GitHub
parent 7959308e0b
commit be21392737
35 changed files with 1041 additions and 95 deletions

View File

@ -1,4 +1,4 @@
import { InplaceInputDateEditMode } from '@/ui/inplace-inputs/components/InplaceInputDateEditMode';
import { InplaceInputDate } from '@/ui/inplace-inputs/components/InplaceInputDate';
type OwnProps = {
value: Date;
@ -13,5 +13,5 @@ export function BoardCardEditableFieldDateEditMode({
onChange(newDate);
}
return <InplaceInputDateEditMode value={value} onChange={handleDateChange} />;
return <InplaceInputDate value={value} onChange={handleDateChange} />;
}

View File

@ -13,11 +13,11 @@ export type ButtonProps = {
const StyledIconButton = styled.button<Pick<ButtonProps, 'variant' | 'size'>>`
align-items: center;
background: ${({ theme, variant, disabled }) => {
background: ${({ theme, variant }) => {
switch (variant) {
case 'shadow':
case 'white':
return theme.background.transparent.lighter;
return theme.background.transparent.primary;
case 'transparent':
case 'border':
default:
@ -35,10 +35,10 @@ const StyledIconButton = styled.button<Pick<ButtonProps, 'variant' | 'size'>>`
return 'none';
}
}};
transition: background 0.1s ease;
border-radius: ${({ theme }) => {
return theme.border.radius.sm;
}};
border-style: solid;
border-width: ${({ variant }) => {
switch (variant) {
case 'border':
@ -50,6 +50,17 @@ const StyledIconButton = styled.button<Pick<ButtonProps, 'variant' | 'size'>>`
return 0;
}
}};
box-shadow: ${({ theme, variant }) => {
switch (variant) {
case 'shadow':
return theme.boxShadow.light;
case 'border':
case 'white':
case 'transparent':
default:
return 'none';
}
}};
color: ${({ theme, disabled }) => {
if (disabled) {
return theme.font.color.extraLight;
@ -57,8 +68,9 @@ const StyledIconButton = styled.button<Pick<ButtonProps, 'variant' | 'size'>>`
return theme.font.color.tertiary;
}};
border-style: solid;
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
display: flex;
flex-shrink: 0;
height: ${({ size }) => {
switch (size) {
case 'large':
@ -70,9 +82,15 @@ const StyledIconButton = styled.button<Pick<ButtonProps, 'variant' | 'size'>>`
return '20px';
}
}};
display: flex;
justify-content: center;
padding: 0;
transition: background 0.1s ease;
user-select: none;
&:hover {
background: ${({ theme, disabled }) => {
return disabled ? 'auto' : theme.background.transparent.light;
}};
}
width: ${({ size }) => {
switch (size) {
case 'large':
@ -84,17 +102,11 @@ const StyledIconButton = styled.button<Pick<ButtonProps, 'variant' | 'size'>>`
return '20px';
}
}};
flex-shrink: 0;
&:hover {
background: ${({ theme, disabled }) => {
return disabled ? 'auto' : theme.background.transparent.light;
}};
}
user-select: none;
&:active {
background: ${({ theme, disabled }) => {
return disabled ? 'auto' : theme.background.transparent.medium;
}};
}
`;
export function IconButton({

View File

@ -12,14 +12,16 @@ export const EditableCellEditModeContainer = styled.div<OwnProps>`
display: flex;
left: ${(props) =>
props.editModeHorizontalAlign === 'right' ? 'auto' : '0'};
margin-left: -2px;
min-height: 100%;
min-width: calc(100% + 20px);
position: absolute;
margin-left: -1px;
margin-top: -1px;
min-height: 100%;
position: absolute;
right: ${(props) =>
props.editModeHorizontalAlign === 'right' ? '0' : 'auto'};
top: ${(props) => (props.editModeVerticalPosition === 'over' ? '0' : '100%')};
width: 100%;
z-index: 1;
${overlayBackground}
`;

View File

@ -1,11 +1,17 @@
import styled from '@emotion/styled';
import { Key } from 'ts-key-enum';
import { useScopedHotkeys } from '@/lib/hotkeys/hooks/useScopedHotkeys';
import { InplaceInputDateEditMode } from '@/ui/inplace-inputs/components/InplaceInputDateEditMode';
import { InplaceInputDate } from '@/ui/inplace-inputs/components/InplaceInputDate';
import { TableHotkeyScope } from '@/ui/tables/types/TableHotkeyScope';
import { useEditableCell } from '../hooks/useEditableCell';
const EditableCellDateEditModeContainer = styled.div`
margin-top: -1px;
width: inherit;
`;
export type EditableDateProps = {
value: Date;
onChange: (date: Date) => void;
@ -31,5 +37,9 @@ export function EditableCellDateEditMode({
[closeEditableCell],
);
return <InplaceInputDateEditMode onChange={handleDateChange} value={value} />;
return (
<EditableCellDateEditModeContainer>
<InplaceInputDate onChange={handleDateChange} value={value} />
</EditableCellDateEditModeContainer>
);
}

View File

@ -14,7 +14,7 @@ const StyledClickable = styled.div`
a {
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.sm};
text-decoration: none;
text-decoration: underline;
}
`;

View File

@ -14,7 +14,6 @@ const StyledClickable = styled.div`
a {
color: inherit;
text-decoration: none;
}
`;

View File

@ -7,12 +7,12 @@ const StyledPropertyBoxContainer = styled.div`
border-radius: ${({ theme }) => theme.border.radius.sm};
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(0.5)};
padding: ${({ theme }) => theme.spacing(2)} ${({ theme }) => theme.spacing(3)};
gap: ${({ theme }) => theme.spacing(3)};
padding: ${({ theme }) => theme.spacing(3)};
`;
interface PropertyBoxProps {
children: JSX.Element;
children: React.ReactNode;
extraPadding?: boolean;
}

View File

@ -0,0 +1,133 @@
import { useState } from 'react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { HotkeyScope } from '@/lib/hotkeys/types/HotkeyScope';
import { useEditableField } from '../hooks/useEditableField';
import { EditableFieldDisplayMode } from './EditableFieldDisplayMode';
import { EditableFieldEditButton } from './EditableFieldEditButton';
import { EditableFieldEditMode } from './EditableFieldEditMode';
const StyledIconContainer = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
svg {
align-items: center;
display: flex;
height: 16px;
justify-content: center;
width: 16px;
}
`;
const StyledLabelAndIconContainer = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledLabel = styled.div<Pick<OwnProps, 'labelFixedWidth'>>`
align-items: center;
width: ${({ labelFixedWidth }) =>
labelFixedWidth ? `${labelFixedWidth}px` : 'fit-content'};
`;
export const EditableFieldBaseContainer = styled.div`
align-items: center;
box-sizing: border-box;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
height: 24px;
position: relative;
user-select: none;
width: 100%;
`;
type OwnProps = {
iconLabel?: React.ReactNode;
label?: string;
labelFixedWidth?: number;
useEditButton?: boolean;
editModeContent: React.ReactNode;
displayModeContent: React.ReactNode;
parentHotkeyScope?: HotkeyScope;
customEditHotkeyScope?: HotkeyScope;
onSubmit?: () => void;
onCancel?: () => void;
};
export function EditableField({
iconLabel,
label,
labelFixedWidth,
useEditButton,
editModeContent,
displayModeContent,
parentHotkeyScope,
customEditHotkeyScope,
onSubmit,
onCancel,
}: OwnProps) {
const [isHovered, setIsHovered] = useState(false);
function handleContainerMouseEnter() {
setIsHovered(true);
}
function handleContainerMouseLeave() {
setIsHovered(false);
}
const { isFieldInEditMode, openEditableField } =
useEditableField(parentHotkeyScope);
function handleDisplayModeClick() {
openEditableField(customEditHotkeyScope);
}
const showEditButton = !isFieldInEditMode && isHovered && useEditButton;
return (
<EditableFieldBaseContainer
onMouseEnter={handleContainerMouseEnter}
onMouseLeave={handleContainerMouseLeave}
>
<StyledLabelAndIconContainer>
{iconLabel && <StyledIconContainer>{iconLabel}</StyledIconContainer>}
{label && (
<StyledLabel labelFixedWidth={labelFixedWidth}>{label}</StyledLabel>
)}
</StyledLabelAndIconContainer>
{isFieldInEditMode ? (
<EditableFieldEditMode onSubmit={onSubmit} onCancel={onCancel}>
{editModeContent}
</EditableFieldEditMode>
) : (
<EditableFieldDisplayMode
disableClick={useEditButton}
onClick={handleDisplayModeClick}
>
{displayModeContent}
</EditableFieldDisplayMode>
)}
{showEditButton && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.1 }}
whileHover={{ scale: 1.04 }}
>
<EditableFieldEditButton customHotkeyScope={customEditHotkeyScope} />
</motion.div>
)}
</EditableFieldBaseContainer>
);
}

View File

@ -0,0 +1,71 @@
import { css } from '@emotion/react';
import styled from '@emotion/styled';
export const EditableFieldNormalModeOuterContainer = styled.div<
Pick<OwnProps, 'disableClick'>
>`
align-items: center;
border-radius: ${({ theme }) => theme.border.radius.sm};
display: flex;
height: 100%;
height: 16px;
overflow: hidden;
padding: ${({ theme }) => theme.spacing(1)};
width: 100%;
${(props) => {
if (props.disableClick) {
return css`
cursor: default;
`;
} else {
return css`
cursor: pointer;
&:hover {
background-color: ${props.theme.background.transparent.light};
}
`;
}
}}
`;
export const EditableFieldNormalModeInnerContainer = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.regular};
height: fit-content;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
type OwnProps = {
disableClick?: boolean;
onClick?: () => void;
};
export function EditableFieldDisplayMode({
children,
disableClick,
onClick,
}: React.PropsWithChildren<OwnProps>) {
return (
<EditableFieldNormalModeOuterContainer
onClick={disableClick ? undefined : onClick}
disableClick={disableClick}
>
<EditableFieldNormalModeInnerContainer>
{children}
</EditableFieldNormalModeInnerContainer>
</EditableFieldNormalModeOuterContainer>
);
}

View File

@ -0,0 +1,49 @@
import styled from '@emotion/styled';
import { IconPencil } from '@tabler/icons-react';
import { HotkeyScope } from '@/lib/hotkeys/types/HotkeyScope';
import { IconButton } from '@/ui/components/buttons/IconButton';
import { overlayBackground } from '@/ui/themes/effects';
import { useEditableField } from '../hooks/useEditableField';
export const StyledEditableFieldEditButton = styled.div`
align-items: center;
border: 1px solid ${({ theme }) => theme.border.color.light};
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.font.color.tertiary};
cursor: pointer;
display: flex;
height: 20px;
justify-content: center;
margin-left: -2px;
width: 20px;
z-index: 1;
${overlayBackground}
`;
type OwnProps = {
customHotkeyScope?: HotkeyScope;
};
export function EditableFieldEditButton({ customHotkeyScope }: OwnProps) {
const { openEditableField } = useEditableField();
function handleClick() {
openEditableField(customHotkeyScope);
}
return (
<IconButton
variant="shadow"
size="small"
onClick={handleClick}
icon={<IconPencil size={14} />}
data-testid="editable-field-edit-mode-container"
/>
);
}

View File

@ -0,0 +1,41 @@
import { useRef } from 'react';
import styled from '@emotion/styled';
import { useRegisterCloseFieldHandlers } from '../hooks/useRegisterCloseFieldHandlers';
export const EditableFieldEditModeContainer = styled.div<OwnProps>`
align-items: center;
display: flex;
margin-left: -${({ theme }) => theme.spacing(1)};
width: inherit;
z-index: 10;
`;
type OwnProps = {
children: React.ReactNode;
onOutsideClick?: () => void;
onCancel?: () => void;
onSubmit?: () => void;
};
export function EditableFieldEditMode({
children,
onCancel,
onSubmit,
}: OwnProps) {
const wrapperRef = useRef(null);
useRegisterCloseFieldHandlers(wrapperRef, onSubmit, onCancel);
return (
<EditableFieldEditModeContainer
data-testid="editable-field-edit-mode-container"
ref={wrapperRef}
>
{children}
</EditableFieldEditModeContainer>
);
}

View File

@ -0,0 +1,60 @@
import { useEffect, useState } from 'react';
import { IconMap } from '@tabler/icons-react';
import { RecoilScope } from '@/recoil-scope/components/RecoilScope';
import { EditableField } from '@/ui/editable-fields/components/EditableField';
import { FieldContext } from '@/ui/editable-fields/states/FieldContext';
import { InplaceInputText } from '@/ui/inplace-inputs/components/InplaceInputText';
import { Company, useUpdateCompanyMutation } from '~/generated/graphql';
type OwnProps = {
company: Pick<Company, 'id' | 'address'>;
};
export function CompanyEditableFieldAddress({ company }: OwnProps) {
const [internalValue, setInternalValue] = useState(company.address);
const [updateCompany] = useUpdateCompanyMutation();
useEffect(() => {
setInternalValue(company.address);
}, [company.address]);
async function handleChange(newValue: string) {
setInternalValue(newValue);
}
async function handleSubmit() {
await updateCompany({
variables: {
id: company.id,
address: internalValue ?? '',
},
});
}
async function handleCancel() {
setInternalValue(company.address);
}
return (
<RecoilScope SpecificContext={FieldContext}>
<EditableField
onSubmit={handleSubmit}
onCancel={handleCancel}
iconLabel={<IconMap />}
editModeContent={
<InplaceInputText
placeholder={'Address'}
autoFocus
value={internalValue}
onChange={(newValue: string) => {
handleChange(newValue);
}}
/>
}
displayModeContent={internalValue !== '' ? internalValue : 'No address'}
/>
</RecoilScope>
);
}

View File

@ -0,0 +1,5 @@
import { RawLink } from '@/ui/components/links/RawLink';
export function FieldDisplayURL({ URL }: { URL: string | undefined }) {
return <RawLink href={URL ? 'https://' + URL : ''}>{URL}</RawLink>;
}

View File

@ -0,0 +1,41 @@
import { useSetHotkeyScope } from '@/lib/hotkeys/hooks/useSetHotkeyScope';
import { HotkeyScope } from '@/lib/hotkeys/types/HotkeyScope';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { FieldContext } from '../states/FieldContext';
import { isFieldInEditModeScopedState } from '../states/isFieldInEditModeScopedState';
import { EditableFieldHotkeyScope } from '../types/EditableFieldHotkeyScope';
// TODO: use atoms for hotkey scopes
export function useEditableField(parentHotkeyScope?: HotkeyScope) {
const [isFieldInEditMode, setIsFieldInEditMode] = useRecoilScopedState(
isFieldInEditModeScopedState,
FieldContext,
);
const setHotkeyScope = useSetHotkeyScope();
function closeEditableField() {
setIsFieldInEditMode(false);
if (parentHotkeyScope) {
setHotkeyScope(parentHotkeyScope.scope, parentHotkeyScope.customScopes);
}
}
function openEditableField(customHotkeyScope?: HotkeyScope) {
setIsFieldInEditMode(true);
if (customHotkeyScope) {
setHotkeyScope(customHotkeyScope.scope, customHotkeyScope.customScopes);
} else {
setHotkeyScope(EditableFieldHotkeyScope.EditableField);
}
}
return {
isFieldInEditMode,
closeEditableField,
openEditableField,
};
}

View File

@ -0,0 +1,41 @@
import { useScopedHotkeys } from '@/lib/hotkeys/hooks/useScopedHotkeys';
import { useListenClickOutsideArrayOfRef } from '@/ui/hooks/useListenClickOutsideArrayOfRef';
import { EditableFieldHotkeyScope } from '../types/EditableFieldHotkeyScope';
import { useEditableField } from './useEditableField';
export function useRegisterCloseFieldHandlers(
wrapperRef: React.RefObject<HTMLDivElement>,
onSubmit?: () => void,
onCancel?: () => void,
) {
const { closeEditableField, isFieldInEditMode } = useEditableField();
useListenClickOutsideArrayOfRef([wrapperRef], () => {
if (isFieldInEditMode) {
onSubmit?.();
closeEditableField();
}
});
useScopedHotkeys(
'enter',
() => {
onSubmit?.();
closeEditableField();
},
EditableFieldHotkeyScope.EditableField,
[closeEditableField, onSubmit],
);
useScopedHotkeys(
'esc',
() => {
closeEditableField();
onCancel?.();
},
EditableFieldHotkeyScope.EditableField,
[closeEditableField, onCancel],
);
}

View File

@ -0,0 +1,3 @@
import { createContext } from 'react';
export const FieldContext = createContext<string | null>(null);

View File

@ -0,0 +1,6 @@
import { atomFamily } from 'recoil';
export const isFieldInEditModeScopedState = atomFamily<boolean, string>({
key: 'isFieldInEditModeScopedState',
default: false,
});

View File

@ -0,0 +1,3 @@
export enum EditableFieldHotkeyScope {
EditableField = 'editable-field',
}

View File

@ -0,0 +1,33 @@
import { HotkeyScope } from '@/lib/hotkeys/types/HotkeyScope';
import { InplaceInputDate } from '@/ui/inplace-inputs/components/InplaceInputDate';
import { parseDate } from '@/utils/datetime/date-utils';
import { useEditableField } from '../../hooks/useEditableField';
type OwnProps = {
value: string;
onChange?: (newValue: string) => void;
parentHotkeyScope?: HotkeyScope;
};
export function EditableFieldEditModeDate({
value,
onChange,
parentHotkeyScope,
}: OwnProps) {
const { closeEditableField } = useEditableField(parentHotkeyScope);
function handleChange(newValue: string) {
onChange?.(newValue);
closeEditableField();
}
return (
<InplaceInputDate
value={parseDate(value).toJSDate()}
onChange={(newDate: Date) => {
handleChange(newDate.toISOString());
}}
/>
);
}

View File

@ -0,0 +1,17 @@
import styled from '@emotion/styled';
import { overlayBackground } from '@/ui/themes/effects';
export const InplaceInputContainer = styled.div`
align-items: center;
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
display: flex;
margin-left: -1px;
min-height: 32px;
width: inherit;
${overlayBackground}
z-index: 10;
`;

View File

@ -2,36 +2,40 @@ import { forwardRef } from 'react';
import styled from '@emotion/styled';
import DatePicker from '@/ui/components/form/DatePicker';
import { humanReadableDate } from '@/utils/utils';
import { formatToHumanReadableDate } from '@/utils/utils';
const StyledContainer = styled.div`
align-items: center;
display: flex;
margin: 0px ${({ theme }) => theme.spacing(2)};
`;
import { InplaceInputContainer } from './InplaceInputContainer';
export type StyledCalendarContainerProps = {
editModeHorizontalAlign?: 'left' | 'right';
};
const StyledInputContainer = styled.div`
display: flex;
padding: ${({ theme }) => theme.spacing(2)};
`;
const StyledCalendarContainer = styled.div<StyledCalendarContainerProps>`
background: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.light};
border-radius: ${({ theme }) => theme.border.radius.md};
box-shadow: ${({ theme }) => theme.boxShadow.strong};
left: -10px;
margin-top: 1px;
position: absolute;
top: 10px;
z-index: 1;
`;
type DivProps = React.HTMLProps<HTMLDivElement>;
const DateDisplay = forwardRef<HTMLDivElement, DivProps>(
export const DateDisplay = forwardRef<HTMLDivElement, DivProps>(
({ value, onClick }, ref) => (
<div onClick={onClick} ref={ref}>
{value && humanReadableDate(new Date(value as string))}
</div>
<StyledInputContainer onClick={onClick} ref={ref}>
{value && formatToHumanReadableDate(new Date(value as string))}
</StyledInputContainer>
),
);
@ -39,7 +43,7 @@ type DatePickerContainerProps = {
children: React.ReactNode;
};
const DatePickerContainer = ({ children }: DatePickerContainerProps) => {
export const DatePickerContainer = ({ children }: DatePickerContainerProps) => {
return <StyledCalendarContainer>{children}</StyledCalendarContainer>;
};
@ -48,15 +52,15 @@ type OwnProps = {
onChange: (newDate: Date) => void;
};
export function InplaceInputDateEditMode({ onChange, value }: OwnProps) {
export function InplaceInputDate({ onChange, value }: OwnProps) {
return (
<StyledContainer>
<InplaceInputContainer>
<DatePicker
date={value}
onChangeHandler={onChange}
customInput={<DateDisplay />}
customCalendarContainer={DatePickerContainer}
/>
</StyledContainer>
</InplaceInputContainer>
);
}

View File

@ -1,9 +1,9 @@
import { humanReadableDate } from '@/utils/utils';
import { formatToHumanReadableDate } from '@/utils/utils';
type OwnProps = {
value: Date;
};
export function InplaceInputDateDisplayMode({ value }: OwnProps) {
return <div>{value && humanReadableDate(value)}</div>;
return <div>{value && formatToHumanReadableDate(value)}</div>;
}

View File

@ -0,0 +1,36 @@
import styled from '@emotion/styled';
import { textInputStyle } from '@/ui/themes/effects';
import { InplaceInputContainer } from './InplaceInputContainer';
export const InplaceInputTextInput = styled.input`
margin: 0;
width: 100%;
${textInputStyle}
`;
type OwnProps = {
placeholder?: string;
value?: string;
onChange?: (newValue: string) => void;
autoFocus?: boolean;
};
export function InplaceInputText({
placeholder,
value,
onChange,
autoFocus,
}: OwnProps) {
return (
<InplaceInputContainer>
<InplaceInputTextInput
autoFocus={autoFocus}
placeholder={placeholder}
value={value}
onChange={(e) => onChange?.(e.target.value)}
/>
</InplaceInputContainer>
);
}

View File

@ -9,11 +9,15 @@ export const overlayBackground = (props: { theme: ThemeType }) =>
box-shadow: ${props.theme.boxShadow.strong};
`;
export const textInputStyle = (props: any) =>
export const textInputStyle = (props: { theme: ThemeType }) =>
css`
background-color: transparent;
border: none;
color: ${props.theme.font.color.primary};
font-family: ${props.theme.font.family};
font-size: ${props.theme.font.size.md};
font-weight: ${props.theme.font.weight.regular};
outline: none;
padding: ${props.theme.spacing(0)} ${props.theme.spacing(2)};