diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8911527d3d5b..fa31eeda90d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,10 +5,7 @@ on: push: branches: - main - - arch - 'v5.*' - - 3.x - - 2.x env: FORCE_COLOR: 1 @@ -66,8 +63,9 @@ jobs: if: github.event_name == 'pull_request' run: | echo "Looking up: ${{ github.event.pull_request.user.login }}" + ENCODED_USERNAME=$(printf '%s' '${{ github.event.pull_request.user.login }}' | jq -sRr @uri) - LOOKUP_USER=$(curl --write-out "%{http_code}" --silent --output /dev/null --location 'https://api.github.com/orgs/tryghost/members/${{ github.event.pull_request.user.login }}' --header 'Authorization: Bearer ${{ secrets.CANARY_DOCKER_BUILD }}') + LOOKUP_USER=$(curl --write-out "%{http_code}" --silent --output /dev/null --location "https://api.github.com/orgs/tryghost/members/$ENCODED_USERNAME" --header "Authorization: Bearer ${{ secrets.CANARY_DOCKER_BUILD }}") if [ "$LOOKUP_USER" == "204" ]; then echo "User is in the org" @@ -208,7 +206,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - fetch-depth: 100 + fetch-depth: 1000 - uses: actions/setup-node@v4 env: FORCE_COLOR: 0 @@ -241,6 +239,7 @@ jobs: if: | needs.job_setup.outputs.changed_comments_ui == 'true' || needs.job_setup.outputs.changed_signup_form == 'true' + || needs.job_setup.outputs.changed_sodo_search == 'true' || needs.job_setup.outputs.changed_portal == 'true' || needs.job_setup.outputs.changed_core == 'true' steps: @@ -434,7 +433,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - fetch-depth: 100 + fetch-depth: 1000 - uses: actions/setup-node@v4 env: FORCE_COLOR: 0 diff --git a/apps/admin-x-activitypub/package.json b/apps/admin-x-activitypub/package.json index eeebde254fa2..e7adda39ca75 100644 --- a/apps/admin-x-activitypub/package.json +++ b/apps/admin-x-activitypub/package.json @@ -31,15 +31,10 @@ "devDependencies": { "@playwright/test": "1.46.1", "@testing-library/react": "14.3.1", - "@tryghost/admin-x-design-system": "0.0.0", - "@tryghost/admin-x-framework": "0.0.0", "@types/jest": "29.5.12", "@types/react": "18.3.3", "@types/react-dom": "18.3.0", - "@radix-ui/react-form": "0.0.3", "jest": "29.7.0", - "react": "18.3.1", - "react-dom": "18.3.1", "ts-jest": "29.1.5" }, "nx": { @@ -67,5 +62,13 @@ ] } } + }, + "dependencies": { + "@radix-ui/react-form": "0.0.3", + "use-debounce": "10.0.3", + "@tryghost/admin-x-design-system": "0.0.0", + "@tryghost/admin-x-framework": "0.0.0", + "react": "18.3.1", + "react-dom": "18.3.1" } } diff --git a/apps/admin-x-activitypub/src/api/activitypub.test.ts b/apps/admin-x-activitypub/src/api/activitypub.test.ts index 63cddb3fbe97..88b7dab110be 100644 --- a/apps/admin-x-activitypub/src/api/activitypub.test.ts +++ b/apps/admin-x-activitypub/src/api/activitypub.test.ts @@ -580,4 +580,552 @@ describe('ActivityPubAPI', function () { expect(actual).toEqual(expected); }); }); + + describe('search', function () { + test('It returns the results of the search', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/actions/search?query=%40foo%40bar.baz': { + response: JSONResponse({ + profiles: [ + { + handle: '@foo@bar.baz', + name: 'Foo Bar' + } + ] + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.search('@foo@bar.baz'); + const expected = { + profiles: [ + { + handle: '@foo@bar.baz', + name: 'Foo Bar' + } + ] + }; + + expect(actual).toEqual(expected); + }); + }); + + describe('getFollowersForProfile', function () { + test('It returns an array of followers for a profile', async function () { + const handle = '@foo@bar.baz'; + + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + [`https://activitypub.api/.ghost/activitypub/profile/${handle}/followers`]: { + response: JSONResponse({ + followers: [ + { + actor: { + id: 'https://example.com/users/bar' + }, + isFollowing: false + }, + { + actor: { + id: 'https://example.com/users/baz' + }, + isFollowing: false + } + ], + next: null + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getFollowersForProfile(handle); + + expect(actual.followers).toEqual([ + { + actor: { + id: 'https://example.com/users/bar' + }, + isFollowing: false + }, + { + actor: { + id: 'https://example.com/users/baz' + }, + isFollowing: false + } + ]); + }); + + test('It returns next if it is present in the response', async function () { + const handle = '@foo@bar.baz'; + + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + [`https://activitypub.api/.ghost/activitypub/profile/${handle}/followers`]: { + response: JSONResponse({ + followers: [], + next: 'abc123' + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getFollowersForProfile(handle); + + expect(actual.next).toEqual('abc123'); + }); + + test('It includes next in the query when provided', async function () { + const handle = '@foo@bar.baz'; + const next = 'abc123'; + + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + [`https://activitypub.api/.ghost/activitypub/profile/${handle}/followers?next=${next}`]: { + response: JSONResponse({ + followers: [ + { + actor: { + id: 'https://example.com/users/qux' + }, + isFollowing: false + } + ], + next: null + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getFollowersForProfile(handle, next); + const expected = { + followers: [ + { + actor: { + id: 'https://example.com/users/qux' + }, + isFollowing: false + } + ], + next: null + }; + + expect(actual).toEqual(expected); + }); + + test('It returns a default return value when the response is null', async function () { + const handle = '@foo@bar.baz'; + + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + [`https://activitypub.api/.ghost/activitypub/profile/${handle}/followers`]: { + response: JSONResponse(null) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getFollowersForProfile(handle); + const expected = { + followers: [], + next: null + }; + + expect(actual).toEqual(expected); + }); + + test('It returns a default return value if followers is not present in the response', async function () { + const handle = '@foo@bar.baz'; + + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + [`https://activitypub.api/.ghost/activitypub/profile/${handle}/followers`]: { + response: JSONResponse({}) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getFollowersForProfile(handle); + const expected = { + followers: [], + next: null + }; + + expect(actual).toEqual(expected); + }); + + test('It returns an empty array of followers if followers in the response is not an array', async function () { + const handle = '@foo@bar.baz'; + + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + [`https://activitypub.api/.ghost/activitypub/profile/${handle}/followers`]: { + response: JSONResponse({ + followers: {} + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getFollowersForProfile(handle); + + expect(actual.followers).toEqual([]); + }); + }); + + describe('getFollowingForProfile', function () { + test('It returns a following arrayfor a profile', async function () { + const handle = '@foo@bar.baz'; + + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + [`https://activitypub.api/.ghost/activitypub/profile/${handle}/following`]: { + response: JSONResponse({ + following: [ + { + actor: { + id: 'https://example.com/users/bar' + }, + isFollowing: false + }, + { + actor: { + id: 'https://example.com/users/baz' + }, + isFollowing: false + } + ], + next: null + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getFollowingForProfile(handle); + + expect(actual.following).toEqual([ + { + actor: { + id: 'https://example.com/users/bar' + }, + isFollowing: false + }, + { + actor: { + id: 'https://example.com/users/baz' + }, + isFollowing: false + } + ]); + }); + + test('It returns next if it is present in the response', async function () { + const handle = '@foo@bar.baz'; + + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + [`https://activitypub.api/.ghost/activitypub/profile/${handle}/following`]: { + response: JSONResponse({ + following: [], + next: 'abc123' + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getFollowingForProfile(handle); + + expect(actual.next).toEqual('abc123'); + }); + + test('It includes next in the query when provided', async function () { + const handle = '@foo@bar.baz'; + const next = 'abc123'; + + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + [`https://activitypub.api/.ghost/activitypub/profile/${handle}/following?next=${next}`]: { + response: JSONResponse({ + following: [ + { + actor: { + id: 'https://example.com/users/qux' + }, + isFollowing: false + } + ], + next: null + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getFollowingForProfile(handle, next); + const expected = { + following: [ + { + actor: { + id: 'https://example.com/users/qux' + }, + isFollowing: false + } + ], + next: null + }; + + expect(actual).toEqual(expected); + }); + + test('It returns a default return value when the response is null', async function () { + const handle = '@foo@bar.baz'; + + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + [`https://activitypub.api/.ghost/activitypub/profile/${handle}/following`]: { + response: JSONResponse(null) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getFollowingForProfile(handle); + const expected = { + following: [], + next: null + }; + + expect(actual).toEqual(expected); + }); + + test('It returns a default return value if following is not present in the response', async function () { + const handle = '@foo@bar.baz'; + + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + [`https://activitypub.api/.ghost/activitypub/profile/${handle}/following`]: { + response: JSONResponse({}) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getFollowingForProfile(handle); + const expected = { + following: [], + next: null + }; + + expect(actual).toEqual(expected); + }); + + test('It returns an empty following array if following in the response is not an array', async function () { + const handle = '@foo@bar.baz'; + + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + [`https://activitypub.api/.ghost/activitypub/profile/${handle}/following`]: { + response: JSONResponse({ + following: {} + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getFollowingForProfile(handle); + + expect(actual.following).toEqual([]); + }); + }); + + describe('getProfile', function () { + test('It returns a profile', async function () { + const handle = '@foo@bar.baz'; + + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + [`https://activitypub.api/.ghost/activitypub/profile/${handle}`]: { + response: JSONResponse({ + handle, + name: 'Foo Bar' + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getProfile(handle); + const expected = { + handle, + name: 'Foo Bar' + }; + + expect(actual).toEqual(expected); + }); + }); }); diff --git a/apps/admin-x-activitypub/src/api/activitypub.ts b/apps/admin-x-activitypub/src/api/activitypub.ts index 61be82fd4f4b..977c3ef75a13 100644 --- a/apps/admin-x-activitypub/src/api/activitypub.ts +++ b/apps/admin-x-activitypub/src/api/activitypub.ts @@ -3,6 +3,34 @@ export type Actor = any; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type Activity = any; +export interface Profile { + actor: Actor; + handle: string; + followerCount: number; + isFollowing: boolean; + posts: Activity[]; +} + +export interface SearchResults { + profiles: Profile[]; +} + +export interface GetFollowersForProfileResponse { + followers: { + actor: Actor; + isFollowing: boolean; + }[]; + next: string | null; +} + +export interface GetFollowingForProfileResponse { + following: { + actor: Actor; + isFollowing: boolean; + }[]; + next: string | null; +} + export class ActivityPubAPI { constructor( private readonly apiUrl: URL, @@ -113,6 +141,68 @@ export class ActivityPubAPI { return 0; } + async getFollowersForProfile(handle: string, next?: string): Promise { + const url = new URL(`.ghost/activitypub/profile/${handle}/followers`, this.apiUrl); + if (next) { + url.searchParams.set('next', next); + } + + const json = await this.fetchJSON(url); + + if (json === null) { + return { + followers: [], + next: null + }; + } + + if (!('followers' in json)) { + return { + followers: [], + next: null + }; + } + + const followers = Array.isArray(json.followers) ? json.followers : []; + const nextPage = 'next' in json && typeof json.next === 'string' ? json.next : null; + + return { + followers, + next: nextPage + }; + } + + async getFollowingForProfile(handle: string, next?: string): Promise { + const url = new URL(`.ghost/activitypub/profile/${handle}/following`, this.apiUrl); + if (next) { + url.searchParams.set('next', next); + } + + const json = await this.fetchJSON(url); + + if (json === null) { + return { + following: [], + next: null + }; + } + + if (!('following' in json)) { + return { + following: [], + next: null + }; + } + + const following = Array.isArray(json.following) ? json.following : []; + const nextPage = 'next' in json && typeof json.next === 'string' ? json.next : null; + + return { + following, + next: nextPage + }; + } + async follow(username: string): Promise { const url = new URL(`.ghost/activitypub/actions/follow/${username}`, this.apiUrl); await this.fetchJSON(url, 'POST'); @@ -280,4 +370,30 @@ export class ActivityPubAPI { const json = await this.fetchJSON(this.userApiUrl); return json; } + + get searchApiUrl() { + return new URL('.ghost/activitypub/actions/search', this.apiUrl); + } + + async search(query: string): Promise { + const url = this.searchApiUrl; + + url.searchParams.set('query', query); + + const json = await this.fetchJSON(url, 'GET'); + + if (json && 'profiles' in json) { + return json as SearchResults; + } + + return { + profiles: [] + }; + } + + async getProfile(handle: string): Promise { + const url = new URL(`.ghost/activitypub/profile/${handle}`, this.apiUrl); + const json = await this.fetchJSON(url); + return json as Profile; + } } diff --git a/apps/admin-x-activitypub/src/components/Activities.tsx b/apps/admin-x-activitypub/src/components/Activities.tsx index a55f6a25e6de..c1dd96190baf 100644 --- a/apps/admin-x-activitypub/src/components/Activities.tsx +++ b/apps/admin-x-activitypub/src/components/Activities.tsx @@ -1,10 +1,12 @@ -import NiceModal from '@ebay/nice-modal-react'; import React, {useEffect, useRef} from 'react'; -import {Button, LoadingIndicator, NoValueLabel} from '@tryghost/admin-x-design-system'; + +import NiceModal from '@ebay/nice-modal-react'; +import {LoadingIndicator, NoValueLabel} from '@tryghost/admin-x-design-system'; import APAvatar, {AvatarBadge} from './global/APAvatar'; import ActivityItem, {type Activity} from './activities/ActivityItem'; import ArticleModal from './feed/ArticleModal'; +import FollowButton from './global/FollowButton'; import MainNavigation from './navigation/MainNavigation'; import getUsername from '../utils/get-username'; @@ -171,13 +173,12 @@ const Activities: React.FC = ({}) => {
{getActivityDescription(activity)}
{getExtendedDescription(activity)} - {isFollower(activity.actor.id) === false && ( - {{/if}}
  • -
  • {{else}} {{#if this.canCopySelection}}
  • -
  • {{/if}} {{#if this.canUnscheduleSelection}}
  • -
  • @@ -32,26 +32,26 @@ {{#if this.canFeatureSelection}} {{#if this.shouldFeatureSelection }}
  • -
  • {{else}}
  • -
  • {{/if}} {{/if}}
  • -
  • {{#if this.membersUtils.isMembersEnabled}}
  • -
  • @@ -59,13 +59,13 @@ {{#if this.session.user.isAdmin}} {{#if this.canCopySelection}}
  • -
  • {{/if}}
  • -
  • diff --git a/ghost/admin/app/components/posts-list/context-menu.js b/ghost/admin/app/components/posts-list/context-menu.js index e689acbd4269..5ff09f7c2b32 100644 --- a/ghost/admin/app/components/posts-list/context-menu.js +++ b/ghost/admin/app/components/posts-list/context-menu.js @@ -348,7 +348,7 @@ export default class PostsContextMenu extends Component { } return filterNql.queryJSON(model.serialize({includeId: true})); }); - // Deleteobjects method from infintiymodel is broken for all models except the first page, so we cannot use this + // Deleteobjects method from infinitymodel is broken for all models except the first page, so we cannot use this this.infinity.replace(this.selectionList.infinityModel[key], remainingModels); } @@ -369,12 +369,8 @@ export default class PostsContextMenu extends Component { id: post.id, type: this.type, attributes: { - visibility - }, - relationships: { - links: { - data: tiers - } + visibility, + tiers // tiers is a weird one, it's set up as an attribute but represents a relationship } } }); @@ -384,6 +380,8 @@ export default class PostsContextMenu extends Component { this.updateFilteredPosts(); close(); + + return true; } @task diff --git a/ghost/admin/app/components/posts-list/modals/edit-posts-access.js b/ghost/admin/app/components/posts-list/modals/edit-posts-access.js index e5858218027e..9369a14eacd7 100644 --- a/ghost/admin/app/components/posts-list/modals/edit-posts-access.js +++ b/ghost/admin/app/components/posts-list/modals/edit-posts-access.js @@ -1,15 +1,26 @@ import Component from '@glimmer/component'; +import EmberObject from '@ember/object'; +import ValidationEngine from 'ghost-admin/mixins/validation-engine'; import {action} from '@ember/object'; import {inject as service} from '@ember/service'; import {task} from 'ember-concurrency'; import {tracked} from '@glimmer/tracking'; +const PostValidatorProxy = EmberObject.extend(ValidationEngine, { + validationType: 'post', + isNew: false, // required for our visibility and tiers validations to work + + visibility: tracked(), + tiers: tracked() +}); + export default class EditPostsAccessModal extends Component { @service store; @service settings; - // We createa new post model to use the same validations as the post model - @tracked post = this.store.createRecord('post', {}); + // We use a simulated post model to use the same validations as the post model without + // putting any dummy records in the store and needing to force an "isNew: false" state + @tracked post = PostValidatorProxy.create(); get selectionList() { return this.args.data.selectionList; @@ -18,29 +29,27 @@ export default class EditPostsAccessModal extends Component { @action setup() { if (this.selectionList.first && this.selectionList.isSingle) { - this.post.set('visibility', this.selectionList.first.visibility); - this.post.set('tiers', this.selectionList.first.tiers); + this.post.visibility = this.selectionList.first.visibility; + this.post.tiers = this.selectionList.first.tiers || []; } else { // Use default - this.post.set('visibility', this.settings.defaultContentVisibility); - this.post.set('tiers', this.settings.defaultContentVisibilityTiers.map((tier) => { + this.post.visibility = this.settings.defaultContentVisibility; + this.post.tiers = this.settings.defaultContentVisibilityTiers.map((tier) => { return { id: tier }; - })); + }); } } async validate() { - // Mark as not new - this.post.set('currentState.parentState.isNew', false); await this.post.validate({property: 'visibility'}); await this.post.validate({property: 'tiers'}); } @action async setVisibility(segment) { - this.post.set('tiers', segment); + this.post.tiers = segment; try { await this.validate(); } catch (e) { diff --git a/ghost/admin/app/components/stats/charts/kpis.js b/ghost/admin/app/components/stats/charts/kpis.js index bab80d909154..9378e3bc2045 100644 --- a/ghost/admin/app/components/stats/charts/kpis.js +++ b/ghost/admin/app/components/stats/charts/kpis.js @@ -54,7 +54,7 @@ export default class KpisComponent extends Component { options={{ grid: { left: '10px', - right: '10px', + right: '20px', top: '10%', bottom: 0, containLabel: true diff --git a/ghost/admin/app/components/stats/charts/technical.hbs b/ghost/admin/app/components/stats/charts/technical.hbs index 07bb4d29fa83..e2cc88672f99 100644 --- a/ghost/admin/app/components/stats/charts/technical.hbs +++ b/ghost/admin/app/components/stats/charts/technical.hbs @@ -1 +1 @@ -
    +
    diff --git a/ghost/admin/app/components/stats/charts/technical.js b/ghost/admin/app/components/stats/charts/technical.js index 0f31c847554f..96b5f3b74122 100644 --- a/ghost/admin/app/components/stats/charts/technical.js +++ b/ghost/admin/app/components/stats/charts/technical.js @@ -29,7 +29,7 @@ export default class TechnicalComponent extends Component { ReactComponent = (props) => { const {selected} = props; - const colorPalette = statsStaticColors.slice(1, 5); + const colorPalette = statsStaticColors.slice(0, 5); const params = getStatsParams( this.config, @@ -66,34 +66,6 @@ export default class TechnicalComponent extends Component { return (
    - - - - - - - - - {transformedData.map((item, index) => ( - - - - - ))} - -
    {tableHead}Visits
    - { - e.preventDefault(); - this.navigateToFilter(indexBy, item.name.toLowerCase()); - }} - className="gh-stats-data-label" - > - - {item.name} - - {formatNumber(item.value)}
    ${fparams.name}: ${formatNumber(fparams.value)}`; + return ` ${fparams.name} ${formatNumber(fparams.value)}`; } }, legend: { @@ -135,8 +107,9 @@ export default class TechnicalComponent extends Component { { animation: true, name: tableHead, + padAngle: 1.5, type: 'pie', - radius: ['60%', '90%'], + radius: ['67%', '90%'], center: ['50%', '50%'], // Adjusted to align the chart to the top data: transformedData, label: { @@ -157,6 +130,34 @@ export default class TechnicalComponent extends Component { }} />
    + + + + + + + + + {transformedData.map((item, index) => ( + + + + + ))} + +
    {tableHead}Visits
    + { + e.preventDefault(); + this.navigateToFilter(indexBy, item.name.toLowerCase()); + }} + className="gh-stats-data-label" + > + + {item.name} + + {formatNumber(item.value)}
    ); }; diff --git a/ghost/admin/app/components/stats/charts/top-locations.hbs b/ghost/admin/app/components/stats/charts/top-locations.hbs index 11fabca6f9a3..0203cd86bef7 100644 --- a/ghost/admin/app/components/stats/charts/top-locations.hbs +++ b/ghost/admin/app/components/stats/charts/top-locations.hbs @@ -3,8 +3,10 @@
    +{{#if this.showSeeAll}}
    -
    \ No newline at end of file + +{{/if}} \ No newline at end of file diff --git a/ghost/admin/app/components/stats/charts/top-locations.js b/ghost/admin/app/components/stats/charts/top-locations.js index a1c10f09670f..df19d4d26552 100644 --- a/ghost/admin/app/components/stats/charts/top-locations.js +++ b/ghost/admin/app/components/stats/charts/top-locations.js @@ -3,18 +3,27 @@ import AllStatsModal from '../modal-stats-all'; import Component from '@glimmer/component'; import React from 'react'; +import countries from 'i18n-iso-countries'; +import enLocale from 'i18n-iso-countries/langs/en.json'; import {BarList, useQuery} from '@tinybirdco/charts'; import {action} from '@ember/object'; import {barListColor, getCountryFlag, getStatsParams} from 'ghost-admin/utils/stats'; import {formatNumber} from 'ghost-admin/helpers/format-number'; import {inject} from 'ghost-admin/decorators/inject'; import {inject as service} from '@ember/service'; +import {tracked} from '@glimmer/tracking'; + +const LIMIT = 6; + +countries.registerLocale(enLocale); export default class TopLocations extends Component { @inject config; @service modals; @service router; + @tracked showSeeAll = true; + @action openSeeAll() { this.modals.open(AllStatsModal, { @@ -36,6 +45,14 @@ export default class TopLocations extends Component { this.router.transitionTo({queryParams: newQueryParams}); } + updateSeeAllVisibility(data) { + this.showSeeAll = data && data.length > LIMIT; + } + + getCountryName = (label) => { + return countries.getName(label, 'en') || 'Unknown'; + }; + ReactComponent = (props) => { const params = getStatsParams( this.config, @@ -49,9 +66,11 @@ export default class TopLocations extends Component { params }); + this.updateSeeAllVisibility(data); + return ( - {getCountryFlag(label)} {label || 'Unknown'} + {getCountryFlag(label)} {this.getCountryName(label) || 'Unknown' || 'Unknown'} ) diff --git a/ghost/admin/app/components/stats/charts/top-pages.hbs b/ghost/admin/app/components/stats/charts/top-pages.hbs index 1a4ca5eef9e2..abcfd368874c 100644 --- a/ghost/admin/app/components/stats/charts/top-pages.hbs +++ b/ghost/admin/app/components/stats/charts/top-pages.hbs @@ -21,8 +21,10 @@
    +{{#if this.showSeeAll}}
    -
    \ No newline at end of file + +{{/if}} \ No newline at end of file diff --git a/ghost/admin/app/components/stats/charts/top-pages.js b/ghost/admin/app/components/stats/charts/top-pages.js index 9b8c2ef23849..16447f19322c 100644 --- a/ghost/admin/app/components/stats/charts/top-pages.js +++ b/ghost/admin/app/components/stats/charts/top-pages.js @@ -11,6 +11,8 @@ import {inject} from 'ghost-admin/decorators/inject'; import {inject as service} from '@ember/service'; import {tracked} from '@glimmer/tracking'; +const LIMIT = 6; + export default class TopPages extends Component { @inject config; @service modals; @@ -18,6 +20,7 @@ export default class TopPages extends Component { @tracked contentOption = CONTENT_OPTIONS[0]; @tracked contentOptions = CONTENT_OPTIONS; + @tracked showSeeAll = true; @action openSeeAll(chartRange, audience) { @@ -45,11 +48,15 @@ export default class TopPages extends Component { this.router.transitionTo({queryParams: newQueryParams}); } + updateSeeAllVisibility(data) { + this.showSeeAll = data && data.length > LIMIT; + } + ReactComponent = (props) => { const params = getStatsParams( this.config, props, - {limit: 7} + {limit: LIMIT + 1} ); const {data, meta, error, loading} = useQuery({ @@ -58,9 +65,11 @@ export default class TopPages extends Component { params }); + this.updateSeeAllVisibility(data); + return ( - {label} + {label} ) diff --git a/ghost/admin/app/components/stats/charts/top-sources.hbs b/ghost/admin/app/components/stats/charts/top-sources.hbs index 80db234c9fa1..47a32fbf3d2c 100644 --- a/ghost/admin/app/components/stats/charts/top-sources.hbs +++ b/ghost/admin/app/components/stats/charts/top-sources.hbs @@ -22,8 +22,10 @@
    +{{#if this.showSeeAll}}
    -
    \ No newline at end of file + +{{/if}} \ No newline at end of file diff --git a/ghost/admin/app/components/stats/charts/top-sources.js b/ghost/admin/app/components/stats/charts/top-sources.js index 5c8254d492dd..9a83016dbf9e 100644 --- a/ghost/admin/app/components/stats/charts/top-sources.js +++ b/ghost/admin/app/components/stats/charts/top-sources.js @@ -11,6 +11,9 @@ import {inject} from 'ghost-admin/decorators/inject'; import {inject as service} from '@ember/service'; import {tracked} from '@glimmer/tracking'; +const LIMIT = 6; +const DEFAULT_ICON_URL = 'https://static.ghost.org/v5.0.0/images/globe-icon.svg'; + export default class TopSources extends Component { @inject config; @service modals; @@ -18,6 +21,7 @@ export default class TopSources extends Component { @tracked campaignOption = CAMPAIGN_OPTIONS[0]; @tracked campaignOptions = CAMPAIGN_OPTIONS; + @tracked showSeeAll = true; @action onCampaignOptionChange(selected) { @@ -45,6 +49,10 @@ export default class TopSources extends Component { this.router.transitionTo({queryParams: newQueryParams}); } + updateSeeAllVisibility(data) { + this.showSeeAll = data && data.length > LIMIT; + } + ReactComponent = (props) => { const {data, meta, error, loading} = useQuery({ endpoint: `${this.config.stats.endpoint}/v0/pipes/top_sources.json`, @@ -56,9 +64,11 @@ export default class TopSources extends Component { ) }); + this.updateSeeAllVisibility(data); + return ( - - {label || 'Direct'} + { + e.target.src = DEFAULT_ICON_URL; + }} /> + {label || 'Direct'} ) diff --git a/ghost/admin/app/components/stats/modal-stats-all.hbs b/ghost/admin/app/components/stats/modal-stats-all.hbs index d402e33de369..88529b1ec6cb 100644 --- a/ghost/admin/app/components/stats/modal-stats-all.hbs +++ b/ghost/admin/app/components/stats/modal-stats-all.hbs @@ -7,7 +7,7 @@ -
    +