Skip to content

feat: add Rust language support and concept group handling #2672

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
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
4 changes: 2 additions & 2 deletions app/components/track-page/card.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<div class="flex items-center justify-between gap-3 mb-4">
<div class="flex items-center flex-wrap gap-3">
<div
class="text-lg md:text-xl font-semibold text-gray-800 dark:text-gray-200 flex items-center
class="text-xl font-semibold text-gray-800 dark:text-gray-200 flex items-center
{{if @isNavigatingToOtherPage 'group-hover:text-teal-500 transition-colors'}}"
data-test-track-page-card-title
>
Expand All @@ -31,7 +31,7 @@
{{#if @isComplete}}
{{svg-jar "check-circle" class="w-8 h-8 fill-current text-teal-500"}}
{{else if (and @logoImageUrl @logoImageAltText)}}
<img src={{@logoImageUrl}} alt={{@logoImageAltText}} class="w-8 md:w-12 transform scale-100 group-hover:scale-105 transition-all" />
<img src={{@logoImageUrl}} alt={{@logoImageAltText}} class="w-8 transform scale-100 group-hover:scale-105 transition-all" />
{{/if}}
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ interface Signature {
};
}

export default class TrackPageIntroductionAndCoursesComponent extends Component<Signature> {
export default class TrackPageCourseCardListComponent extends Component<Signature> {
logoImage = logoImage;
comingSoonImage = comingSoonImage;

Expand All @@ -37,17 +37,10 @@ export default class TrackPageIntroductionAndCoursesComponent extends Component<
};
});
}

get userHasStartedTrack() {
return (
this.authenticator.currentUser &&
this.authenticator.currentUser.repositories.filterBy('language', this.args.language).filterBy('lastSubmissionAt')[0]
);
}
}

declare module '@glint/environment-ember-loose/registry' {
export default interface Registry {
'TrackPage::IntroductionAndCourses': typeof TrackPageIntroductionAndCoursesComponent;
'TrackPage::CourseCardList': typeof TrackPageCourseCardListComponent;
}
}
4 changes: 2 additions & 2 deletions app/components/track-page/course-card.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
</:afterTitle>

<:default>
<div class="leading-6 prose dark:prose-invert mb-6">
<div class="leading-6 prose dark:prose-invert mb-4 pr-8">
{{markdown-to-html this.introductionMarkdown}}
</div>

Expand Down Expand Up @@ -53,7 +53,7 @@
</div>
{{/if}}

<div class="leading-6 prose dark:prose-invert mb-6 pr-8 md:pr-12">
<div class="leading-6 prose dark:prose-invert mb-4 pr-8">
{{markdown-to-html this.introductionMarkdown}}
</div>

