From be213927376fd2225fd602115dbcbe3a892100a6 Mon Sep 17 00:00:00 2001 From: Lucas Bordeau Date: Sun, 16 Jul 2023 04:17:31 +0200 Subject: [PATCH] Feat/company card fields (#686) * wip * Ok * asd * Fixed cancel submit * Renamed * Fixed --- .../components/CompanyAccountOwnerCell.tsx | 19 ++- .../components/CompanyAccountOwnerPicker.tsx | 14 +- .../CompanyAccountOwnerEditableField.tsx | 53 +++++++ ...CompanyAccountOwnerPickerFieldEditMode.tsx | 50 +++++++ .../CompanyAddressEditableField.tsx | 60 ++++++++ .../CompanyCreatedAtEditableField.tsx | 62 ++++++++ .../CompanyDomainNameEditableField.tsx | 62 ++++++++ .../CompanyEmployeesEditableField.tsx | 76 ++++++++++ .../hooks/useFilteredSearchEntityQuery.ts | 4 +- .../BoardCardEditableFieldDateEditMode.tsx | 4 +- .../ui/components/buttons/IconButton.tsx | 36 +++-- .../editable-cell/EditableCellEditMode.tsx | 10 +- .../types/EditableCellDateEditMode.tsx | 14 +- .../ui/components/links/PrimaryLink.tsx | 2 +- .../modules/ui/components/links/RawLink.tsx | 1 - .../components/property-box/PropertyBox.tsx | 6 +- .../components/EditableField.tsx | 133 ++++++++++++++++++ .../components/EditableFieldDisplayMode.tsx | 71 ++++++++++ .../components/EditableFieldEditButton.tsx | 49 +++++++ .../components/EditableFieldEditMode.tsx | 41 ++++++ .../components/EditableFieldEntityText.tsx | 60 ++++++++ .../components/FieldDisplayURL.tsx | 5 + .../editable-fields/hooks/useEditableField.ts | 41 ++++++ .../hooks/useRegisterCloseFieldHandlers.ts | 41 ++++++ .../ui/editable-fields/states/FieldContext.ts | 3 + .../states/isFieldInEditModeScopedState.ts | 6 + .../types/EditableFieldHotkeyScope.ts | 3 + .../components/EditableFieldEditModeDate.tsx | 33 +++++ .../components/InplaceInputContainer.tsx | 17 +++ ...tDateEditMode.tsx => InplaceInputDate.tsx} | 36 ++--- .../InplaceInputDateDisplayMode.tsx | 4 +- .../components/InplaceInputText.tsx | 36 +++++ front/src/modules/ui/themes/effects.ts | 6 +- front/src/modules/utils/utils.ts | 10 +- front/src/pages/companies/CompanyShow.tsx | 68 ++++----- 35 files changed, 1041 insertions(+), 95 deletions(-) create mode 100644 front/src/modules/companies/fields/components/CompanyAccountOwnerEditableField.tsx create mode 100644 front/src/modules/companies/fields/components/CompanyAccountOwnerPickerFieldEditMode.tsx create mode 100644 front/src/modules/companies/fields/components/CompanyAddressEditableField.tsx create mode 100644 front/src/modules/companies/fields/components/CompanyCreatedAtEditableField.tsx create mode 100644 front/src/modules/companies/fields/components/CompanyDomainNameEditableField.tsx create mode 100644 front/src/modules/companies/fields/components/CompanyEmployeesEditableField.tsx create mode 100644 front/src/modules/ui/editable-fields/components/EditableField.tsx create mode 100644 front/src/modules/ui/editable-fields/components/EditableFieldDisplayMode.tsx create mode 100644 front/src/modules/ui/editable-fields/components/EditableFieldEditButton.tsx create mode 100644 front/src/modules/ui/editable-fields/components/EditableFieldEditMode.tsx create mode 100644 front/src/modules/ui/editable-fields/components/EditableFieldEntityText.tsx create mode 100644 front/src/modules/ui/editable-fields/components/FieldDisplayURL.tsx create mode 100644 front/src/modules/ui/editable-fields/hooks/useEditableField.ts create mode 100644 front/src/modules/ui/editable-fields/hooks/useRegisterCloseFieldHandlers.ts create mode 100644 front/src/modules/ui/editable-fields/states/FieldContext.ts create mode 100644 front/src/modules/ui/editable-fields/states/isFieldInEditModeScopedState.ts create mode 100644 front/src/modules/ui/editable-fields/types/EditableFieldHotkeyScope.ts create mode 100644 front/src/modules/ui/editable-fields/variants/components/EditableFieldEditModeDate.tsx create mode 100644 front/src/modules/ui/inplace-inputs/components/InplaceInputContainer.tsx rename front/src/modules/ui/inplace-inputs/components/{InplaceInputDateEditMode.tsx => InplaceInputDate.tsx} (62%) create mode 100644 front/src/modules/ui/inplace-inputs/components/InplaceInputText.tsx diff --git a/front/src/modules/companies/components/CompanyAccountOwnerCell.tsx b/front/src/modules/companies/components/CompanyAccountOwnerCell.tsx index 0ddff6959..6c37a62bc 100644 --- a/front/src/modules/companies/components/CompanyAccountOwnerCell.tsx +++ b/front/src/modules/companies/components/CompanyAccountOwnerCell.tsx @@ -1,6 +1,7 @@ import { PersonChip } from '@/people/components/PersonChip'; import { RelationPickerHotkeyScope } from '@/relation-picker/types/RelationPickerHotkeyScope'; import { EditableCell } from '@/ui/components/editable-cell/EditableCell'; +import { useEditableCell } from '@/ui/components/editable-cell/hooks/useEditableCell'; import { Company, User } from '~/generated/graphql'; import { CompanyAccountOwnerPicker } from './CompanyAccountOwnerPicker'; @@ -14,10 +15,26 @@ export type OwnProps = { }; export function CompanyAccountOwnerCell({ company }: OwnProps) { + const { closeEditableCell } = useEditableCell(); + + function handleCancel() { + closeEditableCell(); + } + + function handleSubmit() { + closeEditableCell(); + } + return ( } + editModeContent={ + + } nonEditModeContent={ company.accountOwner?.displayName ? ( & { accountOwner?: Pick | null; }; + onSubmit?: () => void; + onCancel?: () => void; }; type UserForSelect = EntityForSelect & { entityType: Entity.User; }; -export function CompanyAccountOwnerPicker({ company }: OwnProps) { +export function CompanyAccountOwnerPicker({ + company, + onSubmit, + onCancel, +}: OwnProps) { const [searchFilter] = useRecoilScopedState( relationPickerSearchFilterScopedState, ); const [updateCompany] = useUpdateCompanyMutation(); - const { closeEditableCell } = useEditableCell(); - const companies = useFilteredSearchEntityQuery({ queryHook: useSearchUserQuery, selectedIds: [company?.accountOwner?.id ?? ''], @@ -52,12 +55,13 @@ export function CompanyAccountOwnerPicker({ company }: OwnProps) { }, }); - closeEditableCell(); + onSubmit?.(); } return ( & { + accountOwner?: Pick | null; + }; +}; + +export function CompanyAccountOwnerEditableField({ company }: OwnProps) { + return ( + + + } + editModeContent={ + + } + displayModeContent={ + company.accountOwner?.displayName ? ( + + ) : ( + <> + ) + } + /> + + + ); +} diff --git a/front/src/modules/companies/fields/components/CompanyAccountOwnerPickerFieldEditMode.tsx b/front/src/modules/companies/fields/components/CompanyAccountOwnerPickerFieldEditMode.tsx new file mode 100644 index 000000000..c827cac26 --- /dev/null +++ b/front/src/modules/companies/fields/components/CompanyAccountOwnerPickerFieldEditMode.tsx @@ -0,0 +1,50 @@ +import styled from '@emotion/styled'; + +import { CompanyAccountOwnerPicker } from '@/companies/components/CompanyAccountOwnerPicker'; +import { HotkeyScope } from '@/lib/hotkeys/types/HotkeyScope'; +import { useEditableField } from '@/ui/editable-fields/hooks/useEditableField'; +import { Company, User } from '~/generated/graphql'; + +const CompanyAccountOwnerPickerContainer = styled.div` + left: 24px; + position: absolute; + top: -8px; +`; + +export type OwnProps = { + company: Pick & { + accountOwner?: Pick | null; + }; + onSubmit?: () => void; + onCancel?: () => void; + parentHotkeyScope?: HotkeyScope; +}; + +export function CompanyAccountOwnerPickerFieldEditMode({ + company, + onSubmit, + onCancel, + parentHotkeyScope, +}: OwnProps) { + const { closeEditableField } = useEditableField(parentHotkeyScope); + + function handleSubmit() { + closeEditableField(); + onSubmit?.(); + } + + function handleCancel() { + closeEditableField(); + onCancel?.(); + } + + return ( + + + + ); +} diff --git a/front/src/modules/companies/fields/components/CompanyAddressEditableField.tsx b/front/src/modules/companies/fields/components/CompanyAddressEditableField.tsx new file mode 100644 index 000000000..cea05e082 --- /dev/null +++ b/front/src/modules/companies/fields/components/CompanyAddressEditableField.tsx @@ -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; +}; + +export function CompanyAddressEditableField({ 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 ( + + } + editModeContent={ + { + handleChange(newValue); + }} + /> + } + displayModeContent={internalValue ?? ''} + /> + + ); +} diff --git a/front/src/modules/companies/fields/components/CompanyCreatedAtEditableField.tsx b/front/src/modules/companies/fields/components/CompanyCreatedAtEditableField.tsx new file mode 100644 index 000000000..c2e2c05e8 --- /dev/null +++ b/front/src/modules/companies/fields/components/CompanyCreatedAtEditableField.tsx @@ -0,0 +1,62 @@ +import { useEffect, useState } from 'react'; +import { IconCalendar } 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 { EditableFieldEditModeDate } from '@/ui/editable-fields/variants/components/EditableFieldEditModeDate'; +import { parseDate } from '@/utils/datetime/date-utils'; +import { formatToHumanReadableDate } from '@/utils/utils'; +import { Company, useUpdateCompanyMutation } from '~/generated/graphql'; + +type OwnProps = { + company: Pick; +}; + +export function CompanyCreatedAtEditableField({ company }: OwnProps) { + const [internalValue, setInternalValue] = useState(company.createdAt); + + const [updateCompany] = useUpdateCompanyMutation(); + + useEffect(() => { + setInternalValue(company.createdAt); + }, [company.createdAt]); + + async function handleChange(newValue: string) { + setInternalValue(newValue); + } + + async function handleSubmit() { + await updateCompany({ + variables: { + id: company.id, + createdAt: internalValue ?? '', + }, + }); + } + + async function handleCancel() { + setInternalValue(company.createdAt); + } + + return ( + + } + editModeContent={ + + } + displayModeContent={ + internalValue !== '' + ? formatToHumanReadableDate(parseDate(internalValue).toJSDate()) + : 'No date' + } + /> + + ); +} diff --git a/front/src/modules/companies/fields/components/CompanyDomainNameEditableField.tsx b/front/src/modules/companies/fields/components/CompanyDomainNameEditableField.tsx new file mode 100644 index 000000000..ee3d4f1b4 --- /dev/null +++ b/front/src/modules/companies/fields/components/CompanyDomainNameEditableField.tsx @@ -0,0 +1,62 @@ +import { useEffect, useState } from 'react'; +import { IconLink } from '@tabler/icons-react'; + +import { RecoilScope } from '@/recoil-scope/components/RecoilScope'; +import { EditableField } from '@/ui/editable-fields/components/EditableField'; +import { FieldDisplayURL } from '@/ui/editable-fields/components/FieldDisplayURL'; +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; +}; + +export function CompanyDomainNameEditableField({ company }: OwnProps) { + const [internalValue, setInternalValue] = useState(company.domainName); + + const [updateCompany] = useUpdateCompanyMutation(); + + useEffect(() => { + setInternalValue(company.domainName); + }, [company.domainName]); + + async function handleChange(newValue: string) { + setInternalValue(newValue); + } + + async function handleSubmit() { + await updateCompany({ + variables: { + id: company.id, + domainName: internalValue ?? '', + }, + }); + } + + async function handleCancel() { + setInternalValue(company.domainName); + } + + return ( + + } + onCancel={handleCancel} + onSubmit={handleSubmit} + editModeContent={ + { + handleChange(newValue); + }} + /> + } + displayModeContent={} + useEditButton + /> + + ); +} diff --git a/front/src/modules/companies/fields/components/CompanyEmployeesEditableField.tsx b/front/src/modules/companies/fields/components/CompanyEmployeesEditableField.tsx new file mode 100644 index 000000000..235f11b56 --- /dev/null +++ b/front/src/modules/companies/fields/components/CompanyEmployeesEditableField.tsx @@ -0,0 +1,76 @@ +import { useEffect, useState } from 'react'; +import { IconUsers } 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; +}; + +export function CompanyEmployeesEditableField({ company }: OwnProps) { + const [internalValue, setInternalValue] = useState( + company.employees?.toString(), + ); + + const [updateCompany] = useUpdateCompanyMutation(); + + useEffect(() => { + setInternalValue(company.employees?.toString()); + }, [company.employees]); + + async function handleChange(newValue: string) { + setInternalValue(newValue); + } + + async function handleSubmit() { + if (!internalValue) return; + + try { + const numberValue = parseInt(internalValue); + + if (isNaN(numberValue)) { + throw new Error('Not a number'); + } + + await updateCompany({ + variables: { + id: company.id, + employees: numberValue, + }, + }); + + setInternalValue(numberValue.toString()); + } catch { + handleCancel(); + } + } + + async function handleCancel() { + setInternalValue(company.employees?.toString()); + } + + return ( + + } + editModeContent={ + { + handleChange(newValue); + }} + /> + } + displayModeContent={internalValue} + /> + + ); +} diff --git a/front/src/modules/search/hooks/useFilteredSearchEntityQuery.ts b/front/src/modules/search/hooks/useFilteredSearchEntityQuery.ts index 9f62ec8b6..e466dd524 100644 --- a/front/src/modules/search/hooks/useFilteredSearchEntityQuery.ts +++ b/front/src/modules/search/hooks/useFilteredSearchEntityQuery.ts @@ -10,7 +10,7 @@ import { SortOrder, } from '~/generated/graphql'; -type SelectStringKeys = NonNullable< +export type SelectStringKeys = NonNullable< { [K in keyof T]: K extends '__typename' ? never @@ -20,7 +20,7 @@ type SelectStringKeys = NonNullable< }[keyof T] >; -type ExtractEntityTypeFromQueryResponse = T extends { +export type ExtractEntityTypeFromQueryResponse = T extends { searchResults: Array; } ? U diff --git a/front/src/modules/ui/board-card-field-inputs/components/BoardCardEditableFieldDateEditMode.tsx b/front/src/modules/ui/board-card-field-inputs/components/BoardCardEditableFieldDateEditMode.tsx index 3e6f719ad..0c59fbe38 100644 --- a/front/src/modules/ui/board-card-field-inputs/components/BoardCardEditableFieldDateEditMode.tsx +++ b/front/src/modules/ui/board-card-field-inputs/components/BoardCardEditableFieldDateEditMode.tsx @@ -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 ; + return ; } diff --git a/front/src/modules/ui/components/buttons/IconButton.tsx b/front/src/modules/ui/components/buttons/IconButton.tsx index 334187c28..51e2e3d89 100644 --- a/front/src/modules/ui/components/buttons/IconButton.tsx +++ b/front/src/modules/ui/components/buttons/IconButton.tsx @@ -13,11 +13,11 @@ export type ButtonProps = { const StyledIconButton = styled.button>` 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>` 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>` 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>` 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>` 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>` 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({ diff --git a/front/src/modules/ui/components/editable-cell/EditableCellEditMode.tsx b/front/src/modules/ui/components/editable-cell/EditableCellEditMode.tsx index 3f0ad7d62..936556cb2 100644 --- a/front/src/modules/ui/components/editable-cell/EditableCellEditMode.tsx +++ b/front/src/modules/ui/components/editable-cell/EditableCellEditMode.tsx @@ -12,14 +12,16 @@ export const EditableCellEditModeContainer = styled.div` 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} `; diff --git a/front/src/modules/ui/components/editable-cell/types/EditableCellDateEditMode.tsx b/front/src/modules/ui/components/editable-cell/types/EditableCellDateEditMode.tsx index e9ab0e517..40314f365 100644 --- a/front/src/modules/ui/components/editable-cell/types/EditableCellDateEditMode.tsx +++ b/front/src/modules/ui/components/editable-cell/types/EditableCellDateEditMode.tsx @@ -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 ; + return ( + + + + ); } diff --git a/front/src/modules/ui/components/links/PrimaryLink.tsx b/front/src/modules/ui/components/links/PrimaryLink.tsx index 4a8e3ee65..7af61daa8 100644 --- a/front/src/modules/ui/components/links/PrimaryLink.tsx +++ b/front/src/modules/ui/components/links/PrimaryLink.tsx @@ -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; } `; diff --git a/front/src/modules/ui/components/links/RawLink.tsx b/front/src/modules/ui/components/links/RawLink.tsx index d8b19f366..b0d397354 100644 --- a/front/src/modules/ui/components/links/RawLink.tsx +++ b/front/src/modules/ui/components/links/RawLink.tsx @@ -14,7 +14,6 @@ const StyledClickable = styled.div` a { color: inherit; - text-decoration: none; } `; diff --git a/front/src/modules/ui/components/property-box/PropertyBox.tsx b/front/src/modules/ui/components/property-box/PropertyBox.tsx index 68784e524..c46da9a25 100644 --- a/front/src/modules/ui/components/property-box/PropertyBox.tsx +++ b/front/src/modules/ui/components/property-box/PropertyBox.tsx @@ -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; } diff --git a/front/src/modules/ui/editable-fields/components/EditableField.tsx b/front/src/modules/ui/editable-fields/components/EditableField.tsx new file mode 100644 index 000000000..34a9537ad --- /dev/null +++ b/front/src/modules/ui/editable-fields/components/EditableField.tsx @@ -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>` + 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 ( + + + {iconLabel && {iconLabel}} + {label && ( + {label} + )} + + {isFieldInEditMode ? ( + + {editModeContent} + + ) : ( + + {displayModeContent} + + )} + {showEditButton && ( + + + + )} + + ); +} diff --git a/front/src/modules/ui/editable-fields/components/EditableFieldDisplayMode.tsx b/front/src/modules/ui/editable-fields/components/EditableFieldDisplayMode.tsx new file mode 100644 index 000000000..60f0bc5a3 --- /dev/null +++ b/front/src/modules/ui/editable-fields/components/EditableFieldDisplayMode.tsx @@ -0,0 +1,71 @@ +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; + +export const EditableFieldNormalModeOuterContainer = styled.div< + Pick +>` + 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) { + return ( + + + {children} + + + ); +} diff --git a/front/src/modules/ui/editable-fields/components/EditableFieldEditButton.tsx b/front/src/modules/ui/editable-fields/components/EditableFieldEditButton.tsx new file mode 100644 index 000000000..d9a9f9945 --- /dev/null +++ b/front/src/modules/ui/editable-fields/components/EditableFieldEditButton.tsx @@ -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 ( + } + data-testid="editable-field-edit-mode-container" + /> + ); +} diff --git a/front/src/modules/ui/editable-fields/components/EditableFieldEditMode.tsx b/front/src/modules/ui/editable-fields/components/EditableFieldEditMode.tsx new file mode 100644 index 000000000..e40e2dede --- /dev/null +++ b/front/src/modules/ui/editable-fields/components/EditableFieldEditMode.tsx @@ -0,0 +1,41 @@ +import { useRef } from 'react'; +import styled from '@emotion/styled'; + +import { useRegisterCloseFieldHandlers } from '../hooks/useRegisterCloseFieldHandlers'; + +export const EditableFieldEditModeContainer = styled.div` + 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 ( + + {children} + + ); +} diff --git a/front/src/modules/ui/editable-fields/components/EditableFieldEntityText.tsx b/front/src/modules/ui/editable-fields/components/EditableFieldEntityText.tsx new file mode 100644 index 000000000..f944f9418 --- /dev/null +++ b/front/src/modules/ui/editable-fields/components/EditableFieldEntityText.tsx @@ -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; +}; + +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 ( + + } + editModeContent={ + { + handleChange(newValue); + }} + /> + } + displayModeContent={internalValue !== '' ? internalValue : 'No address'} + /> + + ); +} diff --git a/front/src/modules/ui/editable-fields/components/FieldDisplayURL.tsx b/front/src/modules/ui/editable-fields/components/FieldDisplayURL.tsx new file mode 100644 index 000000000..54b3b3f6f --- /dev/null +++ b/front/src/modules/ui/editable-fields/components/FieldDisplayURL.tsx @@ -0,0 +1,5 @@ +import { RawLink } from '@/ui/components/links/RawLink'; + +export function FieldDisplayURL({ URL }: { URL: string | undefined }) { + return {URL}; +} diff --git a/front/src/modules/ui/editable-fields/hooks/useEditableField.ts b/front/src/modules/ui/editable-fields/hooks/useEditableField.ts new file mode 100644 index 000000000..d158b4d47 --- /dev/null +++ b/front/src/modules/ui/editable-fields/hooks/useEditableField.ts @@ -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, + }; +} diff --git a/front/src/modules/ui/editable-fields/hooks/useRegisterCloseFieldHandlers.ts b/front/src/modules/ui/editable-fields/hooks/useRegisterCloseFieldHandlers.ts new file mode 100644 index 000000000..3890b1b97 --- /dev/null +++ b/front/src/modules/ui/editable-fields/hooks/useRegisterCloseFieldHandlers.ts @@ -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, + 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], + ); +} diff --git a/front/src/modules/ui/editable-fields/states/FieldContext.ts b/front/src/modules/ui/editable-fields/states/FieldContext.ts new file mode 100644 index 000000000..5e115242f --- /dev/null +++ b/front/src/modules/ui/editable-fields/states/FieldContext.ts @@ -0,0 +1,3 @@ +import { createContext } from 'react'; + +export const FieldContext = createContext(null); diff --git a/front/src/modules/ui/editable-fields/states/isFieldInEditModeScopedState.ts b/front/src/modules/ui/editable-fields/states/isFieldInEditModeScopedState.ts new file mode 100644 index 000000000..9a32f6fa9 --- /dev/null +++ b/front/src/modules/ui/editable-fields/states/isFieldInEditModeScopedState.ts @@ -0,0 +1,6 @@ +import { atomFamily } from 'recoil'; + +export const isFieldInEditModeScopedState = atomFamily({ + key: 'isFieldInEditModeScopedState', + default: false, +}); diff --git a/front/src/modules/ui/editable-fields/types/EditableFieldHotkeyScope.ts b/front/src/modules/ui/editable-fields/types/EditableFieldHotkeyScope.ts new file mode 100644 index 000000000..309495c8f --- /dev/null +++ b/front/src/modules/ui/editable-fields/types/EditableFieldHotkeyScope.ts @@ -0,0 +1,3 @@ +export enum EditableFieldHotkeyScope { + EditableField = 'editable-field', +} diff --git a/front/src/modules/ui/editable-fields/variants/components/EditableFieldEditModeDate.tsx b/front/src/modules/ui/editable-fields/variants/components/EditableFieldEditModeDate.tsx new file mode 100644 index 000000000..507be9602 --- /dev/null +++ b/front/src/modules/ui/editable-fields/variants/components/EditableFieldEditModeDate.tsx @@ -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 ( + { + handleChange(newDate.toISOString()); + }} + /> + ); +} diff --git a/front/src/modules/ui/inplace-inputs/components/InplaceInputContainer.tsx b/front/src/modules/ui/inplace-inputs/components/InplaceInputContainer.tsx new file mode 100644 index 000000000..075299655 --- /dev/null +++ b/front/src/modules/ui/inplace-inputs/components/InplaceInputContainer.tsx @@ -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; +`; diff --git a/front/src/modules/ui/inplace-inputs/components/InplaceInputDateEditMode.tsx b/front/src/modules/ui/inplace-inputs/components/InplaceInputDate.tsx similarity index 62% rename from front/src/modules/ui/inplace-inputs/components/InplaceInputDateEditMode.tsx rename to front/src/modules/ui/inplace-inputs/components/InplaceInputDate.tsx index 11f32a99e..3d27e4869 100644 --- a/front/src/modules/ui/inplace-inputs/components/InplaceInputDateEditMode.tsx +++ b/front/src/modules/ui/inplace-inputs/components/InplaceInputDate.tsx @@ -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` 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; -const DateDisplay = forwardRef( +export const DateDisplay = forwardRef( ({ value, onClick }, ref) => ( -
- {value && humanReadableDate(new Date(value as string))} -
+ + {value && formatToHumanReadableDate(new Date(value as string))} + ), ); @@ -39,7 +43,7 @@ type DatePickerContainerProps = { children: React.ReactNode; }; -const DatePickerContainer = ({ children }: DatePickerContainerProps) => { +export const DatePickerContainer = ({ children }: DatePickerContainerProps) => { return {children}; }; @@ -48,15 +52,15 @@ type OwnProps = { onChange: (newDate: Date) => void; }; -export function InplaceInputDateEditMode({ onChange, value }: OwnProps) { +export function InplaceInputDate({ onChange, value }: OwnProps) { return ( - + } customCalendarContainer={DatePickerContainer} /> - + ); } diff --git a/front/src/modules/ui/inplace-inputs/components/InplaceInputDateDisplayMode.tsx b/front/src/modules/ui/inplace-inputs/components/InplaceInputDateDisplayMode.tsx index 5a9f826fa..eb1b949fe 100644 --- a/front/src/modules/ui/inplace-inputs/components/InplaceInputDateDisplayMode.tsx +++ b/front/src/modules/ui/inplace-inputs/components/InplaceInputDateDisplayMode.tsx @@ -1,9 +1,9 @@ -import { humanReadableDate } from '@/utils/utils'; +import { formatToHumanReadableDate } from '@/utils/utils'; type OwnProps = { value: Date; }; export function InplaceInputDateDisplayMode({ value }: OwnProps) { - return
{value && humanReadableDate(value)}
; + return
{value && formatToHumanReadableDate(value)}
; } diff --git a/front/src/modules/ui/inplace-inputs/components/InplaceInputText.tsx b/front/src/modules/ui/inplace-inputs/components/InplaceInputText.tsx new file mode 100644 index 000000000..5c84c64a4 --- /dev/null +++ b/front/src/modules/ui/inplace-inputs/components/InplaceInputText.tsx @@ -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 ( + + onChange?.(e.target.value)} + /> + + ); +} diff --git a/front/src/modules/ui/themes/effects.ts b/front/src/modules/ui/themes/effects.ts index 3f5575127..46e45414b 100644 --- a/front/src/modules/ui/themes/effects.ts +++ b/front/src/modules/ui/themes/effects.ts @@ -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)}; diff --git a/front/src/modules/utils/utils.ts b/front/src/modules/utils/utils.ts index c05fa5ce8..0831916e3 100644 --- a/front/src/modules/utils/utils.ts +++ b/front/src/modules/utils/utils.ts @@ -1,10 +1,14 @@ -export const humanReadableDate = (date: Date) => { +import { parseDate } from './datetime/date-utils'; + +export function formatToHumanReadableDate(date: Date | string) { + const parsedJSDate = parseDate(date).toJSDate(); + return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', year: 'numeric', - }).format(date); -}; + }).format(parsedJSDate); +} export const getLogoUrlFromDomainName = (domainName?: string): string => { return `https://api.faviconkit.com/${domainName}/144`; diff --git a/front/src/pages/companies/CompanyShow.tsx b/front/src/pages/companies/CompanyShow.tsx index fdcec17b7..4da987e95 100644 --- a/front/src/pages/companies/CompanyShow.tsx +++ b/front/src/pages/companies/CompanyShow.tsx @@ -2,11 +2,14 @@ import { useParams } from 'react-router-dom'; import { useTheme } from '@emotion/react'; import { Timeline } from '@/comments/components/timeline/Timeline'; +import { CompanyAccountOwnerEditableField } from '@/companies/fields/components/CompanyAccountOwnerEditableField'; +import { CompanyAddressEditableField } from '@/companies/fields/components/CompanyAddressEditableField'; +import { CompanyCreatedAtEditableField } from '@/companies/fields/components/CompanyCreatedAtEditableField'; +import { CompanyDomainNameEditableField } from '@/companies/fields/components/CompanyDomainNameEditableField'; +import { CompanyEmployeesEditableField } from '@/companies/fields/components/CompanyEmployeesEditableField'; import { useCompanyQuery } from '@/companies/services'; -import { RawLink } from '@/ui/components/links/RawLink'; import { PropertyBox } from '@/ui/components/property-box/PropertyBox'; -import { PropertyBoxItem } from '@/ui/components/property-box/PropertyBoxItem'; -import { IconBuildingSkyscraper, IconLink, IconMap } from '@/ui/icons/index'; +import { IconBuildingSkyscraper } from '@/ui/icons/index'; import { WithTopBarContainer } from '@/ui/layout/containers/WithTopBarContainer'; import { ShowPageLeftContainer } from '@/ui/layout/show-page/containers/ShowPageLeftContainer'; import { ShowPageRightContainer } from '@/ui/layout/show-page/containers/ShowPageRightContainer'; @@ -22,48 +25,33 @@ export function CompanyShow() { const theme = useTheme(); + if (!company) return <>; + return ( } > - <> - - - - <> - } - value={ - - {company?.domainName} - - } - /> - } - value={company?.address ? company?.address : 'No address'} - /> - - - - - - - + + + + + + + + + + + + + ); }