Add v2 create-workspace onboarding screen (#22075)
https://github.com/user-attachments/assets/30d69db2-ef50-48b5-8233-d9a36511b5e8 Builds the second step of the new onboarding flow on top of #22027: the v2 "Create your workspace" screen, shown inside `/welcome-v2` at the `WorkspaceCreation` step. What changed: - New `SignInUpV2Header` (back chevron + Twenty logo) and `SignInUpWorkspaceCreationFormV2` (left-aligned title/subtitle, logo upload, Name + Subdomain fields, "Create workspace"), wired into `SignInUpV2` for the workspace-creation step. - When a subdomain is taken, a box now lists 3 server-verified-available alternatives. Backend `SubdomainAvailabilityDTO` returns `suggestedSubdomains` via a new `findAvailableSubdomains` helper. - The shared `useWorkspaceSubdomainField` hook is extended additively (new `suggestions` + `applySuggestionValue`) so the v1 `/welcome` screen is untouched. Reviewer notes: - `generated-metadata/graphql.ts` was hand-patched (metadata codegen needs a running server). - Storybook: `Pages/Auth/SignInUpV2 → WorkspaceCreation`. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22075?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+3
@@ -10,4 +10,7 @@ export class SubdomainAvailabilityDTO {
|
||||
|
||||
@Field(() => String)
|
||||
suggestedSubdomain: string;
|
||||
|
||||
@Field(() => [String])
|
||||
suggestedSubdomains: string[];
|
||||
}
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { SubdomainManagerService } from 'src/engine/core-modules/domain/subdomain-manager/services/subdomain-manager.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
describe('SubdomainManagerService', () => {
|
||||
let service: SubdomainManagerService;
|
||||
const takenSubdomains = new Set<string>();
|
||||
let areAllSubdomainsTaken = false;
|
||||
|
||||
beforeEach(async () => {
|
||||
takenSubdomains.clear();
|
||||
areAllSubdomainsTaken = false;
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SubdomainManagerService,
|
||||
{
|
||||
provide: getRepositoryToken(WorkspaceEntity),
|
||||
useValue: {
|
||||
findOne: jest.fn((options: { where: { subdomain: string } }) =>
|
||||
Promise.resolve(
|
||||
areAllSubdomainsTaken ||
|
||||
takenSubdomains.has(options.where.subdomain)
|
||||
? ({} as WorkspaceEntity)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
find: jest.fn(
|
||||
(options: { where: { subdomain: { value: string[] } } }) =>
|
||||
Promise.resolve(
|
||||
options.where.subdomain.value
|
||||
.filter(
|
||||
(candidate) =>
|
||||
areAllSubdomainsTaken || takenSubdomains.has(candidate),
|
||||
)
|
||||
.map((subdomain) => ({ subdomain }) as WorkspaceEntity),
|
||||
),
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TwentyConfigService,
|
||||
useValue: {
|
||||
get: jest.fn().mockReturnValue('app'),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(SubdomainManagerService);
|
||||
});
|
||||
|
||||
describe('getSubdomainAvailability', () => {
|
||||
it('returns the input as the only suggestion when it is free', async () => {
|
||||
const result = await service.getSubdomainAvailability('stripe');
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.available).toBe(true);
|
||||
expect(result.suggestedSubdomain).toBe('stripe');
|
||||
expect(result.suggestedSubdomains).toEqual(['stripe']);
|
||||
});
|
||||
|
||||
it('returns three distinct available alternatives when taken', async () => {
|
||||
takenSubdomains.add('stripe');
|
||||
|
||||
const result = await service.getSubdomainAvailability('stripe');
|
||||
|
||||
expect(result.available).toBe(false);
|
||||
expect(result.suggestedSubdomains).toHaveLength(3);
|
||||
expect(new Set(result.suggestedSubdomains).size).toBe(3);
|
||||
expect(result.suggestedSubdomains).not.toContain('stripe');
|
||||
expect(result.suggestedSubdomain).toBe(result.suggestedSubdomains[0]);
|
||||
result.suggestedSubdomains.forEach((candidate) =>
|
||||
expect(takenSubdomains.has(candidate)).toBe(false),
|
||||
);
|
||||
});
|
||||
|
||||
it('skips taken numbered suffixes when collecting alternatives', async () => {
|
||||
takenSubdomains.add('stripe');
|
||||
takenSubdomains.add('stripe-2');
|
||||
|
||||
const result = await service.getSubdomainAvailability('stripe');
|
||||
|
||||
expect(result.suggestedSubdomains).toEqual([
|
||||
'stripe-3',
|
||||
'stripe-4',
|
||||
'stripe-5',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAvailableSubdomains', () => {
|
||||
it('returns the requested number of distinct available subdomains', async () => {
|
||||
takenSubdomains.add('acme');
|
||||
|
||||
const subdomains = await service.findAvailableSubdomains('acme', 3);
|
||||
|
||||
expect(subdomains).toHaveLength(3);
|
||||
expect(new Set(subdomains).size).toBe(3);
|
||||
subdomains.forEach((candidate) =>
|
||||
expect(takenSubdomains.has(candidate)).toBe(false),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not pad with unverified subdomains when availability is scarce', async () => {
|
||||
areAllSubdomainsTaken = true;
|
||||
|
||||
const subdomains = await service.findAvailableSubdomains('acme', 3);
|
||||
|
||||
expect(subdomains).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
+71
-20
@@ -6,7 +6,7 @@ import {
|
||||
getSubdomainSlugFromDisplayName,
|
||||
isDefined,
|
||||
} from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
|
||||
import { type SubdomainAvailabilityDTO } from 'src/engine/core-modules/domain/subdomain-manager/dtos/subdomain-availability.dto';
|
||||
import { type WorkspaceCreationDefaultsDTO } from 'src/engine/core-modules/domain/subdomain-manager/dtos/workspace-creation-defaults.dto';
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
const SUBDOMAIN_MAX_LENGTH = 30;
|
||||
const MAX_NUMBERED_SUFFIX_ATTEMPTS = 50;
|
||||
const MAX_RANDOM_FALLBACK_ATTEMPTS = 10;
|
||||
const SUBDOMAIN_SUGGESTIONS_COUNT = 3;
|
||||
|
||||
@Injectable()
|
||||
export class SubdomainManagerService {
|
||||
@@ -62,6 +63,15 @@ export class SubdomainManagerService {
|
||||
}
|
||||
|
||||
async findAvailableSubdomain(desired: string): Promise<string> {
|
||||
const [availableSubdomain] = await this.findAvailableSubdomains(desired, 1);
|
||||
|
||||
return availableSubdomain;
|
||||
}
|
||||
|
||||
async findAvailableSubdomains(
|
||||
desired: string,
|
||||
count: number,
|
||||
): Promise<string[]> {
|
||||
const derivedBase = isSubdomainValid(desired)
|
||||
? desired
|
||||
: getSubdomainSlugFromDisplayName(desired);
|
||||
@@ -71,27 +81,59 @@ export class SubdomainManagerService {
|
||||
? derivedBase
|
||||
: generateRandomSubdomain();
|
||||
|
||||
if (await this.isSubdomainFreeToUse(base)) {
|
||||
return base;
|
||||
const candidates = this.buildSubdomainCandidates(base);
|
||||
|
||||
const availableSubdomains =
|
||||
await this.filterFreeToUseSubdomains(candidates);
|
||||
|
||||
if (availableSubdomains.length === 0) {
|
||||
return [generateRandomSubdomain()];
|
||||
}
|
||||
|
||||
for (let suffix = 2; suffix <= MAX_NUMBERED_SUFFIX_ATTEMPTS; suffix++) {
|
||||
const candidate = this.appendNumberedSuffix(base, suffix);
|
||||
return availableSubdomains.slice(0, count);
|
||||
}
|
||||
|
||||
if (await this.isSubdomainFreeToUse(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
private buildSubdomainCandidates(base: string): string[] {
|
||||
const numberedCandidates = Array.from(
|
||||
{ length: MAX_NUMBERED_SUFFIX_ATTEMPTS - 1 },
|
||||
(_, index) => this.appendNumberedSuffix(base, index + 2),
|
||||
);
|
||||
|
||||
const randomCandidates = Array.from(
|
||||
{ length: MAX_RANDOM_FALLBACK_ATTEMPTS },
|
||||
() => generateRandomSubdomain(),
|
||||
);
|
||||
|
||||
return [...new Set([base, ...numberedCandidates, ...randomCandidates])];
|
||||
}
|
||||
|
||||
private async filterFreeToUseSubdomains(
|
||||
candidates: string[],
|
||||
): Promise<string[]> {
|
||||
const defaultSubdomain = this.twentyConfigService.get('DEFAULT_SUBDOMAIN');
|
||||
|
||||
const validCandidates = candidates.filter(
|
||||
(candidate) =>
|
||||
isSubdomainValid(candidate) && candidate !== defaultSubdomain,
|
||||
);
|
||||
|
||||
if (validCandidates.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < MAX_RANDOM_FALLBACK_ATTEMPTS; attempt++) {
|
||||
const candidate = generateRandomSubdomain();
|
||||
const existingWorkspaces = await this.workspaceRepository.find({
|
||||
where: { subdomain: In(validCandidates) },
|
||||
withDeleted: true,
|
||||
select: { subdomain: true },
|
||||
});
|
||||
|
||||
if (await this.isSubdomainFreeToUse(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
const takenSubdomains = new Set(
|
||||
existingWorkspaces.map((workspace) => workspace.subdomain),
|
||||
);
|
||||
|
||||
return generateRandomSubdomain();
|
||||
return validCandidates.filter(
|
||||
(candidate) => !takenSubdomains.has(candidate),
|
||||
);
|
||||
}
|
||||
|
||||
async getSubdomainAvailability(
|
||||
@@ -100,12 +142,21 @@ export class SubdomainManagerService {
|
||||
const isValid = isSubdomainValid(subdomain);
|
||||
const available = isValid && (await this.isSubdomainFreeToUse(subdomain));
|
||||
|
||||
// Autofill adopts this directly, so never echo an invalid input back.
|
||||
const suggestedSubdomain = available
|
||||
? subdomain
|
||||
: await this.findAvailableSubdomain(subdomain);
|
||||
// Autofill adopts the first suggestion directly, so never echo an invalid
|
||||
// input back.
|
||||
const suggestedSubdomains = available
|
||||
? [subdomain]
|
||||
: await this.findAvailableSubdomains(
|
||||
subdomain,
|
||||
SUBDOMAIN_SUGGESTIONS_COUNT,
|
||||
);
|
||||
|
||||
return { isValid, available, suggestedSubdomain };
|
||||
return {
|
||||
isValid,
|
||||
available,
|
||||
suggestedSubdomain: suggestedSubdomains[0],
|
||||
suggestedSubdomains,
|
||||
};
|
||||
}
|
||||
|
||||
async isSubdomainAvailable(subdomain: string) {
|
||||
|
||||
Reference in New Issue
Block a user