diff --git a/build/api/admin.api.md b/build/api/admin.api.md index 2e9d3549df..272291a14e 100644 --- a/build/api/admin.api.md +++ b/build/api/admin.api.md @@ -3776,9 +3776,6 @@ identityId: GQLVariableType; memberships: GQLVariableType; }>, TenantMutationResponse>; -// @public (undocumented) -export const Variable: React.MemoExoticComponent<({ name, format }: VariableProps) => ReactElement>; - // @public (undocumented) export interface VariableConfig { // (undocumented) @@ -3788,14 +3785,6 @@ export interface VariableConfig { }>; } -// @public (undocumented) -export interface VariableProps { - // (undocumented) - format?: (value: ReactNode) => ReactNode; - // (undocumented) - name: Environment.Name; -} - // @public (undocumented) export interface Variables { // (undocumented) diff --git a/build/api/react-binding.api.md b/build/api/react-binding.api.md index 276c50673a..903b7642e5 100644 --- a/build/api/react-binding.api.md +++ b/build/api/react-binding.api.md @@ -26,9 +26,11 @@ import type { HasManyRelationMarker } from '@contember/binding'; import type { HasOneRelationMarker } from '@contember/binding'; import { JSX as JSX_2 } from 'react/jsx-runtime'; import { MarkerTreeRoot } from '@contember/binding'; +import { MemoExoticComponent } from 'react'; import { NamedExoticComponent } from 'react'; import type { Persist } from '@contember/binding'; import { PropsWithChildren } from 'react'; +import * as React_2 from 'react'; import { ReactElement } from 'react'; import { ReactNode } from 'react'; import type { RelativeEntityList } from '@contember/binding'; @@ -169,6 +171,16 @@ export interface DeferredSubTreesProps { fallback: ReactNode; } +// @public (undocumented) +export const DimensionRenderer: React_2.NamedExoticComponent; + +// @public (undocumented) +export type DimensionRendererProps = { + dimension: string; + as: string; + children: ReactNode; +}; + // @public (undocumented) export const DirtinessContext: Context; @@ -766,6 +778,17 @@ export const useSortedEntities: (entityList: EntityListAccessor, sortableByField // @public (undocumented) export const useTreeRootId: () => TreeRootId | undefined; +// @public (undocumented) +export const Variable: MemoExoticComponent<({ name, format }: VariableProps) => ReactElement>; + +// @public (undocumented) +export interface VariableProps { + // (undocumented) + format?: (value: ReactNode) => ReactNode; + // (undocumented) + name: Environment.Name; +} + export * from "@contember/binding"; diff --git a/build/api/react-routing.api.md b/build/api/react-routing.api.md index 766ae7c6ba..d302d37f87 100644 --- a/build/api/react-routing.api.md +++ b/build/api/react-routing.api.md @@ -15,6 +15,7 @@ import { JSX as JSX_2 } from 'react/jsx-runtime'; import { NamedExoticComponent } from 'react'; import { ReactElement } from 'react'; import { ReactNode } from 'react'; +import { StateStorageOrName } from '@contember/react-utils'; // @public (undocumented) export const createBindingLinkParametersResolver: (entity: EntityAccessor | undefined) => RoutingParameterResolver; @@ -22,6 +23,24 @@ export const createBindingLinkParametersResolver: (entity: EntityAccessor | unde // @public (undocumented) export const CurrentRequestContext: Context; +// @public (undocumented) +export const DimensionLink: NamedExoticComponent; + +// @public (undocumented) +export type DimensionLinkAction = 'add' | 'toggle' | 'set' | 'unset'; + +// @public (undocumented) +export interface DimensionLinkProps { + // (undocumented) + action?: DimensionLinkAction; + // (undocumented) + children: ReactElement; + // (undocumented) + dimension: string; + // (undocumented) + value: string; +} + // @public (undocumented) export type DynamicRequestParameters = RequestParameters; @@ -269,6 +288,13 @@ export const useBindingLinkParametersResolver: () => RoutingParameterResolver; // @public (undocumented) export const useCurrentRequest: () => RequestState; +// @public (undocumented) +export const useDimensionState: ({ dimension, defaultValue, storage }: { + dimension: string; + defaultValue: string | string[]; + storage?: StateStorageOrName | undefined; +}) => string[]; + // @public (undocumented) export const useLinkFactory: () => (target: RoutingLinkTarget, parameters?: RequestParameters, entity?: EntityAccessor) => RoutingLinkParams; diff --git a/packages/admin/src/components/bindingFacade/environment/index.ts b/packages/admin/src/components/bindingFacade/environment/index.ts index 5e9e1ed2ce..66b91946d2 100644 --- a/packages/admin/src/components/bindingFacade/environment/index.ts +++ b/packages/admin/src/components/bindingFacade/environment/index.ts @@ -1,3 +1,2 @@ export * from './SideDimensions' export * from './DimensionsSwitcher' -export * from './Variable' diff --git a/packages/playground/admin/app/components/layout.tsx b/packages/playground/admin/app/components/layout.tsx index 918863262c..792d2ac1b8 100644 --- a/packages/playground/admin/app/components/layout.tsx +++ b/packages/playground/admin/app/components/layout.tsx @@ -1,3 +1,4 @@ +import * as React from 'react' import { memo, PropsWithChildren } from 'react' import { IdentityLoader } from '../../lib/components/binding/identity' import { Slots } from '../../lib/components/slots' diff --git a/packages/playground/admin/app/components/navigation.tsx b/packages/playground/admin/app/components/navigation.tsx index 70131b347e..33906e61b1 100644 --- a/packages/playground/admin/app/components/navigation.tsx +++ b/packages/playground/admin/app/components/navigation.tsx @@ -1,4 +1,4 @@ -import { ArchiveIcon, BrushIcon, FormInputIcon, GripVertical, HomeIcon, KanbanIcon, TableIcon, UploadIcon } from 'lucide-react' +import { ArchiveIcon, BrushIcon, FormInputIcon, GripVertical, HomeIcon, KanbanIcon, LanguagesIcon, TableIcon, UploadIcon } from 'lucide-react' import { Menu, MenuItem, MenuList } from '../../lib/components/ui/menu' @@ -35,6 +35,7 @@ export const Navigation = () => { + } label={'Dimensions'} to={'dimensions'} /> ) diff --git a/packages/playground/admin/app/pages/dimensions.tsx b/packages/playground/admin/app/pages/dimensions.tsx new file mode 100644 index 0000000000..a034afae4c --- /dev/null +++ b/packages/playground/admin/app/pages/dimensions.tsx @@ -0,0 +1,39 @@ +import { Slots } from '../../lib/components/slots' +import { Binding, PersistButton } from '../../lib/components/binding' +import * as React from 'react' +import { DimensionsSwitcher, SideDimensions } from '../../lib/components/dimensions' +import { EntitySubTree, Field, Variable } from '@contember/interface' +import { InputField, TextareaField } from '../../lib/components/form' +import { Card, CardContent, CardHeader, CardTitle } from '../../lib/components/ui/card' + +export default () => { + return <> + + + + + + + + + + + + + + + + + + + + + + + +} diff --git a/packages/playground/admin/app/pages/index.tsx b/packages/playground/admin/app/pages/index.tsx index cfccf74d63..cd07fccd69 100644 --- a/packages/playground/admin/app/pages/index.tsx +++ b/packages/playground/admin/app/pages/index.tsx @@ -1,7 +1,5 @@ import * as React from 'react' export default () => { - debugger - const foo = React.useMemo(() => 1, []) return <>Hello! } diff --git a/packages/playground/admin/lib/components/dimensions.tsx b/packages/playground/admin/lib/components/dimensions.tsx new file mode 100644 index 0000000000..726cf79e3c --- /dev/null +++ b/packages/playground/admin/lib/components/dimensions.tsx @@ -0,0 +1,135 @@ +import { + Component, + DimensionLink, + DimensionRenderer, + Entity, + EntityAccessor, + Field, + HasOne, + StaticRender, + SugaredQualifiedEntityList, + SugaredRelativeSingleEntity, + SugaredRelativeSingleField, + useDimensionState, + useEntity, +} from '@contember/interface' +import { DataView, DataViewEachRow, DataViewLoaderState, DataViewSortingDirections, useDataViewEntityListAccessor } from '@contember/react-dataview' +import * as React from 'react' +import { ReactNode, useMemo } from 'react' +import { CheckIcon } from 'lucide-react' +import { Loader } from './ui/loader' +import { Popover, PopoverContent, PopoverTrigger } from './ui/popover' +import { Button } from './ui/button' + +export interface DimensionsSwitcherProps { + options: SugaredQualifiedEntityList['entities'] + orderBy?: DataViewSortingDirections + dimension: string + children: ReactNode + slugField: SugaredRelativeSingleField['field'] + isMulti?: boolean +} + +export const DimensionsSwitcher = Component(({ options, dimension, children, slugField, orderBy, isMulti }: DimensionsSwitcherProps) => { + return ( + + + + + + + + + + + +
+ + + {children} + + + + + +
+
+
+
+
+ ) +}) + +const DimensionSwitcherCurrentValues = ({ children, dimension, slugField }: { children: ReactNode, dimension: string, slugField: SugaredRelativeSingleField['field'] }) => { + const entitiesBySlug = useDimensionEntitiesBySlug(slugField) + + const currentDimensionValue = useDimensionState({ + dimension, + defaultValue: Object.keys(entitiesBySlug)[0], + storage: 'local', + }) + + const values = useMemo(() => currentDimensionValue.map(it => entitiesBySlug[it]).filter(Boolean), [currentDimensionValue, entitiesBySlug]) + + return ( +
+ {values.map(it => ( + +
+ {children} +
+
+ ))} +
+ ) +} + + +const DimensionSwitcherItem = ({ children, dimension, slugField, isMulti }: { children: ReactNode, dimension: string, slugField: SugaredRelativeSingleField['field'], isMulti?: boolean }) => { + const entity = useEntity() + const slugValue = entity.getField(slugField).value + if (!slugValue) { + return null + } + + return ( + + + + + ) +} + +export interface SideDimensionsProps { + dimension: string + as: string + field: SugaredRelativeSingleEntity['field'] + children: ReactNode +} + +export const SideDimensions = Component(({ dimension, children, as, field }) => { + return ( +
+ + +
+ {children} +
+
+
+
+ ) +}) + + +const useDimensionEntitiesBySlug = (slugField: SugaredRelativeSingleField['field']): Record => { + const accessor = useDataViewEntityListAccessor() + return useMemo(() => Object.fromEntries(Array.from(accessor ?? []).map(it => [it.getField(slugField).value, it])), [accessor, slugField]) +} diff --git a/packages/playground/api/client/entities.ts b/packages/playground/api/client/entities.ts index 9b4a2ed8ab..7968b94bf2 100644 --- a/packages/playground/api/client/entities.ts +++ b/packages/playground/api/client/entities.ts @@ -4,6 +4,7 @@ import type { InputUnique } from './enums' import type { SelectUnique } from './enums' import type { UploadMediaType } from './enums' import type { UploadOne } from './enums' +import type { DimensionsItemUnique } from './enums' import type { InputRootEnumValue } from './enums' export type JSONPrimitive = string | number | boolean | null @@ -67,6 +68,61 @@ export type BoardUser = { hasManyBy: { } } +export type DimensionsItem = { + name: 'DimensionsItem' + unique: + | Omit<{ id: string}, OverRelation> + | Omit<{ unique: DimensionsItemUnique}, OverRelation> + | Omit<{ locales: DimensionsItemLocale['unique']}, OverRelation> + columns: { + id: string + unique: DimensionsItemUnique + } + hasOne: { + } + hasMany: { + locales: DimensionsItemLocale<'item'> + } + hasManyBy: { + localesByLocale: { entity: DimensionsItemLocale; by: {locale: DimensionsLocale['unique']} } + } +} +export type DimensionsItemLocale = { + name: 'DimensionsItemLocale' + unique: + | Omit<{ id: string}, OverRelation> + | Omit<{ item: DimensionsItem['unique'], locale: DimensionsLocale['unique']}, OverRelation> + columns: { + id: string + title: string + content: string | null + } + hasOne: { + item: DimensionsItem + locale: DimensionsLocale + } + hasMany: { + } + hasManyBy: { + } +} +export type DimensionsLocale = { + name: 'DimensionsLocale' + unique: + | Omit<{ id: string}, OverRelation> + | Omit<{ code: string}, OverRelation> + columns: { + id: string + code: string + label: string + } + hasOne: { + } + hasMany: { + } + hasManyBy: { + } +} export type GridArticle = { name: 'GridArticle' unique: @@ -546,6 +602,9 @@ export type ContemberClientEntities = { BoardTag: BoardTag BoardTask: BoardTask BoardUser: BoardUser + DimensionsItem: DimensionsItem + DimensionsItemLocale: DimensionsItemLocale + DimensionsLocale: DimensionsLocale GridArticle: GridArticle GridArticleComment: GridArticleComment GridAuthor: GridAuthor diff --git a/packages/playground/api/client/enums.ts b/packages/playground/api/client/enums.ts index 07a2aabd87..9a9d36caff 100644 --- a/packages/playground/api/client/enums.ts +++ b/packages/playground/api/client/enums.ts @@ -18,6 +18,8 @@ export type UploadMediaType = | "file" export type UploadOne = | "unique" +export type DimensionsItemUnique = + | "unique" export type InputRootEnumValue = | "a" | "b" diff --git a/packages/playground/api/client/names.ts b/packages/playground/api/client/names.ts index d6c319b0e9..d393889f93 100644 --- a/packages/playground/api/client/names.ts +++ b/packages/playground/api/client/names.ts @@ -82,6 +82,71 @@ export const ContemberClientNames: SchemaNames = { "order" ] }, + "DimensionsItem": { + "name": "DimensionsItem", + "fields": { + "id": { + "type": "column" + }, + "unique": { + "type": "column" + }, + "locales": { + "type": "many", + "entity": "DimensionsItemLocale" + } + }, + "scalars": [ + "id", + "unique" + ] + }, + "DimensionsItemLocale": { + "name": "DimensionsItemLocale", + "fields": { + "id": { + "type": "column" + }, + "item": { + "type": "one", + "entity": "DimensionsItem" + }, + "locale": { + "type": "one", + "entity": "DimensionsLocale" + }, + "title": { + "type": "column" + }, + "content": { + "type": "column" + } + }, + "scalars": [ + "id", + "title", + "content" + ] + }, + "DimensionsLocale": { + "name": "DimensionsLocale", + "fields": { + "id": { + "type": "column" + }, + "code": { + "type": "column" + }, + "label": { + "type": "column" + } + }, + "scalars": [ + "id", + "code", + "label" + ] + }, "GridArticle": { "name": "GridArticle", "fields": { diff --git a/packages/playground/api/migrations/2024-04-18-115011-dimensions.json b/packages/playground/api/migrations/2024-04-18-115011-dimensions.json new file mode 100644 index 0000000000..d225fabb2f --- /dev/null +++ b/packages/playground/api/migrations/2024-04-18-115011-dimensions.json @@ -0,0 +1,201 @@ +{ + "formatVersion": 5, + "modifications": [ + { + "modification": "createEnum", + "enumName": "DimensionsItemUnique", + "values": [ + "unique" + ] + }, + { + "modification": "createEntity", + "entity": { + "name": "DimensionsItem", + "primary": "id", + "primaryColumn": "id", + "tableName": "dimensions_item", + "fields": { + "id": { + "name": "id", + "columnName": "id", + "columnType": "uuid", + "nullable": false, + "type": "Uuid" + } + }, + "unique": [], + "indexes": [], + "eventLog": { + "enabled": true + } + } + }, + { + "modification": "createEntity", + "entity": { + "name": "DimensionsItemLocale", + "primary": "id", + "primaryColumn": "id", + "tableName": "dimensions_item_locale", + "fields": { + "id": { + "name": "id", + "columnName": "id", + "columnType": "uuid", + "nullable": false, + "type": "Uuid" + } + }, + "unique": [], + "indexes": [], + "eventLog": { + "enabled": true + } + } + }, + { + "modification": "createEntity", + "entity": { + "name": "DimensionsLocale", + "primary": "id", + "primaryColumn": "id", + "tableName": "dimensions_locale", + "fields": { + "id": { + "name": "id", + "columnName": "id", + "columnType": "uuid", + "nullable": false, + "type": "Uuid" + } + }, + "unique": [], + "indexes": [], + "eventLog": { + "enabled": true + } + } + }, + { + "modification": "createColumn", + "entityName": "DimensionsItem", + "field": { + "name": "unique", + "columnName": "unique", + "columnType": "DimensionsItemUnique", + "nullable": false, + "type": "Enum", + "default": "unique" + }, + "fillValue": "unique" + }, + { + "modification": "createColumn", + "entityName": "DimensionsItemLocale", + "field": { + "name": "title", + "columnName": "title", + "columnType": "text", + "nullable": false, + "type": "String" + } + }, + { + "modification": "createColumn", + "entityName": "DimensionsItemLocale", + "field": { + "name": "content", + "columnName": "content", + "columnType": "text", + "nullable": true, + "type": "String" + } + }, + { + "modification": "createColumn", + "entityName": "DimensionsLocale", + "field": { + "name": "code", + "columnName": "code", + "columnType": "text", + "nullable": false, + "type": "String" + } + }, + { + "modification": "createColumn", + "entityName": "DimensionsLocale", + "field": { + "name": "label", + "columnName": "label", + "columnType": "text", + "nullable": false, + "type": "String" + } + }, + { + "modification": "createRelation", + "entityName": "DimensionsItemLocale", + "owningSide": { + "type": "ManyHasOne", + "name": "item", + "target": "DimensionsItem", + "joiningColumn": { + "columnName": "item_id", + "onDelete": "restrict" + }, + "nullable": false, + "inversedBy": "locales" + }, + "inverseSide": { + "type": "OneHasMany", + "name": "locales", + "target": "DimensionsItemLocale", + "ownedBy": "item" + } + }, + { + "modification": "createRelation", + "entityName": "DimensionsItemLocale", + "owningSide": { + "type": "ManyHasOne", + "name": "locale", + "target": "DimensionsLocale", + "joiningColumn": { + "columnName": "locale_id", + "onDelete": "restrict" + }, + "nullable": false + } + }, + { + "modification": "createUniqueConstraint", + "entityName": "DimensionsItem", + "unique": { + "fields": [ + "unique" + ] + } + }, + { + "modification": "createUniqueConstraint", + "entityName": "DimensionsItemLocale", + "unique": { + "fields": [ + "item", + "locale" + ] + } + }, + { + "modification": "createUniqueConstraint", + "entityName": "DimensionsLocale", + "unique": { + "fields": [ + "code" + ] + } + } + ] +} diff --git a/packages/playground/api/migrations/2024-04-18-115443-dim-data.ts b/packages/playground/api/migrations/2024-04-18-115443-dim-data.ts new file mode 100644 index 0000000000..ec2815f0fa --- /dev/null +++ b/packages/playground/api/migrations/2024-04-18-115443-dim-data.ts @@ -0,0 +1,52 @@ +import { printMutation } from './utils' +import { queryBuilder } from '../client' + +export default printMutation([ + queryBuilder.create('DimensionsLocale', { + data: { + code: 'cs', + label: 'Czech', + }, + }), + queryBuilder.create('DimensionsLocale', { + data: { + code: 'en', + label: 'English', + }, + }), + queryBuilder.create('DimensionsLocale', { + data: { + code: 'de', + label: 'German', + }, + }), + queryBuilder.create('DimensionsItem', { + data: { + unique: 'unique', + locales: [ + { + create: { + locale: { connect: { code: 'en' } }, + title: 'Hello world', + content: 'Hello world content in English', + }, + }, + { + create: { + locale: { connect: { code: 'cs' } }, + title: 'Ahoj světe', + content: 'Ahoj světe obsah v češtině', + }, + }, + { + create: { + locale: { connect: { code: 'de' } }, + title: 'Hallo Welt', + content: 'Hallo Welt Inhalt auf Deutsch', + }, + }, + ], + }, + }), + +]) diff --git a/packages/playground/api/model/Dimensions.ts b/packages/playground/api/model/Dimensions.ts new file mode 100644 index 0000000000..ed364a802e --- /dev/null +++ b/packages/playground/api/model/Dimensions.ts @@ -0,0 +1,19 @@ +import { c } from '@contember/schema-definition' + +export class DimensionsLocale { + code = c.stringColumn().notNull().unique() + label = c.stringColumn().notNull() +} + +export class DimensionsItem { + unique = c.enumColumn(c.createEnum('unique')).unique().notNull().default('unique') + locales = c.oneHasMany(DimensionsItemLocale, 'item') +} + +@c.Unique('item', 'locale') +export class DimensionsItemLocale { + item = c.manyHasOne(DimensionsItem, 'locales').notNull() + locale = c.manyHasOne(DimensionsLocale).notNull() + title = c.stringColumn().notNull() + content = c.stringColumn() +} diff --git a/packages/playground/api/model/index.ts b/packages/playground/api/model/index.ts index 8be1d32589..b992efa4af 100644 --- a/packages/playground/api/model/index.ts +++ b/packages/playground/api/model/index.ts @@ -1,4 +1,5 @@ export * from './Board' +export * from './Dimensions' export * from './Grid' export * from './Repeater' export * from './Input' diff --git a/packages/react-binding/src/helperComponents/DimensionRenderer.tsx b/packages/react-binding/src/helperComponents/DimensionRenderer.tsx new file mode 100644 index 0000000000..59d12fb2d9 --- /dev/null +++ b/packages/react-binding/src/helperComponents/DimensionRenderer.tsx @@ -0,0 +1,25 @@ +import * as React from 'react' +import { ReactNode } from 'react' +import { EnvironmentMiddleware } from '../accessorPropagation' +import { Component } from '../coreComponents' + +export type DimensionRendererProps = { + dimension: string + as: string + children: ReactNode +} + +export const DimensionRenderer = Component(({ dimension, as, children }, env) => { + const dimensions = env.getDimensionOrElse(dimension, []) + + return <> + {dimensions.map(value => ( + it.withVariables({ [as]: value })} + key={value} + > + {children} + + ))} + +}) diff --git a/packages/admin/src/components/bindingFacade/environment/Variable.tsx b/packages/react-binding/src/helperComponents/Variable.tsx similarity index 84% rename from packages/admin/src/components/bindingFacade/environment/Variable.tsx rename to packages/react-binding/src/helperComponents/Variable.tsx index bc22da65f6..73f11f4d18 100644 --- a/packages/admin/src/components/bindingFacade/environment/Variable.tsx +++ b/packages/react-binding/src/helperComponents/Variable.tsx @@ -1,5 +1,6 @@ -import { Environment, useEnvironment } from '@contember/react-binding' +import { Environment } from '@contember/binding' import { memo, ReactElement, ReactNode, useMemo } from 'react' +import { useEnvironment } from '../accessorPropagation' export interface VariableProps { name: Environment.Name diff --git a/packages/react-binding/src/helperComponents/index.ts b/packages/react-binding/src/helperComponents/index.ts index 73a30639fd..01b50659e2 100644 --- a/packages/react-binding/src/helperComponents/index.ts +++ b/packages/react-binding/src/helperComponents/index.ts @@ -1,7 +1,9 @@ export * from './DeferredSubTrees' +export * from './DimensionRenderer' export * from './EntityView' export * from './If' export * from './FieldView' export * from './LabelMiddleware' export * from './StaticRender' export * from './SugaredField' +export * from './Variable' diff --git a/packages/react-routing/package.json b/packages/react-routing/package.json index cc4f3aeb4e..8b11450f90 100644 --- a/packages/react-routing/package.json +++ b/packages/react-routing/package.json @@ -36,6 +36,7 @@ }, "dependencies": { "@contember/react-binding": "workspace:*", + "@contember/react-utils": "workspace:*", "@contember/utilities": "workspace:*", "@radix-ui/react-slot": "^1.0.2", "path-to-regexp": "6.2.1" diff --git a/packages/react-routing/src/RoutingLink.tsx b/packages/react-routing/src/RoutingLink.tsx index 175e9806d3..c63f728051 100644 --- a/packages/react-routing/src/RoutingLink.tsx +++ b/packages/react-routing/src/RoutingLink.tsx @@ -16,7 +16,7 @@ export interface RoutingLinkProps { * * @group Routing */ -export const RoutingLink = memo(({ to, parametersResolver, parameters, children }) => { +export const RoutingLink = memo(({ to, parametersResolver, parameters, ...props }) => { const { navigate, isActive: active, href } = useRoutingLink(to, parametersResolver, parameters) const innerOnClick = useCallback((e?: ReactMouseEvent) => { @@ -33,9 +33,8 @@ export const RoutingLink = memo(({ to, parametersResolver, par onClick={innerOnClick} data-active={dataAttribute(active)} {...{ href }} - > - {children} - + {...props} + /> ) }) diff --git a/packages/react-routing/src/binding/DimensionLink.tsx b/packages/react-routing/src/binding/DimensionLink.tsx new file mode 100644 index 0000000000..a1185924d4 --- /dev/null +++ b/packages/react-routing/src/binding/DimensionLink.tsx @@ -0,0 +1,63 @@ +import { ReactElement, useCallback, useMemo } from 'react' +import { Link } from './Link' +import { RequestChange } from '../types' +import { dataAttribute } from '@contember/utilities' +import { Component } from '@contember/react-binding' +import { useCurrentRequest } from '../RequestContext' + +export interface DimensionLinkProps { + dimension: string + value: string + children: ReactElement + action?: DimensionLinkAction +} + +export type DimensionLinkAction = + | 'add' + | 'toggle' + | 'set' + | 'unset' + +const emptyDim = [] as string[] + +export const DimensionLink = Component(({ dimension, value, action = 'toggle', ...props }) => { + const currentDimensionValue = useCurrentRequest()?.dimensions[dimension] ?? emptyDim + const isActive = useMemo(() => currentDimensionValue.includes(value), [currentDimensionValue, value]) + + const changeRequest = useCallback< RequestChange>(it => { + if (!it) { + return null + } + const current = it?.dimensions[dimension] ?? [] + const newValue = (() => { + switch (action) { + case 'toggle': + return current.includes(value) ? current.filter(it => it !== value) : [...current, value] + case 'set': + return [value] + case 'unset': + return current.filter(it => it !== value) + case 'add': + return [...current.filter(it => it !== value), value] + } + })() + + return { + ...it, + dimensions: { + ...it.dimensions, + [dimension]: newValue, + }, + } + }, [dimension, value, action]) + + return ( + + ) +}, ({ children }) => { + return <>{children} +}) diff --git a/packages/react-routing/src/binding/index.ts b/packages/react-routing/src/binding/index.ts index 77c068d046..5f584bd8d9 100644 --- a/packages/react-routing/src/binding/index.ts +++ b/packages/react-routing/src/binding/index.ts @@ -1,6 +1,8 @@ +export * from './DimensionLink' export * from './Link' export * from './LinkLanguage' export * from './useRedirect' export * from './useBindingLinkParametersResolver' export type { LinkTarget } from './LinkLanguage' export * from './useLinkFactory' +export * from './useDimensionState' diff --git a/packages/react-routing/src/binding/useDimensionState.tsx b/packages/react-routing/src/binding/useDimensionState.tsx new file mode 100644 index 0000000000..49c53d405b --- /dev/null +++ b/packages/react-routing/src/binding/useDimensionState.tsx @@ -0,0 +1,40 @@ +import { useEffect, useState } from 'react' +import { useCurrentRequest } from '../RequestContext' +import { useRedirect } from './useRedirect' +import { StateStorageOrName, useStoredState } from '@contember/react-utils' + + +const emptyDim = [] as string[] + +export const useDimensionState = ({ dimension, defaultValue, storage = 'null' }: { + dimension: string, + defaultValue: string | string[], + storage?: StateStorageOrName +}) => { + const currentDimensionValue = useCurrentRequest()?.dimensions[dimension] ?? emptyDim + + const [storedState, setStoredState] = useStoredState(storage, ['', `dimension.${dimension}`], it => { + const valuesArray = Array.isArray(defaultValue) ? defaultValue : [defaultValue] + return it ?? valuesArray + }) + const [initialStoredState] = useState(storedState) + const redirect = useRedirect() + + useEffect(() => { + setStoredState(currentDimensionValue) + }, [currentDimensionValue, setStoredState]) + + useEffect(() => { + if (currentDimensionValue.length === 0 && initialStoredState.length > 0) { + redirect(it => it ? { + ...it, + dimensions: { + ...it.dimensions, + [dimension]: initialStoredState, + }, + } : null) + } + }, [currentDimensionValue.length, dimension, initialStoredState, redirect]) + + return currentDimensionValue +} diff --git a/packages/react-routing/src/tsconfig.json b/packages/react-routing/src/tsconfig.json index 76e43d96f5..c96aeac599 100644 --- a/packages/react-routing/src/tsconfig.json +++ b/packages/react-routing/src/tsconfig.json @@ -5,6 +5,7 @@ }, "references": [ { "path": "../../react-binding/src" }, + { "path": "../../react-utils/src" }, { "path": "../../utilities/src" }, ] } diff --git a/yarn.lock b/yarn.lock index f35c29dc16..4ca6028c5d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1710,6 +1710,7 @@ __metadata: resolution: "@contember/react-routing@workspace:packages/react-routing" dependencies: "@contember/react-binding": "workspace:*" + "@contember/react-utils": "workspace:*" "@contember/utilities": "workspace:*" "@radix-ui/react-slot": ^1.0.2 path-to-regexp: 6.2.1