diff --git a/src/cloud/components/ContentManager/ContentManagerToolbar.tsx b/src/cloud/components/ContentManager/ContentManagerToolbar.tsx index 7aae85919a..47b893be0c 100644 --- a/src/cloud/components/ContentManager/ContentManagerToolbar.tsx +++ b/src/cloud/components/ContentManager/ContentManagerToolbar.tsx @@ -292,18 +292,10 @@ const ContentManagerToolbar = ({ const docProp = props[key] as FilledSerializedPropData switch (docProp.type) { - case 'json': - if (docProp.data.dataType === 'timeperiod') { - values[key] = { - type: docProp.type, - subType: 'timeperiod', - value: docProp.data.data, - } - } - break case 'user': values[key] = { type: docProp.type, + subType: docProp.subType, value: docProp.data == null ? [] @@ -315,6 +307,7 @@ const ContentManagerToolbar = ({ case 'status': values[key] = { type: docProp.type, + subType: docProp.subType, value: docProp.data == null ? undefined @@ -328,6 +321,7 @@ const ContentManagerToolbar = ({ case 'string': values[key] = { type: docProp.type, + subType: docProp.subType, value: docProp.data, } break @@ -366,14 +360,6 @@ const ContentManagerToolbar = ({ const docProp = props[key] as FilledSerializedPropData switch (docProp.type) { - case 'json': - if ( - docProp.data.dataType !== 'timeperiod' || - docProp.data.data !== values[key]['value'] - ) { - delete values[key] - } - break case 'user': { let newUserArray = values[key].value.slice() as string[] @@ -391,11 +377,9 @@ const ContentManagerToolbar = ({ case 'status': { const docPropStatus: SerializedStatus | undefined = - props[key] == null - ? undefined - : Array.isArray(props[key].data) - ? props[key].data[0] - : props[key].data + docProp[key] == Array.isArray(docProp[key].data) + ? docProp[key].data[0] + : docProp[key].data if ( docPropStatus == null || docPropStatus.id !== values[key].value.id @@ -595,8 +579,9 @@ const ContentManagerToolbar = ({ updateProp([ propColumn.name, { - type: 'json', - data: { dataType: 'timeperiod', data: val }, + type: 'number', + subType: 'timeperiod', + data: val, }, ]) }} diff --git a/src/cloud/components/DocPage/index.tsx b/src/cloud/components/DocPage/index.tsx index 759a1e8658..deb76ad706 100644 --- a/src/cloud/components/DocPage/index.tsx +++ b/src/cloud/components/DocPage/index.tsx @@ -22,6 +22,7 @@ import ColoredBlock from '../../../design/components/atoms/ColoredBlock' import Editor from '../Editor' import ApplicationPage from '../ApplicationPage' import { freePlanDocLimit, freePlanMembersLimit } from '../../lib/subscription' +import { useCloudDocPreview } from '../../lib/hooks/useCloudDocPreview' interface DocPageProps { doc: SerializedDocWithSupplemental @@ -74,6 +75,7 @@ const DocPage = ({ }, [currentDoc, team]) useTitle(pageTitle) + useCloudDocPreview(team) useEffect(() => { if (currentDoc == null) { diff --git a/src/cloud/components/DocProperties.tsx b/src/cloud/components/DocProperties.tsx index 24054f7aa8..35fce3bafe 100644 --- a/src/cloud/components/DocProperties.tsx +++ b/src/cloud/components/DocProperties.tsx @@ -84,12 +84,9 @@ const DocProperties = ({ {docProperties.map((prop, i) => { const iconPath = getIconPathOfPropType( - prop[1].data.type === 'json' && - prop[1].data.data != null && - prop[1].data.data.dataType != null - ? prop[1].data.data.dataType - : prop[1].data.type + prop[1].data.subType || prop[1].data.type ) + return (
+
+ ) + })} + + ) + }, [defaultValue, team, docsMap, goToDocPreview, update, disabled]) + + return ( + + + openContextModal( + e, + , + { + alignment: popupAlignment, + width: 460, + keepAll: true, + } + ) + } + > + {defaultValue.length !== 0 + ? selectedDocs + : emptyLabel != null + ? emptyLabel + : translate(lngKeys.Unassigned)} + + + ) +} + +const DependencyPastille = ({ type = 'doc' }: { type: DependencyType }) => { + return ( + + + + ) +} + +const PastilleContainer = styled.div` + color: ${({ theme }) => theme.colors.text.subtle}; + &.dependency__pastille--blocker { + color: ${({ theme }) => theme.colors.variants.danger.base}; + } + + &.dependency__pastille--waiting { + color: ${({ theme }) => theme.colors.variants.warning.base}; + } +` + +const Container = styled.div` + .item__property__button { + cursor: pointer; + } + + .dependencies___wrapper { + display: flex; + width: auto; + align-items: center; + flex-wrap: wrap; + } + + .dependency__pastille { + margin-right: ${({ theme }) => theme.sizes.spaces.xsm}px; + } + + .dependency__wrapper { + display: inline-flex; + line-height: 23px; + margin-right: ${({ theme }) => theme.sizes.spaces.xsm}px; + background: ${({ theme }) => theme.colors.background.quaternary}; + border-radius: ${({ theme }) => theme.borders.radius}px; + margin-top: ${({ theme }) => theme.sizes.spaces.xsm}px; + margin-bottom: ${({ theme }) => theme.sizes.spaces.xsm}px; + + .dependency__label { + color: ${({ theme }) => theme.colors.text.primary}; + box-shadow: none !important; + &:hover { + text-decoration: underline; + } + } + } +` + +const NewDependencyModal = ({ + create, + closeModal, +}: { + create: (val: { string: DependencyType; targetDoc: any }) => void + closeModal: () => void +}) => { + const [submitting, setSubmitting] = useState(false) + const [dependencyType, setDependencyType] = useState() + const [doc, setDoc] = useState() + const { initialLoadDone, docsMap } = useNav() + const { translate } = useI18n() + + const dependencyOptions = useMemo(() => { + const optionsMap = new Map() + optionsMap.set('blocker', { + value: 'blocker', + label: ( +
+ + Blocking +
+ ), + }) + optionsMap.set('waiting', { + value: 'waiting', + label: ( +
+ + Waiting on +
+ ), + }) + optionsMap.set('doc', { + value: 'doc', + label: ( +
+ + Documents +
+ ), + }) + return optionsMap + }, []) + + const docOptions = useMemo(() => { + return getMapValues(docsMap).map((doc) => { + const title = getDocTitle(doc, 'Untitled') + return { + value: doc.id, + fullpath: doc.folderPathname + `/${title}`, + label: ( + +
+ {title} + + {doc.folderPathname} + +
+
+ ), + } + }) + }, [docsMap]) + + const onSubmit: React.FormEventHandler = useCallback( + async (event) => { + event.preventDefault() + if (doc == null || dependencyType == null) { + return + } + + setSubmitting(true) + try { + await create({ + string: dependencyType, + targetDoc: doc.value as any, + }) + closeModal() + } catch (error) { + setSubmitting(false) + } + }, + [doc, dependencyType, create, closeModal] + ) + + const filterOption = (option: FormSelectOption, inputValue: string) => { + const { fullpath = '' } = (option as any).data as any + return fullpath.toLocaleLowerCase().includes(inputValue.toLocaleLowerCase()) + } + + return ( + +
+ + + { + setDependencyType(val.value) + }} + options={[...dependencyOptions.values()]} + /> + + + + + { + setDoc(val) + }} + /> + + + + + + +
+
+ ) +} + +const SearchItem = styled.div` + display: flex; + width: 100%; + flex-direction: row; + justify-content: space-between; + align-items: center; + height: 26px; + white-space: nowrap; + font-size: ${({ theme }) => theme.sizes.fonts.df}px; + margin: 0; + overflow: hidden; + + .search__item__label { + ${overflowEllipsis()} + } + + .search__item__label--path { + padding-left: ${({ theme }) => theme.sizes.fonts.sm}px; + font-size: ${({ theme }) => theme.sizes.fonts.sm}px; + color: ${({ theme }) => theme.colors.text.subtle}; + flex: 1 1 auto; + } + .search__item__label--main { + flex: 0 0 auto; + } +` + +const ModalContainer = styled.div` + .form__select__value-container { + height: 26px; + } + .dependency__option { + display: inline-flex; + align-items: center; + height: 26px; + + .dependency__pastille { + margin-right: ${({ theme }) => theme.sizes.spaces.xsm}px; + } + } + + .submit-row .form__row__items { + justify-content: flex-end; + } +` + +export default DocDependencySelect diff --git a/src/cloud/components/Props/Pickers/PropertyValueButton.tsx b/src/cloud/components/Props/Pickers/PropertyValueButton.tsx index 888b62bf63..8823c4bc71 100644 --- a/src/cloud/components/Props/Pickers/PropertyValueButton.tsx +++ b/src/cloud/components/Props/Pickers/PropertyValueButton.tsx @@ -4,6 +4,7 @@ import Spinner from '../../../../design/components/atoms/Spinner' import { contextMenuFormItem, overflowEllipsis, + StyledProps, } from '../../../../design/lib/styled/styleFunctions' import cc from 'classcat' import Icon from '../../../../design/components/atoms/Icon' @@ -19,6 +20,7 @@ interface PropertyValueButtonProps { id?: string className?: string onClick?: React.MouseEventHandler + tag?: 'button' | 'div' } const PropertyValueButton = forwardRef< @@ -37,9 +39,45 @@ const PropertyValueButton = forwardRef< isErrored, className, id, + tag = 'button', }, ref ) => { + if (tag === 'div') { + return ( + + {sending ? ( + <> + Sending... + + ) : ( + <> + {iconPath != null && !empty && ( + + )} + + {empty ? 'Empty' : children} + + + )} + + ) + } + return ( theme.sizes.fonts.df}px; - outline: none; - border-radius: 4px; - border-color: transparent; - border-width: 1px; - border-style: solid; - background: none; - color: inherit; - box-sizing: border-box; - transition: 200ms background-color; - color: ${({ theme }) => theme.colors.text.primary}; - ${({ theme }) => contextMenuFormItem({ theme }, ':focus')} - - .item__property__button__icon { - margin-right: ${({ theme }) => theme.sizes.spaces.xsm}px; - color: ${({ theme }) => theme.colors.text.subtle}; - flex-shrink: 0; - } +const ValueButtonStyle = ({ theme }: StyledProps) => ` +display: flex; +width: fit-content; +height: 32px; +display: flex; +justify-content: left; +align-items: center; +font-size: ${theme.sizes.fonts.df}px; +outline: none; +border-radius: 4px; +border-color: transparent; +border-width: 1px; +border-style: solid; +background: none; +color: inherit; +box-sizing: border-box; +transition: 200ms background-color; +color: ${theme.colors.text.primary}; +${contextMenuFormItem({ theme }, ':focus')} - .item__property__button__label { - text-align: left; - ${overflowEllipsis()} - } +.item__property__button__icon { + margin-right: ${theme.sizes.spaces.xsm}px; + color: ${theme.colors.text.subtle}; + flex-shrink: 0; +} - &.item__property__button--readOnly { - cursor: not-allowed; - &:hover { - background: none !important; - } - } +.item__property__button__label { + text-align: left; + ${overflowEllipsis()} +} - &.item__property__button--empty { - color: ${({ theme }) => theme.colors.text.subtle}; +&.item__property__button--readOnly { + cursor: not-allowed; + &:hover { + background: none !important; } +} - .button__spinner { - margin-right: ${({ theme }) => theme.sizes.spaces.xsm}px; - border-color: ${({ theme }) => theme.colors.variants.primary.text}; - border-right-color: transparent; - } +&.item__property__button--empty { + color: ${theme.colors.text.subtle}; +} - &:not(.button__state--disabled) { - &:hover, - &:active, - &:focus, - &.button__state--active { - background: ${({ theme }) => - theme.colors.background.secondary} !important; - color: ${({ theme }) => theme.colors.text.primary}; - - .item__property__button__icon { - color: ${({ theme }) => theme.colors.text.primary}; - } +.button__spinner { + margin-right: ${theme.sizes.spaces.xsm}px; + border-color: ${theme.colors.variants.primary.text}; + border-right-color: transparent; +} + +&:not(.button__state--disabled) { + &:hover, + &:active, + &:focus, + &.button__state--active { + background: ${theme.colors.background.secondary} !important; + color: ${theme.colors.text.primary}; + + .item__property__button__icon { + color: ${theme.colors.text.primary}; } } +} - &.item__property__button--errored { - background-color: rgba(100, 4, 4, 0.2) !important; +&.item__property__button--errored { + background-color: rgba(100, 4, 4, 0.2) !important; - &:hover { - background-color: rgba(100, 4, 4, 0.2) !important; - } + &:hover { + background-color: rgba(100, 4, 4, 0.2) !important; } +} +` + +const DivContainer = styled.div` + ${(theme) => ValueButtonStyle(theme)} +` + +const ButtonContainer = styled.button` + ${(theme) => ValueButtonStyle(theme)} ` diff --git a/src/cloud/components/Props/Pickers/UrlSelect.tsx b/src/cloud/components/Props/Pickers/UrlSelect.tsx new file mode 100644 index 0000000000..78697357d4 --- /dev/null +++ b/src/cloud/components/Props/Pickers/UrlSelect.tsx @@ -0,0 +1,201 @@ +import React, { useCallback, useMemo, useState } from 'react' +import styled from '../../../../design/lib/styled' +import PropertyValueButton from './PropertyValueButton' +import { mdiLinkVariant, mdiPencil } from '@mdi/js' +import { useModal } from '../../../../design/lib/stores/modal' +import { useRef } from 'react' +import { useEffectOnce } from 'react-use' +import { isValidUrl } from '../../../../lib/string' +import { ExternalLink } from '../../../../design/components/atoms/Link' +import FormInput from '../../../../design/components/molecules/Form/atoms/FormInput' +import Button from '../../../../design/components/atoms/Button' +import Flexbox from '../../../../design/components/atoms/Flexbox' + +interface UrlSelectProps { + sending?: boolean + value?: string + disabled?: boolean + isReadOnly: boolean + showIcon?: boolean + popupAlignment?: 'bottom-left' | 'top-left' + onUrlChange: (text: string) => void +} + +const UrlSelect = ({ + value = '', + sending, + disabled, + isReadOnly, + showIcon, + popupAlignment = 'bottom-left', + onUrlChange, +}: UrlSelectProps) => { + const { openContextModal, closeLastModal } = useModal() + const onUrlChangeCallback = useCallback( + (newTextValue: string) => { + onUrlChange(newTextValue) + }, + [onUrlChange] + ) + + const openSelector: React.MouseEventHandler = useCallback( + (event) => { + event.stopPropagation() + openContextModal( + event, + { + if (newValue !== value) { + onUrlChangeCallback(newValue) + } + closeLastModal() + }} + />, + { + alignment: popupAlignment, + width: 200, + hideBackground: true, + removePadding: true, + keepAll: true, + } + ) + }, + [ + openContextModal, + value, + popupAlignment, + onUrlChangeCallback, + closeLastModal, + ] + ) + + const labelNode = useMemo(() => { + if (value.trim() === '') { + return
+ } + + if (isValidUrl(value)) { + return ( + + {value} + + ) + } + + return ( +
+ Incorrect url +
+ ) + }, [value]) + + return ( + + + + {labelNode} + {!isReadOnly && !disabled && ( + diff --git a/src/cloud/components/Props/PropPicker.tsx b/src/cloud/components/Props/PropPicker.tsx index 202838aa74..b2d8313301 100644 --- a/src/cloud/components/Props/PropPicker.tsx +++ b/src/cloud/components/Props/PropPicker.tsx @@ -1,6 +1,12 @@ import React, { useCallback } from 'react' import { SerializedDocWithSupplemental } from '../../interfaces/db/doc' -import { SerializedPropData, PropData, Props } from '../../interfaces/db/props' +import { + SerializedPropData, + PropData, + Props, + NullablePropData, + SerializedCompoundProp, +} from '../../interfaces/db/props' import { useCloudApi } from '../../lib/hooks/useCloudApi' import AssigneeSelect from './Pickers/AssigneeSelect' import DatePropPicker from './Pickers/DatePropPicker' @@ -14,6 +20,8 @@ import { MixpanelActionTrackTypes } from '../../interfaces/analytics/mixpanel' import NumberSelect from './Pickers/NumberSelect' import TextSelect from './Pickers/TextSelect' import { getISODateFromLocalTime } from '../../lib/date' +import UrlSelect from './Pickers/UrlSelect' +import DocDependencySelect from './Pickers/DocDependencySelect' interface PropPickerProps { parent: { type: 'doc'; target: SerializedDocWithSupplemental } @@ -133,7 +141,8 @@ const PropPicker = ({ return ( ) case 'number': + if (propData.subType === 'timeperiod') { + return ( + { + updateProp({ + type: 'number', + subType: 'timeperiod', + data: val, + }) + }} + /> + ) + } return ( ) case 'string': + if (propData.subType === 'url') { + return ( + + updateProp({ + type: 'string', + subType: 'url', + data: val, + }) + } + /> + ) + } + return ( ) - case 'json': - if ( - propData.data != null && - propData.data.dataType === 'timeperiod' && - (propData.data.data == null || typeof propData.data.data === 'number') - ) { + case 'compound': + if (propData.subType === 'dependency') { return ( - { - updateProp({ - type: 'json', - data: { dataType: 'timeperiod', data: val }, - }) - }} + isLoading={sendingMap.get(parent.target.id) === propName} + readOnly={readOnly} + emptyLabel={emptyLabel} + defaultValue={ + propData.data != null + ? Array.isArray(propData.data) + ? (propData.data.filter((item) => item != null) as any) + : [propData.data] + : [] + } + showIcon={showIcon} + update={(val) => + updateProp( + val.length === 0 || val == null + ? { type: 'compound', subType: 'dependency', data: null } + : { + type: 'compound', + subType: 'dependency', + data: val as NullablePropData, + } + ) + } /> ) } + return null default: return null diff --git a/src/cloud/components/Views/Calendar/CalendarView.tsx b/src/cloud/components/Views/Calendar/CalendarView.tsx index 4d2465a11f..d90af5b807 100644 --- a/src/cloud/components/Views/Calendar/CalendarView.tsx +++ b/src/cloud/components/Views/Calendar/CalendarView.tsx @@ -69,6 +69,7 @@ const CalendarView = ({ } if ( dateProps != null && + dateProps.type === 'date' && dateProps.type === watchedProp.type && dateProps.data != null ) { @@ -76,12 +77,12 @@ const CalendarView = ({ props.start = dateProps.data } else { const orderedDates = dateProps.data.sort((a, b) => { - if (a < b) { + if (a! < b!) { return -1 } else { return 1 } - }) + }) as Date[] if (dateProps.data.length === 2) { props.start = orderedDates[0] const endDate = new Date(orderedDates[1]) diff --git a/src/cloud/components/Views/List/ListDocProperties.tsx b/src/cloud/components/Views/List/ListDocProperties.tsx index f9aff2fb7c..b5197a9021 100644 --- a/src/cloud/components/Views/List/ListDocProperties.tsx +++ b/src/cloud/components/Views/List/ListDocProperties.tsx @@ -65,8 +65,7 @@ const ListDocProperties = ({ getInitialPropDataOfPropType(propType) const isPropDataAccurate = - propData.type === propType || - (propData.type === 'json' && propData.data.dataType === propType) + propData.type === prop.type && propData.subType === prop.subType return ( { +export interface Prop { type: T data: D | D[] + subType?: S createdAt: string } -export type PropData = Omit -export type NullablePropData = T | undefined - export type FilledSerializedPropData = - | Prop<'string', string> - | Prop<'date', Date> - | Prop<'json', any> - | Prop<'number', number> - | Prop<'user', SerializedUserTeamPermissions> - | Prop<'status', SerializedStatus> + | Prop<'string', PropStringSubtype, string> + | Prop<'date', undefined, Date> + | Prop<'number', PropNumberSubtype, number> + | Prop<'user', undefined, SerializedUserTeamPermissions> + | Prop<'status', undefined, SerializedStatus> +export type NullablePropData = T | undefined | null export type SerializedPropData = - | Prop<'string', NullablePropData> - | Prop<'date', NullablePropData> - | Prop<'json', any> - | Prop<'number', NullablePropData> - | Prop<'user', NullablePropData> - | Prop<'status', NullablePropData> + | Prop<'string', PropStringSubtype, NullablePropData> + | Prop<'date', undefined, NullablePropData> + | Prop<'number', PropNumberSubtype, NullablePropData> + | Prop<'user', undefined, NullablePropData> + | Prop<'status', undefined, NullablePropData> + | Prop< + 'compound', + PropCompoundSubType, + NullablePropData + > + +export type PropData = Omit +export type PropNumberSubtype = 'timeperiod' +export type PropStringSubtype = 'url' +export type PropCompoundSubType = 'dependency' export type PropType = SerializedPropData['type'] +export type PropSubType = SerializedPropData['subType'] + export type StaticPropType = 'creation_date' | 'update_date' | 'label' -export type PropSubType = 'timeperiod' export type Props = Record + +export type SerializedCompoundProp = { + string?: string + date?: Date + number?: number + member?: SerializedUserTeamPermissions + status?: SerializedStatus + targetDoc?: SerializedDocWithSupplemental +} diff --git a/src/cloud/interfaces/db/smartView.ts b/src/cloud/interfaces/db/smartView.ts index 936b9ced77..d7a7f5c7c4 100644 --- a/src/cloud/interfaces/db/smartView.ts +++ b/src/cloud/interfaces/db/smartView.ts @@ -34,6 +34,7 @@ export interface ConditionType { export interface PropConditionType { name: string type: T + subType?: string value: U } diff --git a/src/cloud/lib/hooks/props/index.ts b/src/cloud/lib/hooks/props/index.ts index e19cc9fbbc..e02be4a60c 100644 --- a/src/cloud/lib/hooks/props/index.ts +++ b/src/cloud/lib/hooks/props/index.ts @@ -65,8 +65,8 @@ export function useProps( updateDocPropsApi(parent.target, [ propName, isObject(data.data) - ? { type: data.type, data: data.data } - : { type: data.type, data: null }, + ? { type: data.type, subType: data.subType, data: data.data } + : { type: data.type, subType: data.subType, data: null }, ]) } } @@ -138,6 +138,7 @@ export function useProps( trackEvent(MixpanelActionTrackTypes.DocPropDelete, { propName, propType: newProps[propName].type, + propSubType: newProps[propName].subType, }) } delete newProps[propName] diff --git a/src/cloud/lib/hooks/useCloudDocPreview.tsx b/src/cloud/lib/hooks/useCloudDocPreview.tsx index 9f0a518b44..4ea78554f0 100644 --- a/src/cloud/lib/hooks/useCloudDocPreview.tsx +++ b/src/cloud/lib/hooks/useCloudDocPreview.tsx @@ -7,11 +7,11 @@ import { useCloudResourceModals } from './useCloudResourceModals' export const docPreviewCloseEvent = 'doc-preview-close' -export function useCloudDocPreview(team: SerializedTeam) { +export function useCloudDocPreview(team?: SerializedTeam) { const { query } = useRouter() const { openDocPreview } = useCloudResourceModals() const prevPreviewRef = useRef('') - const { docsMap } = useNav() + const { docsMap, mapsInitializedByProps } = useNav() const openDocInPreview = useCallback( (docId: string) => { @@ -19,6 +19,9 @@ export function useCloudDocPreview(team: SerializedTeam) { if (doc == null) { return modalEventEmitter.dispatch({ type: docPreviewCloseEvent }) } + if (team == null) { + return + } return openDocPreview(doc, team) }, [openDocPreview, docsMap, team] @@ -51,11 +54,12 @@ export function useCloudDocPreview(team: SerializedTeam) { useEffect(() => { if ( typeof query.preview !== 'string' || - query.preview === prevPreviewRef.current + query.preview === prevPreviewRef.current || + !mapsInitializedByProps ) { return } prevPreviewRef.current = query.preview openDocInPreviewRef.current(query.preview) - }, [query.preview]) + }, [query.preview, mapsInitializedByProps]) } diff --git a/src/cloud/lib/hooks/useCloudResourceModals.tsx b/src/cloud/lib/hooks/useCloudResourceModals.tsx index 4a8f8ca8c9..2b1f6c6473 100644 --- a/src/cloud/lib/hooks/useCloudResourceModals.tsx +++ b/src/cloud/lib/hooks/useCloudResourceModals.tsx @@ -22,7 +22,7 @@ import { lngKeys } from '../i18n/types' import { useRouter } from '../router' import { useNav } from '../stores/nav' import { usePage } from '../stores/pageStore' -import { resourceDeleteEventEmitter } from '../utils/events' +import { modalEventEmitter, resourceDeleteEventEmitter } from '../utils/events' import { getDocId, getFolderId, @@ -33,6 +33,7 @@ import { import { useCloudApi } from './useCloudApi' import { useI18n } from './useI18n' import { stringify } from 'querystring' +import { docPreviewCloseEvent } from './useCloudDocPreview' export function useCloudResourceModals() { const { openModal, closeLastModal } = useModal() @@ -440,6 +441,7 @@ export function useCloudResourceModals() { const goToDocPreview = useCallback( (doc: SerializedDocWithSupplemental) => { + modalEventEmitter.dispatch({ type: docPreviewCloseEvent }) return push(`${pathname}?preview=${doc.id}`) }, [pathname, push] diff --git a/src/cloud/lib/props.ts b/src/cloud/lib/props.ts index 37d5c13536..0a0360736f 100644 --- a/src/cloud/lib/props.ts +++ b/src/cloud/lib/props.ts @@ -6,7 +6,9 @@ import { mdiContentSaveOutline, mdiFormatText, mdiLabelOutline, + mdiLinkVariant, mdiMusicAccidentalSharp, + mdiSwapHorizontal, mdiTimerOutline, } from '@mdi/js' import { capitalize, isNumber, isObject } from 'lodash' @@ -32,11 +34,13 @@ export const supportedPropTypes: { subType?: PropSubType }[] = [ { type: 'date' }, - { type: 'json', subType: 'timeperiod' }, + { type: 'number', subType: 'timeperiod' }, { type: 'status' }, { type: 'user' }, { type: 'number' }, { type: 'string' }, + { type: 'string', subType: 'url' }, + { type: 'compound', subType: 'dependency' }, ] export function getLabelOfPropType( @@ -53,6 +57,8 @@ export function getLabelOfPropType( return 'Update Date' case 'string': return 'Text' + case 'dependency': + return 'Dependencies' default: return capitalize(propType) } @@ -100,6 +106,8 @@ export function getIconPathOfPropType( type: PropType | StaticPropType | PropSubType ): string | undefined { switch (type) { + case 'url': + return mdiLinkVariant case 'creation_date': return mdiClockOutline case 'update_date': @@ -118,6 +126,8 @@ export function getIconPathOfPropType( return mdiMusicAccidentalSharp case 'string': return mdiFormatText + case 'dependency': + return mdiSwapHorizontal default: return } @@ -127,12 +137,20 @@ export function getInitialPropDataOfPropType( type: PropType | PropSubType ): SerializedPropData { switch (type) { + case 'dependency': + return { + type: 'compound', + subType: 'dependency', + createdAt: new Date().toString(), + data: undefined, + } case 'date': return { type: 'date', data: undefined, createdAt: new Date().toString() } case 'timeperiod': return { - type: 'json', - data: { dataType: 'timeperiod', data: null }, + type: 'number', + subType: 'timeperiod', + data: null, createdAt: new Date().toString(), } case 'user': @@ -149,6 +167,13 @@ export function getInitialPropDataOfPropType( data: undefined, createdAt: new Date().toString(), } + case 'url': + return { + type: 'string', + subType: 'url', + data: '', + createdAt: new Date().toString(), + } case 'string': default: return { @@ -165,6 +190,43 @@ export function getDomainOrInitialDataPropToPropData( let propData = data.data if (data.data != null) { switch (data.type) { + case 'compound': + const copyData = Object.assign({}, propData) + Object.entries(copyData).forEach(([key, value]) => { + if (key == null) { + return + } + switch (key) { + case 'targetDoc': { + const val = Array.isArray(value) ? value : [value] + if (!isUUIDArray(value)) { + copyData[key] = val + .filter((doc: any) => doc != null) + .map((doc: any) => doc.id) + } + return + } + case 'member': { + const val = Array.isArray(value) ? value : [value] + if (!isUUIDArray(value)) { + copyData[key] = val + .filter((user: any) => user != null) + .map((user: any) => user.userId) + } + return + } + case 'status': { + const val = Array.isArray(value) ? value : [value] + if (!isUUIDArray(value)) { + copyData[key] = val + .filter((status: any) => status != null) + .map((status: any) => status.id) + } + return + } + } + }) + break case 'user': const users = Array.isArray(data.data) ? data.data : [data.data] if (!isUUIDArray(users)) { @@ -187,10 +249,10 @@ export function getDomainOrInitialDataPropToPropData( } if (isObject(propData)) { - return { type: data.type, data: propData } + return { type: data.type, subType: data.subType, data: propData } } - return { type: data.type, data: null } + return { type: data.type, subType: data.subType, data: null } } export function getDefaultStaticSuggestionsPerType(): { @@ -212,12 +274,14 @@ export function getDefaultColumnSuggestionsPerType(): { return [ { type: 'user', name: 'Assignees' }, { type: 'user', name: 'Reviewers' }, - { type: 'json', subType: 'timeperiod', name: 'Time Estimate' }, - { type: 'json', subType: 'timeperiod', name: 'Time Tracked' }, + { type: 'number', subType: 'timeperiod', name: 'Time Estimate' }, + { type: 'number', subType: 'timeperiod', name: 'Time Tracked' }, { type: 'status', name: 'Status' }, { type: 'date', name: 'Due Date' }, { type: 'date', name: 'Start Date' }, { type: 'number', name: 'Story Point' }, { type: 'string', name: 'Text' }, + { type: 'string', subType: 'url', name: 'Url' }, + { type: 'compound', subType: 'dependency', name: 'Dependencies' }, ] } diff --git a/src/cloud/lib/smartViews.ts b/src/cloud/lib/smartViews.ts index c8dff6a054..b6c80c8a67 100644 --- a/src/cloud/lib/smartViews.ts +++ b/src/cloud/lib/smartViews.ts @@ -18,6 +18,7 @@ import { } from '@mdi/js' import { getInitialPropDataOfPropType } from './props' import { floorISOTime, getISODateFromLocalTime } from './date' +import { SerializedStatus } from '../interfaces/db/status' export const supportedCustomPropertyTypes: Record< string, @@ -25,7 +26,11 @@ export const supportedCustomPropertyTypes: Record< > = { date: { label: 'Date', value: 'date', icon: mdiCalendarMonthOutline }, person: { label: 'Person', value: 'user', icon: mdiAccountOutline }, - timeperiod: { label: 'Time', value: 'json', icon: mdiTimerOutline }, + timeperiod: { + label: 'Time', + value: 'number/timeperiod', + icon: mdiTimerOutline, + }, status: { label: 'Status', value: 'status', @@ -107,22 +112,23 @@ const validators: Validators = { return false } + if (condition.value.type !== prop.type) { + return false + } + const nonNullableVal = Array.isArray(prop.data) - ? prop.data.filter((d) => d != null) + ? (prop.data as Array).filter((d) => d != null) : prop.data if (Array.isArray(nonNullableVal) && nonNullableVal.length === 0) { return false } - switch (condition.value.type) { case 'date': return validateDateValue( Array.isArray(nonNullableVal) ? nonNullableVal[0] : nonNullableVal, condition.value.value ) - case 'json': - return false case 'user': return Array.isArray(condition.value.value) ? condition.value.value.length === 0 @@ -157,7 +163,7 @@ const validators: Validators = { if (st == null) { return id === -1 } - return st.id === id + return (st as SerializedStatus).id === id }, prop.data, condition.value.value diff --git a/src/cloud/lib/stores/nav/store.tsx b/src/cloud/lib/stores/nav/store.tsx index ecfeee7f15..9d8e3be44e 100644 --- a/src/cloud/lib/stores/nav/store.tsx +++ b/src/cloud/lib/stores/nav/store.tsx @@ -66,6 +66,11 @@ import { PagePropsUpdateEventDetails, PagePropsUpdateEventEmitter, } from '../../utils/events' +import { isObject } from 'lodash' +import { + NullablePropData, + SerializedCompoundProp, +} from '../../../interfaces/db/props' export * from './types' function useNavStore(): NavContext { @@ -84,6 +89,7 @@ function useNavStore(): NavContext { const router = useRouter() const { pushMessage } = useToast() const previousPathRef = useRef('') + const [mapsInitializedByProps, setMapsInitializedByProps] = useState(false) const [initialLoadDone, setInitialLoadDone] = useState(false) const [sideNavCreateButtonState, setSideNavCreateButtonState] = useState< string | undefined @@ -281,6 +287,7 @@ function useNavStore(): NavContext { setViewsMap((prev) => new Map([...prev, ...maps.viewsData])) setSmartViewsMap((prev) => new Map([...prev, ...maps.smartViewsData])) setWorkspacesMap((prev) => new Map([...prev, ...maps.workspacesData])) + setMapsInitializedByProps(true) } }, [pageProps, router.pathname, navigatingBetweenPage]) @@ -1027,6 +1034,7 @@ function useNavStore(): NavContext { dashboardsMap, updateDashboardsMap, removeFromDashboardsMap, + mapsInitializedByProps, } } @@ -1107,9 +1115,52 @@ function getTagsFoldersDocsMapsFromProps( const foldersData = getMapFromEntityArray( folders as SerializedFolderWithBookmark[] ) + const docsData = getMapFromEntityArray( docs as SerializedDocWithSupplemental[] ) + + const docsGivenByProps = (docs as SerializedDocWithSupplemental[]).reduce( + (acc, doc) => { + Object.values(doc.props).forEach((propData) => { + if ( + !( + propData.type === 'compound' && propData.subType === 'dependency' + ) || + propData.data == null + ) { + return + } + + let data: NullablePropData[] + if (!Array.isArray(propData.data)) { + data = [propData.data] + } else { + data = propData.data + } + + for (const dependency of data) { + if ( + dependency == null || + dependency.targetDoc == null || + !isObject(dependency.targetDoc) + ) { + continue + } + acc.push(dependency.targetDoc) + } + }) + return acc + }, + [] as SerializedDocWithSupplemental[] + ) + + docsGivenByProps.forEach((doc) => { + if (!docsData.has(doc.id)) { + docsData.set(doc.id, doc) + } + }) + const tagsData = getMapFromEntityArray(tags as SerializedTag[]) const workspacesData = getMapFromEntityArray( workspaces as SerializedWorkspace[] diff --git a/src/cloud/lib/stores/nav/types.ts b/src/cloud/lib/stores/nav/types.ts index d585fd854e..41a1424cc5 100644 --- a/src/cloud/lib/stores/nav/types.ts +++ b/src/cloud/lib/stores/nav/types.ts @@ -26,6 +26,7 @@ import { SerializedDashboard } from '../../../interfaces/db/dashboard' export interface NavContext { initialLoadDone: boolean + mapsInitializedByProps: boolean sideNavCreateButtonState: string | undefined setSideNavCreateButtonState: (value?: string) => void currentPath: string diff --git a/src/cloud/lib/views/table.ts b/src/cloud/lib/views/table.ts index 0213ac6321..677c7e2a54 100644 --- a/src/cloud/lib/views/table.ts +++ b/src/cloud/lib/views/table.ts @@ -345,13 +345,13 @@ export function mapComparableItem( } case 'column': const docProp = doc.props[sort.columnName] - if (docProp == null) { + if (docProp == null || docProp.type !== sort.columnType) { return { doc, compareValue: null, } } - switch (sort.columnType) { + switch (docProp.type) { case 'string': { const compareValue = isArray(docProp.data) ? docProp.data[0] @@ -380,12 +380,6 @@ export function mapComparableItem( compareValue: isValidDate(compareValue) ? compareValue : null, } } - case 'json': - return { - doc, - compareValue: - docProp.data != null ? JSON.stringify(docProp.data) : null, - } case 'number': { const compareValue = isArray(docProp.data) ? docProp.data[0] @@ -426,7 +420,9 @@ export function mapComparableItem( ) } else { const targetPermission = permissions.find( - (permission) => permission.userId === docProp.data?.userId + (permission) => + permission.userId === + (docProp.data as SerializedUserTeamPermissions)?.userId ) compareValue = diff --git a/src/design/components/molecules/Form/atoms/FormSelect.tsx b/src/design/components/molecules/Form/atoms/FormSelect.tsx index 6129d6c86a..ae3610855f 100644 --- a/src/design/components/molecules/Form/atoms/FormSelect.tsx +++ b/src/design/components/molecules/Form/atoms/FormSelect.tsx @@ -4,6 +4,7 @@ import cc from 'classcat' import styled from '../../../../lib/styled' import { formInputHeight } from '../../../../lib/styled/styleFunctions' import { capitalize } from 'lodash' +import { getOptionLabel, getOptionValue } from 'react-select/src/builtins' export interface FormSelectOption { label: string | React.ReactNode @@ -28,6 +29,8 @@ interface FormSelectCommonProps { onMenuOpen?: () => void minWidth?: string | number placeholder?: React.ReactNode + getOptionLabel?: getOptionLabel + getOptionValue?: getOptionValue } interface StandardFormSelectOptions { @@ -57,6 +60,8 @@ const FormSelect = ({ isLoading = false, isMulti = false, isSearchable = false, + getOptionLabel, + getOptionValue, placeholder = 'Select...', name, filterOption, @@ -81,6 +86,8 @@ const FormSelect = ({ filterOption={filterOption} onChange={onChange} isDisabled={isDisabled} + getOptionLabel={getOptionLabel} + getOptionValue={getOptionValue} isLoading={isLoading} isSearchable={isSearchable} isMulti={isMulti} diff --git a/src/lib/string.ts b/src/lib/string.ts index df91b9930f..946fa5fb89 100644 --- a/src/lib/string.ts +++ b/src/lib/string.ts @@ -31,3 +31,9 @@ export function parseNumberStringOrReturnZero(str: string): number { return 0 } } + +export function isValidUrl(str: string): boolean { + return /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$/.test( + str + ) +} diff --git a/src/mobile/components/molecules/ContentManagerDocRow.tsx b/src/mobile/components/molecules/ContentManagerDocRow.tsx index 6e1aebc092..a8916a67a6 100644 --- a/src/mobile/components/molecules/ContentManagerDocRow.tsx +++ b/src/mobile/components/molecules/ContentManagerDocRow.tsx @@ -237,7 +237,6 @@ const ContentManagerDocRow = ({ itemLink={ { const filteredDocs = documents .filter((doc) => { - if (doc.props.status == null) { + if (doc.props.status == null || doc.props.status.data == null) { if (doc.archivedAt == null) { return true } return statusFilterSet.has('archived') } - return statusFilterSet.has(doc.props.status.data) + return statusFilterSet.has(doc.props.status.data as any) }) .map((doc) => { return {