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:
Raphaël Bosi
2026-06-24 19:13:20 +02:00
committed by GitHub
parent fdea2d6e1e
commit 02120aac42
14 changed files with 773 additions and 50 deletions
@@ -10,4 +10,7 @@ export class SubdomainAvailabilityDTO {
@Field(() => String)
suggestedSubdomain: string;
@Field(() => [String])
suggestedSubdomains: string[];
}
@@ -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);
});
});
});
@@ -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) {