feat(records): scheduled records empty-state hint
Fixes UX bug: пользователь создаёт запись с validFrom на будущую дату
(e.g. 18.05), открывает catalog 15.05 — пустая таблица, никаких хинтов
о том что запись существует.
Backend (read-api):
- New endpoint GET /api/v1/{dict}/records/scheduled-summary
- Returns {count, nearestValidFrom} для записей с validFrom > now AND
validTo > now в текущем scope view
- Single-aggregate JPA query (COUNT + MIN), respect scope filter same way
as listActive — INTERNAL scheduled не учитывается для PUBLIC consumer'а
Frontend:
- New useScheduledSummary hook + ScheduledRecordsSummary type
- Empty-state в dictionaries.$name now shows: "Запланировано N, ближайшая
на DD.MM [Перейти к этой дате]" с CTA который set'ает time-travel на
nearestValidFrom
- i18n keys для ru/en plural forms
Связан с MR !219 (TimeTravelPicker 30-day future window) — теперь slider
позволяет дойти до даты записи, и empty-state hint позволяет узнать что
запись существует.
This commit is contained in:
@@ -756,3 +756,18 @@ export const NOTIFICATION_EVENT_TYPES = [
|
|||||||
'RecordDraftWithdrawn',
|
'RecordDraftWithdrawn',
|
||||||
] as const
|
] as const
|
||||||
export type NotificationEventType = (typeof NOTIFICATION_EVENT_TYPES)[number]
|
export type NotificationEventType = (typeof NOTIFICATION_EVENT_TYPES)[number]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty-state hint payload (read-api scheduled-summary). Подсчёт записей с
|
||||||
|
* {@code validFrom > now AND validTo > now} в текущем scope view.
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code count=0} → frontend hint не показывает.</li>
|
||||||
|
* <li>{@code nearestValidFrom} — ISO datetime ближайшей запланированной;
|
||||||
|
* используется в CTA «Перейти к дате» (time-travel).</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
export type ScheduledRecordsSummary = {
|
||||||
|
count: number
|
||||||
|
nearestValidFrom: string | null
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
type RecordDependentsPage,
|
type RecordDependentsPage,
|
||||||
type RecordResponse,
|
type RecordResponse,
|
||||||
type ReviewQueuePage,
|
type ReviewQueuePage,
|
||||||
|
type ScheduledRecordsSummary,
|
||||||
type SchemaDependent,
|
type SchemaDependent,
|
||||||
type SearchResponse,
|
type SearchResponse,
|
||||||
type WebhookDeliveryPage,
|
type WebhookDeliveryPage,
|
||||||
@@ -784,6 +785,37 @@ export const useRecords = (
|
|||||||
scopeCsv: string,
|
scopeCsv: string,
|
||||||
filter?: RecordsFilter,
|
filter?: RecordsFilter,
|
||||||
) => useQuery(recordsQuery(dictionaryName, scopeCsv, filter))
|
) => useQuery(recordsQuery(dictionaryName, scopeCsv, filter))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduled records hint для empty-state. Fetch'ит count записей с
|
||||||
|
* validFrom > now (но validTo > now — т.е. не сразу закрытые). Показывается
|
||||||
|
* пользователю когда список пуст, но в будущем что-то запланировано.
|
||||||
|
*
|
||||||
|
* <p>Fixes UX bug: user создал запись с validFrom=18.05, открыл catalog 15.05 —
|
||||||
|
* пустая таблица, никаких хинтов. Теперь UI показывает «Запланировано: 1
|
||||||
|
* запись на 18.05 [перейти к дате]».
|
||||||
|
*
|
||||||
|
* <p>Stale 30s — scheduled state changes медленно. Refetch on focus = true
|
||||||
|
* (default) — пользователь возвращается во вкладку → fresh count.
|
||||||
|
*/
|
||||||
|
export const scheduledSummaryQuery = (dictionaryName: string, scopeCsv: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ['scheduled-summary', dictionaryName, scopeCsv] as const,
|
||||||
|
queryFn: async (): Promise<ScheduledRecordsSummary> => {
|
||||||
|
const { data } = await apiClient.get<ScheduledRecordsSummary>(
|
||||||
|
`/${dictionaryName}/records/scheduled-summary`,
|
||||||
|
{ params: { as_scope: scopeCsv } },
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useScheduledSummary = (dictionaryName: string, scopeCsv: string) =>
|
||||||
|
useQuery({
|
||||||
|
...scheduledSummaryQuery(dictionaryName, scopeCsv),
|
||||||
|
enabled: Boolean(dictionaryName),
|
||||||
|
})
|
||||||
export const useRecordRaw = (
|
export const useRecordRaw = (
|
||||||
dictionaryName: string,
|
dictionaryName: string,
|
||||||
businessKey: string | undefined,
|
businessKey: string | undefined,
|
||||||
|
|||||||
@@ -349,6 +349,15 @@ i18n
|
|||||||
'dict.list.fk.usedBy_many': '← {{count}} использ.',
|
'dict.list.fk.usedBy_many': '← {{count}} использ.',
|
||||||
'dict.list.fk.usedBy_other': '← {{count}} использ.',
|
'dict.list.fk.usedBy_other': '← {{count}} использ.',
|
||||||
'dict.empty': 'В этом справочнике пока нет записей',
|
'dict.empty': 'В этом справочнике пока нет записей',
|
||||||
|
'dict.scheduled.hint_one':
|
||||||
|
'Запланирована {{count}} запись на {{date}}',
|
||||||
|
'dict.scheduled.hint_few':
|
||||||
|
'Запланировано {{count}} записи, ближайшая на {{date}}',
|
||||||
|
'dict.scheduled.hint_many':
|
||||||
|
'Запланировано {{count}} записей, ближайшая на {{date}}',
|
||||||
|
'dict.scheduled.hint_other':
|
||||||
|
'Запланировано {{count}} записей, ближайшая на {{date}}',
|
||||||
|
'dict.scheduled.cta': 'Перейти к этой дате',
|
||||||
'dict.col.businessKey': 'Бизнес-ключ',
|
'dict.col.businessKey': 'Бизнес-ключ',
|
||||||
'dict.col.scope': 'Scope',
|
'dict.col.scope': 'Scope',
|
||||||
'dict.col.validFrom': 'Действует с',
|
'dict.col.validFrom': 'Действует с',
|
||||||
@@ -1151,6 +1160,11 @@ i18n
|
|||||||
'dict.list.fk.usedBy_one': '← {{count}} used by',
|
'dict.list.fk.usedBy_one': '← {{count}} used by',
|
||||||
'dict.list.fk.usedBy_other': '← {{count}} used by',
|
'dict.list.fk.usedBy_other': '← {{count}} used by',
|
||||||
'dict.empty': 'No records in this dictionary yet',
|
'dict.empty': 'No records in this dictionary yet',
|
||||||
|
'dict.scheduled.hint_one':
|
||||||
|
'{{count}} record scheduled for {{date}}',
|
||||||
|
'dict.scheduled.hint_other':
|
||||||
|
'{{count}} records scheduled, earliest on {{date}}',
|
||||||
|
'dict.scheduled.cta': 'Jump to that date',
|
||||||
'dict.col.businessKey': 'Business key',
|
'dict.col.businessKey': 'Business key',
|
||||||
'dict.col.scope': 'Scope',
|
'dict.col.scope': 'Scope',
|
||||||
'dict.col.validFrom': 'Valid from',
|
'dict.col.validFrom': 'Valid from',
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
useDictPendingDrafts,
|
useDictPendingDrafts,
|
||||||
useRecordRaw,
|
useRecordRaw,
|
||||||
useRecords,
|
useRecords,
|
||||||
|
useScheduledSummary,
|
||||||
type RecordsFilter,
|
type RecordsFilter,
|
||||||
} from '@/api/queries'
|
} from '@/api/queries'
|
||||||
import { useBulkCloseRecords, useBulkExportRecords, useCreateRecord, useUpdateRecord, useCloseRecord, useFormIdempotencyKey, useSubmitDraft } from '@/api/mutations'
|
import { useBulkCloseRecords, useBulkExportRecords, useCreateRecord, useUpdateRecord, useCloseRecord, useFormIdempotencyKey, useSubmitDraft } from '@/api/mutations'
|
||||||
@@ -169,6 +170,16 @@ function DictionaryDetail() {
|
|||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
const recordsResult = useRecords(name, 'PUBLIC,INTERNAL,RESTRICTED', filter)
|
const recordsResult = useRecords(name, 'PUBLIC,INTERNAL,RESTRICTED', filter)
|
||||||
|
/**
|
||||||
|
* Scheduled records hint (post-Phase-B-2 UX fix). Когда records list пустой
|
||||||
|
* на момент now / time-travel, но в БД есть записи с validFrom > now,
|
||||||
|
* показываем «Запланировано N, ближайшая на DD.MM [Перейти к этой дате]».
|
||||||
|
* Fixes user feedback: «создал запись на 18.05 — её не видно».
|
||||||
|
*
|
||||||
|
* <p>Fetch unconditional (cheap single-aggregate query). Frontend сам решает
|
||||||
|
* показывать hint или нет по {@code count > 0}.
|
||||||
|
*/
|
||||||
|
const scheduledSummary = useScheduledSummary(name, 'PUBLIC,INTERNAL,RESTRICTED')
|
||||||
/**
|
/**
|
||||||
* Approval Workflow v2 Phase 4: pending drafts на этот dict — для бейджа
|
* Approval Workflow v2 Phase 4: pending drafts на этот dict — для бейджа
|
||||||
* "На review" в каждой строке records table. Polled 30s. Bypass'нем если
|
* "На review" в каждой строке records table. Polled 30s. Bypass'нем если
|
||||||
@@ -974,7 +985,30 @@ function DictionaryDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{recordsResult.data && recordsResult.data.length === 0 && (
|
{recordsResult.data && recordsResult.data.length === 0 && (
|
||||||
<EmptyState title={t('dict.empty')} />
|
<EmptyState
|
||||||
|
title={t('dict.empty')}
|
||||||
|
description={
|
||||||
|
scheduledSummary.data && scheduledSummary.data.count > 0 && scheduledSummary.data.nearestValidFrom
|
||||||
|
? t('dict.scheduled.hint', {
|
||||||
|
count: scheduledSummary.data.count,
|
||||||
|
date: new Date(scheduledSummary.data.nearestValidFrom).toLocaleDateString(),
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
scheduledSummary.data && scheduledSummary.data.count > 0 && scheduledSummary.data.nearestValidFrom
|
||||||
|
? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTimeTravelAt(scheduledSummary.data!.nearestValidFrom!)}
|
||||||
|
className="text-cell text-accent hover:underline"
|
||||||
|
>
|
||||||
|
{t('dict.scheduled.cta')}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{recordsResult.data && recordsResult.data.length > 0 && (
|
{recordsResult.data && recordsResult.data.length > 0 && (
|
||||||
|
|||||||
+25
@@ -178,4 +178,29 @@ public interface DictionaryRecordRepository extends JpaRepository<DictionaryReco
|
|||||||
@Param("from") OffsetDateTime from,
|
@Param("from") OffsetDateTime from,
|
||||||
@Param("to") OffsetDateTime to,
|
@Param("to") OffsetDateTime to,
|
||||||
@Param("granularity") String granularity);
|
@Param("granularity") String granularity);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduled records summary: записи которые ещё не активны (validFrom > now),
|
||||||
|
* но запланированы на будущее (validTo > now — т.е. не сразу закрытые).
|
||||||
|
* Используется empty-state hint в admin-UI: «У словаря есть N запланированных
|
||||||
|
* на DD.MM» — fixes UX bug когда пользователь создаёт запись с validFrom
|
||||||
|
* на будущую дату и не видит её в списке.
|
||||||
|
*
|
||||||
|
* <p>Single query → returns {@code [count Long, nearestValidFrom OffsetDateTime]}.
|
||||||
|
* Если scheduled нет — count=0, nearest=null.
|
||||||
|
*
|
||||||
|
* <p>Filter по scope тот же что в read-side listActive: respects user's
|
||||||
|
* view permissions. Anonymous (только PUBLIC) → INTERNAL scheduled не учитывается.
|
||||||
|
*/
|
||||||
|
@Query("""
|
||||||
|
SELECT COUNT(r), MIN(r.validFrom) FROM DictionaryRecord r
|
||||||
|
WHERE r.dictionaryId = :dictionaryId
|
||||||
|
AND r.dataScope IN :allowedScopes
|
||||||
|
AND r.validFrom > :now
|
||||||
|
AND r.validTo > :now
|
||||||
|
""")
|
||||||
|
List<Object[]> findScheduledSummary(
|
||||||
|
@Param("dictionaryId") UUID dictionaryId,
|
||||||
|
@Param("allowedScopes") Collection<DataScope> allowedScopes,
|
||||||
|
@Param("now") OffsetDateTime now);
|
||||||
}
|
}
|
||||||
|
|||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.readapi.dto;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty-state hint payload: сколько записей запланировано в будущем и
|
||||||
|
* на какую ближайшую дату. Используется admin-UI чтобы показать
|
||||||
|
* «У словаря есть N запланированных, ближайшая на DD.MM» вместе с
|
||||||
|
* CTA «Перейти к дате» (set time-travel).
|
||||||
|
*
|
||||||
|
* <p>{@code count == 0} → {@code nearestValidFrom == null}; frontend
|
||||||
|
* не показывает hint в этом случае.
|
||||||
|
*
|
||||||
|
* <p>Scope filtering: same view permissions that {@code listActive}
|
||||||
|
* использует — INTERNAL scheduled не учитывается для PUBLIC consumer'а.
|
||||||
|
*/
|
||||||
|
public record ScheduledRecordsSummary(long count, OffsetDateTime nearestValidFrom) {
|
||||||
|
|
||||||
|
public static ScheduledRecordsSummary empty() {
|
||||||
|
return new ScheduledRecordsSummary(0L, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -6,6 +6,7 @@ import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRep
|
|||||||
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord;
|
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord;
|
||||||
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository;
|
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository;
|
||||||
import cloud.nstart.terravault.ordinis.readapi.dto.FlattenedRecordResponse;
|
import cloud.nstart.terravault.ordinis.readapi.dto.FlattenedRecordResponse;
|
||||||
|
import cloud.nstart.terravault.ordinis.readapi.dto.ScheduledRecordsSummary;
|
||||||
import cloud.nstart.terravault.ordinis.readapi.locale.LocaleAwareJsonFlattener;
|
import cloud.nstart.terravault.ordinis.readapi.locale.LocaleAwareJsonFlattener;
|
||||||
import cloud.nstart.terravault.ordinis.readapi.locale.LocaleNegotiator;
|
import cloud.nstart.terravault.ordinis.readapi.locale.LocaleNegotiator;
|
||||||
import cloud.nstart.terravault.ordinis.readapi.spatial.BoundingBox;
|
import cloud.nstart.terravault.ordinis.readapi.spatial.BoundingBox;
|
||||||
@@ -172,6 +173,45 @@ public class RecordReadService {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduled records summary: подсчитать записи у которых validFrom в будущем
|
||||||
|
* (но validTo тоже в будущем — т.е. не сразу закрытые). Используется
|
||||||
|
* admin-UI empty-state hint: «У dict есть N запланированных, ближайшая на DD.MM».
|
||||||
|
*
|
||||||
|
* <p>Scope rules:
|
||||||
|
* <ul>
|
||||||
|
* <li>Если scope словаря недоступен — 404 (NoSuchElementException), как в listActive</li>
|
||||||
|
* <li>Иначе counts только записи в {@code allowedScopes} (INTERNAL не виден PUBLIC consumer'у)</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Empty result (count=0) — нормальное состояние, возвращается явный
|
||||||
|
* {@link ScheduledRecordsSummary#empty()} (не null) для уменьшения NPE-риска
|
||||||
|
* на frontend.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public ScheduledRecordsSummary scheduledSummary(
|
||||||
|
String dictionaryName, List<DataScope> allowedScopes) {
|
||||||
|
|
||||||
|
DictionaryDefinition def = definitionRepository.findByName(dictionaryName)
|
||||||
|
.orElseThrow(() -> new NoSuchElementException("Dictionary not found: " + dictionaryName));
|
||||||
|
|
||||||
|
if (!allowedScopes.contains(def.getScope())) {
|
||||||
|
throw new ScopeAccessDeniedException(
|
||||||
|
"Scope " + def.getScope() + " not in allowed scopes " + allowedScopes);
|
||||||
|
}
|
||||||
|
|
||||||
|
OffsetDateTime now = OffsetDateTime.now();
|
||||||
|
List<Object[]> rows = recordRepository.findScheduledSummary(
|
||||||
|
def.getId(), allowedScopes, now);
|
||||||
|
if (rows.isEmpty()) return ScheduledRecordsSummary.empty();
|
||||||
|
|
||||||
|
Object[] row = rows.get(0);
|
||||||
|
long count = row[0] == null ? 0L : ((Number) row[0]).longValue();
|
||||||
|
OffsetDateTime nearest = (OffsetDateTime) row[1];
|
||||||
|
if (count == 0L || nearest == null) return ScheduledRecordsSummary.empty();
|
||||||
|
return new ScheduledRecordsSummary(count, nearest);
|
||||||
|
}
|
||||||
|
|
||||||
public static class ScopeAccessDeniedException extends RuntimeException {
|
public static class ScopeAccessDeniedException extends RuntimeException {
|
||||||
public ScopeAccessDeniedException(String message) { super(message); }
|
public ScopeAccessDeniedException(String message) { super(message); }
|
||||||
}
|
}
|
||||||
|
|||||||
+22
@@ -3,6 +3,7 @@ package cloud.nstart.terravault.ordinis.readapi.web;
|
|||||||
import cloud.nstart.terravault.ordinis.auth.ScopeContext;
|
import cloud.nstart.terravault.ordinis.auth.ScopeContext;
|
||||||
import cloud.nstart.terravault.ordinis.domain.DataScope;
|
import cloud.nstart.terravault.ordinis.domain.DataScope;
|
||||||
import cloud.nstart.terravault.ordinis.readapi.dto.FlattenedRecordResponse;
|
import cloud.nstart.terravault.ordinis.readapi.dto.FlattenedRecordResponse;
|
||||||
|
import cloud.nstart.terravault.ordinis.readapi.dto.ScheduledRecordsSummary;
|
||||||
import cloud.nstart.terravault.ordinis.readapi.service.RecordReadService;
|
import cloud.nstart.terravault.ordinis.readapi.service.RecordReadService;
|
||||||
import cloud.nstart.terravault.ordinis.readapi.spatial.BoundingBox;
|
import cloud.nstart.terravault.ordinis.readapi.spatial.BoundingBox;
|
||||||
import cloud.nstart.terravault.ordinis.readapi.spatial.GeoJsonPolygon;
|
import cloud.nstart.terravault.ordinis.readapi.spatial.GeoJsonPolygon;
|
||||||
@@ -105,6 +106,27 @@ public class DictionaryReadController {
|
|||||||
return new ResponseEntity<>(items, headers, 200);
|
return new ResponseEntity<>(items, headers, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduled records summary: empty-state hint для admin-UI. Возвращает
|
||||||
|
* {@code {count, nearestValidFrom}} для записей с {@code validFrom > now AND validTo > now}
|
||||||
|
* в текущем scope view.
|
||||||
|
*
|
||||||
|
* <p>Use case: пользователь создаёт запись с {@code validFrom} на будущее (e.g. 18.05),
|
||||||
|
* затем заходит в catalog 15.05 — табличка пустая. Frontend читает этот endpoint
|
||||||
|
* и показывает «У словаря есть N запланированных, ближайшая на DD.MM. [Перейти к дате]».
|
||||||
|
*
|
||||||
|
* <p>{@code count=0} → frontend ничего не показывает. Дешёвый single query, OK к poll'ить
|
||||||
|
* с те же refetchInterval'ом что и records list.
|
||||||
|
*/
|
||||||
|
@GetMapping("/scheduled-summary")
|
||||||
|
public ScheduledRecordsSummary scheduledSummary(
|
||||||
|
@PathVariable String dictionaryName,
|
||||||
|
@RequestParam(value = "as_scope", required = false) String asScope) {
|
||||||
|
|
||||||
|
List<DataScope> scopes = scopeContext.resolveForRead(asScope).stream().toList();
|
||||||
|
return service.scheduledSummary(dictionaryName, scopes);
|
||||||
|
}
|
||||||
|
|
||||||
/** Bbox parse errors → 400, не 500. Ловится в {@code ReadApiExceptionHandler}. */
|
/** Bbox parse errors → 400, не 500. Ловится в {@code ReadApiExceptionHandler}. */
|
||||||
public static class BadBboxException extends RuntimeException {
|
public static class BadBboxException extends RuntimeException {
|
||||||
public BadBboxException(String message) { super(message); }
|
public BadBboxException(String message) { super(message); }
|
||||||
|
|||||||
Reference in New Issue
Block a user