@@ -0,0 +1,42 @@
|
||||
import {MessageDescriptor} from '@common/i18n/message-descriptor';
|
||||
import {Channel} from '@common/channels/channel';
|
||||
import {ReactElement} from 'react';
|
||||
import {SvgIconProps} from '@common/icons/svg-icon';
|
||||
|
||||
export interface ChannelContentConfig {
|
||||
models: Record<
|
||||
string,
|
||||
{
|
||||
label: MessageDescriptor;
|
||||
sortMethods: string[];
|
||||
layoutMethods: string[];
|
||||
autoUpdateMethods?: string[];
|
||||
}
|
||||
>;
|
||||
sortingMethods: Record<
|
||||
string,
|
||||
{
|
||||
label: MessageDescriptor;
|
||||
contentTypes?: Channel['config']['contentType'][];
|
||||
}
|
||||
>;
|
||||
layoutMethods: Record<
|
||||
string,
|
||||
{
|
||||
label: MessageDescriptor;
|
||||
icon?: ReactElement<SvgIconProps>;
|
||||
}
|
||||
>;
|
||||
autoUpdateMethods: Record<
|
||||
string,
|
||||
{
|
||||
label: MessageDescriptor;
|
||||
provider?: string;
|
||||
value?: {
|
||||
label: MessageDescriptor;
|
||||
inputType: 'text' | 'number';
|
||||
};
|
||||
}
|
||||
>;
|
||||
userSelectableLayouts: string[];
|
||||
}
|
||||
351
common/resources/client/admin/channels/channel-editor/channel-content-editor.tsx
Executable file
351
common/resources/client/admin/channels/channel-editor/channel-content-editor.tsx
Executable file
@@ -0,0 +1,351 @@
|
||||
import {useFormContext} from 'react-hook-form';
|
||||
import {NormalizedModel} from '@common/datatable/filters/normalized-model';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {Table} from '@common/ui/tables/table';
|
||||
import {RowElementProps} from '@common/ui/tables/table-row';
|
||||
import {useIsTouchDevice} from '@common/utils/hooks/is-touch-device';
|
||||
import React, {
|
||||
cloneElement,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
useContext,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {TableContext} from '@common/ui/tables/table-context';
|
||||
import {DragPreviewRenderer} from '@common/ui/interactions/dnd/use-draggable';
|
||||
import {
|
||||
DropPosition,
|
||||
useSortable,
|
||||
} from '@common/ui/interactions/dnd/sortable/use-sortable';
|
||||
import clsx from 'clsx';
|
||||
import {mergeProps} from '@react-aria/utils';
|
||||
import {ColumnConfig} from '@common/datatable/column-config';
|
||||
import {DragHandleIcon} from '@common/icons/material/DragHandle';
|
||||
import {NameWithAvatar} from '@common/datatable/column-templates/name-with-avatar';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {CloseIcon} from '@common/icons/material/Close';
|
||||
import {DragPreview} from '@common/ui/interactions/dnd/drag-preview';
|
||||
import {WarningIcon} from '@common/icons/material/Warning';
|
||||
import {IllustratedMessage} from '@common/ui/images/illustrated-message';
|
||||
import playlist from '../playlist.svg';
|
||||
import {SvgImage} from '@common/ui/images/svg-image/svg-image';
|
||||
import {Link, useParams} from 'react-router-dom';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {RefreshIcon} from '@common/icons/material/Refresh';
|
||||
import {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';
|
||||
import {useUpdateChannelContent} from '@common/admin/channels/requests/use-update-channel-content';
|
||||
import {ChannelContentSearchFieldProps} from '@common/admin/channels/channel-editor/channel-content-search-field';
|
||||
import {useChannelContent} from '@common/channels/requests/use-channel-content';
|
||||
import {PaginationControls} from '@common/ui/navigation/pagination-controls';
|
||||
import {queryClient} from '@common/http/query-client';
|
||||
import {PaginationResponse} from '@common/http/backend-response/pagination-response';
|
||||
import {moveItemInNewArray} from '@common/utils/array/move-item-in-new-array';
|
||||
import {useReorderChannelContent} from '@common/admin/channels/requests/use-reorder-channel-content';
|
||||
import {useAddToChannel} from '@common/admin/channels/requests/use-add-to-channel';
|
||||
import {useRemoveFromChannel} from '@common/admin/channels/requests/use-remove-from-channel';
|
||||
import {ChannelContentItem} from '@common/channels/channel';
|
||||
|
||||
const columnConfig: ColumnConfig<NormalizedModel>[] = [
|
||||
{
|
||||
key: 'dragHandle',
|
||||
width: 'w-42 flex-shrink-0',
|
||||
header: () => <Trans message="Drag handle" />,
|
||||
hideHeader: true,
|
||||
body: () => (
|
||||
<DragHandleIcon className="cursor-pointer text-muted hover:text" />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
header: () => <Trans message="Content item" />,
|
||||
visibleInMode: 'all',
|
||||
body: item => {
|
||||
return (
|
||||
<NameWithAvatar
|
||||
image={item.image}
|
||||
label={
|
||||
item.model_type === 'channel' ? (
|
||||
<Link
|
||||
className="hover:underline"
|
||||
to={`/admin/channels/${item.id}/edit`}
|
||||
target="_blank"
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
) : (
|
||||
item.name
|
||||
)
|
||||
}
|
||||
description={item.description}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
header: () => <Trans message="Content type" />,
|
||||
width: 'w-100 flex-shrink-0',
|
||||
body: item => <span className="capitalize">{item.model_type}</span>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: () => <Trans message="Actions" />,
|
||||
hideHeader: true,
|
||||
align: 'end',
|
||||
width: 'w-42 flex-shrink-0',
|
||||
visibleInMode: 'all',
|
||||
body: item => <RemoveItemColumn item={item} />,
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
searchField: ReactElement<ChannelContentSearchFieldProps>;
|
||||
title?: ReactNode;
|
||||
noResultsMessage?: ReactNode;
|
||||
}
|
||||
export function ChannelContentEditor({
|
||||
searchField,
|
||||
title,
|
||||
noResultsMessage,
|
||||
}: Props) {
|
||||
const {watch, getValues} = useFormContext<UpdateChannelPayload>();
|
||||
const channel = getValues();
|
||||
const contentType = watch('config.contentType');
|
||||
const addToChannel = useAddToChannel();
|
||||
const query = useChannelContent<ChannelContentItem<NormalizedModel>>(
|
||||
channel,
|
||||
{loader: 'editChannelPage', paginate: 'simple'},
|
||||
{paginate: true},
|
||||
);
|
||||
const pagination = query.data!;
|
||||
|
||||
// only show delete and drag buttons when channel content is managed manually
|
||||
const filteredColumns = columnConfig.filter(col => {
|
||||
return !(
|
||||
contentType !== 'manual' &&
|
||||
(col.key === 'actions' || col.key === 'dragHandle')
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-40">
|
||||
<div className="mb-40">
|
||||
<h2 className="mb-10 text-2xl">
|
||||
{title || <Trans message="Channel content" />}
|
||||
</h2>
|
||||
<ContentNotEditableWarning />
|
||||
<UpdateContentButton />
|
||||
{contentType === 'manual'
|
||||
? cloneElement<ChannelContentSearchFieldProps>(searchField, {
|
||||
onResultSelected: result => {
|
||||
addToChannel.mutate({
|
||||
channelId: channel.id,
|
||||
item: result,
|
||||
});
|
||||
},
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
<PaginationControls
|
||||
pagination={query.data}
|
||||
type="simple"
|
||||
className="mb-24"
|
||||
/>
|
||||
<Table
|
||||
className="mt-24"
|
||||
columns={filteredColumns}
|
||||
data={pagination?.data || []}
|
||||
meta={query.queryKey}
|
||||
renderRowAs={contentType === 'manual' ? ContentTableRow : undefined}
|
||||
enableSelection={false}
|
||||
hideHeaderRow
|
||||
/>
|
||||
<PaginationControls
|
||||
pagination={query.data}
|
||||
type="simple"
|
||||
className="mt-24"
|
||||
scrollToTop
|
||||
/>
|
||||
{!pagination.data?.length && contentType === 'manual'
|
||||
? noResultsMessage || (
|
||||
<IllustratedMessage
|
||||
title={<Trans message="Channel is empty" />}
|
||||
description={
|
||||
<Trans message="No content is attached to this channel yet." />
|
||||
}
|
||||
image={<SvgImage src={playlist} />}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentTableRow({
|
||||
item,
|
||||
children,
|
||||
className,
|
||||
...domProps
|
||||
}: RowElementProps<NormalizedModel>) {
|
||||
const isTouchDevice = useIsTouchDevice();
|
||||
const {data, meta} = useContext(TableContext);
|
||||
const {getValues} = useFormContext<UpdateChannelPayload>();
|
||||
const domRef = useRef<HTMLTableRowElement>(null);
|
||||
const reorderContent = useReorderChannelContent();
|
||||
const previewRef = useRef<DragPreviewRenderer>(null);
|
||||
const [dropPosition, setDropPosition] = useState<DropPosition>(null);
|
||||
|
||||
const {sortableProps} = useSortable({
|
||||
ref: domRef,
|
||||
disabled: isTouchDevice ?? false,
|
||||
item,
|
||||
items: data,
|
||||
type: 'channelContentItem',
|
||||
preview: previewRef,
|
||||
strategy: 'line',
|
||||
onDropPositionChange: position => {
|
||||
setDropPosition(position);
|
||||
},
|
||||
onSortEnd: (oldIndex, newIndex) => {
|
||||
// do optimistic reorder
|
||||
const newPagination = queryClient.setQueryData<
|
||||
PaginationResponse<unknown>
|
||||
>(meta, pagination => {
|
||||
if (pagination) {
|
||||
pagination = {
|
||||
...pagination,
|
||||
data: moveItemInNewArray(pagination.data, oldIndex, newIndex),
|
||||
};
|
||||
}
|
||||
return pagination;
|
||||
});
|
||||
|
||||
// reorder on backend
|
||||
if (newPagination) {
|
||||
reorderContent.mutate({
|
||||
channelId: getValues('id'),
|
||||
modelType: item.model_type,
|
||||
ids: newPagination.data.map(item => (item as NormalizedModel).id),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
className,
|
||||
dropPosition === 'before' && 'sort-preview-before',
|
||||
dropPosition === 'after' && 'sort-preview-after',
|
||||
)}
|
||||
ref={domRef}
|
||||
{...mergeProps(sortableProps, domProps)}
|
||||
>
|
||||
{children}
|
||||
{!item.isPlaceholder && <RowDragPreview item={item} ref={previewRef} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RowDragPreviewProps {
|
||||
item: NormalizedModel;
|
||||
}
|
||||
const RowDragPreview = React.forwardRef<
|
||||
DragPreviewRenderer,
|
||||
RowDragPreviewProps
|
||||
>(({item}, ref) => {
|
||||
return (
|
||||
<DragPreview ref={ref}>
|
||||
{() => (
|
||||
<div className="rounded bg-chip p-8 text-base shadow">{item.name}</div>
|
||||
)}
|
||||
</DragPreview>
|
||||
);
|
||||
});
|
||||
|
||||
interface RemoveItemColumnProps {
|
||||
item: NormalizedModel;
|
||||
}
|
||||
function RemoveItemColumn({item}: RemoveItemColumnProps) {
|
||||
const removeFromChannel = useRemoveFromChannel();
|
||||
const {getValues} = useFormContext<UpdateChannelPayload>();
|
||||
return (
|
||||
<IconButton
|
||||
size="md"
|
||||
className="text-muted"
|
||||
disabled={removeFromChannel.isPending}
|
||||
onClick={() => {
|
||||
removeFromChannel.mutate({
|
||||
channelId: getValues('id'),
|
||||
item: item,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentNotEditableWarning() {
|
||||
const {watch} = useFormContext<UpdateChannelPayload>();
|
||||
const contentType = watch('config.contentType');
|
||||
|
||||
if (contentType === 'manual') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-20 mt-4 flex items-center gap-8">
|
||||
<WarningIcon size="xs" />
|
||||
<div className="text-xs text-muted">
|
||||
{contentType === 'listAll' ? (
|
||||
<Trans message="This channel is listing all available content of specified type, and can't be curated manually." />
|
||||
) : null}
|
||||
{contentType === 'autoUpdate' ? (
|
||||
<Trans message="This channel content is set to update automatically and can't be curated manually." />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateContentButton() {
|
||||
const {slugOrId} = useParams();
|
||||
const updateContent = useUpdateChannelContent(slugOrId!);
|
||||
const {setValue, watch, getValues} = useFormContext<UpdateChannelPayload>();
|
||||
|
||||
if (watch('config.contentType') !== 'autoUpdate') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
color="primary"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={() => {
|
||||
updateContent.mutate(
|
||||
{
|
||||
channelConfig: (getValues as any)('config'),
|
||||
},
|
||||
{
|
||||
onSuccess: response => {
|
||||
if (response.channel.content) {
|
||||
(setValue as any)('content', response.channel.content);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
}}
|
||||
disabled={
|
||||
updateContent.isPending ||
|
||||
!watch('config.autoUpdateMethod') ||
|
||||
!watch('id')
|
||||
}
|
||||
>
|
||||
<Trans message="Update content now" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import {NormalizedModel} from '@common/datatable/filters/normalized-model';
|
||||
import {useFormContext} from 'react-hook-form';
|
||||
import {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import React, {useState} from 'react';
|
||||
import {useAddableContent} from '@common/admin/channels/requests/use-addable-content';
|
||||
import {ComboBox} from '@common/ui/forms/combobox/combobox';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {SearchIcon} from '@common/icons/material/Search';
|
||||
import {Item} from '@common/ui/forms/listbox/item';
|
||||
|
||||
export interface ChannelContentSearchFieldProps {
|
||||
onResultSelected?: (result: NormalizedModel) => void;
|
||||
imgRenderer?: (result: NormalizedModel) => React.ReactNode;
|
||||
}
|
||||
export function ChannelContentSearchField({
|
||||
onResultSelected,
|
||||
imgRenderer,
|
||||
}: ChannelContentSearchFieldProps) {
|
||||
const {watch} = useFormContext<UpdateChannelPayload>();
|
||||
const contentModel = watch('config.contentModel');
|
||||
const {trans} = useTrans();
|
||||
const [query, setQuery] = useState('');
|
||||
const {isFetching, data} = useAddableContent({
|
||||
query,
|
||||
modelType: contentModel,
|
||||
limit: 20,
|
||||
});
|
||||
return (
|
||||
<ComboBox
|
||||
isAsync
|
||||
placeholder={trans(message('Search for content to add...'))}
|
||||
isLoading={isFetching}
|
||||
inputValue={query}
|
||||
onInputValueChange={setQuery}
|
||||
clearInputOnItemSelection
|
||||
blurReferenceOnItemSelection
|
||||
selectionMode="none"
|
||||
openMenuOnFocus
|
||||
floatingMaxHeight={670}
|
||||
startAdornment={<SearchIcon />}
|
||||
hideEndAdornment
|
||||
>
|
||||
{data?.results.map(result => (
|
||||
<Item
|
||||
key={result.id}
|
||||
value={result.id}
|
||||
onSelected={() => onResultSelected?.(result)}
|
||||
startIcon={imgRenderer ? imgRenderer(result) : null}
|
||||
description={result.description}
|
||||
textLabel={result.name}
|
||||
>
|
||||
{result.name}
|
||||
</Item>
|
||||
))}
|
||||
</ComboBox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import {TabList} from '@common/ui/tabs/tab-list';
|
||||
import {Tab} from '@common/ui/tabs/tab';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {TabPanel, TabPanels} from '@common/ui/tabs/tab-panels';
|
||||
import {Tabs} from '@common/ui/tabs/tabs';
|
||||
import React, {Fragment, ReactNode} from 'react';
|
||||
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
export function ChannelEditorTabs({children}: Props) {
|
||||
return (
|
||||
<Tabs isLazy>
|
||||
<TabList>
|
||||
<Tab>
|
||||
<Trans message="Details" />
|
||||
</Tab>
|
||||
<Tab>
|
||||
<Trans message="SEO" />
|
||||
</Tab>
|
||||
</TabList>
|
||||
<TabPanels className="pt-20">
|
||||
<TabPanel>{children}</TabPanel>
|
||||
<TabPanel>
|
||||
<SeoFields />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
function SeoFields() {
|
||||
return (
|
||||
<Fragment>
|
||||
<FormTextField
|
||||
name="config.seoTitle"
|
||||
label={<Trans message="SEO title" />}
|
||||
className="mb-24"
|
||||
/>
|
||||
<FormTextField
|
||||
name="config.seoDescription"
|
||||
label={<Trans message="SEO description" />}
|
||||
inputElementType="textarea"
|
||||
rows={6}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import React, {Fragment} from 'react';
|
||||
import {useFormContext} from 'react-hook-form';
|
||||
import {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';
|
||||
import {SlugEditor} from '@common/ui/slug-editor';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {message} from '@common/i18n/message';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
export function ChannelNameField({className, autoFocus}: Props) {
|
||||
return (
|
||||
<Fragment>
|
||||
<FormTextField
|
||||
name="name"
|
||||
label={<Trans message="Title" />}
|
||||
required
|
||||
autoFocus={autoFocus}
|
||||
className={clsx('mb-10', className)}
|
||||
/>
|
||||
<FormSlugField />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function FormSlugField() {
|
||||
const {watch, setValue} = useFormContext<UpdateChannelPayload>();
|
||||
const value = watch('slug');
|
||||
const name = watch('name');
|
||||
const disableSlugEditing = watch('config.lockSlug');
|
||||
const restriction = watch('config.restriction');
|
||||
const restrictionId = watch('config.restrictionModelId');
|
||||
const {trans} = useTrans();
|
||||
return (
|
||||
<SlugEditor
|
||||
hideButton={disableSlugEditing}
|
||||
placeholder={name}
|
||||
suffix={
|
||||
restriction && restrictionId === 'urlParam'
|
||||
? trans(message(':restriction_name', {values: {restriction}}))
|
||||
: undefined
|
||||
}
|
||||
className="text-sm"
|
||||
pattern="[A-Za-z0-9_-]+"
|
||||
minLength={3}
|
||||
maxLength={20}
|
||||
value={value}
|
||||
onChange={newSlug => {
|
||||
setValue('slug', newSlug);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import {FormSelect, Option} from '@common/ui/forms/select/select';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {ChannelContentConfig} from '@common/admin/channels/channel-editor/channel-content-config';
|
||||
|
||||
interface Props {
|
||||
config: ChannelContentConfig;
|
||||
className?: string;
|
||||
}
|
||||
export function ChannelPaginationTypeField({className}: Props) {
|
||||
return (
|
||||
<FormSelect
|
||||
className={className}
|
||||
selectionMode="single"
|
||||
name="config.paginationType"
|
||||
label={<Trans message="Pagination type" />}
|
||||
>
|
||||
<Option value="infiniteScroll">
|
||||
<Trans message="Infinite scroll" />
|
||||
</Option>
|
||||
<Option value="lengthAware">
|
||||
<Trans message="List of page buttons" />
|
||||
</Option>
|
||||
<Option value="simple">
|
||||
<Trans message="Next/previous page buttons only" />
|
||||
</Option>
|
||||
</FormSelect>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import {useFormContext} from 'react-hook-form';
|
||||
import {FormSelect, Option} from '@common/ui/forms/select/select';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {InfoDialogTrigger} from '@common/ui/overlays/dialog/info-dialog-trigger/info-dialog-trigger';
|
||||
import {Fragment, ReactNode} from 'react';
|
||||
import {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';
|
||||
import {ChannelContentConfig} from '@common/admin/channels/channel-editor/channel-content-config';
|
||||
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import clsx from 'clsx';
|
||||
import {ChannelsDocsLink} from '@common/admin/channels/channels-docs-link';
|
||||
|
||||
interface Props {
|
||||
children?: ReactNode;
|
||||
config: ChannelContentConfig;
|
||||
className?: string;
|
||||
}
|
||||
export function ContentAutoUpdateField({children, config, className}: Props) {
|
||||
const {watch, setValue} = useFormContext<UpdateChannelPayload>();
|
||||
const modelConfig = config.models[watch('config.contentModel')];
|
||||
const selectedMethodConfig =
|
||||
config.autoUpdateMethods[watch('config.autoUpdateMethod')!];
|
||||
|
||||
if (
|
||||
watch('config.contentType') !== 'autoUpdate' ||
|
||||
!modelConfig.autoUpdateMethods?.length
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx('items-end gap-14 md:flex', className)}>
|
||||
<FormSelect
|
||||
required
|
||||
className="flex-auto"
|
||||
selectionMode="single"
|
||||
name="config.autoUpdateMethod"
|
||||
onSelectionChange={value => {
|
||||
if (config.autoUpdateMethods[value].provider) {
|
||||
setValue(
|
||||
'config.autoUpdateProvider',
|
||||
config.autoUpdateMethods[value].provider,
|
||||
);
|
||||
}
|
||||
}}
|
||||
label={
|
||||
<Fragment>
|
||||
<Trans message="Auto update method" />
|
||||
<InfoDialogTrigger
|
||||
body={
|
||||
<Fragment>
|
||||
<div className="mb-20">
|
||||
<Trans message="This option will automatically update channel content every 24 hours using the specified method." />
|
||||
</div>
|
||||
<ChannelsDocsLink hash="automatically-update-content-with-specified-method" />
|
||||
</Fragment>
|
||||
}
|
||||
/>
|
||||
</Fragment>
|
||||
}
|
||||
>
|
||||
{modelConfig.autoUpdateMethods.map(method => (
|
||||
<Option value={method} key={method}>
|
||||
<Trans {...config.autoUpdateMethods[method].label} />
|
||||
</Option>
|
||||
))}
|
||||
</FormSelect>
|
||||
{selectedMethodConfig?.value ? (
|
||||
<FormTextField
|
||||
name="config.autoUpdateValue"
|
||||
required
|
||||
className="flex-auto"
|
||||
label={<Trans {...selectedMethodConfig?.value.label} />}
|
||||
type={selectedMethodConfig?.value.inputType}
|
||||
/>
|
||||
) : null}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {useFormContext} from 'react-hook-form';
|
||||
import {FormSelect, Option} from '@common/ui/forms/select/select';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';
|
||||
import {ReactNode} from 'react';
|
||||
import {ChannelContentConfig} from '@common/admin/channels/channel-editor/channel-content-config';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface Props {
|
||||
config: ChannelContentConfig;
|
||||
className?: string;
|
||||
}
|
||||
export function ContentLayoutFields({config, className}: Props) {
|
||||
return (
|
||||
<div className={clsx('items-end gap-14 md:flex', className)}>
|
||||
<LayoutField
|
||||
config={config}
|
||||
name="config.layout"
|
||||
label={<Trans message="Layout" />}
|
||||
/>
|
||||
<LayoutField
|
||||
config={config}
|
||||
name="config.nestedLayout"
|
||||
label={<Trans message="Layout when nested" />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LayoutFieldProps extends Props {
|
||||
name: string;
|
||||
label: ReactNode;
|
||||
}
|
||||
function LayoutField({config, name, label}: LayoutFieldProps) {
|
||||
const {watch} = useFormContext<UpdateChannelPayload>();
|
||||
const contentModel = watch('config.contentModel');
|
||||
const modelConfig = config.models[contentModel];
|
||||
|
||||
if (!modelConfig.layoutMethods?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormSelect
|
||||
className="w-full flex-auto"
|
||||
selectionMode="single"
|
||||
name={name}
|
||||
label={label}
|
||||
>
|
||||
{modelConfig.layoutMethods.map(method => {
|
||||
const label = config.layoutMethods[method].label;
|
||||
return (
|
||||
<Option key={method} value={method}>
|
||||
<Trans {...label} />
|
||||
</Option>
|
||||
);
|
||||
})}
|
||||
</FormSelect>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import {useFormContext} from 'react-hook-form';
|
||||
import {FormSelect, Option} from '@common/ui/forms/select/select';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';
|
||||
import React from 'react';
|
||||
import {ChannelContentConfig} from '@common/admin/channels/channel-editor/channel-content-config';
|
||||
|
||||
interface Props {
|
||||
config: ChannelContentConfig;
|
||||
className?: string;
|
||||
exclude?: string[];
|
||||
}
|
||||
export function ContentModelField({config, className, exclude}: Props) {
|
||||
const {setValue, getValues} = useFormContext<UpdateChannelPayload>();
|
||||
return (
|
||||
<FormSelect
|
||||
className={className}
|
||||
selectionMode="single"
|
||||
name="config.contentModel"
|
||||
label={<Trans message="Type of content" />}
|
||||
onSelectionChange={newValue => {
|
||||
const modelConfig = config.models[newValue];
|
||||
if (
|
||||
getValues('config.contentType') === 'autoUpdate' &&
|
||||
!modelConfig.autoUpdateMethods?.length
|
||||
) {
|
||||
(setValue as any)('config.contentType', 'manual');
|
||||
}
|
||||
setValue('config.autoUpdateMethod', modelConfig.autoUpdateMethods?.[0]);
|
||||
setValue(
|
||||
'config.contentOrder',
|
||||
modelConfig.sortMethods[0] || 'channelables.order:asc',
|
||||
);
|
||||
setValue('config.layout', modelConfig.layoutMethods[0]);
|
||||
}}
|
||||
>
|
||||
{Object.entries(config.models)
|
||||
.filter(([model]) => !exclude?.includes(model))
|
||||
.map(([model, {label}]) => (
|
||||
<Option value={model} key={model}>
|
||||
<Trans {...label} />
|
||||
</Option>
|
||||
))}
|
||||
</FormSelect>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {useFormContext} from 'react-hook-form';
|
||||
import {FormSelect, Option} from '@common/ui/forms/select/select';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';
|
||||
import {ChannelContentConfig} from '@common/admin/channels/channel-editor/channel-content-config';
|
||||
|
||||
interface Props {
|
||||
config: ChannelContentConfig;
|
||||
className?: string;
|
||||
}
|
||||
export function ContentOrderField({config, className}: Props) {
|
||||
const {watch} = useFormContext<UpdateChannelPayload>();
|
||||
const contentType = watch('config.contentType');
|
||||
const modelConfig = config.models[watch('config.contentModel')];
|
||||
const sortMethods = [...modelConfig.sortMethods, 'channelables.order:asc'];
|
||||
|
||||
return (
|
||||
<FormSelect
|
||||
className={className}
|
||||
selectionMode="single"
|
||||
name="config.contentOrder"
|
||||
label={<Trans message="How to order content" />}
|
||||
>
|
||||
{sortMethods.map(method => {
|
||||
const sortConfig = config.sortingMethods[method];
|
||||
if (
|
||||
!sortConfig.contentTypes ||
|
||||
sortConfig.contentTypes.includes(contentType)
|
||||
) {
|
||||
return (
|
||||
<Option value={method} key={method}>
|
||||
<Trans {...sortConfig.label} />
|
||||
</Option>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</FormSelect>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {useFormContext} from 'react-hook-form';
|
||||
import {FormSelect, Option} from '@common/ui/forms/select/select';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';
|
||||
import {ChannelContentConfig} from '@common/admin/channels/channel-editor/channel-content-config';
|
||||
|
||||
interface Props {
|
||||
config: ChannelContentConfig;
|
||||
className?: string;
|
||||
}
|
||||
export function ContentTypeField({config, className}: Props) {
|
||||
const {setValue} = useFormContext<UpdateChannelPayload>();
|
||||
return (
|
||||
<FormSelect
|
||||
className={className}
|
||||
selectionMode="single"
|
||||
name="config.contentType"
|
||||
label={<Trans message="Content" />}
|
||||
onSelectionChange={newValue => {
|
||||
// if content type is "auto update" select first model that
|
||||
// can be auto updated, otherwise select first available model
|
||||
let model = Object.entries(config.models)[0];
|
||||
if (newValue === 'autoUpdate') {
|
||||
const newModel = Object.entries(config.models).find(
|
||||
([, modelConfig]) => modelConfig.autoUpdateMethods?.length,
|
||||
);
|
||||
if (newModel) {
|
||||
model = newModel;
|
||||
}
|
||||
}
|
||||
const [modelName, modelConfig] = model;
|
||||
|
||||
setValue('config.contentModel', modelName);
|
||||
setValue('config.restrictionModelId', undefined);
|
||||
setValue(
|
||||
'config.autoUpdateMethod',
|
||||
newValue === 'autoUpdate' ? modelConfig.autoUpdateMethods?.[0] : '',
|
||||
);
|
||||
setValue('config.contentOrder', modelConfig.sortMethods[0]);
|
||||
(setValue as any)('config.restriction', null);
|
||||
}}
|
||||
>
|
||||
<Option value="listAll">
|
||||
<Trans message="List all content of specified type" />
|
||||
</Option>
|
||||
<Option value="manual">
|
||||
<Trans message="Manage content manually" />
|
||||
</Option>
|
||||
<Option value="autoUpdate">
|
||||
<Trans message="Automatically update content with specified method" />
|
||||
</Option>
|
||||
</FormSelect>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {useForm} from 'react-hook-form';
|
||||
import React, {ReactNode} from 'react';
|
||||
import {CrupdateResourceLayout} from '@common/admin/crupdate-resource-layout';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {EMPTY_PAGINATION_RESPONSE} from '@common/http/backend-response/pagination-response';
|
||||
import {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';
|
||||
import {useCreateChannel} from '@common/admin/channels/requests/use-create-channel';
|
||||
|
||||
interface Props {
|
||||
defaultValues?: Partial<UpdateChannelPayload['config']>;
|
||||
children: ReactNode;
|
||||
}
|
||||
export function CreateChannelPageLayout({defaultValues, children}: Props) {
|
||||
const form = useForm<UpdateChannelPayload>({
|
||||
defaultValues: {
|
||||
content: EMPTY_PAGINATION_RESPONSE.pagination,
|
||||
config: {
|
||||
contentType: 'listAll',
|
||||
contentOrder: 'created_at:desc',
|
||||
nestedLayout: 'carousel',
|
||||
...defaultValues,
|
||||
},
|
||||
},
|
||||
});
|
||||
const createChannel = useCreateChannel(form);
|
||||
|
||||
return (
|
||||
<CrupdateResourceLayout
|
||||
form={form}
|
||||
onSubmit={values => {
|
||||
createChannel.mutate(values);
|
||||
}}
|
||||
title={<Trans message="Add new channel" />}
|
||||
isLoading={createChannel.isPending}
|
||||
>
|
||||
{children}
|
||||
</CrupdateResourceLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {useForm} from 'react-hook-form';
|
||||
import React, {ReactNode} from 'react';
|
||||
import {CrupdateResourceLayout} from '@common/admin/crupdate-resource-layout';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {PageStatus} from '@common/http/page-status';
|
||||
import {useChannel} from '@common/channels/requests/use-channel';
|
||||
import {Channel} from '@common/channels/channel';
|
||||
import {
|
||||
UpdateChannelPayload,
|
||||
useUpdateChannel,
|
||||
} from '@common/admin/channels/requests/use-update-channel';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
export function EditChannelPageLayout({children}: Props) {
|
||||
const query = useChannel(undefined, 'editChannelPage');
|
||||
if (query.data) {
|
||||
return <PageContent channel={query.data.channel}>{children}</PageContent>;
|
||||
}
|
||||
return <PageStatus query={query} loaderIsScreen={false} />;
|
||||
}
|
||||
|
||||
interface PageContentProps {
|
||||
channel: Channel;
|
||||
children: ReactNode;
|
||||
}
|
||||
function PageContent({channel, children}: PageContentProps) {
|
||||
const form = useForm<UpdateChannelPayload>({
|
||||
// @ts-ignore
|
||||
defaultValues: {
|
||||
...channel,
|
||||
},
|
||||
});
|
||||
const updateChannel = useUpdateChannel(form);
|
||||
|
||||
return (
|
||||
<CrupdateResourceLayout
|
||||
form={form}
|
||||
onSubmit={values => {
|
||||
updateChannel.mutate(values);
|
||||
}}
|
||||
title={
|
||||
<Trans message="Edit “:name“ channel" values={{name: channel.name}} />
|
||||
}
|
||||
isLoading={updateChannel.isPending}
|
||||
>
|
||||
{children}
|
||||
</CrupdateResourceLayout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user