Expand Down
33 changes: 17 additions & 16 deletions app/components/track-page/header/index.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -29,25 +29,26 @@
{{#if (has-block "cta")}}
{{yield to="cta"}}
{{else}}
<div class="flex items-center flex-wrap gap-x-2 gap-y-4 mt-5">
{{#if this.currentUserHasStartedTrack}}
{{#if this.currentUserHasStartedTrack}}
<div class="flex items-center flex-wrap gap-x-2 gap-y-4 mt-5">
<TrackPage::ResumeTrackButton @language={{@language}} @courses={{@courses}} />
{{else}}
<TrackPage::StartTrackButton @language={{@language}} @courses={{@courses}} />
{{/if}}

{{#if (gt this.topParticipants.length 0)}}
<div class="hidden sm:flex items-center">
<div class="flex -space-x-1 hover:space-x-1 items-center">
{{#each this.topParticipants as |user|}}
<TrackPage::Header::TopParticipantAvatar @user={{user}} />
{{/each}}
</div>
{{! TODO: Bring this the "Start Track" button once we can account for both concepts & challenges }}
{{!-- <TrackPage::StartTrackButton @language={{@language}} @courses={{@courses}} /> --}}

<span class="text-xs text-gray-600 dark:text-gray-400 ml-2">Join the best</span>
</div>
{{/if}}
</div>
{{#if (gt this.topParticipants.length 0)}}
<div class="hidden sm:flex items-center">
<div class="flex -space-x-1 hover:space-x-1 items-center">
{{#each this.topParticipants as |user|}}
<TrackPage::Header::TopParticipantAvatar @user={{user}} />
{{/each}}
</div>

<span class="text-xs text-gray-600 dark:text-gray-400 ml-2">Join the best</span>
</div>
{{/if}}
</div>
{{/if}}
{{/if}}
</div>
<div class="ml-4 hidden md:flex h-36 w-36 flex-shrink-0">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<LinkTo
@route="concept"
@models={{array @concept.slug}}
class="block hover:bg-gray-50 dark:hover:bg-gray-700/50 py-1.5 -mx-1.5 px-1.5 rounded"
data-test-concept-list-item
...attributes
>
<div class="flex items-center justify-between">
<div class="flex items-center">
<div class="w-2 h-2 {{if @isComplete 'bg-teal-500' 'bg-gray-200 dark:bg-gray-700'}} rounded-full mr-2"></div>

{{! TODO: Qualify all heroicons with solid/outline }}
{{svg-jar "book-open-outline" class="w-6 mr-2 text-gray-400 dark:text-gray-600"}}

<div class="prose dark:prose-invert prose-sm">
{{@concept.title}}
</div>
</div>

{{#if @isComplete}}
{{svg-jar "check" class="ml-1 w-5 text-teal-500"}}
{{else}}
<div class="flex items-center gap-1 pl-4 flex-shrink-0">
<span class="text-xs text-gray-400 dark:text-gray-600">
{{@concept.estimatedReadingTimeInMinutes}}
mins
</span>

{{svg-jar "clock" class="w-4 fill-current text-gray-300 dark:text-gray-700"}}
</div>
{{/if}}

<EmberTooltip @text={{@concept.descriptionMarkdown}} />
</div>
</LinkTo>
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import Component from '@glimmer/component';
import type AuthenticatorService from 'codecrafters-frontend/services/authenticator';
import type ConceptModel from 'codecrafters-frontend/models/concept';
import { service } from '@ember/service';

interface Signature {
Element: HTMLAnchorElement;

Args: {
isComplete: boolean;
concept: ConceptModel;
};
}

export default class ConceptListItemComponent extends Component<Signature> {
@service declare authenticator: AuthenticatorService;
}

declare module '@glint/environment-ember-loose/registry' {
export default interface Registry {
'TrackPage::PrimerConceptGroupSection::ConceptListItem': typeof ConceptListItemComponent;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{{#each this.sortedConcepts as |concept|}}
<TrackPage::PrimerConceptGroupSection::ConceptListItem @concept={{concept}} @isComplete={{false}} />
{{/each}}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import Component from '@glimmer/component';
import ConceptGroupModel from 'codecrafters-frontend/models/concept-group';
import ConceptModel from 'codecrafters-frontend/models/concept';
import Store from '@ember-data/store';
import { inject as service } from '@ember/service';

interface Signature {
Element: HTMLDivElement;

Args: {
conceptGroup: ConceptGroupModel;
};
}

export default class ConceptListComponent extends Component<Signature> {
@service declare store: Store;

get sortedConcepts() {
return this.args.conceptGroup.conceptSlugs.reduce((acc, slug) => {
const concept = this.store.peekAll('concept').find((concept) => concept.slug === slug);

Check warning on line 21 in app/components/track-page/primer-concept-group-section/concept-list.ts

View check run for this annotation

Codecov / codecov/patch

app/components/track-page/primer-concept-group-section/concept-list.ts#L20-L21

Added lines #L20 - L21 were not covered by tests
if (concept) {
acc.push(concept);
}

Check warning on line 24 in app/components/track-page/primer-concept-group-section/concept-list.ts

View check run for this annotation

Codecov / codecov/patch

app/components/track-page/primer-concept-group-section/concept-list.ts#L24

Added line #L24 was not covered by tests

return acc;
}, [] as ConceptModel[]);

Check warning on line 27 in app/components/track-page/primer-concept-group-section/concept-list.ts

View check run for this annotation

Codecov / codecov/patch

app/components/track-page/primer-concept-group-section/concept-list.ts#L27

Added line #L27 was not covered by tests
}
Comment on lines +18 to +28
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Use findAll() instead of peekAll() for reliable data fetching.

The current implementation has potential issues:

  1. peekAll() only returns already loaded records, which could miss concepts if they haven't been loaded yet.
  2. Missing error handling for cases where concepts are not found.

Consider this implementation:

  get sortedConcepts() {
-   return this.args.conceptGroup.conceptSlugs.reduce((acc, slug) => {
-     const concept = this.store.peekAll('concept').find((concept) => concept.slug === slug);
-
-     if (concept) {
-       acc.push(concept);
-     }
-
-     return acc;
-   }, [] as ConceptModel[]);
+   return Promise.all(
+     this.args.conceptGroup.conceptSlugs.map(async (slug) => {
+       try {
+         const concepts = await this.store.findAll('concept');
+         return concepts.find((concept) => concept.slug === slug);
+       } catch (error) {
+         console.error(`Failed to load concept ${slug}:`, error);
+         return null;
+       }
+     })
+   ).then((concepts) => concepts.filter((concept): concept is ConceptModel => concept !== null));
  }

The static analysis indicates missing test coverage. Let's add tests:

import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';

module('Unit | Component | track-page/primer-concept-group-section/concept-list', function(hooks) {
  setupTest(hooks);
  setupMirage(hooks);

  test('sortedConcepts returns concepts in correct order', async function(assert) {
    const store = this.owner.lookup('service:store');
    const component = this.owner.lookup('component:track-page/primer-concept-group-section/concept-list');
    
    // Setup test data
    const conceptGroup = store.createRecord('concept-group', {
      conceptSlugs: ['concept-1', 'concept-2']
    });
    
    // Create test concepts
    store.createRecord('concept', { slug: 'concept-1', title: 'First Concept' });
    store.createRecord('concept', { slug: 'concept-2', title: 'Second Concept' });
    
    component.args = { conceptGroup };
    
    const concepts = await component.sortedConcepts;
    assert.strictEqual(concepts.length, 2, 'Returns all concepts');
    assert.strictEqual(concepts[0].slug, 'concept-1', 'First concept is correct');
    assert.strictEqual(concepts[1].slug, 'concept-2', 'Second concept is correct');
  });
});
🧰 Tools
🪛 GitHub Check: codecov/patch

[warning] 20-21: app/components/track-page/primer-concept-group-section/concept-list.ts#L20-L21
Added lines #L20 - L21 were not covered by tests


[warning] 24-24: app/components/track-page/primer-concept-group-section/concept-list.ts#L24
Added line #L24 was not covered by tests


[warning] 27-27: app/components/track-page/primer-concept-group-section/concept-list.ts#L27
Added line #L27 was not covered by tests

}

declare module '@glint/environment-ember-loose/registry' {
export default interface Registry {
'TrackPage::PrimerConceptGroupSection::ConceptList': typeof ConceptListComponent;
}
}
50 changes: 50 additions & 0 deletions app/components/track-page/primer-concept-group-section/index.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{{! template-lint-disable no-invalid-interactive }}
<div
class="relative group/concept-group-section {{unless this.conceptListIsExpanded 'cursor-pointer'}}"
{{on "click" this.handleSectionClick}}
role={{if this.conceptListIsExpanded "region" "button"}}
...attributes
data-test-primer-concept-group-section
>
<div class="border-b pb-1 mb-4 flex items-center gap-2 {{unless this.conceptListIsExpanded 'group-hover/concept-group-section:border-gray-300'}}">
<div class="text-2xl font-semibold text-gray-800 dark:text-gray-200">
{{@language.name}}
Basics
</div>
<Pill @color="green">
FREE
<EmberTooltip @text="These interactive guides are 100% free to access." />
</Pill>
</div>

<div class="prose dark:prose-invert mb-4">
<p>
New to
{{@language.name}}? Start with our interactive byte-sized guides that cover the basics.
</p>
</div>

<div class={{if this.conceptListIsExpanded "" "h-60 overflow-hidden"}}>
<TrackPage::PrimerConceptGroupSection::ConceptList @conceptGroup={{@conceptGroup}} />

{{#if this.conceptListIsExpanded}}
<div class="flex items-center justify-center mt-2">
<PrimaryButton {{on "click" this.handleCollapseButtonClick}}>
↑ Collapse ↑
</PrimaryButton>
</div>
{{else}}
<div class="absolute top-20 bottom-0 left-0 right-0 vertical-mask dark:vertical-mask-gray-850 rounded-b-md p-4 flex items-end justify-center">
{{#if this.authenticator.isAuthenticated}}
<PrimaryButton class="mb-4 group-hover/concept-group-section:bg-teal-600 w-full sm:w-auto">
↓ Show all ↓
</PrimaryButton>
{{else}}
<PrimaryButton class="mb-4 group-hover/concept-group-section:bg-teal-600 w-full sm:w-auto">
<span>Start learning →</span>
</PrimaryButton>
{{/if}}
</div>
{{/if}}
</div>
</div>
51 changes: 51 additions & 0 deletions app/components/track-page/primer-concept-group-section/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import Component from '@glimmer/component';
import ConceptGroupModel from 'codecrafters-frontend/models/concept-group';
import LanguageModel from 'codecrafters-frontend/models/language';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { next } from '@ember/runloop';
import { service } from '@ember/service';
import type AuthenticatorService from 'codecrafters-frontend/services/authenticator';
import type RouterService from '@ember/routing/router-service';

interface Signature {
Element: HTMLDivElement;

Args: {
language: LanguageModel;
conceptGroup: ConceptGroupModel;
};
}

export default class TrackPagePrimerConceptGroupSectionComponent extends Component<Signature> {
@service declare authenticator: AuthenticatorService;
@service declare router: RouterService;

@tracked conceptListIsExpanded = false;

Check warning on line 25 in app/components/track-page/primer-concept-group-section/index.ts

View check run for this annotation

Codecov / codecov/patch

app/components/track-page/primer-concept-group-section/index.ts#L25

Added line #L25 was not covered by tests
Comment on lines +20 to +25
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

❓ Verification inconclusive

Add tests for component state management.

The conceptListIsExpanded tracked property lacks test coverage. Consider adding tests for:

  • Initial state verification
  • State changes through actions
  • Component rendering in both expanded and collapsed states

🏁 Script executed:

#!/bin/bash
# Check if test file exists
fd -t f "primer-concept-group-section-test.ts" app/components/track-page/

Length of output: 73


Add tests for component state management in TrackPagePrimerConceptGroupSectionComponent.

It appears that the test file (app/components/track-page/primer-concept-group-section-test.ts) is missing—meaning the conceptListIsExpanded tracked property currently lacks proper test coverage. Please add tests to verify that:

  • The property initially evaluates to false.
  • Actions correctly toggle the state.
  • The component renders properly in both the expanded and collapsed states.
🧰 Tools
🪛 GitHub Check: codecov/patch

[warning] 25-25: app/components/track-page/primer-concept-group-section/index.ts#L25
Added line #L25 was not covered by tests

@action
handleCollapseButtonClick() {
next(() => {
this.conceptListIsExpanded = false;
});

Check warning on line 30 in app/components/track-page/primer-concept-group-section/index.ts

View check run for this annotation

Codecov / codecov/patch

app/components/track-page/primer-concept-group-section/index.ts#L29-L30

Added lines #L29 - L30 were not covered by tests
}

@action
handleSectionClick() {
if (this.conceptListIsExpanded) {
return;
}

Check warning on line 37 in app/components/track-page/primer-concept-group-section/index.ts

View check run for this annotation

Codecov / codecov/patch

app/components/track-page/primer-concept-group-section/index.ts#L37

Added line #L37 was not covered by tests

if (this.authenticator.isAuthenticated) {
this.conceptListIsExpanded = true;
} else {

Check warning on line 41 in app/components/track-page/primer-concept-group-section/index.ts

View check run for this annotation

Codecov / codecov/patch

app/components/track-page/primer-concept-group-section/index.ts#L41

Added line #L41 was not covered by tests
this.router.transitionTo('concept', this.args.conceptGroup.conceptSlugs[0]!);
}

Check warning on line 43 in app/components/track-page/primer-concept-group-section/index.ts

View check run for this annotation

Codecov / codecov/patch

app/components/track-page/primer-concept-group-section/index.ts#L43

Added line #L43 was not covered by tests
}
Comment on lines +33 to +44
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Add comprehensive tests for section click handler.

The handleSectionClick action contains important business logic for handling authenticated and unauthenticated states. Add tests to cover:

  • Early return when already expanded
  • Behavior for authenticated users
  • Navigation for unauthenticated users
  • Validation of concept slug access

🏁 Script executed:

#!/bin/bash
# Description: Check test coverage for authentication paths

# Look for existing tests covering authentication scenarios
rg -l "authenticator\.isAuthenticated.*conceptListIsExpanded" app/components/track-page/
rg -l "router\.transitionTo.*concept" app/components/track-page/

Length of output: 221


Enhance Test Coverage for handleSectionClick Action

The handleSectionClick action in app/components/track-page/primer-concept-group-section/index.ts encapsulates critical business logic. Please add unit tests to comprehensively cover the following scenarios:

  • Early Return: Verify that if conceptListIsExpanded is already true, the method returns immediately without further processing.
  • Authenticated Flow: Ensure that when this.authenticator.isAuthenticated is true, the handler correctly sets this.conceptListIsExpanded to true.
  • Unauthenticated Flow: Confirm that if the user is not authenticated, the router navigates to the 'concept' route using the first slug from this.args.conceptGroup.conceptSlugs.
  • Concept Slug Validation: Validate that the correct concept slug is accessed and passed during the navigation process.

These tests will safeguard the action's functionality and ensure robust handling of different authentication states.

🧰 Tools
🪛 GitHub Check: codecov/patch

[warning] 37-37: app/components/track-page/primer-concept-group-section/index.ts#L37
Added line #L37 was not covered by tests


[warning] 41-41: app/components/track-page/primer-concept-group-section/index.ts#L41
Added line #L41 was not covered by tests


[warning] 43-43: app/components/track-page/primer-concept-group-section/index.ts#L43
Added line #L43 was not covered by tests

}

declare module '@glint/environment-ember-loose/registry' {
export default interface Registry {
'TrackPage::PrimerConceptGroupSection': typeof TrackPagePrimerConceptGroupSectionComponent;
}
}
4 changes: 3 additions & 1 deletion app/controllers/track.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import type AuthenticatorService from 'codecrafters-frontend/services/authenticator';
import type CourseModel from 'codecrafters-frontend/models/course';
import type Store from '@ember-data/store';
import { inject as service } from '@ember/service';
import { type ModelType } from 'codecrafters-frontend/routes/track';

export default class TrackController extends Controller {
declare model: ModelType;

@service declare authenticator: AuthenticatorService;
@service declare store: Store;

get courses(): CourseModel[] {
if (this.authenticator.currentUser && this.authenticator.currentUser.isStaff) {
Expand Down
12 changes: 12 additions & 0 deletions app/models/language.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,10 @@
return this.slug === 'go';
}

get isRust() {
return this.slug === 'rust';

Check warning on line 149 in app/models/language.ts

View check run for this annotation

Codecov / codecov/patch

app/models/language.ts#L149

Added line #L149 was not covered by tests
}

get sortPositionForTrack() {
return [
// Popular tracks, sorted by usage
Expand Down Expand Up @@ -247,4 +251,12 @@
zig: 7037,
}[this.slug];
}

get trackPrimerConceptGroupSlug() {
if (this.isRust) {
return 'rust-primer';

Check warning on line 257 in app/models/language.ts

View check run for this annotation

Codecov / codecov/patch

app/models/language.ts#L257

Added line #L257 was not covered by tests
} else {
return null;

Check warning on line 259 in app/models/language.ts

View check run for this annotation

Codecov / codecov/patch

app/models/language.ts#L259

Added line #L259 was not covered by tests
}
}
}
7 changes: 7 additions & 0 deletions app/routes/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import BaseRoute from 'codecrafters-frontend/utils/base-route';
import RepositoryPoller from 'codecrafters-frontend/utils/repository-poller';
import { hash as RSVPHash } from 'rsvp';
import RouteInfoMetadata, { RouteColorScheme } from 'codecrafters-frontend/utils/route-info-metadata';
import type LanguageModel from 'codecrafters-frontend/models/language';

export type ModelType = {
repositories?: RepositoryModel[];
Expand All @@ -26,6 +27,7 @@ export default class CatalogRoute extends BaseRoute {
const modelPromises: {
repositories?: Promise<RepositoryModel[]>;
courses?: Promise<CourseModel[]>;
_languages?: Promise<LanguageModel[]>;
} = {};

if (this.authenticator.isAuthenticated) {
Expand All @@ -39,6 +41,11 @@ export default class CatalogRoute extends BaseRoute {
include: 'extensions,stages,language-configurations.language',
}) as unknown as Promise<CourseModel[]>;

// Resources required by the track page
modelPromises._languages = this.store.findAll('language', {
include: 'primer-concept-group,primer-concept-group.author,primer-concept-group.concepts,primer-concept-group.concepts.author',
}) as unknown as Promise<LanguageModel[]>;

return RSVPHash(modelPromises) as Promise<ModelType>;
}
}
Loading
Loading