Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions cypress/e2e/userFeatureFlags.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const TEST_FEED_URL = '/feeds/gtfs/test-516';
const ALL_DEFAULTS = {
isNotificationsEnabled: false,
isSealFilterEnabled: false,
isSealEnabled: false,
};

interface MockFeature {
Expand Down Expand Up @@ -149,10 +150,7 @@ describe('User Feature Flags', () => {

cy.visit('/');

expectResolvedFlags({
isNotificationsEnabled: false,
isSealFilterEnabled: true,
});
expectResolvedFlags({ ...ALL_DEFAULTS, isSealFilterEnabled: true });
});

it('resolves to defaults when the profile carries no flags', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
'use client';

import { type ReactElement, type ReactNode } from 'react';
import { notFound } from 'next/navigation';
import { useUserFeatureFlags } from '../../../../../../hooks/useUserFeatureFlags';
import SealReliabilitySkeleton from '../../components/SealReliabilitySkeleton';

interface Props {
children: ReactNode;
}

/**
* Entitlement gate for the Seal of Reliability breakdown page.
*
* `isSealEnabled` is a per-user flag with no server-side read by design (see
* docs/user-feature-flags.md), so this is the only place in the route where
* its value exists. The guest half of the route is blocked server-side
* instead — see the sibling `static/seal-of-reliability/page.tsx`.
*
* NOTE: This is a UX gate, not a security boundary. `children` is rendered on
* the server regardless, so an unentitled user can still read the seal data
* out of the RSC payload. If that matters, the feed API is the place to
* enforce it — it already receives `x-mdb-user-context` on these calls, and
* this segment's `error.tsx` handles the rejection.
Comment thread
Alessandro100 marked this conversation as resolved.
*
* Once the seal feature fully rolls out, this route guard will no longer be needed
*/
export default function AuthedFeedReliabilityLayout({
children,
}: Props): ReactElement {
const {
flags: { isSealEnabled },
isResolved,
} = useUserFeatureFlags();

// Pending is not the same as not-entitled: until the flags resolve the
// value is a placeholder, so render the loading state rather than a 404.
if (!isResolved) {
return <SealReliabilitySkeleton />;
}

if (!isSealEnabled) {
notFound();
}

return <>{children}</>;
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import FeedReliabilityView from '../../../../../../screens/Feed/components/FeedReliabilityView';
// import FeedReliabilityView from '../../../../../../screens/Feed/components/FeedReliabilityView';
import { type ReactElement } from 'react';
import { notFound } from 'next/navigation';
import type { Metadata, ResolvingMetadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { fetchGuestFeedData } from '../../lib/guest-feed-data';
import { generateSealFeedMetadata } from '../../lib/generate-feed-metadata';
import { fetchGuestSealAnalysisData } from '../../lib/seal-analysis-data';
import { getLatestDataset } from '../../../../../../screens/Feed/Feed.functions';
// import { fetchGuestSealAnalysisData } from '../../lib/seal-analysis-data';
// import { getLatestDataset } from '../../../../../../screens/Feed/Feed.functions';

interface Props {
params: Promise<{ feedDataType: string; feedId: string }>;
Expand Down Expand Up @@ -52,55 +52,59 @@ export async function generateMetadata(
export default async function StaticFeedReliabilityPage({
params,
}: Props): Promise<ReactElement> {
const { feedId, feedDataType } = await params;
// Since this page is only accessible through a user feature flag
// It will temporarily return a not found immediately for any not
// authed users
notFound();
Comment thread
Alessandro100 marked this conversation as resolved.
// const { feedId, feedDataType } = await params;
Comment thread
davidgamez marked this conversation as resolved.

// Seal of Reliability only exists for GTFS Schedule feeds (see
// isSealAnalysisApplicable in lib/seal-analysis-data.ts).
if (feedDataType !== 'gtfs') {
throw new Error(
`Seal of Reliability is not available for data type ${feedDataType}`,
);
}
// // Seal of Reliability only exists for GTFS Schedule feeds (see
// // isSealAnalysisApplicable in lib/seal-analysis-data.ts).
// if (feedDataType !== 'gtfs') {
// throw new Error(
// `Seal of Reliability is not available for data type ${feedDataType}`,
// );
// }

// Settled rather than all-or-nothing: the two requests fail for unrelated
// reasons and need unrelated responses. A missing feed is a 404; a seal
// loader that can't mint a token or reach its cache is a reliability error
// on a page that does exist.
const [feedResult, sealResult] = await Promise.allSettled([
fetchGuestFeedData(feedDataType, feedId),
fetchGuestSealAnalysisData(feedDataType, feedId),
]);
// // Settled rather than all-or-nothing: the two requests fail for unrelated
// // reasons and need unrelated responses. A missing feed is a 404; a seal
// // loader that can't mint a token or reach its cache is a reliability error
// // on a page that does exist.
// const [feedResult, sealResult] = await Promise.allSettled([
// fetchGuestFeedData(feedDataType, feedId),
// fetchGuestSealAnalysisData(feedDataType, feedId),
// ]);

if (feedResult.status === 'rejected') {
// Layout should have caught non-existent feeds, but handle edge case
console.error(
`[StaticFeedReliabilityPage] Failed to fetch feed ${feedId}:`,
feedResult.reason,
);
notFound();
}
// if (feedResult.status === 'rejected') {
// // Layout should have caught non-existent feeds, but handle edge case
// console.error(
// `[StaticFeedReliabilityPage] Failed to fetch feed ${feedId}:`,
// feedResult.reason,
// );
// notFound();
// }

// Rethrown as-is so this segment's error.tsx renders the full-page
// reliability error, and the original cause keeps its stack.
if (sealResult.status === 'rejected') {
throw sealResult.reason;
}
// // Rethrown as-is so this segment's error.tsx renders the full-page
// // reliability error, and the original cause keeps its stack.
// if (sealResult.status === 'rejected') {
// throw sealResult.reason;
// }

const sealAnalysis = sealResult.value;
// const sealAnalysis = sealResult.value;

if (sealAnalysis?.reliabilityError === true) {
throw new Error(
`Failed to load Seal of Reliability data for feed ${feedId}`,
);
}
// if (sealAnalysis?.reliabilityError === true) {
// throw new Error(
// `Failed to load Seal of Reliability data for feed ${feedId}`,
// );
// }

const { feed, initialDatasets } = feedResult.value;
// const { feed, initialDatasets } = feedResult.value;

return (
<FeedReliabilityView
feed={feed}
latestDataset={getLatestDataset(feed, initialDatasets)}
sealAnalysis={sealAnalysis}
/>
);
// return (
// <FeedReliabilityView
// feed={feed}
// latestDataset={getLatestDataset(feed, initialDatasets)}
// sealAnalysis={sealAnalysis}
// />
// );
}
8 changes: 2 additions & 6 deletions src/app/[locale]/feeds/components/FeedsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -646,9 +646,7 @@ export default function FeedsScreen(): React.ReactElement {
{searchView === 'simple' ? (
<SearchTable
feedsData={feedsData}
enableSealOfReliability={
config.enableSealOfReliability
}
isSealEnabled={isSealOfReliabilityLive}
/>
) : (
<AdvancedSearchTable
Expand All @@ -657,9 +655,7 @@ export default function FeedsScreen(): React.ReactElement {
selectedGbfsVersions={selectedGbfsVersions}
selectedLicenseTags={selectedLicenseTags}
isLoadingFeeds={isLoading || isValidating}
enableSealOfReliability={
config.enableSealOfReliability
}
isSealEnabled={isSealOfReliabilityLive}
/>
)}

Expand Down
6 changes: 5 additions & 1 deletion src/app/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,16 @@ import Image from 'next/image';
import { useTranslations } from 'next-intl';
import { FooterLink, FooterColumnTitle } from './FooterElements';
import { useRemoteConfig } from '../context/RemoteConfigProvider';
import { useUserFeatureFlags } from '../hooks/useUserFeatureFlags';

const Footer: React.FC = () => {
const theme = useTheme();
const { colorScheme } = useColorScheme();
const t = useTranslations('footer');
const { config } = useRemoteConfig();
const {
flags: { isSealEnabled },
} = useUserFeatureFlags();
const FOOTER_COLUMN_WIDTH = '185px';
const SlackSvg = (
<svg
Expand Down Expand Up @@ -184,7 +188,7 @@ const Footer: React.FC = () => {
</Box>

{/* Resources column */}
{config.enableSealOfReliability && (
{isSealEnabled && (
<Box sx={{ width: FOOTER_COLUMN_WIDTH }}>
<FooterColumnTitle>{t('columns.resources')}</FooterColumnTitle>
<FooterLink href='/seal-of-reliability'>
Expand Down
8 changes: 5 additions & 3 deletions src/app/components/SealOfReliabilityChip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Box, Chip, Tooltip } from '@mui/material';
import { useTranslations } from 'next-intl';
import { Link } from '../../i18n/navigation';
import SealOfReliability, { SEAL_INK } from './SealOfReliability';
import { useRemoteConfig } from '../context/RemoteConfigProvider';
import { useUserFeatureFlags } from '../hooks/useUserFeatureFlags';

export interface SealOfReliabilityChipProps {
hasSeal: boolean | undefined;
Expand All @@ -20,9 +20,11 @@ export default function SealOfReliabilityChip({
disableLink = false,
}: SealOfReliabilityChipProps): React.ReactElement | null {
const t = useTranslations('feeds');
const { config } = useRemoteConfig();
const {
flags: { isSealEnabled },
} = useUserFeatureFlags();

if (!config.enableSealOfReliability || hasSeal == undefined) {
if (!isSealEnabled || hasSeal == undefined) {
return null;
}

Expand Down
12 changes: 5 additions & 7 deletions src/app/hooks/useSealOfReliabilityFilterAccess.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { useAuthSession } from '../components/AuthSessionProvider';
import { useRemoteConfig } from '../context/RemoteConfigProvider';
import { useUserFeatureFlags } from './useUserFeatureFlags';

export interface SealOfReliabilityFilterAccess {
/** Global Remote Config switch — whether the feature is live at all. */
/** Whether the Seal of Reliability UI is live for this user at all. */
isFeatureLive: boolean;
/**
* Entitlement is genuinely unknown until the user feature flags resolve —
Expand All @@ -18,16 +17,15 @@ export interface SealOfReliabilityFilterAccess {
}

/**
* Combines the global `enableSealOfReliability` Remote Config flag with the
* per-user `isSealFilterEnabled` feature flag, so every
* Combines the per-user `isSealEnabled` feature flag with the per-user
* `isSealFilterEnabled` feature flag, so every
* consumer (the search filter checkbox, the active-filter chip, and the
* search fetcher) agrees on whether a given user may filter by the seal.
*/
export function useSealOfReliabilityFilterAccess(): SealOfReliabilityFilterAccess {
const { config } = useRemoteConfig();
const { isAuthenticated } = useAuthSession();
const {
flags: { isSealFilterEnabled },
flags: { isSealEnabled, isSealFilterEnabled },
isResolved,
} = useUserFeatureFlags();

Expand All @@ -36,7 +34,7 @@ export function useSealOfReliabilityFilterAccess(): SealOfReliabilityFilterAcces
const hasAccess = !isPending && !hasNoAccess;

return {
isFeatureLive: config.enableSealOfReliability,
isFeatureLive: isSealEnabled,
isPending,
hasAccess,
hasNoAccess,
Expand Down
3 changes: 3 additions & 0 deletions src/app/interface/UserFeatureFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,15 @@ export interface UserFeatureFlags {
isNotificationsEnabled: boolean;
/** Enable the Seal of Reliability filter in the feeds search */
isSealFilterEnabled: boolean;
/** Enable all of the Seal of Reliability UI elements to display on the app */
isSealEnabled: boolean;
}

/** Default values returned when the cookie is absent or a flag is not set for the user. */
export const defaultUserFeatureFlags: UserFeatureFlags = {
isNotificationsEnabled: false,
isSealFilterEnabled: false,
isSealEnabled: false,
Comment thread
Alessandro100 marked this conversation as resolved.
};

/** Union of all known feature flag IDs — derived from UserFeatureFlags. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Button } from '@mui/material';
import { sendGAEvent } from '@next/third-parties/google';
import { useTranslations } from 'next-intl';
import { Link } from '../../../../i18n/navigation';
import { useRemoteConfig } from '../../../context/RemoteConfigProvider';
import { useUserFeatureFlags } from '../../../hooks/useUserFeatureFlags';

export default function ClientQualityAnalysisButton({
feedId,
Expand All @@ -14,7 +14,9 @@ export default function ClientQualityAnalysisButton({
feedDataType: string;
}): React.ReactElement | null {
const t = useTranslations('feeds');
const { config } = useRemoteConfig();
const {
flags: { isSealEnabled },
} = useUserFeatureFlags();

const handleViewFeedQualityAnalysisClick = (): void => {
sendGAEvent('event', 'view_feed_quality_analysis', {
Expand All @@ -23,7 +25,7 @@ export default function ClientQualityAnalysisButton({
});
};

if (!config.enableSealOfReliability) {
if (!isSealEnabled) {
return null;
}

Expand Down
Loading
Loading