diff --git a/apps/client-server/src/app/form-generator/dtos/form-generation-request.dto.ts b/apps/client-server/src/app/form-generator/dtos/form-generation-request.dto.ts index 164b8bf07..abd6aee21 100644 --- a/apps/client-server/src/app/form-generator/dtos/form-generation-request.dto.ts +++ b/apps/client-server/src/app/form-generator/dtos/form-generation-request.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { IFormGenerationRequestDto, SubmissionType } from '@postybirb/types'; -import { IsEnum, IsString } from 'class-validator'; +import { IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator'; export class FormGenerationRequestDto implements IFormGenerationRequestDto { @ApiProperty() @@ -10,4 +10,9 @@ export class FormGenerationRequestDto implements IFormGenerationRequestDto { @ApiProperty({ enum: SubmissionType }) @IsEnum(SubmissionType) type: SubmissionType; + + @ApiProperty() + @IsOptional() + @IsBoolean() + isMultiSubmission?: boolean; } diff --git a/apps/client-server/src/app/form-generator/form-generator.controller.ts b/apps/client-server/src/app/form-generator/form-generator.controller.ts index 7fc16bf7d..c9577162c 100644 --- a/apps/client-server/src/app/form-generator/form-generator.controller.ts +++ b/apps/client-server/src/app/form-generator/form-generator.controller.ts @@ -21,7 +21,7 @@ export class FormGeneratorController { }) getFormForWebsite(@Body() request: FormGenerationRequestDto) { return request.accountId === NULL_ACCOUNT_ID - ? this.service.getDefaultForm(request.type) + ? this.service.getDefaultForm(request.type, request.isMultiSubmission) : this.service.generateForm(request); } } diff --git a/apps/client-server/src/app/form-generator/form-generator.service.ts b/apps/client-server/src/app/form-generator/form-generator.service.ts index 2af06cd91..a269ffe86 100644 --- a/apps/client-server/src/app/form-generator/form-generator.service.ts +++ b/apps/client-server/src/app/form-generator/form-generator.service.ts @@ -66,19 +66,33 @@ export class FormGeneratorService { } const form = formBuilder(formModel, data); - return this.populateUserDefaults(form, request.accountId, request.type); + const formWithPopulatedDefaults = await this.populateUserDefaults( + form, + request.accountId, + request.type, + ); + + if (request.isMultiSubmission) { + delete formWithPopulatedDefaults.title; // Having title here just causes confusion for multi this flow + } + + return formWithPopulatedDefaults; } /** * Returns the default fields form. * @param {SubmissionType} type */ - getDefaultForm(type: SubmissionType) { - return this.populateUserDefaults( + async getDefaultForm(type: SubmissionType, isMultiSubmission = false) { + const form = await this.populateUserDefaults( formBuilder(new DefaultWebsiteOptions(), {}), new NullAccount().id, type, ); + if (isMultiSubmission) { + delete form.title; // Having title here just causes confusion for multi this flow + } + return form; } private async populateUserDefaults( diff --git a/apps/client-server/src/app/submission/dtos/apply-multi-submission.dto.ts b/apps/client-server/src/app/submission/dtos/apply-multi-submission.dto.ts new file mode 100644 index 000000000..a6fb57f11 --- /dev/null +++ b/apps/client-server/src/app/submission/dtos/apply-multi-submission.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IApplyMultiSubmissionDto, SubmissionId } from '@postybirb/types'; +import { IsArray, IsBoolean, IsNotEmpty, IsString } from 'class-validator'; + +export class ApplyMultiSubmissionDto implements IApplyMultiSubmissionDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + originId: SubmissionId; + + @ApiProperty() + @IsArray() + @IsString({ each: true }) + @IsNotEmpty() + submissionIds: SubmissionId[]; + + @ApiProperty() + @IsBoolean() + merge: boolean; +} diff --git a/apps/client-server/src/app/submission/dtos/create-submission.dto.ts b/apps/client-server/src/app/submission/dtos/create-submission.dto.ts index b547d9142..cb755260a 100644 --- a/apps/client-server/src/app/submission/dtos/create-submission.dto.ts +++ b/apps/client-server/src/app/submission/dtos/create-submission.dto.ts @@ -17,4 +17,9 @@ export class CreateSubmissionDto implements ICreateSubmissionDto { @IsOptional() @IsBoolean() isTemplate?: boolean; + + @ApiProperty() + @IsOptional() + @IsBoolean() + isMultiSubmission?: boolean; } diff --git a/apps/client-server/src/app/submission/services/submission.service.ts b/apps/client-server/src/app/submission/services/submission.service.ts index 926259cbb..22ca10837 100644 --- a/apps/client-server/src/app/submission/services/submission.service.ts +++ b/apps/client-server/src/app/submission/services/submission.service.ts @@ -6,6 +6,7 @@ import { Inject, Injectable, NotFoundException, + OnModuleInit, Optional, forwardRef, } from '@nestjs/common'; @@ -42,6 +43,7 @@ import { MulterFileInfo } from '../../file/models/multer-file-info'; import { IsTestEnvironment } from '../../utils/test.util'; import { WSGateway } from '../../web-socket/web-socket-gateway'; import { WebsiteOptionsService } from '../../website-options/website-options.service'; +import { ApplyMultiSubmissionDto } from '../dtos/apply-multi-submission.dto'; import { CreateSubmissionDto } from '../dtos/create-submission.dto'; import { UpdateSubmissionTemplateNameDto } from '../dtos/update-submission-template-name.dto'; import { UpdateSubmissionDto } from '../dtos/update-submission.dto'; @@ -55,7 +57,10 @@ type SubmissionEntity = Submission; * @class SubmissionService */ @Injectable() -export class SubmissionService extends PostyBirbService { +export class SubmissionService + extends PostyBirbService + implements OnModuleInit +{ constructor( dbSubscriber: DatabaseUpdateSubscriber, @InjectRepository(Submission) @@ -86,6 +91,12 @@ export class SubmissionService extends PostyBirbService { ); } + onModuleInit() { + Object.values(SubmissionType).forEach((type) => { + this.populateMultiSubmission(type); + }); + } + /** * Emits submissions onto websocket. */ @@ -116,6 +127,18 @@ export class SubmissionService extends PostyBirbService { ); } + private async populateMultiSubmission(type: SubmissionType) { + const existing = await this.repository.findOne({ + type, + metadata: { isMultiSubmission: true }, + }); + if (existing) { + return; + } + + await this.create({ name: type, type, isMultiSubmission: true }); + } + /** * Creates a submission. * @@ -144,6 +167,11 @@ export class SubmissionService extends PostyBirbService { }; } + if (createSubmissionDto.isMultiSubmission) { + submission.metadata.isMultiSubmission = true; + submission.id = `MULTI-${submission.type}`; + } + let name = 'New submission'; if (createSubmissionDto.name) { name = createSubmissionDto.name; @@ -177,7 +205,10 @@ export class SubmissionService extends PostyBirbService { } case SubmissionType.FILE: { - if (createSubmissionDto.isTemplate) { + if ( + createSubmissionDto.isTemplate || + createSubmissionDto.isMultiSubmission + ) { // Don't need to populate on a template break; } @@ -341,6 +372,67 @@ export class SubmissionService extends PostyBirbService { this.emit(); } + async applyMultiSubmission(applyMultiSubmissionDto: ApplyMultiSubmissionDto) { + const { originId, submissionIds, merge } = applyMultiSubmissionDto; + const origin = await this.repository.findOneOrFail({ id: originId }); + const submissions = await this.repository.find({ + id: { $in: submissionIds }, + }); + if (merge) { + // Keeps unique options, overwrites overlapping options\ + // eslint-disable-next-line no-restricted-syntax + for (const submission of submissions) { + // eslint-disable-next-line no-restricted-syntax + for (const option of origin.options.getItems()) { + const existingOption = submission.options + .getItems() + .find((o) => o.account.id === option.account.id); + if (existingOption) { + // Don't overwrite set title + const opts = { ...option.data, title: existingOption.data.title }; + existingOption.data = opts; + } else { + submission.options.add( + await this.websiteOptionsService.createOption( + submission, + option.account.id, + option.data, + option.isDefault ? undefined : option.data.title, + ), + ); + } + } + } + } else { + // Removes all options not included in the origin submission + // eslint-disable-next-line no-restricted-syntax + for (const submission of submissions) { + const items = submission.options.getItems(); + const defaultOptions = items.find((option) => option.isDefault); + const defaultTitle = defaultOptions?.data.title; + submission.options.removeAll(); + // eslint-disable-next-line no-restricted-syntax + for (const option of origin.options.getItems()) { + const opts = { ...option.data }; + if (option.isDefault) { + opts.title = defaultTitle; + } + submission.options.add( + await this.websiteOptionsService.createOption( + submission, + option.account.id, + opts, + option.isDefault ? defaultTitle : option.data.title, + ), + ); + } + } + } + + await this.repository.persistAndFlush(submissions); + this.emit(); + } + /** * Duplicates a submission. * !Somewhat janky method of doing a clone. diff --git a/apps/client-server/src/app/submission/submission.controller.ts b/apps/client-server/src/app/submission/submission.controller.ts index a804ec3d5..c5718a35b 100644 --- a/apps/client-server/src/app/submission/submission.controller.ts +++ b/apps/client-server/src/app/submission/submission.controller.ts @@ -21,6 +21,7 @@ import { parse } from 'path'; import { PostyBirbController } from '../common/controller/postybirb-controller'; import { Submission } from '../database/entities'; import { MulterFileInfo } from '../file/models/multer-file-info'; +import { ApplyMultiSubmissionDto } from './dtos/apply-multi-submission.dto'; import { CreateSubmissionDto } from './dtos/create-submission.dto'; import { UpdateSubmissionTemplateNameDto } from './dtos/update-submission-template-name.dto'; import { UpdateSubmissionDto } from './dtos/update-submission.dto'; @@ -115,4 +116,11 @@ export class SubmissionController extends PostyBirbController { .updateTemplateName(id, updateSubmissionDto) .then((entity) => entity.toJSON()); } + + @Patch('apply/multi') + @ApiOkResponse({ description: 'Submission applied to multiple submissions.' }) + @ApiNotFoundResponse({ description: 'Submission Id not found.' }) + async applyMulti(@Body() applyMultiSubmissionDto: ApplyMultiSubmissionDto) { + return this.service.applyMultiSubmission(applyMultiSubmissionDto); + } } diff --git a/apps/client-server/src/app/validation/validation.service.ts b/apps/client-server/src/app/validation/validation.service.ts index 1fb725657..49bea483d 100644 --- a/apps/client-server/src/app/validation/validation.service.ts +++ b/apps/client-server/src/app/validation/validation.service.ts @@ -40,7 +40,6 @@ export class ValidationService { public async validateSubmission( submission: ISubmission, ): Promise { - this.logger.debug(`Validating submission ${submission.id}`); return Promise.all( submission.options.map((website) => this.validate(submission, website)), ); @@ -58,12 +57,6 @@ export class ValidationService { websiteOption: IWebsiteOptions, ): Promise { try { - this.logger.debug( - `Validating submission ${submission.id} with website ${websiteOption.account.id} (${websiteOption.id})`, - ); - // TODO figure out why the instances cannot be found - // TODO consider removing the debounce you added to the submission service since the behavior is scuffed visually on the ui - // this will mean fixing the underlying issue of insertion time const website = websiteOption.isDefault ? new DefaultWebsite(websiteOption.account) : this.websiteRegistry.findInstance(websiteOption.account); diff --git a/apps/postybirb-ui/src/api/submission.api.ts b/apps/postybirb-ui/src/api/submission.api.ts index bdfbc4c5a..fa61e4d15 100644 --- a/apps/postybirb-ui/src/api/submission.api.ts +++ b/apps/postybirb-ui/src/api/submission.api.ts @@ -1,4 +1,5 @@ import { + IApplyMultiSubmissionDto, ICreateSubmissionDto, ISubmissionDto, IUpdateSubmissionDto, @@ -44,6 +45,10 @@ class SubmissionsApi extends BaseApi< reorder(id: SubmissionId, index: number) { return this.client.patch(`reorder/${id}/${index}`); } + + applyToMultipleSubmissions(dto: IApplyMultiSubmissionDto) { + return this.client.patch('apply/multi', dto); + } } export default new SubmissionsApi(); diff --git a/apps/postybirb-ui/src/app/postybirb-layout/drawers/account-drawer/website-login-panel/website-login-panel.tsx b/apps/postybirb-ui/src/app/postybirb-layout/drawers/account-drawer/website-login-panel/website-login-panel.tsx index 29cb5f2dc..14452ba50 100644 --- a/apps/postybirb-ui/src/app/postybirb-layout/drawers/account-drawer/website-login-panel/website-login-panel.tsx +++ b/apps/postybirb-ui/src/app/postybirb-layout/drawers/account-drawer/website-login-panel/website-login-panel.tsx @@ -35,6 +35,7 @@ function LoginPanel(props: Omit) { } return ( + // eslint-disable-next-line lingui/no-unlocalized-strings {loginMethod} diff --git a/apps/postybirb-ui/src/components/form/fields/tag-field.tsx b/apps/postybirb-ui/src/components/form/fields/tag-field.tsx index f2e1a10e7..05c0d0d00 100644 --- a/apps/postybirb-ui/src/components/form/fields/tag-field.tsx +++ b/apps/postybirb-ui/src/components/form/fields/tag-field.tsx @@ -64,6 +64,7 @@ export function TagField(props: FormFieldProps): JSX.Element { /> )} ( formGeneratorApi - .getForm({ accountId: account, type: submission.type }) + .getForm({ + accountId: account, + type: submission.type, + isMultiSubmission: submission.isMultiSubmission(), + }) .then((res) => res.body), ); const defaultOption = submission.getDefaultOptions(); diff --git a/apps/postybirb-ui/src/components/form/website-select/website-select.tsx b/apps/postybirb-ui/src/components/form/website-select/website-select.tsx index 681fdd8d1..8970e19f1 100644 --- a/apps/postybirb-ui/src/components/form/website-select/website-select.tsx +++ b/apps/postybirb-ui/src/components/form/website-select/website-select.tsx @@ -1,6 +1,7 @@ import { Trans } from '@lingui/macro'; -import { ComboboxItemGroup, MultiSelect } from '@mantine/core'; +import { ComboboxItemGroup, Group, MultiSelect } from '@mantine/core'; import { IAccountDto, NULL_ACCOUNT_ID } from '@postybirb/types'; +import { IconCheck } from '@tabler/icons-react'; import { useMemo, useState } from 'react'; import { useWebsites } from '../../../hooks/account/use-websites'; import { SubmissionDto } from '../../../models/dtos/submission.dto'; @@ -21,7 +22,7 @@ export function WebsiteSelect(props: WebsiteSelectProps) { filteredAccounts.map((website) => ({ group: website.displayName, items: website.accounts.map((account) => ({ - label: account.name, + label: `[${website.displayName}] ${account.name}`, value: account.id, })), })), @@ -57,6 +58,15 @@ export function WebsiteSelect(props: WebsiteSelectProps) { onCommitChanges(selectedAccounts, true); setIsOpen(false); }} + renderOption={(item) => { + const label = item.option.label.split('] ')[1]; + return ( + + {item.checked ? : null} + {label} + + ); + }} /> ); } diff --git a/apps/postybirb-ui/src/components/submission-templates/template-picker-modal/template-picker-modal.tsx b/apps/postybirb-ui/src/components/submission-templates/template-picker-modal/template-picker-modal.tsx index ffccf1936..1d98a2737 100644 --- a/apps/postybirb-ui/src/components/submission-templates/template-picker-modal/template-picker-modal.tsx +++ b/apps/postybirb-ui/src/components/submission-templates/template-picker-modal/template-picker-modal.tsx @@ -223,6 +223,7 @@ export default function TemplatePickerModal(props: TemplatePickerModalProps) { // On first option pick if (!selectedWebsiteOptions && newOpts.length) { const sub: Record = {}; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const template = [...templates, ...submissions].find( (t) => t.id === newOpts[0], ); diff --git a/apps/postybirb-ui/src/components/submissions/submission-edit-form/submission-edit-form.tsx b/apps/postybirb-ui/src/components/submissions/submission-edit-form/submission-edit-form.tsx index f0ffc523c..e8b3fb482 100644 --- a/apps/postybirb-ui/src/components/submissions/submission-edit-form/submission-edit-form.tsx +++ b/apps/postybirb-ui/src/components/submissions/submission-edit-form/submission-edit-form.tsx @@ -43,7 +43,8 @@ export function SubmissionEditForm(props: SubmissionEditFormProps) { const { state: accounts, isLoading } = useStore(AccountStore); const defaultOption = submission.getDefaultOptions(); - const isTemplate = submission.isTemplate(); + const isSpecialSubmissionType = + submission.isMultiSubmission() || submission.isTemplate(); const top = 109; const optionsGroupedByWebsiteId = useMemo( @@ -125,7 +126,7 @@ export function SubmissionEditForm(props: SubmissionEditFormProps) { const debouncedUpdate = useCallback( debounce((schedule: ISubmissionScheduleInfo) => { submissionApi.update(submission.id, { - isScheduled: isTemplate ? false : submission.isScheduled, + isScheduled: isSpecialSubmissionType ? false : submission.isScheduled, scheduledFor: schedule.scheduledFor, scheduleType: schedule.scheduleType, deletedWebsiteOptions: [], @@ -143,12 +144,12 @@ export function SubmissionEditForm(props: SubmissionEditFormProps) { return ( - {!isTemplate && submission.type === SubmissionType.FILE ? ( + {!isSpecialSubmissionType && submission.type === SubmissionType.FILE ? ( } /> ) : null} - {!isTemplate ? ( + {!isSpecialSubmissionType ? ( Schedule}> void; + onApply: (submissions: SubmissionId[]) => void; +}; + +export function SubmissionPickerModal( + props: PropsWithChildren, +) { + const { children, type, onClose, onApply } = props; + const [selectedSubmissions, setSelectedSubmissions] = useState< + SubmissionId[] + >([]); + return ( + + Choose Submissions + + } + > + + + {children} + + + + + + + ); +} diff --git a/apps/postybirb-ui/src/components/submissions/submission-picker/submission-picker.tsx b/apps/postybirb-ui/src/components/submissions/submission-picker/submission-picker.tsx new file mode 100644 index 000000000..f01acfe2e --- /dev/null +++ b/apps/postybirb-ui/src/components/submissions/submission-picker/submission-picker.tsx @@ -0,0 +1,85 @@ +import { msg, Trans } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { + Box, + ComboboxItem, + Group, + Image, + Loader, + MultiSelect, +} from '@mantine/core'; +import { SubmissionId, SubmissionType } from '@postybirb/types'; +import { IconCheck, IconFile } from '@tabler/icons-react'; +import { SubmissionDto } from '../../../models/dtos/submission.dto'; +import { SubmissionStore } from '../../../stores/submission.store'; +import { useStore } from '../../../stores/use-store'; +import { defaultTargetProvider } from '../../../transports/http-client'; + +type SubmissionPickerProps = { + type: SubmissionType; + value: SubmissionId[]; + onChange: (value: SubmissionId[]) => void; +}; + +function getSubmissionLabel( + submission: SubmissionDto, + label: string, +): JSX.Element { + if (submission.type === SubmissionType.MESSAGE) { + return {label}; + } + + const { files } = submission; + const src = files.length + ? `${defaultTargetProvider()}/api/file/thumbnail/${files[0].id}` + : null; + return ( + + {src ? ( + + ) : ( + + )}{' '} + {label} + + ); +} + +export function SubmissionPicker(props: SubmissionPickerProps) { + const { type, value, onChange } = props; + const { _ } = useLingui(); + const { state, isLoading } = useStore(SubmissionStore); + const submissions = state.filter((submission) => submission.type === type); + + const submissionOptions: ComboboxItem[] = submissions.map((submission) => ({ + label: submission.getDefaultOptions().data.title ?? _(msg`Unknown`), + value: submission.id, + })); + + if (isLoading) { + return ; + } + + return ( + Submissions} + data={submissionOptions} + value={value} + onChange={onChange} + renderOption={(item) => { + const submission = submissions.find((s) => s.id === item.option.value); + if (!submission) { + return undefined; + } + return ( + + {item.checked ? : null} + {getSubmissionLabel(submission, item.option.label)} + + ); + }} + /> + ); +} diff --git a/apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-actions/multi-edit-submissions-action.tsx b/apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-actions/multi-edit-submissions-action.tsx new file mode 100644 index 000000000..10ab6b491 --- /dev/null +++ b/apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-actions/multi-edit-submissions-action.tsx @@ -0,0 +1,26 @@ +import { Trans } from '@lingui/macro'; +import { Button } from '@mantine/core'; +import { useCallback } from 'react'; +import { useNavigate } from 'react-router'; +import { MultiEditSubmissionPath } from '../../../../../pages/route-paths'; +import { SubmissionViewActionProps } from './submission-view-actions.props'; + +export function MultiEditSubmissionsAction({ + type, + submissions, +}: SubmissionViewActionProps) { + const navigateTo = useNavigate(); + const navigate = useCallback(() => { + navigateTo(`${MultiEditSubmissionPath}/${type}`); + }, [navigateTo, type]); + return ( + + ); +} diff --git a/apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-actions/submission-view-actions.tsx b/apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-actions/submission-view-actions.tsx index cf2b0a5ad..3b96c99ca 100644 --- a/apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-actions/submission-view-actions.tsx +++ b/apps/postybirb-ui/src/components/submissions/submission-view/submission-view-card/submission-view-actions/submission-view-actions.tsx @@ -1,4 +1,3 @@ -/* eslint-disable no-nested-ternary */ import { msg } from '@lingui/macro'; import { useLingui } from '@lingui/react'; import { Box, Flex, Group, Input, Paper } from '@mantine/core'; @@ -7,6 +6,7 @@ import { IconSearch } from '@tabler/icons-react'; import { SubmissionDto } from '../../../../../models/dtos/submission.dto'; import { ApplySubmissionTemplateAction } from './apply-submission-template-action'; import { DeleteSubmissionsAction } from './delete-submissions-action'; +import { MultiEditSubmissionsAction } from './multi-edit-submissions-action'; import { PostSelectedSubmissionsActions } from './post-selected-submissions-action'; import { ScheduleSubmissionsActions } from './schedule-submissions-action'; import { SelectSubmissionsAction } from './select-submissions-action'; @@ -50,6 +50,7 @@ export function SubmissionViewActions(props: SubmissionViewActionsProps) { + !o.isDefault) .reduce( (acc, option) => { - const account = accounts.find((a) => a.id === option.account)!; + const account = accounts.find((a) => a.id === option.account); + if (!account) { + return acc; + } const websiteId = account.website; if (!acc[websiteId]) { acc[websiteId] = { diff --git a/apps/postybirb-ui/src/components/submissions/submission-view/submission-view.tsx b/apps/postybirb-ui/src/components/submissions/submission-view/submission-view.tsx index 691b8daee..39009f498 100644 --- a/apps/postybirb-ui/src/components/submissions/submission-view/submission-view.tsx +++ b/apps/postybirb-ui/src/components/submissions/submission-view/submission-view.tsx @@ -14,6 +14,10 @@ function filterSubmissions( submissions: SubmissionDto[], filter: string, ): SubmissionDto[] { + if (!filter) { + return submissions; + } + const filterValue = filter.toLowerCase().trim(); return submissions.filter((submission) => { const defaultOption = submission.getDefaultOptions(); diff --git a/apps/postybirb-ui/src/helpers/sortable.helper.ts b/apps/postybirb-ui/src/helpers/sortable.helper.ts index 5fbae9e92..04a0e547f 100644 --- a/apps/postybirb-ui/src/helpers/sortable.helper.ts +++ b/apps/postybirb-ui/src/helpers/sortable.helper.ts @@ -1,7 +1,7 @@ import type Sortable from 'sortablejs'; export function draggableIndexesAreDefined( - event: Sortable.SortableEvent + event: Sortable.SortableEvent, ): event is { /** * Old index within parent, only counting draggable elements diff --git a/apps/postybirb-ui/src/main.tsx b/apps/postybirb-ui/src/main.tsx index 8e47b5c0e..69dc243e9 100644 --- a/apps/postybirb-ui/src/main.tsx +++ b/apps/postybirb-ui/src/main.tsx @@ -1,57 +1,16 @@ import { createRoot } from 'react-dom/client'; -import { RouterProvider, createBrowserRouter } from 'react-router-dom'; +import { RouterProvider } from 'react-router-dom'; import App from './app/app'; -import HomePage from './pages/home/home-page'; -import NotFound from './pages/not-found/not-found'; -import { SubmissionsPath } from './pages/route-paths'; -import { EditSubmissionPage } from './pages/submission/edit-submission-page'; -import { FileSubmissionManagementPage } from './pages/submission/file-submission-management-page'; -import MessageSubmissionManagementPage from './pages/submission/message-submission-management-page'; -import SubmissionOutletPage from './pages/submission/submission-outlet-page'; +import { CreateRouter } from './pages/routes'; import './styles.css'; function Root() { return ; } -const router = createBrowserRouter([ - { - path: '/', - element: , - children: [ - { - path: '', - element: , - }, - { - path: SubmissionsPath, - element: , - children: [ - { - path: 'message', - element: , - }, - { - path: 'file', - element: , - }, - { - path: 'edit/:id', - element: , - }, - ], - }, - ], - }, - { - path: '*', - element: , - }, -]); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion createRoot(document.getElementById('root')!).render( - , + )} />, ); declare global { diff --git a/apps/postybirb-ui/src/models/dtos/submission.dto.ts b/apps/postybirb-ui/src/models/dtos/submission.dto.ts index 1121f5ed1..0d264396e 100644 --- a/apps/postybirb-ui/src/models/dtos/submission.dto.ts +++ b/apps/postybirb-ui/src/models/dtos/submission.dto.ts @@ -122,6 +122,10 @@ export class SubmissionDto< return Boolean(this.metadata.template); } + public isMultiSubmission(): boolean { + return Boolean(this.metadata.isMultiSubmission); + } + public getTemplateName() { return this.metadata.template?.name ?? i18n.t(msg`Template`); } diff --git a/apps/postybirb-ui/src/pages/route-paths.ts b/apps/postybirb-ui/src/pages/route-paths.ts index 7a71f74bc..76badf7cd 100644 --- a/apps/postybirb-ui/src/pages/route-paths.ts +++ b/apps/postybirb-ui/src/pages/route-paths.ts @@ -3,3 +3,4 @@ export const SubmissionsPath = '/submission'; export const FileSubmissionPath = `${SubmissionsPath}/file`; export const MessageSubmissionPath = `${SubmissionsPath}/message`; export const EditSubmissionPath = `${SubmissionsPath}/edit`; +export const MultiEditSubmissionPath = `${SubmissionsPath}/multi-edit`; diff --git a/apps/postybirb-ui/src/pages/routes.tsx b/apps/postybirb-ui/src/pages/routes.tsx new file mode 100644 index 000000000..1726132bc --- /dev/null +++ b/apps/postybirb-ui/src/pages/routes.tsx @@ -0,0 +1,49 @@ +import { createBrowserRouter } from 'react-router-dom'; +import HomePage from './home/home-page'; +import NotFound from './not-found/not-found'; +import { SubmissionsPath } from './route-paths'; +import { EditSubmissionPage } from './submission/edit-submission-page'; +import { FileSubmissionManagementPage } from './submission/file-submission-management-page'; +import MessageSubmissionManagementPage from './submission/message-submission-management-page'; +import { MultiEditSubmissionPage } from './submission/multi-edit-submission-page'; +import SubmissionOutletPage from './submission/submission-outlet-page'; + +export const CreateRouter = (root: JSX.Element) => + createBrowserRouter([ + { + path: '/', + element: root, + children: [ + { + path: '', + element: , + }, + { + path: SubmissionsPath, + element: , + children: [ + { + path: 'message', + element: , + }, + { + path: 'file', + element: , + }, + { + path: 'edit/:id', + element: , + }, + { + path: 'multi-edit/:type', + element: , + }, + ], + }, + ], + }, + { + path: '*', + element: , + }, + ]); diff --git a/apps/postybirb-ui/src/pages/submission/multi-edit-submission-page.tsx b/apps/postybirb-ui/src/pages/submission/multi-edit-submission-page.tsx new file mode 100644 index 000000000..eeb9363b3 --- /dev/null +++ b/apps/postybirb-ui/src/pages/submission/multi-edit-submission-page.tsx @@ -0,0 +1,196 @@ +import { Trans } from '@lingui/macro'; +import { Box, Button, Loader, Radio, Space } from '@mantine/core'; +import { notifications } from '@mantine/notifications'; +import { ISubmissionDto, SubmissionType } from '@postybirb/types'; +import { + IconDeviceFloppy, + IconFile, + IconMessage, + IconTemplate, +} from '@tabler/icons-react'; +import { useMemo, useState } from 'react'; +import { useParams } from 'react-router'; +import submissionApi from '../../api/submission.api'; +import websiteOptionsApi from '../../api/website-options.api'; +import { PageHeader } from '../../components/page-header/page-header'; +import TemplatePickerModal from '../../components/submission-templates/template-picker-modal/template-picker-modal'; +import { SubmissionEditForm } from '../../components/submissions/submission-edit-form/submission-edit-form'; +import { SubmissionPickerModal } from '../../components/submissions/submission-picker/submission-picker-modal'; +import { SubmissionDto } from '../../models/dtos/submission.dto'; +import { MultiSubmissionStore } from '../../stores/multi-submission.store'; +import { useStore } from '../../stores/use-store'; +import { FileSubmissionPath, MessageSubmissionPath } from '../route-paths'; + +function ApplyTemplateAction({ submission }: { submission: SubmissionDto }) { + const [templatePickerVisible, setTemplatePickerVisible] = useState(false); + const picker = templatePickerVisible ? ( + setTemplatePickerVisible(false)} + onApply={(options) => { + setTemplatePickerVisible(false); + Promise.all( + options.map((option) => + websiteOptionsApi.create({ + submission: submission.id, + account: option.account, + data: option.data, + }), + ), + ) + .then(() => { + notifications.show({ + color: 'green', + title: submission.getDefaultOptions().data.title, + message: Template applied, + }); + }) + .catch((err) => { + notifications.show({ + color: 'red', + title: submission.getDefaultOptions().data.title, + message: err.message, + }); + }); + }} + /> + ) : null; + return ( + <> + + {picker} + + ); +} + +function ApplyMultiSubmissionAction({ + submission, +}: { + submission: SubmissionDto; +}) { + const [modalVisible, setModalVisible] = useState(false); + const [mergeMode, setMergeMode] = useState('1'); + const picker = modalVisible ? ( + setModalVisible(false)} + onApply={(submissions) => { + submissionApi + .applyToMultipleSubmissions({ + originId: submission.id, + submissionIds: submissions, + merge: mergeMode === '1', + }) + .then(() => { + notifications.show({ + color: 'green', + message: Updates applied, + }); + }) + .catch((err) => { + notifications.show({ + color: 'red', + message: err.message, + }); + }); + setModalVisible(false); + }} + > + Merge} + withAsterisk + value={mergeMode} + onChange={(value) => setMergeMode(value)} + > + + Overwrite overlapping website options only. This will keep any + website options that already exist and only overwrite ones + specified in the multi-update form. + + } + /> + + Only use website options specified and delete website options + missing from multi-update form. + + } + /> + + + ) : null; + return ( + <> + + {picker} + + ); +} + +export function MultiEditSubmissionPage() { + const { type } = useParams<{ type: string }>(); + const { state: submissions, isLoading } = useStore(MultiSubmissionStore); + + // The multi-submission is mapped by type, so we can just find the submission by type + const data = submissions.find((s) => s.type === type); + const submission = useMemo( + () => data ?? new SubmissionDto({} as ISubmissionDto), + [data], + ); + + const isFile = type === SubmissionType.FILE; + + if (isLoading) { + return ; + } + + return ( + <> + : } + title={Edit Multiple} + breadcrumbs={[ + { + text: isFile ? ( + File Submissions + ) : ( + Message Submissions + ), + target: isFile ? FileSubmissionPath : MessageSubmissionPath, + }, + { text: submission.type, target: '#' }, + ]} + actions={[ + , + , + ]} + /> + + + + + + ); +} diff --git a/apps/postybirb-ui/src/stores/multi-submission.store.ts b/apps/postybirb-ui/src/stores/multi-submission.store.ts new file mode 100644 index 000000000..c0561e2c4 --- /dev/null +++ b/apps/postybirb-ui/src/stores/multi-submission.store.ts @@ -0,0 +1,21 @@ +import { SUBMISSION_UPDATES } from '@postybirb/socket-events'; +import { ISubmissionDto } from '@postybirb/types'; +import submissionsApi from '../api/submission.api'; +import { SubmissionDto } from '../models/dtos/submission.dto'; +import StoreManager from './store-manager'; + +const filter = (submission: SubmissionDto | ISubmissionDto) => + Boolean(submission.metadata.isMultiSubmission); + +export const MultiSubmissionStore: StoreManager = + new StoreManager( + SUBMISSION_UPDATES, + () => + submissionsApi + .getAll() + .then(({ body }) => + body.filter(filter).map((d) => new SubmissionDto(d)), + ), + SubmissionDto, + filter, + ); diff --git a/apps/postybirb-ui/src/stores/submission.store.ts b/apps/postybirb-ui/src/stores/submission.store.ts index 1b1b95c67..8ed3b2b77 100644 --- a/apps/postybirb-ui/src/stores/submission.store.ts +++ b/apps/postybirb-ui/src/stores/submission.store.ts @@ -5,7 +5,8 @@ import { SubmissionDto } from '../models/dtos/submission.dto'; import StoreManager from './store-manager'; const filter = (submission: SubmissionDto | ISubmissionDto) => - submission.metadata.template === undefined; + submission.metadata.template === undefined && + Boolean(submission.metadata.isMultiSubmission) === false; export const SubmissionStore: StoreManager = new StoreManager( diff --git a/libs/types/src/dtos/index.ts b/libs/types/src/dtos/index.ts index 211e86472..8eddf3e0e 100644 --- a/libs/types/src/dtos/index.ts +++ b/libs/types/src/dtos/index.ts @@ -10,6 +10,7 @@ export * from './post/queue-post-record-request.dto'; export * from './post/website-post-record.dto'; export * from './settings/settings.dto'; export * from './settings/update-settings.dto'; +export * from './submission/apply-multi-submission.dto'; export * from './submission/create-submission.dto'; export * from './submission/submission-file.dto'; export * from './submission/submission.dto'; diff --git a/libs/types/src/dtos/submission/apply-multi-submission.dto.ts b/libs/types/src/dtos/submission/apply-multi-submission.dto.ts new file mode 100644 index 000000000..fde77b365 --- /dev/null +++ b/libs/types/src/dtos/submission/apply-multi-submission.dto.ts @@ -0,0 +1,30 @@ +import { SubmissionId } from '../../models'; + +/** + * The DTO for applying a submission's data to multiple submissions. + * + * @interface IApplyMultiSubmissionDto + */ +export type IApplyMultiSubmissionDto = { + /** + * The origin submission id. + * @type {SubmissionId} + */ + originId: SubmissionId; + + /** + * The submission ids to apply the origin submission to. + * + * @type {SubmissionId[]} + */ + submissionIds: SubmissionId[]; + + /** + * Whether to merge the origin submission data with the target submissions. + * + * A value of `true` will result in the overwrite of overlapping website options, but the preservation of unique options. + * A value of `false` will result in the overwrite of all website options, and delete the non-included options. + * @type {boolean} + */ + merge: boolean; +}; diff --git a/libs/types/src/dtos/website/form-generation-request.dto.ts b/libs/types/src/dtos/website/form-generation-request.dto.ts index 3dca75d22..4bb7bb33e 100644 --- a/libs/types/src/dtos/website/form-generation-request.dto.ts +++ b/libs/types/src/dtos/website/form-generation-request.dto.ts @@ -4,4 +4,5 @@ import { AccountId } from '../../models'; export interface IFormGenerationRequestDto { accountId: AccountId; type: SubmissionType; + isMultiSubmission?: boolean; } diff --git a/libs/types/src/models/submission/submission-metadata.interface.ts b/libs/types/src/models/submission/submission-metadata.interface.ts index f75a46e65..619ec13b4 100644 --- a/libs/types/src/models/submission/submission-metadata.interface.ts +++ b/libs/types/src/models/submission/submission-metadata.interface.ts @@ -1,6 +1,6 @@ -// eslint-disable-next-line @typescript-eslint/no-empty-interface export interface ISubmissionMetadata { template?: SubmissionTemplateMetadata; + isMultiSubmission?: boolean; } export type SubmissionTemplateMetadata = { diff --git a/package.json b/package.json index 19760a24d..89e9c4966 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "make": "electron-builder -w", "test": "nx test", "test:client-server": "nx test client-server && rimraf test", - "lint": "nx run-many -t lint --parallel", + "lint": "nx run-many -t lint --parallel --fix", "e2e": "nx e2e", "affected:apps": "nx affected:apps", "affected:libs": "nx affected:libs",