From fe0f7a91a3fad3097e1b3cc5db4c9fdc3862defd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BE=E3=81=A3=E3=81=A1=E3=82=83=E3=81=A8=E3=83=BC?= =?UTF-8?q?=E3=81=AB=E3=82=85?= <17376330+u1-liquid@users.noreply.github.com> Date: Fri, 28 Jul 2023 00:29:33 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E3=81=8A=E7=9F=A5=E3=82=89=E3=81=9B?= =?UTF-8?q?=E3=81=AE=E5=84=AA=E5=85=88=E9=A0=86=E4=BD=8D=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=20(#118)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ついでにお知らせのページング処理、わかったのボタン押したとき更新されるように --- locales/en-US.yml | 1 + locales/index.d.ts | 1 + locales/ja-JP.yml | 1 + ...690463372775-announcement-display-order.js | 13 ++++ .../src/models/entities/Announcement.ts | 7 ++ .../endpoints/admin/announcements/create.ts | 6 ++ .../api/endpoints/admin/announcements/list.ts | 25 ++++--- .../endpoints/admin/announcements/update.ts | 2 + .../src/server/api/endpoints/announcements.ts | 49 ++++++------- .../src/pages/admin/announcements.vue | 71 ++++++++++++------- packages/frontend/src/pages/announcements.vue | 18 +++-- packages/frontend/src/ui/visitor.vue | 1 + 12 files changed, 126 insertions(+), 69 deletions(-) create mode 100644 packages/backend/migration/1690463372775-announcement-display-order.js diff --git a/locales/en-US.yml b/locales/en-US.yml index 565537155d..9cb00c8ee9 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -251,6 +251,7 @@ noSuchUser: "User not found" lookup: "Lookup" announcements: "Announcements" imageUrl: "Image URL" +displayOrder: "Position" remove: "Delete" removed: "Successfully deleted" removeAreYouSure: "Are you sure that you want to remove \"{x}\"?" diff --git a/locales/index.d.ts b/locales/index.d.ts index 9b0823ad6b..672d586b09 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -254,6 +254,7 @@ export interface Locale { "lookup": string; "announcements": string; "imageUrl": string; + "displayOrder": string; "remove": string; "removed": string; "removeAreYouSure": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 27a05cefb8..290a847223 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -251,6 +251,7 @@ noSuchUser: "ユーザーが見つかりません" lookup: "照会" announcements: "お知らせ" imageUrl: "画像URL" +displayOrder: "表示順" remove: "削除" removed: "削除しました" removeAreYouSure: "「{x}」を削除しますか?" diff --git a/packages/backend/migration/1690463372775-announcement-display-order.js b/packages/backend/migration/1690463372775-announcement-display-order.js new file mode 100644 index 0000000000..b02f5d7d2e --- /dev/null +++ b/packages/backend/migration/1690463372775-announcement-display-order.js @@ -0,0 +1,13 @@ +export class AnnouncementDisplayOrder1690463372775 { + name = 'AnnouncementDisplayOrder1690463372775' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "announcement" ADD "displayOrder" integer NOT NULL DEFAULT '0'`); + await queryRunner.query(`CREATE INDEX "IDX_b64d293ca4bef21e91963054b0" ON "announcement" ("displayOrder") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_b64d293ca4bef21e91963054b0"`); + await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "displayOrder"`); + } +} diff --git a/packages/backend/src/models/entities/Announcement.ts b/packages/backend/src/models/entities/Announcement.ts index 3fd3cb0a46..bcadb851f0 100644 --- a/packages/backend/src/models/entities/Announcement.ts +++ b/packages/backend/src/models/entities/Announcement.ts @@ -33,6 +33,13 @@ export class Announcement { }) public imageUrl: string | null; + // UIに表示する際の並び順用(大きいほど先頭) + @Index() + @Column('integer', { + default: 0, + }) + public displayOrder: number; + @Index() @Column('varchar', { ...id(), diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/create.ts b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts index 04d7336686..fed39c1e13 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts @@ -42,6 +42,10 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + displayOrder: { + type: 'number', + optional: false, nullable: false, + }, userId: { type: 'string', optional: false, nullable: true, @@ -60,6 +64,7 @@ export const paramDef = { title: { type: 'string', minLength: 1 }, text: { type: 'string', minLength: 1 }, imageUrl: { type: 'string', nullable: true, minLength: 1 }, + displayOrder: { type: 'number' }, userId: { type: 'string', nullable: true, format: 'misskey:id' }, closeDuration: { type: 'number', nullable: false }, }, @@ -83,6 +88,7 @@ export default class extends Endpoint { title: ps.title, text: ps.text, imageUrl: ps.imageUrl, + displayOrder: ps.displayOrder, userId: ps.userId ?? null, closeDuration: ps.closeDuration, }).then(x => this.announcementsRepository.findOneByOrFail(x.identifiers[0])); diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts index ffcbc0a695..1679cc2674 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts @@ -48,6 +48,10 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + displayOrder: { + type: 'number', + optional: false, nullable: false, + }, userId: { type: 'string', optional: false, nullable: true, @@ -74,8 +78,7 @@ export const paramDef = { type: 'object', properties: { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - sinceId: { type: 'string', format: 'misskey:id' }, - untilId: { type: 'string', format: 'misskey:id' }, + offset: { type: 'integer', default: 0 }, userId: { type: 'string', format: 'misskey:id' }, }, required: [], @@ -94,20 +97,25 @@ export default class extends Endpoint { @Inject(DI.usersRepository) private usersRepository: UsersRepository, - private queryService: QueryService, private userEntityService: UserEntityService, ) { super(meta, paramDef, async (ps, me) => { - const builder = this.announcementsRepository.createQueryBuilder('announcement'); + const query = this.announcementsRepository.createQueryBuilder('announcement'); if (ps.userId) { - builder.where('"userId" = :userId', { userId: ps.userId }); + query.where('"userId" = :userId', { userId: ps.userId }); } else { - builder.where('"userId" IS NULL'); + query.where('"userId" IS NULL'); } - const query = this.queryService.makePaginationQuery(builder, ps.sinceId, ps.untilId); + query.orderBy({ + 'announcement."displayOrder"': 'DESC', + 'announcement."createdAt"': 'DESC', + }); - const announcements = await query.limit(ps.limit).getMany(); + const announcements = await query + .offset(ps.offset) + .limit(ps.limit) + .getMany(); const reads = new Map(); @@ -131,6 +139,7 @@ export default class extends Endpoint { title: announcement.title, text: announcement.text, imageUrl: announcement.imageUrl, + displayOrder: announcement.displayOrder, userId: announcement.userId, user: packedUsers.find(user => user.id === announcement.userId), reads: reads.get(announcement)!, diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/update.ts b/packages/backend/src/server/api/endpoints/admin/announcements/update.ts index e9e6b7f9c7..01122571a3 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/update.ts @@ -26,6 +26,7 @@ export const paramDef = { title: { type: 'string', minLength: 1 }, text: { type: 'string', minLength: 1 }, imageUrl: { type: 'string', nullable: true, minLength: 0 }, + displayOrder: { type: 'number' }, userId: { type: 'string', nullable: true, format: 'misskey:id' }, closeDuration: { type: 'number', nullable: false }, }, @@ -57,6 +58,7 @@ export default class extends Endpoint { text: ps.text, /* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- 空の文字列の場合、nullを渡すようにするため */ imageUrl: ps.imageUrl || null, + displayOrder: ps.displayOrder, userId: ps.userId ?? null, closeDuration: ps.closeDuration, }); diff --git a/packages/backend/src/server/api/endpoints/announcements.ts b/packages/backend/src/server/api/endpoints/announcements.ts index 3fbf94eb41..8bc813ae0d 100644 --- a/packages/backend/src/server/api/endpoints/announcements.ts +++ b/packages/backend/src/server/api/endpoints/announcements.ts @@ -1,8 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; import { DI } from '@/di-symbols.js'; -import type { AnnouncementReadsRepository, AnnouncementsRepository } from '@/models/index.js'; +import type { AnnouncementsRepository } from '@/models/index.js'; +import { Announcement, AnnouncementRead } from '@/models/index.js'; export const meta = { tags: ['meta'], @@ -65,9 +65,8 @@ export const paramDef = { type: 'object', properties: { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + offset: { type: 'integer', default: 0 }, withUnreads: { type: 'boolean', default: false }, - sinceId: { type: 'string', format: 'misskey:id' }, - untilId: { type: 'string', format: 'misskey:id' }, privateOnly: { type: 'boolean', default: false }, }, required: [], @@ -79,39 +78,37 @@ export default class extends Endpoint { constructor( @Inject(DI.announcementsRepository) private announcementsRepository: AnnouncementsRepository, - - @Inject(DI.announcementReadsRepository) - private announcementReadsRepository: AnnouncementReadsRepository, - - private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const builder = this.announcementsRepository.createQueryBuilder('announcement'); + const query = this.announcementsRepository.createQueryBuilder('announcement'); if (me) { + query.leftJoinAndSelect(AnnouncementRead, 'reads', 'reads."announcementId" = announcement.id AND reads."userId" = :userId', { userId: me.id }); + query.select([ + 'announcement.*', + 'CASE WHEN reads.id IS NULL THEN FALSE ELSE TRUE END as "isRead"', + ]); if (ps.privateOnly) { - builder.where('"userId" = :userId', { userId: me.id }); + query.where('announcement."userId" = :userId', { userId: me.id }); } else { - builder.where('"userId" IS NULL'); - builder.orWhere('"userId" = :userId', { userId: me.id }); + query.where('announcement."userId" IS NULL'); + query.orWhere('announcement."userId" = :userId', { userId: me.id }); } } else { - builder.where('"userId" IS NULL'); + query.where('announcement."userId" IS NULL'); } - const query = this.queryService.makePaginationQuery(builder, ps.sinceId, ps.untilId); - const announcements = await query.limit(ps.limit).getMany(); + query.orderBy({ + '"isRead"': 'ASC', + 'announcement."displayOrder"': 'DESC', + 'announcement."createdAt"': 'DESC', + }); - if (me) { - const reads = (await this.announcementReadsRepository.findBy({ - userId: me.id, - })).map(x => x.announcementId); + const announcements = await query + .offset(ps.offset) + .limit(ps.limit) + .getRawMany(); - for (const announcement of announcements) { - (announcement as any).isRead = reads.includes(announcement.id); - } - } - - return (ps.withUnreads ? announcements.filter((a: any) => !a.isRead) : announcements).map((a) => ({ + return (ps.withUnreads ? announcements.filter(i => !i.isRead) : announcements).map((a) => ({ ...a, createdAt: a.createdAt.toISOString(), updatedAt: a.updatedAt?.toISOString() ?? null, diff --git a/packages/frontend/src/pages/admin/announcements.vue b/packages/frontend/src/pages/admin/announcements.vue index 6ae3b2fcea..d415b5cb44 100644 --- a/packages/frontend/src/pages/admin/announcements.vue +++ b/packages/frontend/src/pages/admin/announcements.vue @@ -19,7 +19,7 @@ -
+
@@ -30,6 +30,9 @@ + + + @@ -43,6 +46,7 @@
+ {{ i18n.ts.loadMore }} @@ -61,33 +65,31 @@ import * as os from '@/os'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; +const announceTitleEl = $shallowRef(null); +const user = ref(null); +const offset = ref(0); +const hasMore = ref(false); + let announcements: any[] = $ref([]); -const user = ref(null); -const announceTitleEl = $shallowRef(null); +function insertEmoji(ev: MouseEvent): void { + os.openEmojiPicker(ev.currentTarget ?? ev.target, {}, announceTitleEl); +} -function selectUserFilter() { +function selectUserFilter(): void { os.selectUser().then(_user => { user.value = _user; }); } -function editUser(an) { +function editUser(announcement): void { os.selectUser().then(_user => { - an.userId = _user.id; - an.user = _user; + announcement.userId = _user.id; + announcement.user = _user; }); } -async function insertEmoji(ev: MouseEvent) { - os.openEmojiPicker(ev.currentTarget ?? ev.target, {}, announceTitleEl); -} - -os.api('admin/announcements/list').then(announcementResponse => { - announcements = announcementResponse; -}); - -function add() { +function add(): void { announcements.unshift({ id: null, title: '', @@ -95,11 +97,12 @@ function add() { imageUrl: null, userId: null, user: null, + displayOrder: 0, closeDuration: 10, }); } -function remove(announcement) { +function remove(announcement): void { os.confirm({ type: 'warning', text: i18n.t('removeAreYouSure', { x: announcement.title }), @@ -110,14 +113,14 @@ function remove(announcement) { }); } -function save(announcement) { +function save(announcement): void { if (announcement.id == null) { os.api('admin/announcements/create', announcement).then(() => { os.alert({ type: 'success', text: i18n.ts.saved, }); - refresh(); + fetch(true); }).catch(err => { os.alert({ type: 'error', @@ -139,15 +142,26 @@ function save(announcement) { } } -function refresh() { - os.api('admin/announcements/list', { userId: user.value?.id }).then(announcementResponse => { - announcements = announcementResponse; +function fetch(resetOffset = false): void { + if (resetOffset) { + announcements = []; + offset.value = 0; + } + + os.api('admin/announcements/list', { + offsetMode: true, + offset: offset.value, + limit: 10, + userId: user.value?.id, + }).then(announcementResponse => { + announcements = announcements.concat(announcementResponse); + hasMore.value = announcementResponse?.length === 10; + offset.value += announcements.length; }); } -watch(user, refresh); - -refresh(); +watch(user, () => fetch(true)); +fetch(); const headerActions = $computed(() => [{ asFullButton: true, @@ -163,3 +177,10 @@ definePageMetadata({ icon: 'ti ti-speakerphone', }); + + diff --git a/packages/frontend/src/pages/announcements.vue b/packages/frontend/src/pages/announcements.vue index bbfa877767..4312ef7fcf 100644 --- a/packages/frontend/src/pages/announcements.vue +++ b/packages/frontend/src/pages/announcements.vue @@ -2,7 +2,7 @@ - +
@@ -10,7 +10,7 @@
@@ -19,7 +19,6 @@