diff --git a/server/pkg/builtin/manifest.yml b/server/pkg/builtin/manifest.yml index b6d0667b8a..2d26b657f3 100644 --- a/server/pkg/builtin/manifest.yml +++ b/server/pkg/builtin/manifest.yml @@ -1996,6 +1996,7 @@ extensions: - id: menu title: Menu list: true + representativeField: menuTitle availableIf: field: buttonType type: string diff --git a/web/src/beta/components/fields/ListField/index.tsx b/web/src/beta/components/fields/ListField/index.tsx index b9f00bcdae..3f9b072e13 100644 --- a/web/src/beta/components/fields/ListField/index.tsx +++ b/web/src/beta/components/fields/ListField/index.tsx @@ -15,6 +15,7 @@ type ListItem = { }; export type Props = { + className?: string; name?: string; description?: string; items: ListItem[]; @@ -26,6 +27,7 @@ export type Props = { } & Pick; const ListField: React.FC = ({ + className, name, description, items, @@ -65,7 +67,7 @@ const ListField: React.FC = ({ return ( - + uniqueKey="ListField" items={items} diff --git a/web/src/beta/components/fields/PropertyFields/hooks.ts b/web/src/beta/components/fields/Property/PropertyField/hooks.ts similarity index 100% rename from web/src/beta/components/fields/PropertyFields/hooks.ts rename to web/src/beta/components/fields/Property/PropertyField/hooks.ts diff --git a/web/src/beta/components/fields/Property/PropertyField/index.tsx b/web/src/beta/components/fields/Property/PropertyField/index.tsx new file mode 100644 index 0000000000..055d0fa551 --- /dev/null +++ b/web/src/beta/components/fields/Property/PropertyField/index.tsx @@ -0,0 +1,164 @@ +import { useMemo } from "react"; + +import { FlyTo } from "@reearth/beta/lib/core/types"; +import { LatLng } from "@reearth/beta/utils/value"; +import { Field, SchemaField } from "@reearth/services/api/propertyApi/utils"; + +import CameraField from "../../CameraField"; +import { Camera } from "../../CameraField/types"; +import ColorField from "../../ColorField"; +import DateTimeField from "../../DateTimeField"; +import LocationField from "../../LocationField"; +import NumberField from "../../NumberField"; +import SelectField from "../../SelectField"; +import SliderField from "../../SliderField"; +import SpacingInput, { SpacingValues } from "../../SpacingInput"; +import TextInput from "../../TextField"; +import ToggleField from "../../ToggleField"; +import URLField from "../../URLField"; + +import useHooks from "./hooks"; + +type Props = { + propertyId: string; + itemId?: string; + schemaGroup: string; + schema: SchemaField; + field?: Field; + currentCamera?: Camera; + onFlyTo?: FlyTo; +}; + +const PropertyField: React.FC = ({ + propertyId, + itemId, + field, + schemaGroup, + schema, + currentCamera, + onFlyTo, +}) => { + const { handlePropertyValueUpdate } = useHooks(propertyId, schemaGroup); + + const value = useMemo( + () => field?.mergedValue ?? field?.value ?? schema.defaultValue, + [field?.mergedValue, field?.value, schema.defaultValue], + ); + + const handleChange = handlePropertyValueUpdate(schema.id, schema.type, itemId); + return ( + <> + {schema.type === "string" ? ( + schema.ui === "datetime" ? ( + + ) : schema.ui === "color" ? ( + + ) : schema.ui === "selection" || schema.choices ? ( + + ) : schema.ui === "buttons" ? ( +

Button radio field

+ ) : ( + + ) + ) : schema.type === "url" ? ( + + ) : schema.type === "spacing" ? ( + + ) : schema.type === "bool" ? ( + + ) : schema.type === "number" ? ( + schema.ui === "slider" ? ( + + ) : ( + + ) + ) : schema.type === "latlng" ? ( + + ) : schema.type === "camera" ? ( + + ) : ( +

{schema.name} field

+ )} + + ); +}; + +export default PropertyField; diff --git a/web/src/beta/components/fields/Property/PropertyItem/index.tsx b/web/src/beta/components/fields/Property/PropertyItem/index.tsx new file mode 100644 index 0000000000..1a66f5e8b8 --- /dev/null +++ b/web/src/beta/components/fields/Property/PropertyItem/index.tsx @@ -0,0 +1,129 @@ +import { useMemo, useState } from "react"; + +import { FlyTo } from "@reearth/beta/lib/core/types"; +import { Camera, ValueType, ValueTypes, zeroValues } from "@reearth/beta/utils/value"; +import { Group, GroupListItem, Item } from "@reearth/services/api/propertyApi/utils"; +import { useT } from "@reearth/services/i18n"; + +import PropertyField from "../PropertyField"; +import PropertyList, { ListItem } from "../PropertyList"; + +type Props = { + propertyId: string; + item?: Item; + currentCamera?: Camera; + onFlyTo?: FlyTo; +}; + +const PropertyItem: React.FC = ({ propertyId, item, currentCamera, onFlyTo }) => { + const t = useT(); + const [selected, select] = useState(); + + const isList = item && "items" in item; + const layerMode = useMemo(() => { + if (!isList || !item?.representativeField) return false; + const sf = item.schemaFields.find(f => f.id === item.representativeField); + return sf?.type === "ref" && sf.ui === "layer"; + }, [isList, item?.representativeField, item?.schemaFields]); + + const groups = useMemo<(GroupListItem | Group)[]>( + () => (item && "items" in item ? item.items : item ? [item] : []), + [item], + ); + + const selectedItem = isList ? groups.find(g => g.id === selected) : groups[0]; + + const propertyListItems = useMemo( + () => + groups + .map(i => { + if (!i.id) return; + + const representativeField = item?.representativeField + ? i.fields.find(f => f.id === item.representativeField) + : undefined; + const nameSchemaField = item?.schemaFields?.find( + sf => sf.id === item.representativeField, + ); + + const value = representativeField?.value || nameSchemaField?.defaultValue; + + const choice = nameSchemaField?.choices + ? nameSchemaField?.choices?.find(c => c.key === value)?.label + : undefined; + + const title = valueToString(choice || value); + + return { + id: i.id, + title: (!layerMode ? title : undefined) ?? t("Settings"), + layerId: layerMode ? title : undefined, + }; + }) + .filter((g): g is ListItem => !!g), + [groups, layerMode, item, t], + ); + const schemaFields = useMemo( + () => + selectedItem + ? item?.schemaFields.map(f => { + const field = selectedItem?.fields.find(f2 => f2.id === f.id); + const condf = f.only && selectedItem?.fields.find(f2 => f2.id === f.only?.field); + const condsf = f.only && item.schemaFields.find(f2 => f2.id === f.only?.field); + const condv = + condf?.value ?? + condf?.mergedValue ?? + condsf?.defaultValue ?? + (condsf?.type ? zeroValues[condsf.type] : undefined); + return { + schemaField: f, + field, + hidden: f.only && (!condv || condv !== f.only.value), + }; + }) + : [], + [item?.schemaFields, selectedItem], + ); + + return ( + <> + {isList && !!item && ( + + )} + {!!item && + schemaFields?.map(f => { + if ((layerMode && f.schemaField.id === item.representativeField) || f.hidden) return null; + return ( + + ); + })} + + ); +}; + +export default PropertyItem; + +const valueToString = (v: ValueTypes[ValueType] | undefined): string | undefined => { + if (typeof v === "string" || typeof v === "number") { + return v.toString(); + } + return undefined; +}; diff --git a/web/src/beta/components/fields/Property/PropertyList/index.stories.tsx b/web/src/beta/components/fields/Property/PropertyList/index.stories.tsx new file mode 100644 index 0000000000..0eeb5896e6 --- /dev/null +++ b/web/src/beta/components/fields/Property/PropertyList/index.stories.tsx @@ -0,0 +1,70 @@ +import { useArgs } from "@storybook/preview-api"; +import { Meta, StoryObj } from "@storybook/react"; +import { useCallback } from "react"; + +import { styled } from "@reearth/services/theme"; + +import ListField, { Props } from "."; + +const meta: Meta = { + component: ListField, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = (args: Props) => { + const [_, updateArgs] = useArgs(); + + const onSelect = useCallback((id: string) => updateArgs({ selected: id }), [updateArgs]); + + return ( + +
+ +
+
+ ); +}; + +const Wrapper = styled.div` + display: flex; + flex-direction: column; + gap: 10%; + margin-left: 2rem; + margin-top: 2rem; + height: 300px; + width: 300px; +`; + +Default.args = { + name: "List Field", + description: "List field Sample description", + items: [ + { + id: "w3tlwi", + title: "Item w3tlwi", + }, + { + id: "77eg5", + title: "Item 77eg5", + }, + { + id: "7p218", + title: "Item 7p218", + }, + { + id: "xquyo", + title: "Item xquyo", + }, + { + id: "2mewj", + title: "Item 2mewj", + }, + { + id: "d2gmu", + title: "Item d2gmu", + }, + ], +}; diff --git a/web/src/beta/components/fields/Property/PropertyList/index.tsx b/web/src/beta/components/fields/Property/PropertyList/index.tsx new file mode 100644 index 0000000000..78d304a76e --- /dev/null +++ b/web/src/beta/components/fields/Property/PropertyList/index.tsx @@ -0,0 +1,150 @@ +import { useCallback, useEffect, useMemo } from "react"; + +import Button from "@reearth/beta/components/Button"; +import DragAndDropList from "@reearth/beta/components/DragAndDropList"; +import Property from "@reearth/beta/components/fields"; +import Text from "@reearth/beta/components/Text"; +import { useT } from "@reearth/services/i18n"; +import { styled } from "@reearth/services/theme"; + +import useHooks from "../hooks"; + +export type ListItem = { + id: string; + title: string; +}; + +export type Props = { + className?: string; + name?: string; + description?: string; + items: ListItem[]; + propertyId: string; + schemaGroup: string; + selected?: string; + atLeastOneItem?: boolean; + onSelect: (id: string) => void; +}; + +const PropertyList: React.FC = ({ + className, + name, + description, + items, + propertyId, + schemaGroup, + selected, + atLeastOneItem, + onSelect, +}: Props) => { + const t = useT(); + + const { handleAddPropertyItem, handleRemovePropertyItem, handleMovePropertyItem } = useHooks( + propertyId, + schemaGroup, + ); + + const deleteItem = useCallback(() => { + if (!selected) return; + handleRemovePropertyItem(selected); + }, [selected, handleRemovePropertyItem]); + + const getId = useCallback(({ id }: ListItem) => { + return id; + }, []); + + const disableRemoveButton = useMemo(() => { + if (!selected || (atLeastOneItem && items.length === 1)) return true; + + return !items.find(({ id }) => id == selected); + }, [items, selected, atLeastOneItem]); + + // if atleastOneItem is true, make sure one item is always selected + useEffect(() => { + if (!atLeastOneItem) return; + + const updateSelected = !selected || !items.find(({ id }) => id === selected); + if (updateSelected) { + onSelect(items[0]?.id); + } + }, [selected, items, atLeastOneItem, onSelect]); + + return ( + + + + uniqueKey="ListField" + items={items} + onItemDrop={handleMovePropertyItem} + getId={getId} + renderItem={({ id, title }) => ( + onSelect(id)} selected={selected === id}> + {title} + + )} + gap={0} + /> + + + + + + + ); +}; + +const FieldWrapper = styled.div` + min-height: 84px; + max-height: 224px; + border-radius: 4px; + border: 1px solid rgba(77, 83, 88, 1); + overflow: auto; +`; + +const Item = styled.div<{ selected: boolean }>` + display: flex; + align-items: center; + padding: 0 12px; + height: 28px; + cursor: pointer; + background: ${({ theme, selected }) => (selected ? theme.select.main : "inherit")}; + &:hover { + background: ${({ theme, selected }) => (selected ? theme.select.main : theme.bg[2])}; + } +`; + +const StyledText = styled(Text)` + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; +`; + +const ButtonGroup = styled.div` + display: flex; + gap: 4px; +`; + +const ButtonWrapper = styled(Button)` + height: 28px; + width: 100%; + padding: 0px; + margin: 0px; + opacity: ${({ disabled }) => (disabled ? 0.6 : 1)}; +`; + +export default PropertyList; diff --git a/web/src/beta/components/fields/Property/hooks.ts b/web/src/beta/components/fields/Property/hooks.ts new file mode 100644 index 0000000000..abc9e321ad --- /dev/null +++ b/web/src/beta/components/fields/Property/hooks.ts @@ -0,0 +1,43 @@ +import { useCallback } from "react"; + +import { ValueType, ValueTypes } from "@reearth/beta/utils/value"; +import { usePropertyFetcher } from "@reearth/services/api"; + +export default (propertyId: string, schemaGroup: string) => { + const { useUpdatePropertyValue, useAddPropertyItem, useRemovePropertyItem, useMovePropertyItem } = + usePropertyFetcher(); + + const handlePropertyValueUpdate = useCallback( + (fieldId: string, vt: ValueType, itemId?: string) => { + return async (v?: ValueTypes[ValueType]) => { + await useUpdatePropertyValue(propertyId, schemaGroup, itemId, fieldId, "en", v, vt); + }; + }, + [propertyId, schemaGroup, useUpdatePropertyValue], + ); + + const handleAddPropertyItem = useCallback(() => { + return useAddPropertyItem(propertyId, schemaGroup); + }, [propertyId, schemaGroup, useAddPropertyItem]); + + const handleRemovePropertyItem = useCallback( + (itemId: string) => { + return useRemovePropertyItem(propertyId, schemaGroup, itemId); + }, + [propertyId, schemaGroup, useRemovePropertyItem], + ); + + const handleMovePropertyItem = useCallback( + ({ id }: { id: string }, index: number) => { + return useMovePropertyItem(propertyId, schemaGroup, id, index); + }, + [propertyId, schemaGroup, useMovePropertyItem], + ); + + return { + handlePropertyValueUpdate, + handleAddPropertyItem, + handleRemovePropertyItem, + handleMovePropertyItem, + }; +}; diff --git a/web/src/beta/components/fields/PropertyFields/index.tsx b/web/src/beta/components/fields/PropertyFields/index.tsx deleted file mode 100644 index 5d98c4768a..0000000000 --- a/web/src/beta/components/fields/PropertyFields/index.tsx +++ /dev/null @@ -1,214 +0,0 @@ -import { useState, useMemo } from "react"; - -import CameraField from "@reearth/beta/components/fields/CameraField"; -import ColorField from "@reearth/beta/components/fields/ColorField"; -import ListField from "@reearth/beta/components/fields/ListField"; -import LocationField from "@reearth/beta/components/fields/LocationField"; -import NumberField from "@reearth/beta/components/fields/NumberField"; -import SelectField from "@reearth/beta/components/fields/SelectField"; -import SliderField from "@reearth/beta/components/fields/SliderField"; -import SpacingInput, { type SpacingValues } from "@reearth/beta/components/fields/SpacingInput"; -import TextInput from "@reearth/beta/components/fields/TextField"; -import ToggleField from "@reearth/beta/components/fields/ToggleField"; -import URLField from "@reearth/beta/components/fields/URLField"; -import type { FlyTo } from "@reearth/beta/lib/core/types"; -import type { Camera, LatLng } from "@reearth/beta/utils/value"; -import type { Item } from "@reearth/services/api/propertyApi/utils"; - -import DateTimeField from "../DateTimeField"; - -import useHooks from "./hooks"; - -type Props = { - propertyId: string; - item: Item; - currentCamera?: Camera; - onFlyTo?: FlyTo; -}; - -const PropertyFields: React.FC = ({ propertyId, item, currentCamera, onFlyTo }) => { - const { - handlePropertyValueUpdate, - handleAddPropertyItem, - handleRemovePropertyItem, - handleMovePropertyItem, - } = useHooks(propertyId, item.schemaGroup); - - // Just for the ListItem Property - const isList = item && "items" in item; - - // TODO: Only applies to list, should be refactored - const [selected, setSelected] = useState(); - - // TODO: Only applies to list, should be refactored - const propertyListItems: Array<{ id: string; value: string }> = useMemo( - () => - isList - ? item.items - .filter(i => "id" in i) - .map(i => { - const representativeField = item?.representativeField - ? i.fields.find(f => f.id === item.representativeField) - : undefined; - const nameSchemaField = item?.schemaFields?.find( - sf => sf.id === item.representativeField, - ); - - const value = representativeField?.value || nameSchemaField?.defaultValue; - - const choice = nameSchemaField?.choices - ? nameSchemaField?.choices?.find(c => c.key === value)?.label - : undefined; - - const title = choice || value; - - return { - id: i.id, - value: title as string, - }; - }) - : [], - [isList, item], - ); - - const showFields = useMemo(() => { - return isList ? (selected ? item.items.find(({ id }) => id === selected) : false) : true; - }, [item, selected, isList]); - - return ( - <> - {isList && ( - - )} - {showFields && - item?.schemaFields.map(sf => { - const value = - (isList - ? item.items.find(({ id }) => selected == id)?.fields.find(f => f.id == sf.id)?.value - : item.fields.find(f => f.id === sf.id)?.value) ?? sf.defaultValue; - - const handleChange = handlePropertyValueUpdate(sf.id, sf.type, selected); - - return sf.type === "string" ? ( - sf.ui === "datetime" ? ( - - ) : sf.ui === "color" ? ( - - ) : sf.ui === "selection" || sf.choices ? ( - - ) : sf.ui === "buttons" ? ( -

Button radio field

- ) : ( - - ) - ) : sf.type === "url" ? ( - - ) : sf.type === "spacing" ? ( - - ) : sf.type === "bool" ? ( - - ) : sf.type === "number" ? ( - sf.ui === "slider" ? ( - - ) : ( - - ) - ) : sf.type === "latlng" ? ( - - ) : sf.type === "camera" ? ( - - ) : ( -

{sf.name} field

- ); - })} - - ); -}; - -export default PropertyFields; diff --git a/web/src/beta/components/fields/utils.ts b/web/src/beta/components/fields/utils.ts new file mode 100644 index 0000000000..9543d0d4f8 --- /dev/null +++ b/web/src/beta/components/fields/utils.ts @@ -0,0 +1,23 @@ +import { Field, Item, SchemaField } from "@reearth/services/api/propertyApi/utils"; + +export const filterVisibleItems = (items?: Item[]) => { + if (!items) return; + + return items.filter(i => { + if (!i.only) return true; + const res = searchField(items, i.only.field); + return res && (res[1]?.value ?? res[0].defaultValue) === i.only.value; + }); +}; + +const searchField = (items: Item[], fid: string): [SchemaField, Field | undefined] | undefined => { + for (const i of items) { + const sf2 = i.schemaFields.find(f => f.id === fid); + if (!("fields" in i)) return; + const field = i.fields.find(f => f.id === fid); + if (sf2) { + return [sf2, field]; + } + } + return; +}; diff --git a/web/src/beta/features/Editor/Settings/index.tsx b/web/src/beta/features/Editor/Settings/index.tsx index 535b5bb91f..5407313895 100644 --- a/web/src/beta/features/Editor/Settings/index.tsx +++ b/web/src/beta/features/Editor/Settings/index.tsx @@ -1,5 +1,8 @@ +import { useMemo } from "react"; + import CheckBoxField from "@reearth/beta/components/CheckboxField"; -import FieldComponents from "@reearth/beta/components/fields/PropertyFields"; +import PropertyItem from "@reearth/beta/components/fields/Property/PropertyItem"; +import { filterVisibleItems } from "@reearth/beta/components/fields/utils"; import SidePanelSectionField from "@reearth/beta/components/SidePanelSectionField"; import type { FlyTo } from "@reearth/beta/lib/core/types"; import type { Camera } from "@reearth/beta/utils/value"; @@ -41,6 +44,8 @@ const Settings: React.FC = ({ onPageUpdate, }); + const visibleItems = useMemo(() => filterVisibleItems(propertyItems), [propertyItems]); + return ( {tab == "story" && ( @@ -68,9 +73,10 @@ const Settings: React.FC = ({ )} - {propertyItems?.map((i, idx) => ( - - ( + + = ({ id, propertyItems }) => { + const t = useT(); + const visibleItems = useMemo(() => filterVisibleItems(propertyItems), [propertyItems]); return ( - {propertyItems?.map((i, idx) => ( - - + {visibleItems?.map((i, idx) => ( + + ))} diff --git a/web/src/services/api/storytellingApi/blocks.ts b/web/src/services/api/storytellingApi/blocks.ts index edeca019dd..0145c52a7c 100644 --- a/web/src/services/api/storytellingApi/blocks.ts +++ b/web/src/services/api/storytellingApi/blocks.ts @@ -223,7 +223,7 @@ export const getInstalledStoryBlocks = ( id: b.id, pluginId: b.pluginId, extensionId: b.extensionId, - name: block?.name ?? "Undefined", + name: block?.name ?? "Story Block", description: block?.description, icon: block?.icon, property: { diff --git a/web/src/services/i18n/translations/en.yml b/web/src/services/i18n/translations/en.yml index 1735edb07c..b75163f5a5 100644 --- a/web/src/services/i18n/translations/en.yml +++ b/web/src/services/i18n/translations/en.yml @@ -24,9 +24,10 @@ Time: Time Time Zone: Time Zone set: set Remove: Remove -Add Item: '' -Please choose an option: '' -Add text here: '' +Add Item: Add Item +Settings: Settings +Please choose an option: Please choose an option +Add text here: Add text here Timeline Settings: Timeline Settings '* Start Time': '* Start Time' Start time for the timeline: Start time for the timeline diff --git a/web/src/services/i18n/translations/ja.yml b/web/src/services/i18n/translations/ja.yml index f3db94ca3b..02d9fefa6f 100644 --- a/web/src/services/i18n/translations/ja.yml +++ b/web/src/services/i18n/translations/ja.yml @@ -24,9 +24,10 @@ Time: 時間 Time Zone: 時間帯 set: '' Remove: 削除 -Add Item: '' +Add Item: アイテムを追加 +Settings: 設定 Please choose an option: '' -Add text here: '' +Add text here: テキストを書きましょう Timeline Settings: タイムライン設定 '* Start Time': '' Start time for the timeline: '' diff --git a/web/yarn.lock b/web/yarn.lock index 4d3d51c194..e2053e7c48 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -8606,7 +8606,7 @@ magic-string "^0.27.0" react-refresh "^0.14.0" -"@vitest/coverage-v8@^0.34.6": +"@vitest/coverage-v8@0.34.6": version "0.34.6" resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-0.34.6.tgz#931d9223fa738474e00c08f52b84e0f39cedb6d1" integrity sha512-fivy/OK2d/EsJFoEoxHFEnNGTg+MmdZBAVK9Ka4qhXR2K3J0DS08vcGVwzDtXSuUMabLv4KtPcpSKkcMXFDViw==